diff --git a/.gitignore b/.gitignore index f34b126..2b000c9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist/ .env .env.development +.env.production diff --git a/package.json b/package.json index a8648af..4cdeb56 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prisma-migrate": "prisma migrate deploy", "seed": "NODE_ENV=development node -r ts-node/register --env-file=.env.development ./scripts/seed.ts", "sync-releases": "NODE_ENV=development node -r ts-node/register --env-file=.env.development ./scripts/sync-releases.ts", + "sync-releases:production": "NODE_ENV=production node -r ts-node/register --env-file=.env.production ./scripts/sync-releases.ts", "build": "tsc", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/sync-releases.ts b/scripts/sync-releases.ts index 8ae0624..30e965a 100644 --- a/scripts/sync-releases.ts +++ b/scripts/sync-releases.ts @@ -1,3 +1,13 @@ +import { spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { stdin, stdout } from "node:process"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createInterface } from "node:readline/promises"; + import { GetObjectCommand, HeadObjectCommand, @@ -7,7 +17,9 @@ import { import { PrismaClient } from "@prisma/client"; import semver from "semver"; -import { streamToString } from "../src/helpers"; +import { objectKeyFromArtifactUrl, streamToString } from "../src/helpers"; + +const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95"; type ReleaseType = "app" | "system"; @@ -41,6 +53,372 @@ function legacyCompatibleSkus(): string[] { return [DEFAULT_SKU]; } +const DEFAULT_ROLLOUT_PERCENTAGE = 10; + +type ReleaseOutcome = + | "created" + | "already-synced" + | "no-artifacts" + | "skipped" + | "aborted"; + +type ReleaseDecision = + | { kind: "create"; rolloutPercentage: number } + | { kind: "skip" } + | { kind: "abort" }; + +interface LatestExistingRelease { + version: string; + rolloutPercentage: number; +} + +type SignatureStatus = + | { kind: "absent" } + | { kind: "valid"; signingFpr: string; rootFpr: string } + | { kind: "wrong-root"; signingFpr: string; rootFpr: string } + | { kind: "invalid"; reason: string } + | { kind: "missing-pubkey"; rootFpr?: string } + | { kind: "gpg-unavailable" }; + +interface ArtifactDisplayInfo { + artifact: ReleaseArtifactInput; + signature: SignatureStatus; +} + +function shortFpr(fpr: string): string { + // Keep the leading 16 hex chars (8 bytes) — enough to be unambiguous in a log + // line while staying readable. The full fingerprint is what we actually + // compare against; this is just for display. + return fpr.slice(0, 16); +} + +function describeSignature(status: SignatureStatus): string { + switch (status.kind) { + case "absent": + return "NO (no .sig file in S3)"; + case "valid": + return `yes (root ${shortFpr(status.rootFpr)})`; + case "wrong-root": + return `WRONG ROOT (got ${shortFpr(status.rootFpr)}, expected ${shortFpr(OTA_ROOT_KEY_FPR)})`; + case "invalid": + return `INVALID (${status.reason})`; + case "missing-pubkey": + return `cannot verify (OTA root key ${shortFpr(OTA_ROOT_KEY_FPR)} not in local GPG keyring)`; + case "gpg-unavailable": + return "cannot verify (gpg not installed)"; + } +} + +async function downloadObjectToFile( + s3Client: S3Client, + bucketName: string, + key: string, + destPath: string, +): Promise { + const response = await s3Client.send( + new GetObjectCommand({ Bucket: bucketName, Key: key }), + ); + if (!response.Body) { + throw new Error(`Empty body from S3 for key ${key}`); + } + await pipeline(response.Body as Readable, createWriteStream(destPath)); +} + +function runGpgVerify( + sigPath: string, + artifactPath: string, +): Promise<{ exitCode: number; statusOutput: string; stderrOutput: string }> { + return new Promise((resolve, reject) => { + const proc = spawn( + "gpg", + ["--batch", "--status-fd=1", "--verify", sigPath, artifactPath], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let statusOutput = ""; + let stderrOutput = ""; + proc.stdout.on("data", chunk => (statusOutput += chunk.toString())); + proc.stderr.on("data", chunk => (stderrOutput += chunk.toString())); + proc.on("error", reject); + proc.on("close", exitCode => { + resolve({ exitCode: exitCode ?? -1, statusOutput, stderrOutput }); + }); + }); +} + +interface GpgStatus { + validSig?: { signingFpr: string; rootFpr: string }; + noPubkey?: boolean; + // ERRSIG `rc` field. GnuPG documents rc=4 (unsupported algorithm), + // rc=9 (missing public key); other codes are possible and we leave + // them as raw strings for the caller to format. + errSigRc?: string; + badSig?: boolean; +} + +const ERRSIG_RC_REASONS: Record = { + "4": "unsupported algorithm", + "9": "missing public key", +}; + +function describeErrSigRc(rc: string): string { + return ERRSIG_RC_REASONS[rc] ?? `gpg error code ${rc}`; +} + +function parseGpgStatus(statusOutput: string): GpgStatus { + const result: GpgStatus = {}; + for (const rawLine of statusOutput.split("\n")) { + const line = rawLine.replace(/^\[GNUPG:\]\s+/, "").trim(); + + if (line.startsWith("VALIDSIG ")) { + // VALIDSIG + // + // Fields are space-separated; index 10 is the primary key fingerprint. + const parts = line.split(/\s+/); + if (parts.length >= 11) { + result.validSig = { signingFpr: parts[1], rootFpr: parts[10] }; + } + } else if (line.startsWith("NO_PUBKEY ")) { + result.noPubkey = true; + } else if (line.startsWith("ERRSIG ")) { + // ERRSIG