UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

371 lines (369 loc) • 14.9 kB
const require_http_client = require('../../utils/http-client.cjs'); const require_run = require('../../utils/run.cjs'); const require_theme = require('../../utils/theme.cjs'); let node_fs = require("node:fs"); let node_path = require("node:path"); //#region src/services/deploy/image.ts /** * Image reference resolution precedence (ADR 0021): `BOS_IMAGE` env → derived * from `repository`. Only the root runtime builds — children never own image * bytes; their ref derives from the platform release manifest (ADR 0034). */ function resolveImageRef(input) { const envImage = input.env?.BOS_IMAGE; if (envImage) return { image: envImage, source: "env" }; const repo = input.repository?.replace(/^https?:\/\/(www\.)?github\.com\//i, "").replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase(); if (!repo?.includes("/") || repo.split("/").length !== 2) return void 0; return { image: `ghcr.io/${repo}`, source: "repository" }; } /** Compose the runnable image ref: `image@sha256:…` when a digest exists, * `image` alone otherwise. Never docker-run the bare ref in a deploy flow — * the implicit `:latest` is held to the last stable release during * prereleases (ADR 0021), which boots a different framework build than the * one this train pinned. */ function imageRefWithDigest(image, digest) { return digest ? `${image}@${digest}` : image; } async function hasDocker() { try { return (await require_run.run("docker", [ "info", "--format", "{{.ServerVersion}}" ], { capture: true }))?.exitCode === 0; } catch { return false; } } function parseDigest(pushOutput) { return pushOutput.match(/digest:\s*(sha256:[a-f0-9]+)/i)?.[1]; } const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; /** * The runtime framework version baked into the image is the workspace source's * `everything-dev` package version — the image builds from `configDir`, so that * file is the truth. Missing (child repos without the workspace) → undefined. * Exported for the platform release manifest composer (ADR 0034) — one version * source for the image, the manifest, and the npm gate. */ function readFrameworkVersion(configDir) { try { const raw = (0, node_fs.readFileSync)((0, node_path.join)(configDir, "packages", "everything-dev", "package.json"), "utf-8"); const pkg = JSON.parse(raw); return typeof pkg.version === "string" && SEMVER_PATTERN.test(pkg.version) ? pkg.version : void 0; } catch { return; } } /** * Tag scheme (ADR 0021): `sha-<short>` always; the exact `v<version>` tag; a * floating major tag (`v2`) on stable releases only. `latest` is held during * prereleases (npm's `rc` dist-tag analog) so existing deployments pinned to * `:latest` keep serving the last stable image — and `:rc` is the floating * ref for the prerelease train (npm's `rc` dist-tag mirror): it moves on * every prerelease deploy and freezes at the last rc-train build once the * workspace releases a stable version. */ function computeImageTags(input) { const tags = [`sha-${input.shortSha}`]; const match = input.version?.trim().match(SEMVER_PATTERN); if (!match) return { tags, pushLatest: true, pushRc: false }; const [, major, minor, patch, prerelease] = match; const exact = prerelease ? `v${major}.${minor}.${patch}-${prerelease}` : `v${major}.${minor}.${patch}`; if (prerelease) return { tags: [...tags, exact], pushLatest: false, latestHoldReason: `${exact} is a prerelease — :latest still serves the last stable image`, pushRc: true }; return { tags: [ ...tags, exact, `v${major}` ], pushLatest: true, pushRc: false }; } const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; /** A Railway pin is `FROM image@<digest>` — anything else cannot resolve. */ function isValidImageDigest(digest) { return DIGEST_PATTERN.test(digest); } /** * Split a container image ref into registry/repository/tag/digest. The * registry must be explicit (a dotted host, optionally ported, or * `localhost`) — the platform's refs are always `ghcr.io/<owner>/<name>` * (ADR 0034 manifest ref, or the `repository` derivation). */ function parseRegistryRef(ref) { const slash = ref.indexOf("/"); if (slash <= 0) return void 0; const registry = ref.slice(0, slash); if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?$/.test(registry) && !/^localhost(?::\d+)?$/.test(registry)) return; const rest = ref.slice(slash + 1); if (!rest.includes("/")) return void 0; let name = rest; let tag; let digest; const at = rest.indexOf("@"); if (at >= 0) { digest = rest.slice(at + 1); const addressed = rest.slice(0, at); const colon = addressed.lastIndexOf(":"); name = colon > addressed.lastIndexOf("/") ? addressed.slice(0, colon) : addressed; } else { const colon = rest.lastIndexOf(":"); if (colon > rest.lastIndexOf("/")) { tag = rest.slice(colon + 1); name = rest.slice(0, colon); } } if (!name) return void 0; return { registry, repository: name, tag, digest }; } /** Manifest media types a registry may serve — both OCI and Docker, index * and manifest, so the HEAD is answered for any image layout. */ const MANIFEST_ACCEPT = [ "application/vnd.oci.image.index.v1+json", "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", "application/vnd.docker.distribution.manifest.v2+json" ].join(", "); /** * Resolve a tag's digest from the OCI distribution API (ADR 0034: the digest * derives, never embeds). Follows the spec's token flow — an anonymous probe * reads the `WWW-Authenticate` challenge, mints a pull token at the realm, * and retries; public images (ghcr.io) never need credentials. A missing * manifest throws — the child pin cannot be verified, and that is a failure. */ async function resolveRegistryDigest(input) { const parsed = parseRegistryRef(input.ref); if (!parsed) throw new Error(`Cannot resolve a digest — unparsable image ref "${input.ref}"`); const doFetch = input.fetch ?? require_http_client.fetchResponse; const manifestUrl = `https://${parsed.registry}/v2/${parsed.repository}/manifests/${parsed.digest ?? parsed.tag ?? "latest"}`; const head = async (headers) => { try { return await doFetch(manifestUrl, { method: "HEAD", headers: { Accept: MANIFEST_ACCEPT, ...headers } }); } catch (cause) { throw new Error(`Registry ${parsed.registry} unreachable while resolving ${input.ref}: ${cause instanceof Error ? cause.message : String(cause)}`); } }; let res = await head({}); if (res.status === 401) { const challenge = res.headers.get("www-authenticate") ?? ""; const realm = /realm="([^"]+)"/.exec(challenge)?.[1]; if (realm) { const tokenUrl = new URL(realm); const service = /service="([^"]+)"/.exec(challenge)?.[1]; const scope = /scope="([^"]+)"/.exec(challenge)?.[1]; if (service) tokenUrl.searchParams.set("service", service); if (scope) tokenUrl.searchParams.set("scope", scope); const tokenRes = await doFetch(tokenUrl.href, { headers: { Accept: "application/json" } }); if (!tokenRes.ok) throw new Error(`Registry ${parsed.registry} token realm returned HTTP ${tokenRes.status} while resolving ${input.ref}`); const body = await tokenRes.json(); if (body.token) res = await head({ Authorization: `Bearer ${body.token}` }); } } const digest = res.headers.get("docker-content-digest"); if (!res.ok || !digest) throw new Error(`The registry has no manifest for ${input.ref} (HTTP ${res.status}) — the pin cannot be verified`); return digest; } /** * Image leg (ADR 0021): build the runtime stage, tag `sha-<short>` + version * tags, push them, capture the digest, then push the floating refs — `:latest` * unless the version is a prerelease, and `:rc` when it is. Callers skip the * leg with a notice when no image is configured (child repos get * build+upload+publish only). */ async function buildAndPushImage(input) { const shaResult = await require_run.run("git", [ "rev-parse", "--short=7", "HEAD" ], { cwd: input.configDir, capture: true }); const shortSha = shaResult?.stdout.trim(); if (!shaResult || shaResult.exitCode !== 0 || !shortSha) { const detail = shaResult?.stderr.trim(); throw new Error(`Failed to resolve the current git SHA in ${input.configDir} (exit code ${shaResult?.exitCode ?? "unknown"}${detail ? `: ${detail}` : ""})`); } const fullShaResult = await require_run.run("git", ["rev-parse", "HEAD"], { cwd: input.configDir, capture: true }); const fullSha = fullShaResult?.exitCode === 0 ? fullShaResult.stdout.trim() : void 0; const { tags, pushLatest, latestHoldReason, pushRc } = computeImageTags({ shortSha, version: readFrameworkVersion(input.configDir) }); const refs = tags.map((tag) => `${input.image}:${tag}`); const latestRef = `${input.image}:latest`; const rcRef = `${input.image}:rc`; const buildRefs = pushLatest ? [...refs, latestRef] : [...refs]; if (pushRc) buildRefs.push(rcRef); console.log(` Building runtime image ${require_theme.colors.cyan(buildRefs.join(", "))}${pushLatest ? "" : require_theme.colors.yellow(" (latest held)")}`); await require_run.run("docker", [ "build", "--target", "runtime", ...fullSha ? ["--build-arg", `BOS_GIT_SHA=${fullSha}`] : [], ...buildRefs.flatMap((ref) => ["-t", ref]), "." ], { cwd: input.configDir }); console.log(` Pushing ${require_theme.colors.cyan(refs.join(", "))}...`); let digest; for (const ref of refs) { const push = await require_run.run("docker", ["push", ref], { cwd: input.configDir, capture: true }); if (!push || push.exitCode !== 0) throw new Error(`docker push ${ref} failed with exit code ${push?.exitCode ?? "unknown"}`); digest ??= parseDigest(push.stdout); if (input.verbose && push.stdout.trim()) console.log(require_theme.colors.dim(push.stdout.trim())); } const pushFloating = async (ref) => { const push = await require_run.run("docker", ["push", ref], { cwd: input.configDir, capture: true }); if (!push || push.exitCode !== 0) throw new Error(`docker push ${ref} failed with exit code ${push?.exitCode ?? "unknown"}`); digest ??= parseDigest(push.stdout); if (input.verbose && push.stdout.trim()) console.log(require_theme.colors.dim(push.stdout.trim())); return true; }; let latestPushed = false; if (pushLatest) latestPushed = await pushFloating(latestRef); else console.log(require_theme.colors.yellow(` latest held: ${latestHoldReason}`)); const rcPushed = pushRc ? await pushFloating(rcRef) : false; if (!digest) console.log(require_theme.colors.yellow(" Could not capture the image digest from push output")); return { image: input.image, tag: tags[0], tags, latestPushed, rcPushed, digest }; } async function requireRailwayCli(configDir) { let version; try { version = (await require_run.run("railway", ["--version"], { cwd: configDir, capture: true }))?.stdout; } catch { version = void 0; } if (!version?.trim()) throw new Error("Railway CLI not found. Install it: npm i -g @railway/cli"); console.log(` ${require_theme.colors.dim(`railway ${version.trim()}`)}`); return version.trim(); } async function runRailwayStreaming(args, input) { const collected = { stdout: "", stderr: "" }; const result = await require_run.run("railway", [...args], { cwd: input.configDir, capture: true, onChunk: (stream, chunk) => { collected[stream] += chunk.toString("utf-8"); for (const line of chunk.toString("utf-8").split("\n")) if (line.trim()) console.log(` ${require_theme.colors.dim(line.trim())}`); } }); if (!result || result.exitCode !== 0) { const exitCode = result?.exitCode ?? "unknown"; const tail = (stream, text) => text.trim() ? `\nrailway ${stream} (tail):\n${text.trim().split("\n").slice(-20).join("\n")}` : ""; throw new Error(`railway ${args[0]} failed with exit code ${exitCode}` + tail("stdout", collected.stdout) + tail("stderr", collected.stderr)); } } /** * Pin the service's source to an exact image ref (Railway `ServiceConnect`): * a child deploy repoints the dashboard-connected source at the platform * manifest's `ref:tag` (ADR 0034), so the following `redeploy --from-source` * pulls the pinned bytes instead of whatever a floating ref moved to. The * project-scoped RAILWAY_TOKEN already authorizes the redeploy — connect * runs in the same context. The ref pins by tag: the train's version/sha * tags are append-only, and Railway's deployment history pins the exact * digest each deployment pulled (ADR 0021). */ async function repointServiceSource(input) { console.log(` Pinning Railway service ${require_theme.colors.cyan(input.service)} source to ${require_theme.colors.cyan(input.image)}...`); await requireRailwayCli(input.configDir); await runRailwayStreaming([ "service", "source", "connect", "--image", input.image, "--service", input.service, "--json" ], input); console.log(require_theme.colors.green(` ${require_theme.icons.ok} Railway service source pinned`)); } /** * Railway leg (ADR 0021, amended): image-backed deploy. The service's source * is a Docker image ref (connected once in the dashboard — `:rc` during the * prerelease train — or repointed to the child's platform-manifest pin by * `repointServiceSource`), and `redeploy --from-source` pulls and deploys * whatever that ref points at. The image leg pushes `:rc` (and the * `sha-<short>` / version tags) immediately before this step, so the pulled * bytes are exactly what this train validated. Railway skips the build * entirely for Docker-image sources — nothing is uploaded, nothing is built * on Railway, and the digest-pin Dockerfile mechanism this replaces is gone * (the CLI v5 dropped RAILWAY_DOCKERFILE_PATH, its snapshot respects * .gitignore so generated Dockerfiles never shipped, and its Metal builder * rejected the repo Dockerfile's cache-mount ids). */ async function deployImageToRailway(input) { console.log(` Deploying to Railway service ${require_theme.colors.cyan(input.service)} (image-backed)...`); await requireRailwayCli(input.configDir); await runRailwayStreaming([ "redeploy", "--service", input.service, "--from-source", "--yes" ], input); console.log(require_theme.colors.green(` ${require_theme.icons.ok} Railway deploy complete`)); } //#endregion exports.buildAndPushImage = buildAndPushImage; exports.computeImageTags = computeImageTags; exports.deployImageToRailway = deployImageToRailway; exports.hasDocker = hasDocker; exports.imageRefWithDigest = imageRefWithDigest; exports.isValidImageDigest = isValidImageDigest; exports.parseRegistryRef = parseRegistryRef; exports.readFrameworkVersion = readFrameworkVersion; exports.repointServiceSource = repointServiceSource; exports.resolveImageRef = resolveImageRef; exports.resolveRegistryDigest = resolveRegistryDigest; //# sourceMappingURL=image.cjs.map