diff --git a/docs/deploy/aws-lambda.mdx b/docs/deploy/aws-lambda.mdx index 31fbf07390..d85dfa153b 100644 --- a/docs/deploy/aws-lambda.mdx +++ b/docs/deploy/aws-lambda.mdx @@ -47,7 +47,7 @@ The Lambda handler is a thin dispatch: parse the Step Functions event, download | AWS credentials | The CLI and the deploy step both call AWS APIs. | Env vars, `~/.aws/credentials`, SSO, or IMDS — any chain the AWS SDK for JavaScript v3 would resolve. | | AWS SAM CLI | `hyperframes lambda deploy/destroy` shells out to `sam deploy`/`sam delete`. | [Install guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) | | Lambda adapter | The published CLI loads the AWS adapter only for Lambda commands. Each release pins a prebuilt Lambda handler ZIP (a GitHub release asset); `lambda deploy` downloads it, checks its SHA-256 and deploys it with the adapter's SAM template. No repo checkout needed. | `npm install -g @hyperframes/aws-lambda` alongside the CLI, at the same version | -| HyperFrames repo checkout (optional) | Inside a checkout, or with `HYPERFRAMES_REPO_ROOT` pointing at one, `lambda deploy` builds the handler ZIP from source instead (needs `bun`). Use it to deploy unreleased changes, `--chrome-source=chrome-headless-shell`, or an adapter release older than the prebuilt handler. | `git clone https://github.com/heygen-com/hyperframes`, and `npm install -g bun` or [bun.sh](https://bun.sh) | +| HyperFrames repo checkout (optional) | Inside a checkout, or with `HYPERFRAMES_REPO_ROOT` pointing at one, `lambda deploy` builds the handler ZIP from source instead (needs `bun`). Use it to deploy unreleased changes, `--chrome-source=chrome-headless-shell`, or an adapter release older than the prebuilt handler. A checkout deploy on arm64 also needs the video image built from that checkout (`HYPERFRAMES_LAMBDA_VIDEO_IMAGE`), or `--video-arch x86_64`. | `git clone https://github.com/heygen-com/hyperframes`, and `npm install -g bun` or [bun.sh](https://bun.sh) | ## Three deployment paths @@ -102,6 +102,10 @@ The `siteId` is content-addressed via a SHA-256 of the project tree. A site is a The stack's bucket expires objects under `cas/sha256/` 30 days after they were last written (the `ExpireSharedContent` lifecycle rule). Each render or `sites create` that reuses a file at least 7 days old rewrites it in place, so files in use never expire. If a site's files did expire, `lambda render --site-id` re-uploads them from the project directory, if its copies still match. Otherwise, or when `renderToLambda` gets a `siteHandle` but no `projectDir`, the render stops with `SITE_FILES_MISSING` before starting; run `sites create` again to restore them. +#### Upgrading from a version that deployed x86 only + +`lambda deploy` now runs the plan and the render chunks on an arm64 container-image function by default; audio and assemble stay on the x86 function. Redeploying an existing stack without `--video-arch` moves it to arm64 and adds the `-video` ECR repository. To keep a stack on x86, pass `--video-arch x86_64` on every deploy. `--chrome-source chrome-headless-shell` also needs `--video-arch x86_64`, because the arm64 image bundles sparticuz Chrome only. + #### Upgrading from a version before shared content storage - **Redeploy the stack before upgrading the CLI or SDK that starts renders.** A stack from an earlier version cannot read `files.json` sites. The CLI and `renderToLambda` check the state machine first and stop with `STACK_TOO_OLD` before uploading anything; run `hyperframes lambda deploy` (or redeploy your SAM template or CDK construct from the same version) and render again. @@ -231,6 +235,8 @@ If progress doesn't advance for >10 minutes, check the Step Functions execution The render bucket is created with CloudFormation `Retain` on delete — `hyperframes lambda destroy` (or `sam delete`) tears the function + state machine down but the bucket survives. This is intentional: it protects final-rendered MP4s from being lost when you re-deploy. To fully reclaim storage, empty + delete the bucket via the AWS console / `aws s3 rb`. +A stack deployed on arm64 (the default) also has an ECR repository, `-video`, holding the video function's image. `hyperframes lambda destroy` deletes it after the stack. + ### `STACK_TOO_OLD` The stack was deployed by an earlier version that cannot read this version's sites. Redeploy it (`hyperframes lambda deploy`), then render again. See [Upgrading](#upgrading-from-a-version-before-shared-content-storage). diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index f8621df07e..bec3a8c116 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -2207,6 +2207,7 @@ deploy: | `--concurrency` | none | Lambda reserved concurrency. Without it, a new stack reserves none and each render runs up to 90% of the account's unreserved concurrency; a redeploy keeps the stack's current reservation; `none` removes it | | `--memory` | `10240` | Lambda memory in MB | | `--chrome-source` | `sparticuz` | `sparticuz` or `chrome-headless-shell` | +| `--video-arch` | `arm64` | `arm64` runs the plan and the chunks on an arm64 container-image function, pushed to an ECR repository named `-video`; audio and assemble stay on x86. Needs `sparticuz`. `x86_64` runs everything on the x86 function | | `--skip-build` | off | Reuse the existing `handler.zip` | And these drive `render` and `render-batch`: diff --git a/packages/aws-lambda/src/sdk/costAccounting.test.ts b/packages/aws-lambda/src/sdk/costAccounting.test.ts index be789daab9..eef443b102 100644 --- a/packages/aws-lambda/src/sdk/costAccounting.test.ts +++ b/packages/aws-lambda/src/sdk/costAccounting.test.ts @@ -19,6 +19,15 @@ describe("computeRenderCost", () => { expect(result.breakdown.stepFunctionsUsd).toBe(0); }); + it("prices arm64 invocations at the arm64 rate", () => { + // 10 GiB × 6 s = 60 GB-s × $0.0000133334 = $0.0008. + const result = computeRenderCost( + [{ billedDurationMs: 6_000, memorySizeMb: 10_240, estimated: false, architecture: "arm64" }], + 0, + ); + expect(result.breakdown.lambdaUsd).toBeCloseTo(0.0008, 4); + }); + it("sums multiple Lambda invocations", () => { const invs: BilledLambdaInvocation[] = [ { billedDurationMs: 1_000, memorySizeMb: 1_024, estimated: false }, // 1 GB-s diff --git a/packages/aws-lambda/src/sdk/costAccounting.ts b/packages/aws-lambda/src/sdk/costAccounting.ts index 2679d48c02..1197ba741a 100644 --- a/packages/aws-lambda/src/sdk/costAccounting.ts +++ b/packages/aws-lambda/src/sdk/costAccounting.ts @@ -17,8 +17,9 @@ * raw number. */ -/** On-demand Lambda price, us-east-1, x86_64, on-demand: USD per GB-second. */ -const LAMBDA_USD_PER_GB_SECOND = 0.0000166667; +/** On-demand Lambda price, us-east-1: USD per GB-second, by function architecture. */ +const LAMBDA_USD_PER_GB_SECOND = { x86_64: 0.0000166667, arm64: 0.0000133334 } as const; +export type LambdaArchitecture = keyof typeof LAMBDA_USD_PER_GB_SECOND; /** Step Functions Standard Workflows, us-east-1: USD per state transition. */ const SFN_USD_PER_TRANSITION = 0.000025; @@ -30,6 +31,8 @@ export interface BilledLambdaInvocation { memorySizeMb: number; /** `true` if the event payload did NOT carry a billed duration and we fell back to `Duration` or a constant. */ estimated: boolean; + /** Architecture of the invoked function; x86_64 when omitted. */ + architecture?: LambdaArchitecture; } /** Result of {@link computeRenderCost}. */ @@ -63,7 +66,7 @@ export function computeRenderCost( let anyEstimated = false; for (const inv of lambdaInvocations) { const gbSeconds = (inv.memorySizeMb / 1024) * (inv.billedDurationMs / 1000); - lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND; + lambdaUsd += gbSeconds * LAMBDA_USD_PER_GB_SECOND[inv.architecture ?? "x86_64"]; if (inv.estimated) anyEstimated = true; } const stepFunctionsUsd = stateTransitions * SFN_USD_PER_TRANSITION; diff --git a/packages/aws-lambda/src/sdk/getRenderProgress.test.ts b/packages/aws-lambda/src/sdk/getRenderProgress.test.ts index 3d2802ae39..7d5c04a7b9 100644 --- a/packages/aws-lambda/src/sdk/getRenderProgress.test.ts +++ b/packages/aws-lambda/src/sdk/getRenderProgress.test.ts @@ -275,6 +275,36 @@ describe("getRenderProgress", () => { expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.001, 4); }); + it("prices the plan and chunk invocations at the video function's architecture", async () => { + const sfn = new FakeSFN(); + sfn.historyPages = [ + [ + stateEntered("Plan"), + taskSucceeded({ Action: "plan", TotalFrames: 30, DurationMs: 100_000 }), + stateEntered("RenderChunk"), + taskSucceeded({ Action: "renderChunk", FramesEncoded: 30, DurationMs: 4_000 }), + stateEntered("Assemble"), + taskSucceeded({ Action: "assemble", DurationMs: 2_000 }), + ], + ]; + const progress = await getRenderProgress({ + executionArn: "arn", + defaultMemorySizeMb: 10_240, + videoArchitecture: "arm64", + sfn: sfn as unknown as SFNClient, + }); + const billed = (ms: number) => ({ + billedDurationMs: ms, + memorySizeMb: 10_240, + estimated: false, + }); + const arm64 = (ms: number) => ({ ...billed(ms), architecture: "arm64" as const }); + const expected = [arm64(100_000), arm64(4_000), billed(2_000)]; + expect(progress.costs.breakdown.lambdaUsd).toBe( + computeRenderCost(expected, 0).breakdown.lambdaUsd, + ); + }); + it("captures Lambda failures with the enclosing state name", async () => { const sfn = new FakeSFN(); const failed: HistoryEvent = { @@ -451,6 +481,43 @@ describe("getRenderProgress", () => { expect(p.costs.breakdown.lambdaUsd).toBe(computeRenderCost(billed, 0).breakdown.lambdaUsd); }); + it("prices the plan and chunks at the arm64 rate when an arm64 video function runs them", async () => { + const sfn = new FakeSFN(); + const chunks = Array.from({ length: CHUNKS }, (_, i) => ({ + ChunkIndex: i, + FramesEncoded: 16, + DurationMs: 4000, + })); + sfn.historyPages = [ + [ + ...parentUntilMapRun(3600), + { + type: "MapStateExited", + id: 1, + timestamp: new Date(), + stateExitedEventDetails: { + name: "RenderChunksV2", + output: JSON.stringify({ Chunks: chunks }), + }, + } as HistoryEvent, + ], + ]; + const p = await getRenderProgress({ + executionArn: "arn:x", + videoArchitecture: "arm64", + sfn: sfn as unknown as SFNClient, + }); + const plan = { billedDurationMs: 1000, memorySizeMb: 10240, estimated: false }; + const chunk = { billedDurationMs: 4000, memorySizeMb: 10240, estimated: false }; + const onArm = (inv: typeof plan) => ({ ...inv, architecture: "arm64" as const }); + const arm64 = [onArm(plan), ...chunks.map(() => onArm(chunk))]; + const allX86 = [plan, ...chunks.map(() => chunk)]; + expect(p.costs.breakdown.lambdaUsd).toBe(computeRenderCost(arm64, 0).breakdown.lambdaUsd); + expect(p.costs.breakdown.lambdaUsd).toBeLessThan( + computeRenderCost(allX86, 0).breakdown.lambdaUsd, + ); + }); + it("surfaces a failed chunk's own error, reading only failed children", async () => { const statuses = Array.from({ length: CHUNKS }, (_, i) => i === 7 ? "FAILED" : i < 100 ? "SUCCEEDED" : "ABORTED", diff --git a/packages/aws-lambda/src/sdk/getRenderProgress.ts b/packages/aws-lambda/src/sdk/getRenderProgress.ts index 1029766276..9f2294f9eb 100644 --- a/packages/aws-lambda/src/sdk/getRenderProgress.ts +++ b/packages/aws-lambda/src/sdk/getRenderProgress.ts @@ -32,6 +32,7 @@ import { import { type BilledLambdaInvocation, computeRenderCost, + type LambdaArchitecture, type RenderCost, } from "./costAccounting.js"; @@ -45,6 +46,8 @@ export interface GetRenderProgressOptions { * `LambdaMemoryMb` parameter the stack was deployed with. */ defaultMemorySizeMb?: number; + /** Architecture of the stack's video function, which runs the plan and the chunks: `arm64` when it has one. */ + videoArchitecture?: LambdaArchitecture; region?: string; /** Test injection seam. */ sfn?: SFNClient; @@ -113,10 +116,10 @@ export async function getRenderProgress(opts: GetRenderProgressOptions): Promise const endedAt = describe.stopDate?.toISOString() ?? null; const history = await loadFullHistory(sfn, opts.executionArn); - const summary = summarizeHistory(history, memoryMb); + const summary = summarizeHistory(history, memoryMb, opts.videoArchitecture); const mapRun = findChunkMapRun(history); const warnings: string[] = []; - if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb); + if (mapRun?.exited) addChunkResults(summary, mapRun.output, memoryMb, opts.videoArchitecture); else if (mapRun) { await addRunningMapRun(sfn, mapRun.arn, summary, memoryMb).catch((err: unknown) => { if ((err as { name?: unknown } | null)?.name !== "AccessDeniedException") throw err; @@ -194,17 +197,17 @@ function findChunkMapRun(events: HistoryEvent[]): ChunkMapRun | null { } /** Exact chunk frames and cost from the chunk results the Map's output carries. */ -function addChunkResults(summary: HistorySummary, mapOutput: string | undefined, memoryMb: number) { +function addChunkResults( + summary: HistorySummary, + mapOutput: string | undefined, + memoryMb: number, + architecture: LambdaArchitecture | undefined, +) { const chunks = (parseJson(mapOutput) as { Chunks?: unknown } | null)?.Chunks; for (const chunk of Array.isArray(chunks) ? chunks : []) { summary.lambdasInvoked++; summary.stateTransitions++; - const billedDurationMs = inferBilledMs(chunk); - summary.lambdaInvocations.push({ - billedDurationMs, - memorySizeMb: memoryMb, - estimated: billedDurationMs === 0, - }); + summary.lambdaInvocations.push(billedInvocation(chunk, memoryMb, architecture)); applyPayloadFrameCounts(chunk, "RenderChunk", (delta) => { summary.framesRendered += delta; }); @@ -267,7 +270,11 @@ interface HistorySummary { * invocations = `LambdaFunctionScheduled` count. Frame totals come from * the success-payload of each Lambda invocation. */ -function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySummary { +function summarizeHistory( + events: HistoryEvent[], + memoryMb: number, + videoArchitecture?: LambdaArchitecture, +): HistorySummary { let framesRendered = 0; let totalFrames: number | null = null; let lambdasInvoked = 0; @@ -314,12 +321,13 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm if (ev.taskSucceededEventDetails?.resourceType !== "lambda") break; const wrapped = parseJson(ev.taskSucceededEventDetails?.output); const payload = unwrapLambdaPayload(wrapped); - const billedDurationMs = inferBilledMs(payload); - lambdaInvocations.push({ - billedDurationMs, - memorySizeMb: memoryMb, - estimated: billedDurationMs === 0, - }); + lambdaInvocations.push( + billedInvocation( + payload, + memoryMb, + stateArchitecture(currentLambdaState, videoArchitecture), + ), + ); applyPayloadFrameCounts(payload, currentLambdaState, (delta) => { framesRendered += delta; }); @@ -331,12 +339,13 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm } case "LambdaFunctionSucceeded": { const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output); - const billedDurationMs = inferBilledMs(payload); - lambdaInvocations.push({ - billedDurationMs, - memorySizeMb: memoryMb, - estimated: billedDurationMs === 0, - }); + lambdaInvocations.push( + billedInvocation( + payload, + memoryMb, + stateArchitecture(currentLambdaState, videoArchitecture), + ), + ); applyPayloadFrameCounts(payload, currentLambdaState, (delta) => { framesRendered += delta; }); @@ -477,6 +486,30 @@ function isAssembleState(name: string | null | undefined): boolean { * on `LambdaFunctionSucceeded` (the dedicated `BilledDuration` field * is in CloudWatch Metrics, not the SFN history payload). */ +function billedInvocation( + payload: unknown, + memoryMb: number, + architecture: LambdaArchitecture | undefined, +): BilledLambdaInvocation { + const billedDurationMs = inferBilledMs(payload); + return { + billedDurationMs, + memorySizeMb: memoryMb, + estimated: billedDurationMs === 0, + architecture, + }; +} + +/** The video function runs the plan and the chunks; audio and assemble run on the render function. */ +function stateArchitecture( + state: string | null, + videoArchitecture: LambdaArchitecture | undefined, +): LambdaArchitecture | undefined { + return state === "Plan" || state === "PlanV2" || isRenderChunkState(state) + ? videoArchitecture + : undefined; +} + function inferBilledMs(payload: unknown): number { if (!payload || typeof payload !== "object") return 0; const obj = payload as Record; diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index 7e02a7dcb8..7a241ad021 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -14,6 +14,7 @@ import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk"; import { type CanvasResolution, DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core"; import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js"; import type { Example } from "./_examples.js"; +import type { VideoArch } from "./lambda/videoImage.js"; import { c } from "../ui/colors.js"; import { defaultCloudFps } from "../utils/compositionFps.js"; import { isMissingPackageError, missingPackageMessage } from "../utils/missingPackage.js"; @@ -120,6 +121,11 @@ export default defineCommand({ description: "sparticuz | chrome-headless-shell (default: sparticuz)", }, memory: { type: "string", description: "Lambda memory MB (default: 10240)" }, + "video-arch": { + type: "string", + description: + "arm64 | x86_64: where the plan and chunks run; arm64 adds a container-image function (default: arm64)", + }, "skip-build": { type: "boolean", description: "Reuse existing handler.zip (deploy)" }, // sites / render @@ -258,6 +264,7 @@ export default defineCommand({ reservedConcurrency: parseReservedConcurrency(args.concurrency), chromeSource: parseChromeSource(args["chrome-source"]), lambdaMemoryMb: parsePositiveInt(args.memory, "--memory"), + videoArch: parseVideoArch(args["video-arch"]), skipBuild: Boolean(args["skip-build"]), }); return; @@ -483,6 +490,9 @@ const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined => parseEnum(raw, QUALITIES, "[lambda render] --quality", undefined); const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] => parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!; +const VIDEO_ARCHS = ["x86_64", "arm64"] as const satisfies readonly VideoArch[]; +const parseVideoArch = (raw: unknown): (typeof VIDEO_ARCHS)[number] | undefined => + parseEnum(raw, VIDEO_ARCHS, "[lambda deploy] --video-arch", undefined); /** * Lambda flavor of the shared {@link parseOutputResolutionFlag} — same wire diff --git a/packages/cli/src/commands/lambda/deploy.test.ts b/packages/cli/src/commands/lambda/deploy.test.ts index fa8579a36b..d96e162701 100644 --- a/packages/cli/src/commands/lambda/deploy.test.ts +++ b/packages/cli/src/commands/lambda/deploy.test.ts @@ -11,16 +11,27 @@ vi.mock("./sam.js", async (importOriginal) => ({ samDeploy: vi.fn(), fetchStackOutputs: vi.fn(() => ({ bucketName: "b", functionName: "f", stateMachineArn: "s" })), })); -vi.mock("./state.js", () => ({ DEFAULT_STACK_NAME: "hf", writeStackOutputs: () => "state.json" })); +const written = vi.hoisted(() => [] as Record[]); +vi.mock("./state.js", () => ({ + DEFAULT_STACK_NAME: "hf", + readStackOutputs: () => null, + writeStackOutputs: (outputs: Record) => written.push(outputs) && "state.json", +})); +vi.mock("./videoImage.js", () => ({ + pushVideoImage: vi.fn(async () => ({ imageUri: "repo@sha256:a", repositoryName: "hf-video" })), +})); const { runDeploy } = await import("./deploy.js"); const { samDeploy } = await import("./sam.js"); +const { pushVideoImage } = await import("./videoImage.js"); let dir: string | undefined; let quiet: { mockRestore(): void }; beforeEach(() => { vi.mocked(samDeploy).mockClear(); + vi.mocked(pushVideoImage).mockClear(); + written.length = 0; quiet = vi.spyOn(console, "log").mockImplementation(() => {}); }); @@ -40,13 +51,13 @@ describe("runDeploy", () => { }); it("outside a checkout, refuses a Chrome source the prebuilt handler does not bundle", async () => { - await expect(runDeploy({ chromeSource: "chrome-headless-shell" })).rejects.toThrow( - /needs a hyperframes checkout/, - ); + await expect( + runDeploy({ videoArch: "x86_64", chromeSource: "chrome-headless-shell" }), + ).rejects.toThrow(/needs a hyperframes checkout/); expect(samDeploy).not.toHaveBeenCalled(); }); - it("inside a checkout, deploys the checkout's template as before", async () => { + it("with --video-arch x86_64, deploys the checkout's template with no video function", async () => { dir = mkdtempSync(join(tmpdir(), "hf-deploy-checkout-")); const samDir = join(dir, "examples", "aws-lambda"); mkdirSync(samDir, { recursive: true }); @@ -55,7 +66,33 @@ describe("runDeploy", () => { writeFileSync(join(dir, "packages", "aws-lambda", "dist", "handler.zip"), "zip"); root.value = dir; - await runDeploy({ skipBuild: true }); + await runDeploy({ skipBuild: true, videoArch: "x86_64" }); expect(vi.mocked(samDeploy).mock.calls[0]?.[0]).toMatchObject({ samDir }); + expect(vi.mocked(samDeploy).mock.calls[0]?.[0].videoImageUri).toBeUndefined(); + expect(pushVideoImage).not.toHaveBeenCalled(); + }); + + it("by default, deploys the pushed arm64 image and records its repository", async () => { + dir = mkdtempSync(join(tmpdir(), "hf-deploy-arm64-")); + mkdirSync(join(dir, "examples", "aws-lambda"), { recursive: true }); + writeFileSync(join(dir, "examples", "aws-lambda", "template.yaml"), "Resources: {}\n"); + mkdirSync(join(dir, "packages", "aws-lambda", "dist"), { recursive: true }); + writeFileSync(join(dir, "packages", "aws-lambda", "dist", "handler.zip"), "zip"); + root.value = dir; + + await runDeploy({ skipBuild: true, stackName: "hf" }); + expect(vi.mocked(pushVideoImage).mock.calls[0]?.[0]).toMatchObject({ inCheckout: true }); + expect(vi.mocked(samDeploy).mock.calls[0]?.[0].videoImageUri).toBe("repo@sha256:a"); + expect(written[0]).toMatchObject({ videoRepository: "hf-video" }); }); + + it.each([undefined, "arm64" as const])( + "refuses arm64 (--video-arch %s) with a Chrome source the video image does not bundle, naming x86_64", + async (videoArch) => { + await expect(runDeploy({ videoArch, chromeSource: "chrome-headless-shell" })).rejects.toThrow( + /sparticuz Chrome only[\s\S]*--video-arch x86_64/, + ); + expect(pushVideoImage).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/cli/src/commands/lambda/deploy.ts b/packages/cli/src/commands/lambda/deploy.ts index 823fdbaa5b..9e8ca5a4cf 100644 --- a/packages/cli/src/commands/lambda/deploy.ts +++ b/packages/cli/src/commands/lambda/deploy.ts @@ -15,7 +15,8 @@ import { c } from "../../ui/colors.js"; import { stagePrebuiltHandler } from "./prebuiltHandler.js"; import { fetchStackOutputs, locateSamTemplate, samDeploy } from "./sam.js"; import { repoRoot } from "./repoRoot.js"; -import { DEFAULT_STACK_NAME, writeStackOutputs } from "./state.js"; +import { DEFAULT_STACK_NAME, readStackOutputs, writeStackOutputs } from "./state.js"; +import { pushVideoImage, type VideoArch } from "./videoImage.js"; export interface DeployArgs { stackName: string; @@ -25,6 +26,7 @@ export interface DeployArgs { reservedConcurrency?: number; chromeSource: "sparticuz" | "chrome-headless-shell"; lambdaMemoryMb: number; + videoArch: VideoArch; /** Override the handler-ZIP rebuild step. Useful for CI runs that already built it. */ skipBuild: boolean; } @@ -40,10 +42,26 @@ export async function runDeploy(args: Partial = {}): Promise { reservedConcurrency: args.reservedConcurrency, chromeSource: args.chromeSource ?? "sparticuz", lambdaMemoryMb: args.lambdaMemoryMb ?? DEFAULT_MEMORY_MB, + videoArch: args.videoArch ?? "arm64", skipBuild: args.skipBuild ?? false, }; - const samDir = await prepareSamDir(repoRoot(), resolved); + if (resolved.videoArch === "arm64" && resolved.chromeSource !== "sparticuz") { + throw new Error( + "[lambda deploy] The arm64 video image bundles sparticuz Chrome only. " + + "Pass --video-arch x86_64 to deploy with --chrome-source chrome-headless-shell.", + ); + } + const root = repoRoot(); + const samDir = await prepareSamDir(root, resolved); + const video = + resolved.videoArch === "arm64" + ? await pushVideoImage({ + stackName: resolved.stackName, + region: resolved.region, + inCheckout: root !== undefined, + }) + : undefined; console.log(c.dim(`→ sam deploy (stack=${resolved.stackName} region=${resolved.region})`)); samDeploy({ @@ -54,6 +72,7 @@ export async function runDeploy(args: Partial = {}): Promise { reservedConcurrency: resolved.reservedConcurrency, lambdaMemoryMb: resolved.lambdaMemoryMb, chromeSource: resolved.chromeSource, + videoImageUri: video?.imageUri, }); console.log(c.dim("→ Reading stack outputs")); @@ -71,6 +90,9 @@ export async function runDeploy(args: Partial = {}): Promise { functionName: outputs.functionName, lambdaMemoryMb: resolved.lambdaMemoryMb, reservedConcurrency: outputs.reservedConcurrency, + videoFunctionName: outputs.videoFunctionName, + // Back on x86 the repository outlives the video function, so destroy still needs its name. + videoRepository: video?.repositoryName ?? readStackOutputs(resolved.stackName)?.videoRepository, deployedAt: new Date().toISOString(), }); @@ -96,6 +118,9 @@ export async function runDeploy(args: Partial = {}): Promise { console.log(` ${c.dim("Bucket:")} ${outputs.bucketName}`); console.log(` ${c.dim("State machine:")} ${outputs.stateMachineArn}`); console.log(` ${c.dim("Function:")} ${outputs.functionName}`); + if (outputs.videoFunctionName) { + console.log(` ${c.dim("Video function:")} ${outputs.videoFunctionName} (arm64)`); + } console.log(` ${c.dim("State file:")} ${resolve(statePath)}`); console.log(); console.log(c.dim(`Render with: hyperframes lambda render `)); diff --git a/packages/cli/src/commands/lambda/destroy.ts b/packages/cli/src/commands/lambda/destroy.ts index 0e2d94cacc..fb4c74c32f 100644 --- a/packages/cli/src/commands/lambda/destroy.ts +++ b/packages/cli/src/commands/lambda/destroy.ts @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { c } from "../../ui/colors.js"; import { deleteStackOutputs, requireStack } from "./state.js"; -import { samDelete } from "./sam.js"; +import { deleteEcrRepository, samDelete } from "./sam.js"; import { repoRoot } from "./repoRoot.js"; export interface DestroyArgs { @@ -25,6 +25,7 @@ export async function runDestroy(args: DestroyArgs): Promise { const stack = requireStack(args.stackName); console.log(c.dim(`→ sam delete (stack=${stack.stackName} region=${stack.region})`)); const root = repoRoot(); + const awsProfile = args.awsProfile ?? process.env.AWS_PROFILE; // Mirror deploy.ts's AWS_PROFILE env fallback — `AWS_PROFILE=prod // hyperframes lambda destroy` should hit the same account `deploy` // did, not the default credentials chain. @@ -32,8 +33,16 @@ export async function runDestroy(args: DestroyArgs): Promise { samDir: root ? join(root, "examples", "aws-lambda") : tmpdir(), stackName: stack.stackName, region: stack.region, - awsProfile: args.awsProfile ?? process.env.AWS_PROFILE, + awsProfile, }); + if (stack.videoRepository) { + console.log(c.dim(`→ Deleting ECR repository ${stack.videoRepository}`)); + deleteEcrRepository({ + repositoryName: stack.videoRepository, + region: stack.region, + awsProfile, + }); + } deleteStackOutputs(args.stackName); console.log(); console.log(c.success("Stack torn down.")); diff --git a/packages/cli/src/commands/lambda/policies.ts b/packages/cli/src/commands/lambda/policies.ts index ae18df260b..863c9f6fc7 100644 --- a/packages/cli/src/commands/lambda/policies.ts +++ b/packages/cli/src/commands/lambda/policies.ts @@ -81,6 +81,23 @@ const REQUIRED_ACTIONS = { "cloudwatch:DescribeAlarms", "cloudwatch:PutMetricAlarm", ], + // `deploy --video-arch arm64` tags and pushes the video image; Set/GetRepositoryPolicy let Lambda pull it. + ecr: [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:CreateRepository", + "ecr:DeleteRepository", + "ecr:DescribeRepositories", + "ecr:GetDownloadUrlForLayer", + "ecr:GetRepositoryPolicy", + "ecr:InitiateLayerUpload", + "ecr:ListTagsForResource", + "ecr:PutImage", + "ecr:SetRepositoryPolicy", + "ecr:TagResource", + "ecr:UploadLayerPart", + ], iam: [ "iam:AttachRolePolicy", "iam:CreateRole", diff --git a/packages/cli/src/commands/lambda/prebuiltHandler.ts b/packages/cli/src/commands/lambda/prebuiltHandler.ts index 0be52c6c5d..81d0f72fa4 100644 --- a/packages/cli/src/commands/lambda/prebuiltHandler.ts +++ b/packages/cli/src/commands/lambda/prebuiltHandler.ts @@ -4,10 +4,10 @@ import { createHash } from "node:crypto"; import { copyFileSync, + createReadStream, existsSync, mkdirSync, readdirSync, - readFileSync, renameSync, unlinkSync, } from "node:fs"; @@ -29,7 +29,7 @@ export interface StageDeps { log: (line: string) => void; } -const defaultDeps: StageDeps = { +export const defaultDeps: StageDeps = { loadAdapter: () => import("@hyperframes/aws-lambda/sdk"), download: downloadFile, cacheDir: HANDLER_CACHE_DIR, @@ -50,8 +50,48 @@ export function noHandlerMessage(adapterInstalled: boolean): string { ); } -function sha256File(path: string): string { - return createHash("sha256").update(readFileSync(path)).digest("hex"); +async function sha256File(path: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +export interface PinnedAsset { + version: string; + url: string; + sha256: string; + bytes: number; +} + +export async function fetchPinned( + pinned: PinnedAsset, + asset: { fileName: string; label: string; urlOverride?: string }, + deps: Pick, +): Promise { + const dir = join(deps.cacheDir, `${pinned.version}-${pinned.sha256}`); + const file = join(dir, asset.fileName); + if (existsSync(file) && (await sha256File(file)) === pinned.sha256) { + deps.log(`→ Using verified ${asset.label} ${pinned.version} from ${dir}`); + return file; + } + // Override for mirrors and pre-release testing; the pinned sha256 still decides. + const url = asset.urlOverride ?? pinned.url; + deps.log(`→ Downloading ${asset.label} ${pinned.version} (${pinned.bytes} bytes)`); + mkdirSync(dir, { recursive: true }); + const partial = `${file}.download`; + await deps.download(url, partial, { maxBytes: pinned.bytes }); + const actual = await sha256File(partial); + if (actual !== pinned.sha256) { + unlinkSync(partial); + const urlWithoutCredentials = url.split("?")[0]; + throw new Error( + `[lambda deploy] Refusing the ${asset.label} from ${urlWithoutCredentials}: sha256 ${actual} does not match ` + + `the ${pinned.sha256} pinned by @hyperframes/aws-lambda@${pinned.version}.`, + ); + } + renameSync(partial, file); + deps.log(`→ Verified sha256 ${pinned.sha256}`); + return file; } /** Returns the directory holding the verified `handler.zip` and its `template.yaml`. */ @@ -68,29 +108,16 @@ export async function stagePrebuiltHandler(deps: StageDeps = defaultDeps): Promi const pinned = sdk.prebuiltHandler?.(); if (!pinned) throw new Error(noHandlerMessage(true)); - const dir = join(deps.cacheDir, `${pinned.version}-${pinned.sha256}`); - const zip = join(dir, "handler.zip"); - if (existsSync(zip) && sha256File(zip) === pinned.sha256) { - deps.log(`→ Using verified handler ZIP ${pinned.version} from ${dir}`); - } else { - // Override for mirrors and pre-release testing; the pinned sha256 still decides. - const url = process.env.HYPERFRAMES_LAMBDA_HANDLER_URL ?? pinned.url; - deps.log(`→ Downloading handler ZIP ${pinned.version} (${pinned.bytes} bytes)`); - mkdirSync(dir, { recursive: true }); - const partial = `${zip}.download`; - await deps.download(url, partial, { maxBytes: pinned.bytes }); - const actual = sha256File(partial); - if (actual !== pinned.sha256) { - unlinkSync(partial); - const urlWithoutCredentials = url.split("?")[0]; - throw new Error( - `[lambda deploy] Refusing the handler ZIP from ${urlWithoutCredentials}: sha256 ${actual} does not match ` + - `the ${pinned.sha256} pinned by @hyperframes/aws-lambda@${pinned.version}.`, - ); - } - renameSync(partial, zip); - deps.log(`→ Verified sha256 ${pinned.sha256}`); - } + const zip = await fetchPinned( + pinned, + { + fileName: "handler.zip", + label: "handler ZIP", + urlOverride: process.env.HYPERFRAMES_LAMBDA_HANDLER_URL, + }, + deps, + ); + const dir = dirname(zip); const templateDir = dirname(pinned.templatePath); for (const file of readdirSync(templateDir).filter((name) => name.endsWith(".yaml"))) { copyFileSync(join(templateDir, file), join(dir, file)); diff --git a/packages/cli/src/commands/lambda/progress.ts b/packages/cli/src/commands/lambda/progress.ts index dd25a86100..915c50222e 100644 --- a/packages/cli/src/commands/lambda/progress.ts +++ b/packages/cli/src/commands/lambda/progress.ts @@ -7,7 +7,7 @@ import { setCommandExitCode } from "../../utils/commandResult.js"; */ import { c } from "../../ui/colors.js"; -import { requireStack } from "./state.js"; +import { videoArchitecture, requireStack } from "./state.js"; export interface ProgressArgs { /** Full SFN execution ARN OR the renderId (== execution name). */ @@ -33,6 +33,7 @@ export async function runProgress(args: ProgressArgs): Promise { executionArn, region: stack.region, defaultMemorySizeMb: stack.lambdaMemoryMb, + videoArchitecture: videoArchitecture(stack), }); if (args.json) { diff --git a/packages/cli/src/commands/lambda/render.ts b/packages/cli/src/commands/lambda/render.ts index 3d936b5e64..2f01eeb4a4 100644 --- a/packages/cli/src/commands/lambda/render.ts +++ b/packages/cli/src/commands/lambda/render.ts @@ -19,7 +19,7 @@ import { validateVariablesAgainstProject, } from "../../utils/variables.js"; import { warnOnDimensionMismatch } from "./_dimensions.js"; -import { requireStack, stateFilePath } from "./state.js"; +import { videoArchitecture, requireStack, type StackOutputs, stateFilePath } from "./state.js"; // Dynamic-import the SDK so tsup keeps it out of the static-import head of // the CLI bundle. See sites.ts loadSDK() for the full rationale. @@ -175,7 +175,7 @@ export async function runRender(args: RenderArgs): Promise { async function waitForCompletion( executionArn: string, - stack: { region: string; functionName: string; lambdaMemoryMb: number }, + stack: StackOutputs, intervalMs: number, json: boolean, ): Promise { @@ -188,6 +188,7 @@ async function waitForCompletion( executionArn, region: stack.region, defaultMemorySizeMb: stack.lambdaMemoryMb, + videoArchitecture: videoArchitecture(stack), }); for (const warning of progress.warnings) { if (!warned.has(warning)) console.warn(c.warn(warning)); diff --git a/packages/cli/src/commands/lambda/sam.test.ts b/packages/cli/src/commands/lambda/sam.test.ts index 9b9f177236..2c702550cd 100644 --- a/packages/cli/src/commands/lambda/sam.test.ts +++ b/packages/cli/src/commands/lambda/sam.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + deleteEcrRepository, fetchStackOutputs, locateSamTemplate, parseReservedConcurrency, @@ -55,15 +56,24 @@ describe("samDeploy", () => { samDeploy({ samDir: "/repo", stackName: "s", region: "us-east-2", reservedConcurrency: 8 }); expect(overrides()).toContain("ReservedConcurrency=8"); }); + + // Omitted, UsePreviousValue would keep the video function after a switch back to x86. + it("always sends VideoImageUri, empty without an image", () => { + samDeploy({ samDir: "/repo", stackName: "s", region: "r" }); + expect(vi.mocked(spawnSync).mock.lastCall?.[1]).toContain('VideoImageUri=""'); + samDeploy({ samDir: "/repo", stackName: "s", region: "r", videoImageUri: "repo@sha256:a" }); + expect(vi.mocked(spawnSync).mock.lastCall?.[1]).toContain("VideoImageUri=repo@sha256:a"); + }); }); describe("fetchStackOutputs", () => { - const stack = (reserved: string) => + const stack = (reserved: string, extra: { OutputKey: string; OutputValue: string }[] = []) => JSON.stringify({ Outputs: [ { OutputKey: "RenderBucketName", OutputValue: "bucket" }, { OutputKey: "RenderFunctionArn", OutputValue: "arn:aws:lambda:r:1:function:fn" }, { OutputKey: "RenderStateMachineArn", OutputValue: "arn:sm" }, + ...extra, ], Parameters: [{ ParameterKey: "ReservedConcurrency", ParameterValue: reserved }], }); @@ -74,6 +84,29 @@ describe("fetchStackOutputs", () => { vi.mocked(execFileSync).mockReturnValue(stack("-1")); expect(fetchStackOutputs({ stackName: "s", region: "r" }).reservedConcurrency).toBeUndefined(); }); + + it("reads the video function's name when the stack has one", () => { + vi.mocked(execFileSync).mockReturnValue(stack("-1")); + expect(fetchStackOutputs({ stackName: "s", region: "r" }).videoFunctionName).toBeUndefined(); + const video = { OutputKey: "VideoFunctionArn", OutputValue: "arn:aws:lambda:r:1:function:v" }; + vi.mocked(execFileSync).mockReturnValue(stack("-1", [video])); + expect(fetchStackOutputs({ stackName: "s", region: "r" }).videoFunctionName).toBe("v"); + }); +}); + +describe("deleteEcrRepository", () => { + const exit = (status: number, stderr = "") => + vi.mocked(spawnSync).mockReturnValueOnce({ status, stderr } as never); + + it("counts a repository that is already gone as deleted, and throws on any other failure", () => { + exit(254, "An error occurred (RepositoryNotFoundException) when calling DeleteRepository"); + expect(() => deleteEcrRepository({ repositoryName: "s-video", region: "r" })).not.toThrow(); + exit(254, "An error occurred (AccessDeniedException)"); + expect(() => deleteEcrRepository({ repositoryName: "s-video", region: "r" })).toThrow( + "AccessDeniedException", + ); + expect(vi.mocked(spawnSync).mock.lastCall?.[1]).toContain("--force"); + }); }); describe("parseReservedConcurrency", () => { diff --git a/packages/cli/src/commands/lambda/sam.ts b/packages/cli/src/commands/lambda/sam.ts index 0020dd6f55..e9bf49feca 100644 --- a/packages/cli/src/commands/lambda/sam.ts +++ b/packages/cli/src/commands/lambda/sam.ts @@ -55,6 +55,8 @@ export interface DeployOptions { /** Lambda memory in MB. Forwarded as the `LambdaMemoryMb` parameter override. */ lambdaMemoryMb?: number; chromeSource?: "sparticuz" | "chrome-headless-shell"; + /** ECR image for the arm64 video function; omitted, chunks render on the x86 function. */ + videoImageUri?: string; /** Pass-through stdio. Defaults to "inherit" so SAM's progress lines stream live. */ stdio?: "inherit" | "pipe"; } @@ -91,7 +93,11 @@ export function parseReservedConcurrency(raw: unknown): number | undefined { /** Run `sam deploy` non-interactively. Returns when SAM exits 0; throws on non-zero. */ export function samDeploy(opts: DeployOptions): void { assertSamAvailable(); - const paramOverrides = [`ChromeSource=${opts.chromeSource ?? "sparticuz"}`]; + const paramOverrides = [ + `ChromeSource=${opts.chromeSource ?? "sparticuz"}`, + // Always sent: omitted, SAM's UsePreviousValue would keep a video function the user dropped. + `VideoImageUri=${opts.videoImageUri ?? '""'}`, + ]; // Omitted, SAM sends UsePreviousValue: a redeploy keeps the stack's reservation, a new stack gets -1. if (opts.reservedConcurrency !== undefined) { paramOverrides.push(`ReservedConcurrency=${opts.reservedConcurrency}`); @@ -153,6 +159,8 @@ export interface StackOutputBag { functionName: string; stateMachineArn: string; reservedConcurrency?: number; + /** Present when the stack has the arm64 video function. */ + videoFunctionName?: string; } /** @@ -205,5 +213,30 @@ export function fetchStackOutputs(opts: { functionName: functionName.split(":").pop() ?? functionName, stateMachineArn, reservedConcurrency: reserved > 0 ? reserved : undefined, + videoFunctionName: byKey.get("VideoFunctionArn")?.split(":").pop(), }; } + +/** Delete an ECR repository and its images; one that is already gone counts as deleted. */ +export function deleteEcrRepository(opts: { + repositoryName: string; + region: string; + awsProfile?: string; +}): void { + assertAwsCliAvailable(); + const args = [ + "ecr", + "delete-repository", + "--repository-name", + opts.repositoryName, + "--force", + "--region", + opts.region, + ]; + if (opts.awsProfile) args.push("--profile", opts.awsProfile); + const result = spawnSync("aws", args, { encoding: "utf-8" }); + if (result.status === 0 || result.stderr?.includes("RepositoryNotFoundException")) return; + throw new Error( + `[lambda] aws ecr delete-repository ${opts.repositoryName} exited with code ${result.status ?? "unknown"}: ${result.stderr ?? ""}`, + ); +} diff --git a/packages/cli/src/commands/lambda/state.ts b/packages/cli/src/commands/lambda/state.ts index 03d6158a9e..b4b20be5bd 100644 --- a/packages/cli/src/commands/lambda/state.ts +++ b/packages/cli/src/commands/lambda/state.ts @@ -24,9 +24,15 @@ export interface StackOutputs { /** Lambda memory MB used during deploy; carried so cost math doesn't have to re-derive it. */ lambdaMemoryMb: number; reservedConcurrency?: number; + videoFunctionName?: string; + videoRepository?: string; deployedAt: string; } +export function videoArchitecture(stack: StackOutputs): "arm64" | undefined { + return stack.videoFunctionName ? "arm64" : undefined; +} + const STATE_DIR_NAME = ".hyperframes"; const STATE_FILE_PREFIX = "lambda-stack-"; /** diff --git a/packages/cli/src/commands/lambda/videoImage.test.ts b/packages/cli/src/commands/lambda/videoImage.test.ts new file mode 100644 index 0000000000..fafb9347de --- /dev/null +++ b/packages/cli/src/commands/lambda/videoImage.test.ts @@ -0,0 +1,121 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { StageDeps } from "./prebuiltHandler.js"; +import { pushVideoImage } from "./videoImage.js"; + +const dirs: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-video-image-test-")); + dirs.push(dir); + return dir; +} + +/** A tarball of a minimal OCI layout, as the release attaches. */ +function ociTar(): Buffer { + const layout = tempDir(); + mkdirSync(join(layout, "blobs", "sha256"), { recursive: true }); + writeFileSync(join(layout, "index.json"), '{"manifests":[]}'); + writeFileSync(join(layout, "oci-layout"), '{"imageLayoutVersion":"1.0.0"}'); + const tar = join(tempDir(), "image.oci.tar"); + expect(spawnSync("tar", ["-cf", tar, "-C", layout, "."]).status).toBe(0); + return readFileSync(tar); +} + +function setup(opts: { architecture?: string } = {}) { + const tar = ociTar(); + const sha256 = createHash("sha256").update(tar).digest("hex"); + const pushedLayouts: string[] = []; + const pushed: Record[] = []; + const sdk = { + prebuiltVideoImage: vi.fn(() => ({ + version: "9.8.7", + url: "https://x/v.tar", + sha256, + bytes: tar.length, + })), + ensureEcrRepository: vi.fn(async ({ repositoryName }: { repositoryName: string }) => ({ + repositoryUri: `1.dkr.ecr.r.amazonaws.com/${repositoryName}`, + })), + pushImageToEcr: vi.fn(async (o: { layoutDir: string; repositoryUri: string }) => { + pushedLayouts.push(readFileSync(join(o.layoutDir, "index.json"), "utf-8")); + pushed.push(o); + return { + imageUri: `${o.repositoryUri}@sha256:abc`, + digest: "sha256:abc", + architecture: opts.architecture ?? "arm64", + os: "linux", + }; + }), + }; + const deps: StageDeps = { + loadAdapter: async () => sdk as never, + download: async (_url, dest) => writeFileSync(dest, tar), + cacheDir: join(tempDir(), "cache"), + log: () => {}, + }; + return { sdk, deps, tar, pushedLayouts, pushed }; +} + +describe("pushVideoImage", () => { + it("outside a checkout, pushes the pinned image to the stack's repository and returns its digest URI", async () => { + const { deps, sdk, pushedLayouts, pushed } = setup(); + const result = await pushVideoImage( + { stackName: "My-Stack", region: "r", inCheckout: false }, + deps, + ); + expect(result).toEqual({ + imageUri: "1.dkr.ecr.r.amazonaws.com/my-stack-video@sha256:abc", + repositoryName: "my-stack-video", + }); + expect(pushedLayouts).toEqual(['{"manifests":[]}']); + // destroy deletes this repository, so it is only ever one this stack created and tagged. + expect(sdk.ensureEcrRepository).toHaveBeenCalledWith( + expect.objectContaining({ tags: [{ Key: "hyperframes:stack", Value: "My-Stack" }] }), + ); + expect(existsSync(pushed[0]?.layoutDir as string)).toBe(false); + }); + + it("refuses an image that is not linux/arm64", async () => { + const { deps } = setup({ architecture: "amd64" }); + await expect( + pushVideoImage({ stackName: "s", region: "r", inCheckout: false }, deps), + ).rejects.toThrow("is a linux/amd64 image; the video function needs linux/arm64"); + }); + + it("in a checkout, refuses the release image, whose handler code may differ", async () => { + const { deps, sdk } = setup(); + await expect( + pushVideoImage({ stackName: "s", region: "r", inCheckout: true }, deps), + ).rejects.toThrow(/set HYPERFRAMES_LAMBDA_VIDEO_IMAGE[\s\S]*--video-arch x86_64/); + expect(sdk.pushImageToEcr).not.toHaveBeenCalled(); + }); + + it("outside a checkout, an adapter that pins no image names --video-arch x86_64", async () => { + const { deps, sdk } = setup(); + sdk.prebuiltVideoImage.mockReturnValue(undefined as never); + await expect( + pushVideoImage({ stackName: "s", region: "r", inCheckout: false }, deps), + ).rejects.toThrow(/pins no arm64 video image[\s\S]*--video-arch x86_64/); + expect(sdk.pushImageToEcr).not.toHaveBeenCalled(); + }); + + it("pushes a local image from HYPERFRAMES_LAMBDA_VIDEO_IMAGE instead of the pinned one", async () => { + const { deps, sdk, tar, pushedLayouts } = setup(); + const local = join(tempDir(), "local.oci.tar"); + writeFileSync(local, tar); + vi.stubEnv("HYPERFRAMES_LAMBDA_VIDEO_IMAGE", local); + await pushVideoImage({ stackName: "s", region: "r", inCheckout: true }, deps); + expect(sdk.prebuiltVideoImage).not.toHaveBeenCalled(); + expect(pushedLayouts).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/lambda/videoImage.ts b/packages/cli/src/commands/lambda/videoImage.ts new file mode 100644 index 0000000000..a337fda99f --- /dev/null +++ b/packages/cli/src/commands/lambda/videoImage.ts @@ -0,0 +1,85 @@ +// `lambda deploy --video-arch arm64`: push the arm64 video function image to the stack's own ECR +// repository and return its digest-pinned URI for the template's VideoImageUri. + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultDeps, fetchPinned, type StageDeps } from "./prebuiltHandler.js"; + +export type VideoArch = "x86_64" | "arm64"; + +/** ECR repository names must be lowercase; stack names need not be. */ +function videoRepositoryName(stackName: string): string { + return `${stackName.toLowerCase()}-video`; +} + +export interface PushedVideoImage { + imageUri: string; + repositoryName: string; +} + +export async function pushVideoImage( + opts: { stackName: string; region: string; inCheckout: boolean }, + deps: StageDeps = defaultDeps, +): Promise { + const sdk = await deps.loadAdapter(); + const tarball = await videoImageTarball(sdk, opts.inCheckout, deps); + if (!sdk.ensureEcrRepository || !sdk.pushImageToEcr) { + throw new Error(noVideoImageMessage(opts.inCheckout)); + } + const layoutDir = mkdtempSync(join(tmpdir(), "hf-video-image-")); + try { + const untar = spawnSync("tar", ["-xf", tarball, "-C", layoutDir], { stdio: "inherit" }); + if (untar.status !== 0) { + throw new Error( + `[lambda deploy] tar could not extract ${tarball} (exit ${untar.status ?? "unknown"})`, + ); + } + const repositoryName = videoRepositoryName(opts.stackName); + const { repositoryUri } = await sdk.ensureEcrRepository({ + region: opts.region, + repositoryName, + tags: [{ Key: "hyperframes:stack", Value: opts.stackName }], + }); + deps.log(`→ Pushing the arm64 video image to ${repositoryUri}`); + const pushed = await sdk.pushImageToEcr({ + region: opts.region, + layoutDir, + repositoryName, + repositoryUri, + tag: "video", + log: deps.log, + }); + if (pushed.os !== "linux" || pushed.architecture !== "arm64") { + throw new Error( + `[lambda deploy] ${tarball} is a ${pushed.os}/${pushed.architecture} image; the video function needs linux/arm64.`, + ); + } + return { imageUri: pushed.imageUri, repositoryName }; + } finally { + rmSync(layoutDir, { recursive: true, force: true }); + } +} + +async function videoImageTarball( + sdk: Awaited>, + inCheckout: boolean, + deps: StageDeps, +): Promise { + const local = process.env.HYPERFRAMES_LAMBDA_VIDEO_IMAGE; + if (local) return local; + // A checkout deploys its own handler build, so the release's image could run different code. + const pinned = inCheckout ? undefined : sdk.prebuiltVideoImage?.(); + if (!pinned) throw new Error(noVideoImageMessage(inCheckout)); + return fetchPinned(pinned, { fileName: "video-arm64.oci.tar", label: "arm64 video image" }, deps); +} + +function noVideoImageMessage(inCheckout: boolean): string { + return inCheckout + ? "[lambda deploy] An arm64 deploy from a checkout needs an image built from that checkout: " + + "build packages/aws-lambda/image's `video` target as an OCI tarball and set HYPERFRAMES_LAMBDA_VIDEO_IMAGE to it, " + + "or pass --video-arch x86_64." + : "[lambda deploy] The installed @hyperframes/aws-lambda pins no arm64 video image. " + + "Install a newer one, or pass --video-arch x86_64."; +}