From 307828a6b99f7874914cba685a49553004701a40 Mon Sep 17 00:00:00 2001 From: Marc Pomar Date: Tue, 21 Jul 2026 13:34:53 +0200 Subject: [PATCH] feat(deploy): remote-only, remove local buildpacks/Docker build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI no longer builds: it always uploads the source and the platform builds server-side. Removes the local build path (upload_tag, check_environment, upload_logs), the @faabletools/buildpacks dep, and the --local/--buildpack/ --remote flags. Sends source with a manifest only — the builder re-detects the framework and syncs runtime_strategy on completion. No local fallback: a rejected admission (build_mode=local opt-out, kill-switch off) or a build error exits red. The remote builder is unaffected (built from the buildpacks repo, not the npm package). See arch/deploy/remote-artifact-default-cutover.md. --- package.json | 1 - src/commands/deploy/check_environment.ts | 9 -- src/commands/deploy/index.ts | 168 +------------------- src/commands/deploy/remote/index.ts | 61 +++---- src/commands/deploy/remote/manifest.test.ts | 6 +- src/commands/deploy/remote/manifest.ts | 8 +- src/commands/deploy/upload_logs.test.ts | 128 --------------- src/commands/deploy/upload_logs.ts | 79 --------- src/commands/deploy/upload_tag.test.ts | 70 -------- src/commands/deploy/upload_tag.ts | 100 ------------ 10 files changed, 36 insertions(+), 594 deletions(-) delete mode 100644 src/commands/deploy/check_environment.ts delete mode 100644 src/commands/deploy/upload_logs.test.ts delete mode 100644 src/commands/deploy/upload_logs.ts delete mode 100644 src/commands/deploy/upload_tag.test.ts delete mode 100644 src/commands/deploy/upload_tag.ts diff --git a/package.json b/package.json index 58889fe..5269978 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ ], "dependencies": { "@actions/core": "^3.0.0", - "@faabletools/buildpacks": "^1.7.0", "axios": "^1.13.2", "fs-extra": "^11.3.2", "handlebars": "^4.7.8", diff --git a/src/commands/deploy/check_environment.ts b/src/commands/deploy/check_environment.ts deleted file mode 100644 index 97e39c6..0000000 --- a/src/commands/deploy/check_environment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { cmd } from "../../lib/cmd"; - -export const check_environment = async () => { - try { - await cmd("docker ps"); - } catch (error) { - throw new Error(`Docker is not running`, { cause: error }); - } -}; diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index aa38ddb..a483d64 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -3,30 +3,15 @@ import { requireApi } from '../../api/context' import { Configuration } from '../../lib/Configuration' import { log } from '../../log' import { link } from '../link' -import { - BuildPlan, - buildpack_names, - configure_buildpacks, - detect_buildpack, - get_buildpack, - plan_summary -} from '@faabletools/buildpacks' -import { cmd } from '../../lib/cmd' -import { check_environment } from './check_environment' import { git_context } from './git_context' import { deploy_remote } from './remote' import { resolve_app_id } from './resolve_app_id' import { secrets } from './secrets' import { is_superseded } from './superseded' -import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs' -import { upload_tag } from './upload_tag' export interface DeployCommandArgs { app_id: string workdir?: string - buildpack?: string - remote?: boolean - local?: boolean } export const deploy: CommandModule = { @@ -47,35 +32,12 @@ export const deploy: CommandModule = { type: 'string', description: 'Working directory' }) - .option('buildpack', { - alias: 'b', - type: 'string', - choices: buildpack_names(), - description: - 'Force a specific buildpack (overrides auto-detection and faable.json)' - }) - .option('remote', { - type: 'boolean', - description: - 'Build server-side (remote build), regardless of the app build_mode' - }) - .option('local', { - type: 'boolean', - description: - 'Build locally with Docker, even if the app is set to remote builds' - }) - .conflicts('remote', 'local') .showHelpOnFail(false) as any }, handler: async args => { const workdir = args.workdir || process.cwd() - // Wire the shared buildpacks package to the CLI's sinks: pino (which tees - // into the build-log buffer) and cmd() (which captures subprocess output - // into the same buffer). Must happen before any detect/build call. - configure_buildpacks({ log, exec: cmd }) - // Pass the explicit app target to the OIDC exchange so a monorepo (several // apps, one repo) can be disambiguated in CI. const ctx = await requireApi(args.app_id) @@ -98,128 +60,14 @@ export const deploy: CommandModule = { // it came from and who pushed it (env in CI, git fallback locally). const git = await git_context({ workdir }) - // Resolve the buildpack plan (detection or forced override). All the build - // thinking happens here; build() below just executes the plan. Detection can - // fail (e.g. an app whose start command can't be inferred) — and it runs - // before the create-first deployment exists, so without this a detection - // failure would leave NO deployment row: no deploy-failed email, nothing in - // the dashboard, just a red X in the CI logs the user may never see. Record - // it as a failed deployment instead. - let plan: BuildPlan - try { - plan = await detect_buildpack( - { workdir, config }, - args.buildpack || config.buildpack - ) - } catch (error: any) { - // Log the reason into the build buffer first (so it rides along in the - // attached logs), then record a typeless BUILD_ERROR row — typeless so it - // can't rewrite the app's runtime_strategy. Best-effort: a create that - // itself fails (e.g. the free-plan quota gate) must not mask the original - // detection error. - log.error(error.message) - const failed = await api - .createDeployment({ app_id: app.id, ...git }) - .catch(() => null) - if (failed) { - await mark_build_failure(api, { deployment_id: failed.id, app }) - } - throw error - } - - // Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): the - // server decides via the app's build_mode; --remote/--local override for - // testing. Pre-build failures with a server-decided mode fall back to the - // local build below (deploy_remote returns null); with --remote they - // fail hard. - let deployment: { id: string } - const want_remote = - !args.local && (Boolean(args.remote) || app.build_mode === 'remote') - const remote_deployment = want_remote - ? await deploy_remote({ - api, - app, - plan, - git, - workdir, - explicit: Boolean(args.remote) - }) - : null - - if (remote_deployment) { - deployment = remote_deployment - } else { - if (want_remote) { - log.warn('↩️ Falling back to a local build') - } - - // Create-first: register the deployment BEFORE building. Gate rejections - // (free-plan quota 429, disabled app 409) surface here, at second 0 — - // before any build minutes are spent. The row is born QUEUED; the CLI - // owns it until the built image lands (or the build fails). `type` rides - // on the create — the buildpack plan is already resolved. - deployment = await api.createDeployment({ - app_id: app.id, - type: plan.type, - ...git - }) - - try { - // Check if we can build docker images - await check_environment() - - const runtime_label = plan.runtime.version - ? `${plan.runtime.name}-${plan.runtime.version}` - : plan.runtime.name - log.info( - `🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}` - ) - log.info(`🧩 Build plan ${plan_summary(plan)}`) - - // get environment variables - const env_vars = await api.getAppSecrets(app.id) - - const buildpack = get_buildpack(plan.buildpack) - if (!buildpack) { - throw new Error(`No buildpack registered for plan=${plan.buildpack}`) - } - - // The build starts now: declare it (QUEUED → BUILDING) and stream the - // captured output to the deployment while it runs (best-effort). - await api - .updateDeploymentStatus(deployment.id, { phase: 'BUILDING' }) - .catch((error: any) => - log.warn(`Could not mark deployment BUILDING: ${error.message}`) - ) - const stop_log_sync = start_log_sync(api, deployment.id) - try { - await buildpack.build({ workdir, config, app, env_vars, deployment }, plan) - - // Upload to Faable registry, tagged by version and pinned to digest - const { image_ref } = await upload_tag({ - app, - api, - deployment_id: deployment.id - }) - - // Complete the deployment with the built image — this is the handoff: - // the controller claims it and materializes the workload. - await api.completeDeployment(deployment.id, image_ref) - } finally { - stop_log_sync() - } - } catch (error: any) { - // The deployment already exists (created pre-build), so a failed build - // marks THAT row BUILD_ERROR with the captured logs — no extra row, no - // second quota hit. Gate rejections can't reach here anymore: the - // create happens before any building. - await mark_build_failure(api, { deployment_id: deployment.id, app }) - throw error - } - - // Attach the final build output to the deployment (best-effort). - await upload_logs(api, deployment.id) - } // end local build path (remote builds upload their own logs server-side) + // Remote build only (arch/deploy/remote-artifact-default-cutover.md): the + // CLI no longer builds — it uploads the source and the platform builds + // server-side (framework detection, buildpacks, artifact/image output all + // live in the builder). No Docker, no local fallback: a rejected admission + // (build_mode=local opt-out, or the global kill-switch off) or a build + // error throws and exits red. + log.info(`🚀 Deploying "${app.name}" (${app.id})`) + const deployment = await deploy_remote({ api, app, git, workdir }) const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}` log.info(`Preparing to deploy in faable cloud · ${deployment.id}`) diff --git a/src/commands/deploy/remote/index.ts b/src/commands/deploy/remote/index.ts index ff1bbf6..6aaf007 100644 --- a/src/commands/deploy/remote/index.ts +++ b/src/commands/deploy/remote/index.ts @@ -1,4 +1,3 @@ -import { BuildPlan } from "@faabletools/buildpacks"; import { FaableApi, FaableApp } from "../../../api/FaableApi"; import { log } from "../../../log"; import { git_context } from "../git_context"; @@ -9,57 +8,39 @@ import { upload_missing_blobs } from "./upload"; export interface DeployRemoteProps { api: FaableApi; app: FaableApp; - plan: BuildPlan; git: Awaited>; workdir: string; - /** --remote was passed: fail hard instead of falling back to local. */ - explicit: boolean; } /** - * Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): upload the - * source delta to the CAS, create the deployment with `source` (manifest + - * serialized BuildPlan) and follow the server-side build until the image - * handoff. No Docker daemon needed. + * Remote build (arch/deploy/remote-artifact-default-cutover.md): upload the + * source delta to the CAS, create the deployment with `source` (manifest only — + * the builder re-detects the framework server-side) and follow the server-side + * build until the handoff. No Docker daemon, no local buildpacks. * - * Fallback contract: - * - PRE-build failures (collection, upload, create — including the server - * gate `remote_build_disabled`) return null when the mode came from the - * server, so the caller falls back to a local build. With --remote they - * fail hard (testing wants to see the errors). - * - Failures AFTER the deployment exists never fall back: a BUILD_ERROR - * would fail locally too (it's the user's build), and a timeout may still - * be building — a local fallback would double-deploy. + * There is no local fallback anymore: the CLI no longer builds. Any failure — + * a rejected admission (`remote_build_disabled` when the app opted out with + * build_mode=local, or the global kill-switch is off), an upload error, or a + * BUILD_ERROR — throws so the command exits red. */ export const deploy_remote = async ( props: DeployRemoteProps -): Promise<{ id: string } | null> => { - const { api, app, plan, git, workdir, explicit } = props; +): Promise<{ id: string }> => { + const { api, app, git, workdir } = props; - log.info( - `☁️ Remote build (${explicit ? "--remote" : `app build_mode=remote`})` - ); + log.info(`☁️ Remote build`); - let deployment: { id: string }; - try { - const manifest = await collect_manifest(workdir); - log.info(`🗂️ ${manifest.length} files in the source manifest`); - await upload_missing_blobs(api, app.id, workdir, manifest); + const manifest = await collect_manifest(workdir); + log.info(`🗂️ ${manifest.length} files in the source manifest`); + await upload_missing_blobs(api, app.id, workdir, manifest); - deployment = await api.createDeployment({ - app_id: app.id, - type: plan.type, - source: { manifest, plan }, - ...git, - }); - } catch (error: any) { - if (explicit) throw error; - const code = error?.response?.data?.code; - log.warn( - `☁️ Remote build unavailable${code ? ` (${code})` : `: ${error.message}`}` - ); - return null; - } + // No `plan`: the builder re-detects on the assembled tree and syncs the app's + // runtime_strategy on completion. + const deployment = await api.createDeployment({ + app_id: app.id, + source: { manifest }, + ...git, + }); log.info(`Preparing to build in faable cloud · ${deployment.id}`); await follow_remote_build(api, deployment.id); diff --git a/src/commands/deploy/remote/manifest.test.ts b/src/commands/deploy/remote/manifest.test.ts index d373d8d..f511df4 100644 --- a/src/commands/deploy/remote/manifest.test.ts +++ b/src/commands/deploy/remote/manifest.test.ts @@ -49,14 +49,14 @@ test("symlinks are rejected with an actionable error", async (t) => { const dir = git_fixture(); symlinkSync("src/index.js", join(dir, "link.js")); await t.throwsAsync(() => collect_manifest(dir), { - message: /Symlinks are not supported.*--local/s, + message: /Symlinks are not supported/s, }); }); -test("a non-git directory is rejected toward --local", async (t) => { +test("a non-git directory is rejected", async (t) => { const dir = mkdtempSync(join(tmpdir(), "faable-nogit-")); writeFileSync(join(dir, "package.json"), "{}"); await t.throwsAsync(() => collect_manifest(dir), { - message: /git repository.*--local/s, + message: /git repository/s, }); }); diff --git a/src/commands/deploy/remote/manifest.ts b/src/commands/deploy/remote/manifest.ts index 76b5bf5..0874add 100644 --- a/src/commands/deploy/remote/manifest.ts +++ b/src/commands/deploy/remote/manifest.ts @@ -23,8 +23,8 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024; * them like a fresh clone would. * * Symlinks are rejected (no representation in the manifest by design — see - * deploy-v2-remote-build.md) and a non-git directory is unsupported in the - * MVP (deploy with --local instead). + * deploy-v2-remote-build.md) and a non-git directory is unsupported (the source + * tree is collected via git). */ export const collect_manifest = async ( workdir: string @@ -34,7 +34,7 @@ export const collect_manifest = async ( }).catch(() => null); if (!inside || String(inside.stdout).trim() !== "true") { throw new Error( - "Remote builds need a git repository (the source tree is collected via git). Deploy with --local instead." + "Deploys need a git repository (the source tree is collected via git). Initialize one with `git init` and commit your files." ); } @@ -72,7 +72,7 @@ export const collect_manifest = async ( if (stat.isDirectory()) continue; if (stat.isSymbolicLink()) { throw new Error( - `Symlinks are not supported in remote builds: ${rel}. Deploy with --local instead.` + `Symlinks are not supported: ${rel}. Replace the symlink with the real file.` ); } if (stat.size > MAX_FILE_BYTES) { diff --git a/src/commands/deploy/upload_logs.test.ts b/src/commands/deploy/upload_logs.test.ts deleted file mode 100644 index 9c4849a..0000000 --- a/src/commands/deploy/upload_logs.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import test from "ava"; -import { buildLog } from "../../lib/log_buffer"; -import { mark_build_failure, start_log_sync, upload_logs } from "./upload_logs"; - -// Both helpers are best-effort: an API failure must never reject (a broken -// log upload must not fail an otherwise-good deploy, and the failure path -// must not mask the original build error). - -test.serial("upload_logs resolves even when the API rejects", async (t) => { - buildLog.reset(); - buildLog.append("some build output\n"); - const api: any = { - uploadDeploymentLogs: () => Promise.reject(new Error("404 Not Found")), - }; - await t.notThrowsAsync(() => upload_logs(api, "deployment_x")); -}); - -test.serial("upload_logs skips the call when there is no output", async (t) => { - buildLog.reset(); - let called = false; - const api: any = { - uploadDeploymentLogs: async () => { - called = true; - }, - }; - await upload_logs(api, "deployment_x"); - t.false(called); -}); - -test.serial("upload_logs sends the captured content", async (t) => { - buildLog.reset(); - buildLog.append("npm ci\nbuild ok\n"); - let sent: any = null; - const api: any = { - uploadDeploymentLogs: async (_id: string, body: any) => { - sent = body; - return { id: "log_1", truncated: false, size: 0 }; - }, - }; - await upload_logs(api, "deployment_x"); - t.truthy(sent); - t.true(sent.content.includes("build ok")); - t.false(sent.truncated); -}); - -test.serial( - "mark_build_failure flips the existing deployment to BUILD_ERROR with logs — no new row", - async (t) => { - buildLog.reset(); - buildLog.append("error TS2322: kaboom\n"); - const calls: any = { create: null, status: null, logs: null }; - const api: any = { - createDeployment: async () => { - calls.create = true; - return { id: "never" }; - }, - updateDeploymentStatus: async (id: string, status: any) => { - calls.status = { id, status }; - }, - uploadDeploymentLogs: async (id: string, body: any) => { - calls.logs = { id, body }; - return { id: "log_1", truncated: false, size: 0 }; - }, - }; - await mark_build_failure(api, { - deployment_id: "deployment_existing", - app: { id: "app_x", team: "team_x" }, - }); - // Create-first: the failure is recorded on the pre-created row. Creating - // a second deployment here would burn quota (the old flow's double-429). - t.is(calls.create, null); - t.deepEqual(calls.status, { - id: "deployment_existing", - status: { phase: "BUILD_ERROR" }, - }); - t.is(calls.logs.id, "deployment_existing"); - t.true(calls.logs.body.content.includes("kaboom")); - } -); - -test.serial("mark_build_failure never rejects", async (t) => { - buildLog.reset(); - buildLog.append("boom\n"); - const api: any = { - updateDeploymentStatus: () => Promise.reject(new Error("api down")), - uploadDeploymentLogs: () => Promise.reject(new Error("api down")), - }; - await t.notThrowsAsync(() => - mark_build_failure(api, { - deployment_id: "deployment_x", - app: { id: "app_x", team: "team_x" }, - }) - ); -}); - -test.serial( - "start_log_sync re-uploads while the buffer grows, skips when idle, and stops cleanly", - async (t) => { - buildLog.reset(); - const uploads: any[] = []; - const api: any = { - uploadDeploymentLogs: async (_id: string, body: any) => { - uploads.push(body.content); - return { id: "log_1", truncated: false, size: 0 }; - }, - }; - const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); - - const stop = start_log_sync(api, "deployment_x", 20); - buildLog.append("step 1\n"); - await wait(50); - t.is(uploads.length, 1); - - // No growth → no re-upload. - await wait(50); - t.is(uploads.length, 1); - - buildLog.append("step 2\n"); - await wait(50); - t.is(uploads.length, 2); - t.true(uploads[1].includes("step 2")); - - stop(); - buildLog.append("after stop\n"); - await wait(50); - t.is(uploads.length, 2); - } -); diff --git a/src/commands/deploy/upload_logs.ts b/src/commands/deploy/upload_logs.ts deleted file mode 100644 index 7efdeaa..0000000 --- a/src/commands/deploy/upload_logs.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { FaableApi } from "../../api/FaableApi"; -import { buildLog } from "../../lib/log_buffer"; -import { log } from "../../log"; - -// Best-effort by design: attaching logs (or recording a failed build) must -// never break or fail a deploy — an older API without these endpoints just -// produces a warn. - -export const upload_logs = async ( - api: FaableApi, - deployment_id: string -): Promise => { - try { - const { content, truncated } = buildLog.contents(); - if (!content) return; - await api.uploadDeploymentLogs(deployment_id, { content, truncated }); - log.info(`📝 Build logs attached to deployment ${deployment_id}`); - } catch (error: any) { - log.warn(`Could not upload build logs (non-fatal): ${error.message}`); - } -}; - -// Live log sync during the build: re-upload the whole buffer every interval -// while it grows. The API endpoint is an idempotent replace (one log row per -// deployment), so partial uploads are simply superseded — the dashboard sees -// the build progressing instead of only the final dump. Rate-limit friendly: -// 10s interval = 6 req/min against the endpoint's 30/min cap. -export const start_log_sync = ( - api: FaableApi, - deployment_id: string, - intervalMs = 10_000 -): (() => void) => { - let lastSize = 0; - let inFlight = false; - const timer = setInterval(async () => { - if (inFlight) return; - const { content, truncated, size } = buildLog.contents(); - if (!content || size === lastSize) return; - inFlight = true; - try { - await api.uploadDeploymentLogs(deployment_id, { content, truncated }); - lastSize = size; - } catch (error: any) { - // Quiet: the final upload_logs still runs at the end of the deploy. - log.debug(`Log sync failed (non-fatal): ${error.message}`); - } finally { - inFlight = false; - } - }, intervalMs); - // Never keep the process alive just for log syncing. - timer.unref?.(); - return () => clearInterval(timer); -}; - -// Mark the create-first deployment as a failed build (phase BUILD_ERROR) and -// attach the captured logs. The deployment row already exists — created -// before the build started — so unlike the old flow no extra row is created -// (and no quota is consumed) to record the failure. -export const mark_build_failure = async ( - api: FaableApi, - { - deployment_id, - app, - }: { deployment_id: string; app: { id: string; team: string } } -): Promise => { - try { - await api - .updateDeploymentStatus(deployment_id, { phase: "BUILD_ERROR" }) - .catch((error: any) => - log.warn(`Could not mark deployment BUILD_ERROR: ${error.message}`) - ); - await upload_logs(api, deployment_id); - log.error( - `❌ Build failed — logs attached to deployment ${deployment_id} · https://dashboard.faable.com/deploy/${app.team}/app/${app.id}` - ); - } catch (error: any) { - log.warn(`Could not report the failed build (non-fatal): ${error.message}`); - } -}; diff --git a/src/commands/deploy/upload_tag.test.ts b/src/commands/deploy/upload_tag.test.ts deleted file mode 100644 index cb3c6f8..0000000 --- a/src/commands/deploy/upload_tag.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import test from "ava"; -import { - build_upload_tagname, - pin_tag_to_digest, - strip_image_tag, -} from "./upload_tag"; - -const ECR = "439102239196.dkr.ecr.eu-west-3.amazonaws.com"; - -test("build_upload_tagname tags by deployment id", (t) => { - t.is( - build_upload_tagname(ECR, "deploy/app_66f53552", "dep_abc123"), - `${ECR}/deploy/app_66f53552:dep_abc123` - ); -}); - -test("strip_image_tag removes the tag", (t) => { - t.is( - strip_image_tag(`${ECR}/deploy/app_66f53552:dep_abc123`), - `${ECR}/deploy/app_66f53552` - ); -}); - -test("strip_image_tag keeps registry ports intact", (t) => { - t.is( - strip_image_tag("localhost:5000/deploy/app_1:dep_1"), - "localhost:5000/deploy/app_1" - ); - t.is(strip_image_tag("localhost:5000/deploy/app_1"), "localhost:5000/deploy/app_1"); -}); - -test("strip_image_tag is a no-op without tag", (t) => { - t.is(strip_image_tag(`${ECR}/deploy/app_1`), `${ECR}/deploy/app_1`); -}); - -test("pin_tag_to_digest grafts the digest onto the tagged ref", (t) => { - const tag = `${ECR}/deploy/app_1:dep_9`; - const digest = - "sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b"; - t.is( - pin_tag_to_digest(tag, [`${ECR}/deploy/app_1@${digest}`]), - `${tag}@${digest}` - ); -}); - -test("pin_tag_to_digest picks the digest of this repo among several", (t) => { - const tag = `${ECR}/deploy/app_1:dep_9`; - const digest = "sha256:aaaa"; - const digests = [ - "ghcr.io/faable/app_1@sha256:bbbb", // other registry - `${ECR}/deploy/app_2@sha256:cccc`, // other repo - `${ECR}/deploy/app_1@${digest}`, - ]; - t.is(pin_tag_to_digest(tag, digests), `${tag}@${digest}`); -}); - -test("pin_tag_to_digest returns null when no digest matches the repo", (t) => { - const tag = `${ECR}/deploy/app_1:dep_9`; - t.is(pin_tag_to_digest(tag, ["ghcr.io/faable/app_1@sha256:bbbb"]), null); - t.is(pin_tag_to_digest(tag, []), null); -}); - -test("pin_tag_to_digest does not substring-match repo prefixes", (t) => { - // deploy/app_1 must not match deploy/app_12's digest - const tag = `${ECR}/deploy/app_1:dep_9`; - t.is( - pin_tag_to_digest(tag, [`${ECR}/deploy/app_12@sha256:dddd`]), - null - ); -}); diff --git a/src/commands/deploy/upload_tag.ts b/src/commands/deploy/upload_tag.ts deleted file mode 100644 index cfd522c..0000000 --- a/src/commands/deploy/upload_tag.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { FaableApi, FaableApp } from "../../api/FaableApi"; -import { log } from "../../log"; -import { cmd } from "../../lib/cmd"; - -interface ImageUploadArgs { - api: FaableApi; - app: FaableApp; - /** Deployment being built — its id becomes the immutable image tag. */ - deployment_id: string; -} - -/** - * `/:` — one tag per build, never - * overwritten. Without a tag docker resolves `:latest`, which every deploy - * rewrites: any rescheduled pod (node drain, eviction) would silently pull - * whatever build was pushed last, breaking the deployment ↔ code identity. - */ -export const build_upload_tagname = ( - hostname: string, - image: string, - deployment_id: string -) => `${hostname}/${image}:${deployment_id}`; - -/** - * Strips the tag from an image ref, keeping registry ports intact - * (`host:5000/img:tag` → `host:5000/img`). A colon only denotes a tag when - * it appears after the last path segment separator. - */ -export const strip_image_tag = (ref: string): string => { - const last_slash = ref.lastIndexOf("/"); - const last_colon = ref.lastIndexOf(":"); - return last_colon > last_slash ? ref.slice(0, last_colon) : ref; -}; - -/** - * Pins a pushed tag to its content digest: `repo:tag@sha256:…`. Kubernetes - * pulls by digest (immutable even if the tag were re-pushed); the tag stays - * as the human-readable label. `RepoDigests` entries come as `repo@sha256:…` - * (no tag) and may reference other registries — only the entry for this - * repo counts. Returns null when none matches (caller falls back to the tag). - */ -export const pin_tag_to_digest = ( - tagname: string, - repo_digests: string[] -): string | null => { - const repo = strip_image_tag(tagname); - const match = repo_digests.find((d) => d.startsWith(`${repo}@sha256:`)); - if (!match) return null; - return `${tagname}@${match.slice(repo.length + 1)}`; -}; - -export const upload_tag = async (args: ImageUploadArgs) => { - const { api, app, deployment_id } = args; - log.info(`🔁 Uploading...`); - - const registry = await api.getRegistry(app.id); - - // Registry login - const { user, password, hostname, image } = registry; - - await cmd( - `echo "${password}" | docker login --username "${user}" --password-stdin ${hostname}` - ); - - // Tag the local build (tagged `app.id` by the buildpack) for this version - const upload_tagname = build_upload_tagname(hostname, image, deployment_id); - await cmd(`docker tag ${app.id} ${upload_tagname}`); - - // Upload the image to faable registry - await cmd(`docker push ${upload_tagname}`); - - // Pin to the digest the registry just assigned. Best-effort: the tagged - // ref alone is already unique per build, the digest just makes it - // tamper-proof. - let image_ref = upload_tagname; - try { - const inspect = await cmd( - `docker inspect --format '{{json .RepoDigests}}' ${upload_tagname}` - ); - const repo_digests: string[] = JSON.parse(String(inspect.stdout).trim()); - const pinned = pin_tag_to_digest(upload_tagname, repo_digests); - if (pinned) { - image_ref = pinned; - } else { - log.warn(`Could not resolve the pushed digest; deploying by tag only.`); - } - } catch (error: any) { - log.warn( - `Could not inspect the pushed image (${error.message}); deploying by tag only.` - ); - } - - log.info(`✅ Upload completed.`); - - return { - upload_tagname, - /** Immutable ref recorded on the deployment: `repo:tag@sha256:…` */ - image_ref, - }; -};