import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse } from 'yaml'; import { generateSharedInfraYaml, imageTag } from './docker-compose-generator'; import type { DockerReader } from './live-stack'; import { RESTAGED_SIMS, recreateOnRebuiltImage, runsSupersededImage } from './shared-infra'; /** * A container keeps the image it was created from. `cele2e build-infra` leaves * the shared stack up on success, so a second bake rebuilt the registry sim's * image, found the stack "already running", and installed the previous build's * CLI from the container still running the old image. The bake's own * staged-vs-installed guard then refused it (celilo#1502). * * The fake models the one fact that matters: a container has an image id, a tag * has an image id, and only a recreate makes the first follow the second. */ const OLD = 'sha256:e36504c2b1794d93375c3d758d053711f1bd47ff615da3f92415eecaa8b4e861'; const NEW = 'sha256:2e878b8edcd7fcf8457afe72bc5316fbcb5eac5b7a5a0c4678973b12119593d4'; interface Host { /** service -> image id its running container was created from; absent = not running. */ containers: Record; /** image tag -> image id the tag points at now. */ tags: Record; /** Live containers outside the shared project. */ runOwned?: string[]; /** A recreate that does not take: the container keeps its old image. */ recreateDoesNothing?: boolean; } function fakeDocker(host: Host): { docker: DockerReader; commands: string[] } { const commands: string[] = []; const idOf = (service: string) => `cid-${service}`; const docker: DockerReader = (args) => { const line = args.join(' '); commands.push(line); if (args[0] === 'compose' && args.includes('ps')) { const service = args[args.length - 1] as string; return service in host.containers ? idOf(service) : ''; } if (args[0] === 'inspect') { const service = (args[args.length - 1] as string).replace('cid-', ''); return host.containers[service] ?? ''; } if (args[0] === 'image') { const id = host.tags[args[args.length - 1] as string]; if (!id) throw new Error('No such image'); return id; } if (args[0] === 'ps') { const shared = Object.keys(host.containers).map((s) => `celilo-e2e-shared-${s}-1\trunning`); const owned = (host.runOwned ?? []).map((name) => `${name}\trunning`); return [...shared, ...owned].join('\n'); } if (args[0] === 'compose' && args.includes('--force-recreate')) { if (host.recreateDoesNothing) return ''; for (const service of args.slice(args.indexOf('--force-recreate') + 1)) { const sim = RESTAGED_SIMS.find((s) => s.service === service); if (sim) host.containers[service] = host.tags[imageTag(sim.dockerfile)] as string; } return ''; } return ''; }; return { docker, commands }; } const REGISTRY = imageTag('docker/Dockerfile.npm-registry-sim'); const WEBSITE = imageTag('docker/Dockerfile.celilo-website-sim'); const recreates = (commands: string[]) => commands.filter((c) => c.includes('--force-recreate')); const quiet = () => {}; describe('runsSupersededImage', () => { test('a container on a different image from its tag is superseded', () => { expect(runsSupersededImage(OLD, NEW)).toBe(true); }); test('a container on the image its tag points at is current', () => { expect(runsSupersededImage(NEW, NEW)).toBe(false); }); test('an id that could not be read is not evidence of a rebuild', () => { // Recreating on no evidence would restart the sims on every bake. expect(runsSupersededImage('', NEW)).toBe(false); expect(runsSupersededImage(OLD, '')).toBe(false); }); }); describe('recreateOnRebuiltImage', () => { test('the second bake: the registry sim was rebuilt under a running stack, so it is recreated', () => { const host: Host = { containers: { 'npm-registry': OLD, 'celilo-website': NEW }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, }; const { docker, commands } = fakeDocker(host); expect(recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toEqual(['npm-registry']); // Only the service that fell behind. The website sim holds a Pebble cert and // a needless recreate costs an ACME issuance on every bake. expect(recreates(commands)).toHaveLength(1); expect(recreates(commands)[0]).toEndWith('up -d --force-recreate npm-registry'); expect(host.containers['npm-registry']).toBe(NEW); }); test('it says which image was running and which replaced it', () => { // The issue could only infer the cause: nobody had read the container's // image id before tearing down. The bake now prints both. const lines: string[] = []; const { docker } = fakeDocker({ containers: { 'npm-registry': OLD, 'celilo-website': NEW }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, }); recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', (line) => lines.push(line)); const said = lines.join('\n'); expect(said).toContain('npm-registry'); expect(said).toContain(OLD.slice(0, 19)); expect(said).toContain(NEW.slice(0, 19)); }); test('both sims rebuilt: one recreate, naming both', () => { const { docker, commands } = fakeDocker({ containers: { 'npm-registry': OLD, 'celilo-website': OLD }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, }); expect(recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toEqual([ 'celilo-website', 'npm-registry', ]); expect(recreates(commands)).toHaveLength(1); }); test('the first bake: nothing was rebuilt under the stack, so nothing is recreated', () => { const { docker, commands } = fakeDocker({ containers: { 'npm-registry': NEW, 'celilo-website': NEW }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, }); expect(recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toEqual([]); expect(recreates(commands)).toEqual([]); }); test('a sim that is not running is left for `up` to create', () => { const { docker, commands } = fakeDocker({ containers: {}, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, }); expect(recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toEqual([]); expect(recreates(commands)).toEqual([]); }); test('a per-test stack is live: refuse before the bake, and remove nothing', () => { // Recreating the registry under a running suite would pull it out from // under that suite. The bake cannot proceed either: it would install the // old CLI and be refused 70 seconds later. So it stops here and says why. const { docker, commands } = fakeDocker({ containers: { 'npm-registry': OLD, 'celilo-website': NEW }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, runOwned: ['celilo-e2e-abc123-management-1'], }); expect(() => recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toThrow( /celilo-e2e-abc123-management-1[\s\S]*cele2e down/, ); expect(recreates(commands)).toEqual([]); }); test('a live per-test stack is no obstacle when nothing needs recreating', () => { const { docker } = fakeDocker({ containers: { 'npm-registry': NEW, 'celilo-website': NEW }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, runOwned: ['celilo-e2e-abc123-management-1'], }); expect(recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toEqual([]); }); test('a recreate that did not take is an error, not a line of output', () => { const { docker } = fakeDocker({ containers: { 'npm-registry': OLD, 'celilo-website': NEW }, tags: { [REGISTRY]: NEW, [WEBSITE]: NEW }, recreateDoesNothing: true, }); expect(() => recreateOnRebuiltImage(RESTAGED_SIMS, docker, '/e2e', quiet)).toThrow( /npm-registry[\s\S]*still/, ); }); }); describe('the sims the bake restages', () => { // The recreate looks services up by name. A service renamed in the generator // would make it find no container and do nothing, which is the bug again. test('each is a shared-infra service running the image its Dockerfile builds', () => { const shared = parse(generateSharedInfraYaml()) as { services: Record; }; for (const sim of RESTAGED_SIMS) { expect(shared.services[sim.service]?.image).toStartWith(imageTag(sim.dockerfile)); } expect(RESTAGED_SIMS.map((s) => s.service).sort()).toEqual(['celilo-website', 'npm-registry']); }); }); describe('the bake orders its steps so the sims cannot fall behind', () => { // Same shape as the bake-probe tests: the bin script has no import surface, // so this reads the real source. const src = readFileSync(join(import.meta.dir, '..', 'bin', 'e2e-bake-management'), 'utf-8'); const body = /async function bakeViaSim\(\): Promise \{[\s\S]*?\n\}\n/.exec(src)?.[0] ?? ''; const at = (needle: string) => body.indexOf(needle); test('it rebuilds the sim images before it starts shared infra', () => { expect(at('restageSimulatorInputs();')).toBeGreaterThan(-1); expect(at('restageSimulatorInputs();')).toBeLessThan(at('await ensureSharedInfra();')); }); test('then recreates a sim left running on the image it just replaced, before the per-test stack', () => { expect(at('recreateOnRebuiltImage(')).toBeGreaterThan(at('await ensureSharedInfra();')); expect(at('recreateOnRebuiltImage(')).toBeLessThan(at('network().topology(')); }); test('the rebuild loop and the recreate read one list', () => { expect(src).toContain('for (const { dockerfile } of RESTAGED_SIMS)'); }); });