142 lines
10 KiB
TypeScript
142 lines
10 KiB
TypeScript
import { lstat, mkdtemp, mkdir, readFile, readlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { describe, expect, test } from 'vitest';
|
|
|
|
import { buildReleasePackage } from '../../scripts/build-release.mjs';
|
|
import { activateRelease, cleanupOrphanStages, prepareRelease, readControlState, rollbackRelease } from '../../scripts/deploy/lib/release-transaction.mjs';
|
|
import { createReleaseFixture, initializeReleaseRoot } from './release-test-fixture.ts';
|
|
|
|
type Fixture = Awaited<ReturnType<typeof createReleaseFixture>>;
|
|
|
|
function buildInput(fixture: Fixture, outputPath: string) {
|
|
return {
|
|
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
|
|
verifierModulePath: fixture.verifierModulePath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
|
trustPolicyPath: fixture.trustPolicyPath, protectedState: fixture.protectedState,
|
|
now: '2026-09-02T01:00:00Z', outputPath,
|
|
};
|
|
}
|
|
|
|
function transactionInput(root: string, archivePath: string, transactionId: string, fixture: Fixture) {
|
|
return {
|
|
root, archivePath, transactionId, verifierModulePath: fixture.verifierModulePath,
|
|
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, trustPolicyPath: fixture.trustPolicyPath,
|
|
uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, now: '2026-09-02T01:00:00Z',
|
|
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
|
|
await readFile(join(releaseDir, 'index.html'));
|
|
return Buffer.from('internal-nginx-smoke-ok\n');
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('fenced atomic Nginx release transaction', () => {
|
|
test('prepares immutably without changing current, then activates with one atomic pointer swap', async () => {
|
|
const temp = await mkdtemp(join(tmpdir(), 'portfolio-atomic-'));
|
|
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
|
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
|
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
|
await prepareRelease(transactionInput(root, archive, 'tx-a', fixture));
|
|
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
|
await activateRelease({ ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
|
|
const target = await readlink(join(root, 'current'));
|
|
expect(target).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
|
expect((await lstat(join(root, target, 'index.html'))).mode & 0o777).toBe(0o444);
|
|
expect((await lstat(join(root, target))).mode & 0o777).toBe(0o555);
|
|
});
|
|
|
|
test('rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer', async () => {
|
|
const temp = await mkdtemp(join(tmpdir(), 'portfolio-fence-'));
|
|
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
|
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
|
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
|
const input = transactionInput(root, archive, 'tx-a', fixture);
|
|
await prepareRelease(input);
|
|
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('fence');
|
|
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
|
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 1, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('predecessor');
|
|
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: fixture.expiresAt })).rejects.toThrow('expired');
|
|
const attempts = await Promise.allSettled([
|
|
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
|
|
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
|
|
]);
|
|
expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
|
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
|
});
|
|
|
|
test('rechecks trusted time after smoke so an expiry crossing never becomes ready', async () => {
|
|
const temp = await mkdtemp(join(tmpdir(), 'portfolio-expiry-crossing-'));
|
|
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
|
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
|
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
|
const times = ['2026-09-02T01:00:00Z', fixture.expiresAt];
|
|
await expect(prepareRelease({ ...transactionInput(root, archive, 'tx-a', fixture), clock: () => times.shift() ?? fixture.expiresAt })).rejects.toThrow('expired while staging');
|
|
expect((await readControlState(root)).activeTransaction).toBeNull();
|
|
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
|
});
|
|
|
|
test('a crash before swap leaves the predecessor visible and retry after a post-swap crash converges on the committed release', async () => {
|
|
const temp = await mkdtemp(join(tmpdir(), 'portfolio-crash-'));
|
|
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
|
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
|
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
|
const input = { ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' };
|
|
await prepareRelease(input);
|
|
await expect(activateRelease({ ...input, crashPoint: 'before-swap' })).rejects.toThrow('before pointer');
|
|
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
|
await expect(activateRelease({ ...input, crashPoint: 'after-swap' })).rejects.toThrow('after pointer');
|
|
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
|
const recovered = await activateRelease(input);
|
|
expect(recovered.recoveredAfterPointerSwap).toBe(true);
|
|
expect((await readControlState(root)).currentManifestDigest).toBe(fixture.manifestDigest);
|
|
});
|
|
|
|
test('rollback requires a fresh signed manifest and never replays the selected old manifest', async () => {
|
|
const temp = await mkdtemp(join(tmpdir(), 'portfolio-rollback-'));
|
|
const fixtureA = await createReleaseFixture(join(temp, 'fixture-a'));
|
|
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
|
const archiveA = join(temp, 'a.tar'); await buildReleasePackage(buildInput(fixtureA, archiveA));
|
|
const inputA = transactionInput(root, archiveA, 'tx-a', fixtureA);
|
|
await prepareRelease(inputA);
|
|
await activateRelease({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
|
|
const releaseA = (await readlink(join(root, 'current'))).split('/')[1] as string;
|
|
|
|
const fixtureB = await createReleaseFixture(join(temp, 'fixture-b'));
|
|
fixtureB.protectedState.currentManifestDigest = fixtureA.manifestDigest; fixtureB.protectedState.publisherFence = 1;
|
|
await writeFile(join(fixtureB.distDir, 'index.html'), '<!doctype html><title>Portfolio B</title>\n');
|
|
const signedB = await fixtureB.signFor({ buildId: 'release-b', distDir: fixtureB.distDir, predecessorManifestDigest: fixtureA.manifestDigest, publisherFence: 1, outputPath: fixtureB.signedManifestPath });
|
|
const archiveB = join(temp, 'b.tar'); await buildReleasePackage({ ...buildInput(fixtureB, archiveB), now: '2026-09-02T02:00:00Z' });
|
|
const inputB = { ...transactionInput(root, archiveB, 'tx-b', fixtureB), now: '2026-09-02T02:00:00Z' };
|
|
await prepareRelease(inputB);
|
|
await activateRelease({ ...inputB, expectedCurrentManifestDigest: fixtureA.manifestDigest, expectedFence: 2, now: '2026-09-02T02:01:00Z' });
|
|
|
|
await expect(rollbackRelease({ ...inputA, targetReleaseId: releaseA, transactionId: 'tx-replay', expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' })).rejects.toThrow();
|
|
|
|
fixtureA.protectedState.currentManifestDigest = signedB.manifestDigest; fixtureA.protectedState.publisherFence = 2;
|
|
const freshSigned = join(temp, 'rollback-signed.json');
|
|
const fresh = await fixtureA.signFor({ buildId: 'rollback-a', distDir: fixtureA.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned });
|
|
fixtureA.signedManifestPath = freshSigned;
|
|
const rollbackArchive = join(temp, 'rollback.tar'); await buildReleasePackage({ ...buildInput(fixtureA, rollbackArchive), now: '2026-09-02T03:00:00Z' });
|
|
const rollback = await rollbackRelease({ ...transactionInput(root, rollbackArchive, 'tx-rollback', fixtureA), targetReleaseId: releaseA,
|
|
expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' });
|
|
expect(rollback.manifestDigest).toBe(fresh.manifestDigest);
|
|
expect(await readlink(join(root, 'current'))).toContain('rollback-a-');
|
|
});
|
|
|
|
test('orphan cleanup and all mutations remain confined to a validated explicit release root', async () => {
|
|
const temp = await mkdtemp(join(tmpdir(), 'portfolio-cleanup-'));
|
|
const outside = join(temp, 'outside.txt'); await writeFile(outside, 'keep');
|
|
const invalidRoot = join(temp, 'not-a-release-root'); await mkdir(join(invalidRoot, 'staging', 'orphan.partial'), { recursive: true });
|
|
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow('sentinel');
|
|
expect(await readFile(outside, 'utf8')).toBe('keep');
|
|
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
|
await mkdir(join(root, 'staging', 'orphan.partial'), { recursive: true });
|
|
await writeFile(join(root, 'staging', 'orphan.partial', 'marker'), 'orphan');
|
|
expect(await cleanupOrphanStages(root)).toEqual(['orphan.partial']);
|
|
await expect(lstat(join(root, 'staging', 'orphan.partial'))).rejects.toThrow();
|
|
expect(await readFile(outside, 'utf8')).toBe('keep');
|
|
await expect(cleanupOrphanStages('/')).rejects.toThrow('Broad');
|
|
});
|
|
});
|