From f6cbf87cddfda33d805dd37ee23fce9694db84c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 23 Oct 2026 02:41:18 +0000 Subject: [PATCH] feat(aws-lambda): arm64 video function image from the handler ZIP (#6064) * feat(aws-lambda): arm64 video function image from the handler ZIP The image is the release handler ZIP with its x86 ffmpeg and Chrome swapped for the native ffmpeg tree and the sha256-pinned Sparticuz arm64 Chrome pack, so the x86 and arm64 functions run the same handler code. The image stage only copies, so any host builds it. CI builds it and runs ffmpeg, a real Chrome render and the Lambda runtime on an ARM runner. * fix(aws-lambda): launch Chrome in the image smoke the way Lambda does @sparticuz/chromium inflates its AL2023 libraries only when AWS_EXECUTION_ENV names a Node runtime; Lambda sets it, a bare docker run does not. The workflow also runs on @sparticuz/chromium bumps and handler ZIP layout changes, which re-stage the image. * fix(aws-lambda): smoke the arm64 image through its own Lambda runtime The smoke is now a handler invoked through the image's entrypoint, whose bootstrap sets AWS_EXECUTION_ENV the way Lambda does, so @sparticuz/chromium takes its Lambda branch with nothing set by hand. It returns the value it saw. * fix(aws-lambda): agree the move of the ffmpeg build workflow into the image workflow * fix(aws-lambda): build the Chrome pack URL from the pin and hand the checked pack to tar in memory --- .fallowrc.jsonc | 2 + .github/workflows/lambda-arm64-image.yml | 106 ++++++++++++++++ .github/workflows/lambda-ffmpeg.yml | 47 -------- packages/aws-lambda/image/Dockerfile | 7 ++ packages/aws-lambda/image/smoke-video.mjs | 30 +++++ .../scripts/stage-arm64-image.test.ts | 101 ++++++++++++++++ .../aws-lambda/scripts/stage-arm64-image.ts | 114 ++++++++++++++++++ scripts/check-no-main-deletions.mjs | 4 + 8 files changed, 364 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/lambda-arm64-image.yml delete mode 100644 .github/workflows/lambda-ffmpeg.yml create mode 100644 packages/aws-lambda/image/smoke-video.mjs create mode 100644 packages/aws-lambda/scripts/stage-arm64-image.test.ts create mode 100644 packages/aws-lambda/scripts/stage-arm64-image.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 0e3a4cc380..6a3787fba7 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -30,6 +30,8 @@ "packages/cli/scripts/generate-font-data.ts", "packages/engine/scripts/test-fitTextFontSize-browser.ts", "packages/aws-lambda/scripts/*.ts", + // Run inside the arm64 video image by the lambda-arm64-image workflow. + "packages/aws-lambda/image/smoke-video.mjs", // Built as standalone IIFE for the browser-side sandbox runtime; // referenced by file path (not import) in build-hyperframes-runtime-artifact.ts. "packages/core/src/runtime/entry.ts", diff --git a/.github/workflows/lambda-arm64-image.yml b/.github/workflows/lambda-arm64-image.yml new file mode 100644 index 0000000000..1f330694b2 --- /dev/null +++ b/.github/workflows/lambda-arm64-image.yml @@ -0,0 +1,106 @@ +name: Lambda arm64 image + +permissions: + contents: read + +# Builds the arm64 Lambda video function image: the native ffmpeg on two separate ARM runners (each runs +# the codec check, the tarballs must match), then the image from the x86 handler ZIP, smoke-run on ARM. +on: + pull_request: + paths: + - "packages/aws-lambda/image/**" + - "packages/aws-lambda/scripts/stage-arm64-image.ts" + # A @sparticuz/chromium bump or a ZIP layout change must re-stage the image. + - "packages/aws-lambda/package.json" + - "packages/aws-lambda/scripts/build-zip.ts" + - ".github/workflows/lambda-arm64-image.yml" + workflow_dispatch: + +concurrency: + group: lambda-arm64-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + ffmpeg: + name: ffmpeg ${{ matrix.copy }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + strategy: + matrix: + copy: [a, b] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Build and check + run: >- + docker buildx build --progress=plain --platform linux/arm64 --target artifact + --output type=local,dest=out packages/aws-lambda/image + - run: cat out/hf-media-check.txt && sha256sum out/*.tar.gz + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: hf-media-${{ matrix.copy }} + path: out/ + retention-days: 7 + + reproducible: + name: ffmpeg builds match + needs: ffmpeg + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - run: cmp hf-media-a/hf-media-linux-aarch64.tar.gz hf-media-b/hf-media-linux-aarch64.tar.gz + + handler-zip: + name: Handler ZIP + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - run: bun install --frozen-lockfile + - run: bun run build + - run: bun run --cwd packages/aws-lambda build:zip + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: handler-zip + path: packages/aws-lambda/dist/handler.zip + retention-days: 7 + + image: + name: Image smoke + needs: [ffmpeg, handler-zip] + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: artifacts + - name: Stage /var/task + run: >- + bun packages/aws-lambda/scripts/stage-arm64-image.ts + --zip=artifacts/handler-zip/handler.zip + --ffmpeg=artifacts/hf-media-a/hf-media-linux-aarch64.tar.gz --out=staged + - name: Build image + run: >- + docker buildx build --progress=plain --platform linux/arm64 --target video + --build-context task=staged/task --load -t hf-video:ci packages/aws-lambda/image + - name: ffmpeg and Chrome run under the image's own Lambda runtime + run: | + docker run -d --name hf-smoke -p 9001:8080 \ + -v "$PWD/packages/aws-lambda/image/smoke-video.mjs:/var/task/smoke-video.mjs:ro" \ + hf-video:ci smoke-video.handler + reply=$(curl -sS --retry 30 --retry-all-errors --retry-delay 1 \ + -d '{}' http://localhost:9001/2015-03-31/functions/function/invocations) + docker logs hf-smoke + echo "$reply" + jq -e '.arch == "arm64" and .screenshotBytes > 0 and (.ffmpeg | startswith("ffmpeg version 7.0.2"))' <<<"$reply" + - name: The Lambda runtime loads the handler + run: | + docker run -d --name hf-video -p 9000:8080 hf-video:ci + reply=$(curl -sS --retry 30 --retry-all-errors --retry-delay 1 \ + -d '{"Action":"not-an-action"}' http://localhost:9000/2015-03-31/functions/function/invocations) + docker logs hf-video + echo "$reply" + grep -q 'no recognised Action' <<<"$reply" diff --git a/.github/workflows/lambda-ffmpeg.yml b/.github/workflows/lambda-ffmpeg.yml deleted file mode 100644 index 00c0ce4edc..0000000000 --- a/.github/workflows/lambda-ffmpeg.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Lambda arm64 ffmpeg - -permissions: - contents: read - -# Builds the native ffmpeg for the arm64 Lambda video function on two separate ARM runners. -# Each build runs the codec check on the bare Lambda base; the tarballs must be byte-identical. -on: - pull_request: - paths: - - "packages/aws-lambda/image/**" - - ".github/workflows/lambda-ffmpeg.yml" - workflow_dispatch: - -concurrency: - group: lambda-ffmpeg-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build ${{ matrix.copy }} - runs-on: ubuntu-24.04-arm - timeout-minutes: 90 - strategy: - matrix: - copy: [a, b] - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Build and check - run: >- - docker buildx build --progress=plain --platform linux/arm64 --target artifact - --output type=local,dest=out packages/aws-lambda/image - - run: cat out/hf-media-check.txt && sha256sum out/*.tar.gz - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: hf-media-${{ matrix.copy }} - path: out/ - retention-days: 7 - - reproducible: - name: Builds match - needs: build - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - - run: cmp hf-media-a/hf-media-linux-aarch64.tar.gz hf-media-b/hf-media-linux-aarch64.tar.gz diff --git a/packages/aws-lambda/image/Dockerfile b/packages/aws-lambda/image/Dockerfile index 1cc7b124ba..36548885c8 100644 --- a/packages/aws-lambda/image/Dockerfile +++ b/packages/aws-lambda/image/Dockerfile @@ -32,3 +32,10 @@ RUN cp /certs/ca.crt /etc/pki/ca-trust/source/anchors/ && update-ca-trust && bas FROM scratch AS artifact COPY --from=build /out/ / COPY --from=check /check.txt /hf-media-check.txt + +# The video function. /var/task comes from scripts/stage-arm64-image.ts as the `task` build context: +# docker buildx build --platform linux/arm64 --target video --build-context task=/task packages/aws-lambda/image +# Copy only, so any host builds it without emulation. +FROM ${BASE} AS video +COPY --from=task / /var/task/ +CMD ["handler.handler"] diff --git a/packages/aws-lambda/image/smoke-video.mjs b/packages/aws-lambda/image/smoke-video.mjs new file mode 100644 index 0000000000..94b61d0027 --- /dev/null +++ b/packages/aws-lambda/image/smoke-video.mjs @@ -0,0 +1,30 @@ +// A handler mounted into the video image and invoked through its own Lambda bootstrap, which sets +// AWS_EXECUTION_ENV as Lambda does: the bundled ffmpeg answers and arm64 Chrome renders, launched as +// src/chromium.ts launches it. +import { execFileSync } from "node:child_process"; +import chromium from "@sparticuz/chromium"; +import puppeteer from "puppeteer-core"; + +export const handler = async () => { + const ffmpeg = execFileSync("/var/task/bin/ffmpeg", ["-hide_banner", "-version"], { + encoding: "utf-8", + }).split("\n")[0]; + const browser = await puppeteer.launch({ + executablePath: await chromium.executablePath(), + args: chromium.args, + }); + try { + const page = await browser.newPage(); + await page.setContent("
"); + const shot = await page.screenshot({ clip: { x: 0, y: 0, width: 64, height: 64 } }); + return { + executionEnv: process.env.AWS_EXECUTION_ENV, + arch: process.arch, + ffmpeg, + chrome: await browser.version(), + screenshotBytes: shot.length, + }; + } finally { + await browser.close(); + } +}; diff --git a/packages/aws-lambda/scripts/stage-arm64-image.test.ts b/packages/aws-lambda/scripts/stage-arm64-image.test.ts new file mode 100644 index 0000000000..c25fbc73cd --- /dev/null +++ b/packages/aws-lambda/scripts/stage-arm64-image.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { CHROMIUM_ARM64_PACK_SHA256, stageArm64Image } from "./stage-arm64-image.js"; + +const X86_64 = 0x3e; +const AARCH64 = 0xb7; +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function elf(machine: number): Buffer { + const head = Buffer.alloc(64); + head.writeUInt32BE(0x7f454c46, 0); + head[4] = 2; + head.writeUInt16LE(machine, 18); + return head; +} + +function tree(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "stage-arm64-")); + dirs.push(dir); + for (const [path, body] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), body); + } + return dir; +} + +function pack(cmd: string, args: string[], cwd: string): void { + expect(spawnSync(cmd, args, { cwd }).status).toBe(0); +} + +function fixture(opts: { ffmpegMachine?: number; zipExtra?: Record } = {}) { + const zipDir = tree({ + "handler.mjs": "export const handler = () => {};", + "bin/ffmpeg": elf(X86_64), + "bin/ffprobe": elf(X86_64), + "node_modules/@sparticuz/chromium/package.json": JSON.stringify({ version: "148.0.0" }), + "node_modules/@sparticuz/chromium/bin/chromium.br": "x64 chrome", + ...opts.zipExtra, + }); + pack("zip", ["-qr", "handler.zip", "."], zipDir); + const machine = opts.ffmpegMachine ?? AARCH64; + const mediaDir = tree({ + "hf-media/bin/ffmpeg": elf(machine), + "hf-media/bin/ffprobe": elf(machine), + "hf-media/lib/libgnutls.so.30": "gnutls", + }); + pack("tar", ["-czf", "hf-media.tar.gz", "hf-media"], mediaDir); + const packDir = tree({ "chromium.br": "arm64 chrome", "al2023.tar.br": "arm64 libs" }); + pack("tar", ["-cf", "pack.tar", "chromium.br", "al2023.tar.br"], packDir); + const packBytes = readFileSync(join(packDir, "pack.tar")); + return { + handlerZip: join(zipDir, "handler.zip"), + ffmpegTarball: join(mediaDir, "hf-media.tar.gz"), + outDir: join(tree({}), "out"), + fetchPack: async () => packBytes, + packPins: { "148.0.0": createHash("sha256").update(packBytes).digest("hex") }, + }; +} + +describe("stageArm64Image", () => { + it("swaps the handler ZIP's x86 ffmpeg and Chrome for the arm64 ones", async () => { + const task = await stageArm64Image(fixture()); + expect(readFileSync(join(task, "bin/ffmpeg")).readUInt16LE(18)).toBe(AARCH64); + expect(readFileSync(join(task, "bin/ffprobe")).readUInt16LE(18)).toBe(AARCH64); + expect(readFileSync(join(task, "lib/libgnutls.so.30"), "utf-8")).toBe("gnutls"); + const chromeBin = join(task, "node_modules/@sparticuz/chromium/bin"); + expect(readFileSync(join(chromeBin, "chromium.br"), "utf-8")).toBe("arm64 chrome"); + expect(readFileSync(join(chromeBin, "al2023.tar.br"), "utf-8")).toBe("arm64 libs"); + expect(readFileSync(join(task, "handler.mjs"), "utf-8")).toContain("handler"); + }); + + it("refuses an ffmpeg tarball that is not arm64", async () => { + await expect(stageArm64Image(fixture({ ffmpegMachine: X86_64 }))).rejects.toThrow( + "is not a 64-bit arm64 ELF binary", + ); + }); + + it("refuses a Chrome pack whose sha256 is not the pinned one", async () => { + const opts = { ...fixture(), packPins: CHROMIUM_ARM64_PACK_SHA256 }; + await expect(stageArm64Image(opts)).rejects.toThrow("pinned c18a5241"); + }); + + it("refuses a @sparticuz/chromium version with no pinned arm64 pack", async () => { + await expect(stageArm64Image({ ...fixture(), packPins: {} })).rejects.toThrow( + "no arm64 Chrome pack pinned for @sparticuz/chromium 148.0.0", + ); + }); + + it("refuses a handler ZIP built with chrome-headless-shell", async () => { + const opts = fixture({ zipExtra: { "bin/chrome-headless-shell": "x86 chrome" } }); + await expect(stageArm64Image(opts)).rejects.toThrow("sparticuz Chrome only"); + }); +}); diff --git a/packages/aws-lambda/scripts/stage-arm64-image.ts b/packages/aws-lambda/scripts/stage-arm64-image.ts new file mode 100644 index 0000000000..b7356aacc5 --- /dev/null +++ b/packages/aws-lambda/scripts/stage-arm64-image.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun +// Stages /var/task for the arm64 video function image: the handler ZIP with its x86 ffmpeg and Chrome +// swapped for the native ffmpeg tree (image/Dockerfile's artifact) and Sparticuz's arm64 Chrome pack. +// bun packages/aws-lambda/scripts/stage-arm64-image.ts --ffmpeg= [--zip=..] [--out=..] +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readSync, + rmSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// The npm package ships x64 Chrome only. Bumping @sparticuz/chromium fails here until its arm64 pack is pinned. +export const CHROMIUM_ARM64_PACK_SHA256: Record = { + "148.0.0": "c18a5241b0a460cba233fff3abccd6678ebdcf855b8548bb7c58240a477f5f0e", +}; + +export interface StageOptions { + handlerZip: string; + ffmpegTarball: string; + outDir: string; + fetchPack?: (url: string) => Promise; + packPins?: Record; +} + +async function fetchBytes(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`[stage-arm64-image] GET ${url} answered ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +function run(cmd: string, args: string[], input?: Uint8Array): void { + const result = spawnSync(cmd, args, { + stdio: [input ? "pipe" : "inherit", "inherit", "inherit"], + input, + }); + if (result.status !== 0) { + throw new Error(`[stage-arm64-image] ${cmd} ${args.join(" ")} exited ${result.status}`); + } +} + +export function assertArm64Elf(path: string): void { + const head = Buffer.alloc(20); + const fd = openSync(path, "r"); + try { + readSync(fd, head, 0, head.length, 0); + } finally { + closeSync(fd); + } + const elf64 = head.readUInt32BE(0) === 0x7f454c46 && head[4] === 2; + if (!elf64 || head.readUInt16LE(18) !== 0xb7) { + throw new Error(`[stage-arm64-image] ${path} is not a 64-bit arm64 ELF binary`); + } +} + +async function stageChromiumArm64(chromiumDir: string, opts: StageOptions) { + const { version } = JSON.parse(readFileSync(join(chromiumDir, "package.json"), "utf-8")) as { + version: string; + }; + // The URL is built from the pin table's own key, never from the version read off disk. + const [pinnedVersion, pinned] = + Object.entries(opts.packPins ?? CHROMIUM_ARM64_PACK_SHA256).find(([v]) => v === version) ?? []; + if (!pinnedVersion || !pinned) { + throw new Error( + `[stage-arm64-image] no arm64 Chrome pack pinned for @sparticuz/chromium ${version}; ` + + "add its sha256 to CHROMIUM_ARM64_PACK_SHA256", + ); + } + const url = `https://github.com/Sparticuz/chromium/releases/download/v${pinnedVersion}/chromium-v${pinnedVersion}-pack.arm64.tar`; + const pack = await (opts.fetchPack ?? fetchBytes)(url); + const actual = createHash("sha256").update(pack).digest("hex"); + if (actual !== pinned) { + throw new Error(`[stage-arm64-image] ${url} has sha256 ${actual}, pinned ${pinned}`); + } + // Only bytes that matched the pin reach tar, over stdin; the pack is never written to a file. + run("tar", ["-xf", "-", "-C", join(chromiumDir, "bin")], pack); +} + +export async function stageArm64Image(opts: StageOptions): Promise { + const task = join(opts.outDir, "task"); + rmSync(opts.outDir, { recursive: true, force: true }); + mkdirSync(task, { recursive: true }); + run("unzip", ["-q", opts.handlerZip, "-d", task]); + if (existsSync(join(task, "bin", "chrome-headless-shell"))) { + throw new Error("[stage-arm64-image] the arm64 image bundles sparticuz Chrome only"); + } + rmSync(join(task, "bin", "ffmpeg")); + rmSync(join(task, "bin", "ffprobe")); + run("tar", ["-xzf", opts.ffmpegTarball, "-C", task, "--strip-components=1"]); + for (const bin of ["ffmpeg", "ffprobe"]) assertArm64Elf(join(task, "bin", bin)); + await stageChromiumArm64(join(task, "node_modules", "@sparticuz", "chromium"), opts); + return task; +} + +if (import.meta.main) { + const distDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist"); + const flag = (name: string) => + process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); + const ffmpegTarball = flag("ffmpeg"); + if (!ffmpegTarball) + throw new Error("[stage-arm64-image] --ffmpeg= is required"); + const task = await stageArm64Image({ + handlerZip: flag("zip") ?? join(distDir, "handler.zip"), + ffmpegTarball, + outDir: flag("out") ?? join(distDir, "arm64-image"), + }); + console.log(`[stage-arm64-image] staged ${task}`); +} diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs index 5792c5ea4e..dc48eb8748 100644 --- a/scripts/check-no-main-deletions.mjs +++ b/scripts/check-no-main-deletions.mjs @@ -1909,6 +1909,10 @@ export const ALLOWED_DELETIONS = new Map([ "packages/engine/src/services/frameCapture-releaseInjectedVideoMedia.browser.test.ts", "the revert of #5795 removes releaseInjectedVideoMedia, the only code this file tested", ], + [ + ".github/workflows/lambda-ffmpeg.yml", + "the ffmpeg build moves into lambda-arm64-image.yml, which also builds and smokes the image", + ], ]); export function parseBase(argv, fallback = "origin/main") {