From 7474f38420652ea675305562917eaae49ccb477d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 11:12:11 -0400 Subject: [PATCH] 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. --- .fallowrc.jsonc | 2 + .github/workflows/lambda-arm64-image.yml | 98 +++++++++++++++ .github/workflows/lambda-ffmpeg.yml | 47 -------- packages/aws-lambda/image/Dockerfile | 7 ++ packages/aws-lambda/image/smoke-video.mjs | 22 ++++ .../scripts/stage-arm64-image.test.ts | 101 ++++++++++++++++ .../aws-lambda/scripts/stage-arm64-image.ts | 112 ++++++++++++++++++ 7 files changed, 342 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..0a1338d4f2 --- /dev/null +++ b/.github/workflows/lambda-arm64-image.yml @@ -0,0 +1,98 @@ +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" + - ".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 in the image + run: >- + docker run --rm --entrypoint /var/lang/bin/node + -v "$PWD/packages/aws-lambda/image/smoke-video.mjs:/var/task/smoke-video.mjs:ro" + hf-video:ci /var/task/smoke-video.mjs + - name: The Lambda runtime loads the handler + run: | + docker run -d --name hf-video -p 9000:8080 hf-video:ci + reply=$(curl -sS --retry 20 --retry-connrefused --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..6f4337efcf --- /dev/null +++ b/packages/aws-lambda/image/smoke-video.mjs @@ -0,0 +1,22 @@ +// Runs inside the video image, mounted into /var/task: the bundled ffmpeg answers and the arm64 Chrome +// renders a page, launched the way src/chromium.ts launches it. +import { execFileSync } from "node:child_process"; +import chromium from "@sparticuz/chromium"; +import puppeteer from "puppeteer-core"; + +const ffmpeg = execFileSync("/var/task/bin/ffmpeg", ["-hide_banner", "-version"], { + encoding: "utf-8", +}); +console.log(ffmpeg.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 } }); + console.log(`${await browser.version()} on ${process.arch}: ${shot.length} byte screenshot`); +} 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..9e34f01a8f --- /dev/null +++ b/packages/aws-lambda/scripts/stage-arm64-image.ts @@ -0,0 +1,112 @@ +#!/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, + writeFileSync, +} 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[]): void { + const result = spawnSync(cmd, args, { stdio: "inherit" }); + 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; + }; + const pinned = (opts.packPins ?? CHROMIUM_ARM64_PACK_SHA256)[version]; + if (!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${version}/chromium-v${version}-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}`); + } + const tar = join(chromiumDir, "pack.arm64.tar"); + writeFileSync(tar, pack); + run("tar", ["-xf", tar, "-C", join(chromiumDir, "bin")]); + rmSync(tar); +} + +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}`); +}