diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 0e3a4cc3805..6a3787fba7e 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 00000000000..1f330694b2a --- /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 00c0ce4edc0..00000000000 --- 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 1cc7b124ba4..36548885c8b 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 00000000000..94b61d00271 --- /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 00000000000..c25fbc73cdd --- /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 00000000000..b7356aacc57 --- /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 5792c5ea4e3..dc48eb87484 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") {