Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions .github/workflows/lambda-arm64-image.yml
Original file line number Diff line number Diff line change
@@ -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"
47 changes: 0 additions & 47 deletions .github/workflows/lambda-ffmpeg.yml

This file was deleted.

7 changes: 7 additions & 0 deletions packages/aws-lambda/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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=<out>/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"]
30 changes: 30 additions & 0 deletions packages/aws-lambda/image/smoke-video.mjs
Original file line number Diff line number Diff line change
@@ -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("<div style='width:64px;height:64px;background:#0a0'></div>");
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();
}
};
101 changes: 101 additions & 0 deletions packages/aws-lambda/scripts/stage-arm64-image.test.ts
Original file line number Diff line number Diff line change
@@ -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, string | Buffer>): 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<string, string> } = {}) {
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");
});
});
Loading
Loading