import { execFileSync } from 'node:child_process' import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import yaml from 'js-yaml' import { afterEach, describe, expect, it } from 'vitest' /** * #3164: the self-host container served its Next.js bundle from a 400 MB * tmpfs at /app/.next. The bundle grew past that (470 MB in the image built * from e35560c), the entrypoint's cp hit ENOSPC under `set -e`, and the * restart policy looped it, so every install was down and cron never started. * A bigger tmpfs is no fix: its pages count against mem_limit (1g) and cannot * be reclaimed, so it trades ENOSPC for an OOM kill as the bundle keeps * growing. /app/.next is a named volume now, and because a volume outlives the * container, the entrypoint empties it before every copy. * * These tests hold that contract at PR time. The end-to-end proof is * scripts/self-host/smoke-boot.sh, which docker-publish.yml runs against the * built image before `latest` moves. */ const ROOT = process.cwd() const read = (file: string) => readFileSync(path.join(ROOT, file), 'utf8') type ComposeService = { tmpfs?: string[] volumes?: string[] read_only?: boolean cap_drop?: string[] mem_limit?: string } type ComposeFile = { services: Record; volumes?: Record } const compose = yaml.load(read('docker-compose.yml')) as ComposeFile const app = compose.services.app describe('docker-compose.yml: where the app serves its bundle from', () => { it('does not put /app/.next on a tmpfs, whatever its size', () => { const nextTmpfs = (app.tmpfs ?? []).filter((entry) => entry.split(':')[0] === '/app/.next') expect(nextTmpfs).toEqual([]) }) it('mounts a named volume at /app/.next that the file declares', () => { const mount = (app.volumes ?? []).find((entry) => entry.split(':')[1] === '/app/.next') expect(mount, 'no volume mounted at /app/.next').toBeDefined() const source = mount!.split(':')[0] // A bare name, not a host path: a bind mount would need a host directory // owned by uid 1001, which `cap_drop: ALL` cannot fix up at start. expect(source).toMatch(/^[a-z0-9][a-z0-9_.-]*$/) expect(Object.keys(compose.volumes ?? {})).toContain(source) }) it('keeps the hardening the unprivileged entrypoint was built for', () => { expect(app.read_only).toBe(true) expect(app.cap_drop).toEqual(['ALL']) expect(app.mem_limit).toBeDefined() }) }) describe('docker/Dockerfile: the mount point the volume is seeded from', () => { it('creates /app/.next owned by nextjs with mode 750', () => { // Docker seeds a new named volume with the owner and mode of the image // directory it covers; that is what lets the entrypoint, running as // nextjs under cap_drop ALL, write into it. const dockerfile = read('docker/Dockerfile') expect(dockerfile).toMatch(/chown nextjs:nodejs [^\n]*\/app\/\.next(\s|$)/m) expect(dockerfile).toMatch(/chmod 750 \/app\/\.next(\s|$)/m) }) }) /** * The entrypoint hardcodes /app and /opt/gnubok-template, so its populate * section (everything between its "Populate" and "Replace" headers: the part * #3164 changed) is cut out, pointed at a temp root and run with sh. The rest * of the script (busybox sed -i, exec) is exercised by the smoke boot. */ function populateSection(root: string): string { const src = read('docker/docker-entrypoint.sh') const start = src.indexOf('# ─── Populate') const end = src.indexOf('# ─── Replace build-time placeholder') expect(start, 'populate header not found').toBeGreaterThan(-1) expect(end, 'replace header not found').toBeGreaterThan(start) return ( 'set -e\n' + src .slice(start, end) .replaceAll('/opt/gnubok-template', `${root}/opt/gnubok-template`) .replace(/(? { let root = '' function setUp() { root = mkdtempSync(path.join(tmpdir(), 'accounted-entrypoint-')) const template = path.join(root, 'opt/gnubok-template') mkdirSync(path.join(template, '.next/server/chunks'), { recursive: true }) mkdirSync(path.join(template, 'public'), { recursive: true }) writeFileSync(path.join(template, '.next/BUILD_ID'), 'new-build\n') writeFileSync(path.join(template, '.next/server/chunks/new.js'), 'module.exports = 2\n') writeFileSync(path.join(template, 'public/sw.js'), 'self.x = 1\n') mkdirSync(path.join(root, 'app/.next'), { recursive: true }) mkdirSync(path.join(root, 'app/public'), { recursive: true }) } afterEach(() => { if (!root) return // The previous start's copy is write-protected on purpose; give the // directories their write bit back so the temp tree can be removed. try { execFileSync('chmod', ['-R', 'u+w', root]) } catch { // best effort } rmSync(root, { recursive: true, force: true }) root = '' }) it("replaces the previous start's write-protected copy instead of layering onto it", () => { setUp() // What a volume holds after a start of an older image: its chunks, with // the write bits the immutability step removed, directories included. const stale = path.join(root, 'app/.next/server/chunks/old-release.js') mkdirSync(path.dirname(stale), { recursive: true }) writeFileSync(stale, 'module.exports = 1\n') writeFileSync(path.join(root, 'app/.next/BUILD_ID'), 'old-build\n') chmodSync(stale, 0o444) chmodSync(path.join(root, 'app/.next/BUILD_ID'), 0o444) chmodSync(path.join(root, 'app/.next/server/chunks'), 0o555) chmodSync(path.join(root, 'app/.next/server'), 0o555) const result = runPopulate(root) expect(result.stderr).toBe('') expect(result.status).toBe(0) expect(existsSync(stale)).toBe(false) expect(readFileSync(path.join(root, 'app/.next/BUILD_ID'), 'utf8')).toBe('new-build\n') expect(existsSync(path.join(root, 'app/.next/server/chunks/new.js'))).toBe(true) expect(existsSync(path.join(root, 'app/.next/cache'))).toBe(true) expect(existsSync(path.join(root, 'app/public/sw.js'))).toBe(true) }) it('sets the mount roots to mode 750 even when an older image created the volume at 755', () => { setUp() // Docker copies the image directory's mode into a named volume only at // creation, and images before #3164 had /app/.next at 755. chmodSync(path.join(root, 'app/.next'), 0o755) chmodSync(path.join(root, 'app/public'), 0o755) const result = runPopulate(root) expect(result.stderr).toBe('') expect(result.status).toBe(0) expect(statSync(path.join(root, 'app/.next')).mode & 0o777).toBe(0o750) expect(statSync(path.join(root, 'app/public')).mode & 0o777).toBe(0o750) }) it('stops with one readable error, naming the fix, when /app/.next cannot hold the bundle', () => { setUp() // The target reports 1 KB free, as a too-small tmpfs from an old compose // file effectively does once the bundle outgrows it. const stubs = path.join(root, 'stubs') mkdirSync(stubs) const df = path.join(stubs, 'df') writeFileSync( df, '#!/bin/sh\n' + 'echo "Filesystem 1024-blocks Used Available Capacity Mounted on"\n' + 'echo "tmpfs 400 399 1 100% /app/.next"\n', ) chmodSync(df, 0o755) const result = runPopulate(root, stubs) expect(result.status).toBe(1) expect(result.stderr).toContain('ERROR: ') expect(result.stderr).toContain('/app/.next has 0 MB free') // The message names the volume the compose file actually declares. const volume = (app.volumes ?? []).find((entry) => entry.split(':')[1] === '/app/.next')! expect(result.stderr).toContain(`${volume.split(':')[0]} volume`) // Refused before copying: no per-file write errors, nothing half-copied. expect(result.stderr).not.toContain('cp:') expect(existsSync(path.join(root, 'app/.next/BUILD_ID'))).toBe(false) }) }) describe('docker-publish.yml: the boot smoke test gates `latest`', () => { type Step = { name?: string; run?: string; uses?: string; id?: string } const workflow = yaml.load(read('.github/workflows/docker-publish.yml')) as { jobs: Record } it('boots every per-platform build with smoke-boot.sh before merge tags it', () => { const steps = workflow.jobs.build.steps const buildIndex = steps.findIndex((step) => step.id === 'build') const smokeIndex = steps.findIndex((step) => (step.run ?? '').includes('scripts/self-host/smoke-boot.sh')) expect(buildIndex).toBeGreaterThan(-1) expect(smokeIndex).toBeGreaterThan(buildIndex) // `merge` is the only job that applies tags, and it waits for `build`. expect(workflow.jobs.merge.needs).toBe('build') }) it('smoke-boot.sh parses and boots the repo compose file, not hand-copied flags', () => { const script = path.join(ROOT, 'scripts/self-host/smoke-boot.sh') expect(() => execFileSync('bash', ['-n', script])).not.toThrow() const src = readFileSync(script, 'utf8') expect(src).toContain('set -euo pipefail') expect(src).toContain('"$REPO_ROOT/docker-compose.yml"') // A restart re-runs the entrypoint over a populated, write-protected // volume: the path every restart and upgrade takes. expect(src).toMatch(/compose restart app/) }) })