From f45b5548d6e884b86c5038ffb8a8920472ebce3e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 20:29:49 -0600 Subject: [PATCH] Separate the source-built native image from runtime configuration --- .dockerignore | 37 +++ .github/workflows/native-container.yml | 45 +++ Dockerfile | 62 ++++ docs/guide/native-container.md | 46 +++ scripts/test-native-container.mjs | 380 +++++++++++++++++++++++++ 5 files changed, 570 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/native-container.yml create mode 100644 Dockerfile create mode 100644 docs/guide/native-container.md create mode 100644 scripts/test-native-container.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..fff9d078b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Allow only build inputs, never the entire operator checkout. +** +!Cargo.toml +!Cargo.lock +!.tool-versions +!rust-toolchain.toml +!edgezero.toml +!.cargo/ +!.cargo/config.toml +!crates/ +!crates/** +# Exclude operator TOMLs even if placed below a crate. +**/*.toml +!**/Cargo.toml +!rust-toolchain.toml +!edgezero.toml +!.cargo/config.toml +!trusted-server.example.toml +**/.git +**/target +**/node_modules +**/dist +crates/trusted-server-adapter-cloudflare/build +crates/trusted-server-adapter-cloudflare/pkg +**/.env +**/.env.* +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/keys +**/secrets +**/.aws +**/.npmrc +**/.cargo/credentials* +**/.ts-tmp +**/.tsjs-npm-ci.lock diff --git a/.github/workflows/native-container.yml b/.github/workflows/native-container.yml new file mode 100644 index 0000000000..344e727173 --- /dev/null +++ b/.github/workflows/native-container.yml @@ -0,0 +1,45 @@ +name: Native RC container + +on: + pull_request: + paths: + - Dockerfile + - .dockerignore + - .tool-versions + - rust-toolchain.toml + - Cargo.toml + - Cargo.lock + - edgezero.toml + - .cargo/** + - crates/trusted-server-adapter-axum/** + - crates/trusted-server-core/** + - crates/trusted-server-js/** + - crates/trusted-server-openrtb*/** + - scripts/test-native-container.mjs + - .github/workflows/native-container.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + local-provider-image: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Read pinned Node version + id: node + run: | + version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node.outputs.version }} + - name: Build local-provider image + run: docker build --build-arg TRUSTED_SERVER__GIT_VERSION="${{ github.sha }}" -t ts-rc-local:test . + - name: Check real TS routes, startup rejection and drain + run: node scripts/test-native-container.mjs ts-rc-local:test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..12c0072388 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +# Adapted from the pinned EdgeZero RC's hardened native recipe. Build at workspace root. +FROM node:24.12.0-bookworm-slim@sha256:7326fb2dbdce998edd72140946851be64ef4a643e8715e138ca467e8e9d92c99 AS node +FROM rust:1.98.1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS builder +COPY --from=node /usr/local/ /usr/local/ +WORKDIR /src +COPY . . +# AWS providers and CloudFront Geo are default-off. Forward them explicitly. +ARG NATIVE_FEATURES="" +# Optional existing build metadata input; .git never enters the build context. +ARG TRUSTED_SERVER__GIT_VERSION="" +RUN <<'SH' +set -eu +test "$(rustc --version | awk '{print $2}')" = 1.98.1 +test "$(node --version)" = v24.12.0 +test "$(awk '$1 == "rust" {print $2}' .tool-versions)" = 1.98.1 +grep -Eq '^channel = "1.98.1"$' rust-toolchain.toml +feature_ifs=$IFS +IFS=, +for feature in $NATIVE_FEATURES; do + case "$feature" in + aws-appconfig-agent|aws-secrets-manager|aws-dynamodb-ec|celld-ec|geo-cloudfront) ;; + *) echo 'Only the declared native store, EC and Geo features are supported' >&2; exit 1 ;; + esac +done +IFS=$feature_ifs +unset feature feature_ifs +apt-get update +apt-get install -y --no-install-recommends cmake +rm -rf /var/lib/apt/lists/* +npm --prefix crates/trusted-server-js/lib ci --no-audit --no-fund +# No host target, node_modules or prebuilt dist enters this context. build.rs +# builds and validates new bundles in Cargo's private OUT_DIR before embedding. +host=$(rustc -vV | awk '$1 == "host:" {print $2}') +set -- +if test -n "$NATIVE_FEATURES"; then set -- --features "$NATIVE_FEATURES"; fi +cargo build -p trusted-server-adapter-axum --bin trusted-server-axum --release --locked --target "$host" "$@" +mkdir /out +cp "target/$host/release/trusted-server-axum" /out/app +strip --strip-debug /out/app +SH + +# Retain an auditable hash list of the exact context, not host working-tree state. +RUN find . -type d \( -name target -o -name node_modules \) -prune -o -type f ! -name .tsjs-npm-ci.lock -print0 \ + | sort -z | xargs -0 sha256sum > /out/build-inputs.sha256 + +FROM debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && apt-get install -y --no-install-recommends ca-certificates libgcc-s1 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /out/app /usr/local/bin/app +COPY --from=builder /out/build-inputs.sha256 /usr/local/share/ts-build-inputs.sha256 +ENV EDGEZERO__ADAPTER__HOST=0.0.0.0 \ + EDGEZERO__ADAPTER__PORT=8787 \ + EDGEZERO__ADAPTER__CONFIG_DIR=/config \ + EDGEZERO__ADAPTER__DATA_DIR=/data +USER 10001:10001 +EXPOSE 8787 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/app"] diff --git a/docs/guide/native-container.md b/docs/guide/native-container.md new file mode 100644 index 0000000000..382e0f573d --- /dev/null +++ b/docs/guide/native-container.md @@ -0,0 +1,46 @@ +# Native container + +Build the Axum adapter as a native Linux container from the workspace root: + +```sh +docker build \ + --build-arg TRUSTED_SERVER__GIT_VERSION="$(git rev-parse HEAD)" \ + -t ts-native:local . +``` + +The multi-stage build uses pinned Rust 1.98.1 and Node 24.12.0 images. The Docker Node version is independent of the host Node version in `.tool-versions`. It installs JS dependencies with `npm ci` and builds fresh bundles through Cargo. Host `target`, `node_modules` and `dist` directories never enter the build context. + +```mermaid +flowchart TD + A[Workspace build inputs] --> B[Rust and Node builder] + B --> C[Native binary and input hashes] + C --> D[Non-root Debian runtime] +``` + +The runtime image contains the application binary, CA roots and runtime libraries, without Rust or Node. It runs as UID and GID `10001`, listens on port `8787` on all container interfaces, and uses `/config` and `/data` for runtime configuration and data. Mount configuration read-only and provide writable storage for `/data` and `/tmp` when using a read-only root filesystem. Supply configuration and secrets at runtime, never as image build arguments or files in the source checkout. + +The `.dockerignore` file limits the build context to workspace build inputs. It excludes operator TOMLs, environment files, credentials and host build outputs. The image stores an input hash list at `/usr/local/share/ts-build-inputs.sha256`. `TRUSTED_SERVER__GIT_VERSION` supplies optional build metadata because `.git` is excluded. + +## Optional AWS providers + +Local providers are the default. Enable only the native AWS providers needed by the application: + +```sh +docker build \ + --build-arg NATIVE_FEATURES=aws-appconfig-agent,aws-secrets-manager \ + -t ts-native:aws . +``` + +`NATIVE_FEATURES` accepts an empty value or a comma-separated selection of `aws-appconfig-agent`, `aws-secrets-manager`, `aws-dynamodb-ec`, `celld-ec` and `geo-cloudfront`. Compiling a provider does not select it. Validated runtime configuration selects EC storage, and a separate boot profile selects protected CloudFront Geo. Credentials and provider configuration belong in the runtime environment, not the image. The local-provider smoke test does not validate AWS access. + +## Local smoke test + +With Docker available, use the host Node version pinned in `.tool-versions`: + +```sh +node scripts/test-native-container.mjs ts-native:local +``` + +The test creates a private Docker network, a synthetic publisher origin and temporary local configuration. It checks startup rejection for invalid configuration and missing secrets, the published listener, HTML rewriting, embedded JS, non-root execution, CA roots, writable mounts and SIGTERM draining with a request in flight. Fixtures use only `example.com` identities and synthetic secrets. The test removes its containers, network and temporary files on completion. + +The `Native container` workflow builds the local-provider image and runs this test on relevant pull requests or manual dispatch. It does not publish an image or deploy a service. diff --git a/scripts/test-native-container.mjs b/scripts/test-native-container.mjs new file mode 100644 index 0000000000..c484a44380 --- /dev/null +++ b/scripts/test-native-container.mjs @@ -0,0 +1,380 @@ +#!/usr/bin/env node +// Synthetic, account-free smoke. Usage: node scripts/test-native-container.mjs IMAGE +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { promisify } from 'node:util' + +const exec = promisify(execFile) +const image = process.argv[2] +assert(image, 'supply a built local image tag') +const work = await mkdtemp(join(tmpdir(), 'ts-container-')) +const owned = new Set() +let interrupted = false +const interrupt = () => { + interrupted = true +} +process.on('SIGINT', interrupt) +process.on('SIGTERM', interrupt) +const docker = async (...args) => + ( + await exec('docker', args, { timeout: 20000, maxBuffer: 1024 * 1024 }) + ).stdout.trim() +const check = () => assert(!interrupted, 'interrupted') +const bounded = async (operation, milliseconds, message) => { + const end = Date.now() + milliseconds + while (Date.now() < end) { + check() + if (await operation()) return + await delay(50) + } + throw new Error(message) +} +function sorted(value) { + if (Array.isArray(value)) return value.map(sorted) + if (value !== null && typeof value === 'object') + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]) + ) + return value +} +function envelope(data) { + data = sorted(data) + return JSON.stringify({ + version: 1, + generated_at: '2026-10-20T00:00:00Z', + data, + sha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + }) +} +async function request(endpoint, path) { + // Direct HTTP avoids inherited operator proxy settings and redirect following. + return new Promise((resolve, reject) => { + const req = http.get(`${endpoint}${path}`, { agent: false }, (res) => { + const chunks = [] + let size = 0 + res.on('data', (chunk) => { + size += chunk.length + if (size > 2 * 1024 * 1024) req.destroy(new Error('response limit')) + else chunks.push(chunk) + }) + res.on('error', reject) + res.on('end', () => + resolve({ + status: res.statusCode, + headers: new Headers( + Object.entries(res.headers).map(([key, value]) => [ + key, + String(value), + ]) + ), + body: Buffer.concat(chunks).toString('utf8'), + }) + ) + }) + const timer = setTimeout( + () => req.destroy(new Error('request deadline')), + 10000 + ) + req.on('close', () => clearTimeout(timer)) + req.on('error', reject) + }) +} +const network = `ts-container-network-${randomUUID()}` +let networkCreated = false +const originName = `ts-container-origin-${randomUUID()}` +try { + await chmod(work, 0o755) + // Register before creation so a timed-out Docker client cannot orphan the network. + networkCreated = true + await docker('network', 'create', network) + await writeFile( + join(work, 'origin.mjs'), + ` +import http from 'node:http'; +import { writeFileSync } from 'node:fs'; +http.createServer((req, res) => { + const send = () => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('synthetic-origincontainer-fixture'); + }; + if (req.url === '/slow') { + writeFileSync('/tmp/slow-started', 'started'); + setTimeout(send, 1200); + } else send(); +}).listen(8788, '0.0.0.0'); +` + ) + await chmod(join(work, 'origin.mjs'), 0o444) + owned.add(originName) + await docker( + 'run', + '-d', + '--name', + originName, + '--network', + network, + '--network-alias', + 'origin.publisher.example.com', + '--read-only', + '--user', + '10001:10001', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--tmpfs', + '/tmp:rw,noexec,nosuid,size=1m,uid=10001,gid=10001,mode=0700', + '--mount', + `type=bind,src=${work}/origin.mjs,dst=/origin.mjs,readonly`, + 'node:24.12.0-bookworm-slim@sha256:7326fb2dbdce998edd72140946851be64ef4a643e8715e138ca467e8e9d92c99', + 'node', + '/origin.mjs' + ) + const fixture = { + ec: { passphrase: 'ec_key' }, + handlers: [ + { path: '^/_ts/admin', username: 'admin', password: 'admin_key' }, + ], + publisher: { + domain: 'publisher.example.com', + cookie_domain: '.publisher.example.com', + origin_url: 'http://origin.publisher.example.com:8788', + proxy_secret: 'proxy_key', + }, + request_signing: null, + response_headers: { 'x-container-snapshot': 'synthetic' }, + } + const secrets = + 'EDGEZERO__SECRETS__EC_KEY=synthetic-container-ec-passphrase-32-bytes\nEDGEZERO__SECRETS__PROXY_KEY=synthetic-container-proxy-secret-32-bytes\nEDGEZERO__SECRETS__ADMIN_KEY=synthetic-container-admin-secret-32-bytes\n' + const launch = async (label, data, secretText) => { + check() + const root = join(work, label) + await mkdir(root, { mode: 0o755 }) + await writeFile( + join(root, 'local-config-trusted_server_config.json'), + JSON.stringify({ trusted_server_config: data }) + ) + await writeFile(join(root, 'local.env'), secretText) + // Synthetic values only. Make fixtures readable by the image's numeric UID, + // independently of the caller's umask; bind mounts remain read-only. + await chmod(root, 0o755) + await chmod(join(root, 'local-config-trusted_server_config.json'), 0o444) + await chmod(join(root, 'local.env'), 0o444) + const reservation = net.createServer() + await new Promise((resolve, reject) => { + reservation.once('error', reject) + reservation.listen(0, '127.0.0.1', resolve) + }) + const hostPort = reservation.address().port + await new Promise((resolve) => reservation.close(resolve)) + const name = `ts-container-${randomUUID()}` + // Register before launch so even an interrupted/failed Docker client is cleaned up. + owned.add(name) + await docker( + 'run', + '-d', + '--name', + name, + '--read-only', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--network', + network, + '--publish', + `127.0.0.1:${hostPort}:8787`, + '--mount', + `type=bind,src=${root},dst=/config,readonly`, + '--mount', + `type=bind,src=${root}/local.env,dst=/run/secrets/local.env,readonly`, + '--tmpfs', + '/data:rw,noexec,nosuid,size=16m,uid=10001,gid=10001,mode=0700', + '--tmpfs', + '/tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001,mode=0700', + '--env', + 'EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS=3', + '--env', + 'TRUSTED_SERVER__EC__PASSPHRASE=poison', + '--env', + 'TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://poison.example.com', + '--entrypoint', + '/bin/sh', + image, + '-ec', + 'set -a; . /run/secrets/local.env; exec /usr/local/bin/app' + ) + // Fast startup rejection can remove Docker's active port mapping before inspect. + return { name, endpoint: `http://127.0.0.1:${hostPort}` } + } + const state = async (name) => + JSON.parse(await docker('inspect', '--format={{json .State}}', name)) + for (const [label, data, secretText] of [ + ['invalid-envelope', 'invalid', secrets], + ['missing-secret', envelope(fixture), ''], + ]) { + const app = await launch(label, data, secretText) + await bounded( + async () => { + // A response of any status is a failure, not readiness. + let response + try { + response = await request(app.endpoint, '/health') + } catch { + /* Connection refusal is expected. */ + } + assert(!response, `${label} must not listen`) + const current = await state(app.name) + if (current.Running) return false + assert.notEqual(current.ExitCode, 0) + assert.equal(current.OOMKilled, false) + return true + }, + 15000, + `${label} startup rejection deadline` + ) + const logs = await docker('logs', app.name) + assert.match( + logs, + /Trusted Server initialization failed/, + 'must reject application inputs, not fail the launcher' + ) + assert.doesNotMatch( + logs, + /phase=ready/, + 'invalid inputs must never reach readiness' + ) + console.log(`PASS ${label} fails before listen`) + console.log(logs) + } + const app = await launch('valid', envelope(fixture), secrets) + await bounded( + async () => { + assert((await state(app.name)).Running, 'valid startup exited') + let result + try { + result = await request(app.endpoint, '/health') + } catch { + return false + } + assert.equal(result.status, 200) + assert.equal(result.body, 'ok') + assert.equal(result.headers.get('x-container-snapshot'), 'synthetic') + return true + }, + 15000, + 'valid readiness deadline' + ) + console.log('PASS published port reaches non-loopback container listener') + const page = await request(app.endpoint, '/') + assert.equal(page.status, 200) + assert.match(page.body, /container-fixture/) + const jsPath = page.body.match(/\/static\/tsjs=[^"'\s<>]+/)?.[0] + assert(jsPath, 'page must inject actual TSJS URL') + const js = await request(app.endpoint, jsPath) + assert.equal(js.status, 200) + assert(js.body.length > 100, 'embedded TSJS must not be empty') + assert.equal(js.headers.get('x-geo-info-available'), 'false') + console.log('PASS ordinary TS envelope, page rewrite and embedded TSJS') + await docker( + 'exec', + app.name, + 'sh', + '-ec', + 'test $(id -u) = 10001; test -s /etc/ssl/certs/ca-certificates.crt; ! command -v node; ! command -v rustc; ! touch /app/read-only-proof; touch /data/write-proof /tmp/write-proof' + ) + console.log( + 'PASS numeric non-root, CA roots, no Node/Rust, read-only root and explicit writable mounts' + ) + const inFlight = request(app.endpoint, '/slow') + // Attach immediately to avoid an unhandled rejection during signal testing. + const outcome = inFlight.then( + (value) => ({ value }), + (error) => ({ error }) + ) + await bounded( + async () => { + try { + await docker('exec', originName, 'test', '-f', '/tmp/slow-started') + return true + } catch { + return false + } + }, + 5000, + 'in-flight origin deadline' + ) + const started = Date.now() + await docker('kill', '--signal=TERM', app.name) + await bounded( + async () => !(await state(app.name)).Running, + 7000, + 'SIGTERM exit deadline' + ) + const final = await state(app.name) + assert.equal(final.ExitCode, 0, 'RC signal hooks should exit normally') + const completed = await outcome + assert(!completed.error, 'in-flight request must complete during drain') + assert.equal(completed.value.status, 200) + assert.match(completed.value.body, /container-fixture/) + console.log( + `PASS in-flight SIGTERM drains and exits normally within ${Date.now() - started}ms` + ) + const runtimeLogs = await docker('logs', app.name) + assert.match(runtimeLogs, /phase=draining reason=drain_requested/) + assert.match(runtimeLogs, /phase=stopped reason=stopped/) + assert( + runtimeLogs.indexOf('phase=draining') < + runtimeLogs.lastIndexOf('request_terminal'), + 'the in-flight request must finish after RC starts draining' + ) + console.log(runtimeLogs) + console.log( + await docker( + 'image', + 'inspect', + '--format={{.Id}} {{.Os}}/{{.Architecture}} user={{.Config.User}}', + image + ) + ) +} catch (error) { + for (const name of owned) { + try { + console.error( + `Logs for owned fixture ${name}:\n${await docker('logs', name)}` + ) + } catch { + /* Launch may have failed before container creation. */ + } + } + throw error +} finally { + for (const name of [...owned].reverse()) { + try { + await docker('rm', '-f', name) + } catch (error) { + console.error( + `cleanup failed for owned container ${name}: ${error.message}` + ) + process.exitCode = 1 + } + } + if (networkCreated) { + try { + await docker('network', 'rm', network) + } catch (error) { + console.error( + `cleanup failed for owned network ${network}: ${error.message}` + ) + process.exitCode = 1 + } + } + await rm(work, { recursive: true, force: true }) + process.off('SIGINT', interrupt) + process.off('SIGTERM', interrupt) +}