feat: package atomic reversible nginx releases
This commit is contained in:
17
scripts/build-release.d.mts
Normal file
17
scripts/build-release.d.mts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface BuildReleaseInput {
|
||||
distDir: string;
|
||||
signedManifestPath: string;
|
||||
sbomPath: string;
|
||||
outputPath: string;
|
||||
verifierModulePath: string;
|
||||
trustPolicyPath: string;
|
||||
protectedState: Record<string, unknown>;
|
||||
now: string;
|
||||
verifySignedReleaseEnvelope?: (input: any) => any;
|
||||
}
|
||||
|
||||
export function buildReleasePackage(input: BuildReleaseInput): Promise<{
|
||||
releaseId: string;
|
||||
manifestDigest: string;
|
||||
archivePath: string;
|
||||
}>;
|
||||
88
scripts/build-release.mjs
Executable file
88
scripts/build-release.mjs
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { canonicalValueBytes } from '../src/security/canonical-digest.ts';
|
||||
import {
|
||||
createDeterministicTar, enumerateRegularFiles, packageIndexFor, parseDeterministicTar, validatePackageFiles,
|
||||
verifySignedRelease, writeArchiveFiles,
|
||||
} from './deploy/lib/release-common.mjs';
|
||||
|
||||
function parseArgs(args) {
|
||||
const allowed = new Set(['dist', 'signed-manifest', 'sbom', 'output', 'verifier-module', 'trust-policy', 'protected-state', 'now']);
|
||||
const values = {};
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const token = args[index];
|
||||
const value = args[index + 1];
|
||||
if (typeof token !== 'string' || !token.startsWith('--') || typeof value !== 'string' || value.length === 0) throw new Error('Release package arguments must be complete name/value pairs.');
|
||||
const name = token.slice(2);
|
||||
if (!allowed.has(name) || Object.hasOwn(values, name)) throw new Error(`Unknown or duplicate release package argument: ${token}`);
|
||||
values[name] = value;
|
||||
}
|
||||
if ([...allowed].some((name) => !Object.hasOwn(values, name))) throw new Error('Release package arguments are incomplete.');
|
||||
let protectedState;
|
||||
try { protectedState = JSON.parse(values['protected-state']); } catch { throw new Error('Protected state must be valid JSON.'); }
|
||||
return {
|
||||
distDir: resolve(values.dist), signedManifestPath: resolve(values['signed-manifest']), sbomPath: resolve(values.sbom),
|
||||
outputPath: resolve(values.output), verifierModulePath: resolve(values['verifier-module']), trustPolicyPath: resolve(values['trust-policy']),
|
||||
protectedState, now: values.now,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildReleasePackage(input) {
|
||||
const outputInfo = await lstat(input.outputPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
|
||||
if (outputInfo !== null) throw new Error('Release archive output already exists.');
|
||||
const verified = await verifySignedRelease(input);
|
||||
const { admission, manifest, signedReleaseBytes } = verified;
|
||||
const sourceFiles = await enumerateRegularFiles(input.distDir);
|
||||
const actualPaths = sourceFiles.map((file) => file.publicPath);
|
||||
const expectedPaths = [...manifest.deployFiles].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
||||
if (actualPaths.length !== expectedPaths.length || actualPaths.some((path, index) => path !== expectedPaths[index])) {
|
||||
throw new Error('dist files must exactly match the signed deploy closure.');
|
||||
}
|
||||
const sourceByPath = new Map(sourceFiles.map((file) => [file.publicPath, file.path]));
|
||||
const artifactFiles = [];
|
||||
for (const artifact of manifest.artifacts) {
|
||||
if (artifact.path.startsWith('.release/')) throw new Error('Signed deploy closure uses the reserved .release path.');
|
||||
const source = sourceByPath.get(artifact.path);
|
||||
if (source === undefined) throw new Error(`Signed artifact is missing: ${artifact.path}`);
|
||||
const bytes = await readFile(source);
|
||||
const digest = (await import('../src/security/canonical-digest.ts')).byteDigest(bytes);
|
||||
if (digest !== artifact.sha256 || bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
|
||||
artifactFiles.push({ path: artifact.path, bytes });
|
||||
}
|
||||
const sbomBytes = await readFile(input.sbomPath);
|
||||
const index = packageIndexFor({ admission, manifest, signedBytes: signedReleaseBytes, sbomBytes });
|
||||
const archiveBytes = createDeterministicTar([
|
||||
...artifactFiles,
|
||||
{ path: '.release/package-index.json', bytes: canonicalValueBytes(index) },
|
||||
{ path: '.release/sbom.cdx.json', bytes: sbomBytes },
|
||||
{ path: '.release/signed-release-manifest.json', bytes: signedReleaseBytes },
|
||||
]);
|
||||
const temporary = `${input.outputPath}.partial-${process.pid}`;
|
||||
await mkdir(dirname(input.outputPath), { recursive: true });
|
||||
await writeFile(temporary, archiveBytes, { flag: 'wx', mode: 0o400 });
|
||||
const verifyRoot = `${input.outputPath}.verify-${process.pid}`;
|
||||
try {
|
||||
const parsed = parseDeterministicTar(await readFile(temporary));
|
||||
validatePackageFiles(parsed);
|
||||
await writeArchiveFiles(parsed, verifyRoot);
|
||||
const extracted = await enumerateRegularFiles(verifyRoot);
|
||||
if (extracted.length !== parsed.length) throw new Error('Extracted release archive closure mismatch.');
|
||||
await rename(temporary, input.outputPath);
|
||||
} finally {
|
||||
await rm(verifyRoot, { recursive: true, force: true });
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
return { releaseId: index.releaseId, manifestDigest: admission.manifestDigest, archivePath: input.outputPath };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await buildReleasePackage(parseArgs(process.argv.slice(2)));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => { process.stderr.write(`Release packaging failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
}
|
||||
15
scripts/deploy/activate-release.mjs
Executable file
15
scripts/deploy/activate-release.mjs
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { activateRelease } from './lib/release-transaction.mjs';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 7 || args.length > 8) throw new Error('Usage: activate-release.mjs <root> <transaction-id> <expected-current|none> <expected-fence> <verifier-module> <trust-policy> <trusted-now> [test-crash-point]');
|
||||
const [root, transactionId, expectedCurrentManifestDigest, expectedFence, verifierModulePath, trustPolicyPath, now, crashPoint] = args;
|
||||
if (crashPoint !== undefined && process.env.PORTFOLIO_DEPLOY_TEST_HOOKS !== '1') throw new Error('Crash hooks are disabled outside the isolated test harness.');
|
||||
const receipt = await activateRelease({ root: resolve(root), transactionId, expectedCurrentManifestDigest, expectedFence: Number(expectedFence),
|
||||
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), now, crashPoint });
|
||||
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release activation failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
15
scripts/deploy/activate-release.sh
Executable file
15
scripts/deploy/activate-release.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo 'Usage: activate-release.sh <dedicated-root> ...' >&2
|
||||
exit 64
|
||||
fi
|
||||
release_root=$1
|
||||
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
|
||||
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
|
||||
exit 65
|
||||
fi
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
exec node "$script_dir/activate-release.mjs" "$@"
|
||||
7
scripts/deploy/lib/release-common.d.mts
Normal file
7
scripts/deploy/lib/release-common.d.mts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ArchiveFile {
|
||||
path: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer;
|
||||
export function parseDeterministicTar(source: Uint8Array): ArchiveFile[];
|
||||
343
scripts/deploy/lib/release-common.mjs
Normal file
343
scripts/deploy/lib/release-common.mjs
Normal file
@@ -0,0 +1,343 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { byteDigest, canonicalJsonDigest, canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../../src/security/canonical-digest.ts';
|
||||
import { validatePublicPath } from '../../../src/security/production-input-root.ts';
|
||||
import { validateReleaseManifest } from '../../../src/security/release-manifest.ts';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const reservedPrefix = '.release/';
|
||||
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
||||
const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||
|
||||
export function exactObject(value, fields, label) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...fields].sort();
|
||||
if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) {
|
||||
throw new Error(`${label} contains missing or unknown fields.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertToken(value, label) {
|
||||
if (typeof value !== 'string' || !safeToken.test(value)) throw new Error(`${label} is invalid.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertTimestamp(value, label) {
|
||||
if (typeof value !== 'string' || !timestamp.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} is invalid.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertDigest(value, label) {
|
||||
if (!isSha256Hex(value)) throw new Error(`${label} is invalid.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function readCanonicalFile(path, label) {
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular file.`);
|
||||
return parseCanonicalJson(await readFile(path));
|
||||
}
|
||||
|
||||
export async function loadVerifier(modulePath) {
|
||||
if (!isAbsolute(modulePath)) throw new Error('Verifier module path must be absolute.');
|
||||
const info = await lstat(modulePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Verifier module must be a regular file.');
|
||||
const module = await import(/* @vite-ignore */ pathToFileURL(await realpath(modulePath)).href);
|
||||
if (typeof module.verifySignedReleaseEnvelope !== 'function') throw new Error('Verifier module does not expose verifySignedReleaseEnvelope.');
|
||||
return module.verifySignedReleaseEnvelope;
|
||||
}
|
||||
|
||||
export function validateProtectedState(value) {
|
||||
const state = exactObject(value, ['latestLedgerHeadHash', 'latestLedgerRevision', 'currentManifestDigest', 'publisherFence'], 'protected state');
|
||||
assertDigest(state.latestLedgerHeadHash, 'protected ledger head');
|
||||
if (!Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0 ||
|
||||
!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 ||
|
||||
(state.currentManifestDigest !== null && !isSha256Hex(state.currentManifestDigest))) throw new Error('Protected state is invalid.');
|
||||
return state;
|
||||
}
|
||||
|
||||
export function validateAdmission(value, protectedState, now) {
|
||||
const admission = exactObject(value, [
|
||||
'schema', 'manifestDigest', 'manifestPayload', 'expiresAt', 'predecessorManifestDigest', 'latestLedgerHeadHash',
|
||||
'latestLedgerRevision', 'expectedFence', 'signatureVerified',
|
||||
], 'verified release admission');
|
||||
if (admission.schema !== 'portfolio-verified-release-admission/v1' || admission.signatureVerified !== true) {
|
||||
throw new Error('Release signature was not verified.');
|
||||
}
|
||||
assertDigest(admission.manifestDigest, 'manifest digest');
|
||||
assertTimestamp(now, 'trusted time');
|
||||
assertTimestamp(admission.expiresAt, 'admission expiry');
|
||||
if (Date.parse(now) >= Date.parse(admission.expiresAt)) throw new Error('Release admission is expired.');
|
||||
if (admission.predecessorManifestDigest !== null) assertDigest(admission.predecessorManifestDigest, 'predecessor manifest digest');
|
||||
const state = validateProtectedState(protectedState);
|
||||
if (admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || admission.latestLedgerRevision !== state.latestLedgerRevision) {
|
||||
throw new Error('Release admission is stale relative to the protected ledger.');
|
||||
}
|
||||
if (admission.predecessorManifestDigest !== state.currentManifestDigest || admission.expectedFence !== state.publisherFence + 1) {
|
||||
throw new Error('Release admission predecessor or fence is stale.');
|
||||
}
|
||||
const manifest = validateReleaseManifest(admission.manifestPayload, { now });
|
||||
if (manifest.expiresAt !== admission.expiresAt || manifest.currentManifestDigest !== admission.predecessorManifestDigest ||
|
||||
manifest.predecessorManifestDigest !== admission.predecessorManifestDigest) throw new Error('Admission does not bind the embedded release manifest.');
|
||||
return { admission, manifest };
|
||||
}
|
||||
|
||||
export async function verifySignedRelease({ signedManifestPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState, now }) {
|
||||
const [signedRelease, trustPolicy] = await Promise.all([
|
||||
readCanonicalFile(signedManifestPath, 'signed release'), readCanonicalFile(trustPolicyPath, 'trust policy'),
|
||||
]);
|
||||
const verifier = verifySignedReleaseEnvelope ?? await loadVerifier(verifierModulePath);
|
||||
if (typeof verifier !== 'function') throw new Error('A release signature verifier is required.');
|
||||
const result = await verifier({ signedRelease, trustPolicy, now, protectedState });
|
||||
const verified = validateAdmission(result, protectedState, now);
|
||||
return { ...verified, signedRelease, signedReleaseBytes: canonicalValueBytes(signedRelease) };
|
||||
}
|
||||
|
||||
export async function enumerateRegularFiles(root) {
|
||||
const rootPath = await realpath(root);
|
||||
const output = [];
|
||||
async function visit(directory, prefix = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
|
||||
for (const entry of entries) {
|
||||
const path = join(directory, entry.name);
|
||||
const publicPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
||||
if (entry.isSymbolicLink()) throw new Error(`Symlink is forbidden in release closure: ${publicPath}`);
|
||||
if (entry.isDirectory()) await visit(path, publicPath);
|
||||
else if (entry.isFile()) output.push({ path, publicPath });
|
||||
else throw new Error(`Non-regular release input is forbidden: ${publicPath}`);
|
||||
}
|
||||
}
|
||||
await visit(rootPath);
|
||||
return output;
|
||||
}
|
||||
|
||||
function splitTarPath(path) {
|
||||
const bytes = encoder.encode(path);
|
||||
if (bytes.length <= 100) return { name: path, prefix: '' };
|
||||
const segments = path.split('/');
|
||||
for (let index = segments.length - 1; index > 0; index -= 1) {
|
||||
const prefix = segments.slice(0, index).join('/');
|
||||
const name = segments.slice(index).join('/');
|
||||
if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix };
|
||||
}
|
||||
throw new Error(`Release path is too long for deterministic ustar: ${path}`);
|
||||
}
|
||||
|
||||
function writeString(target, offset, length, value) {
|
||||
const bytes = encoder.encode(value);
|
||||
if (bytes.length > length) throw new Error('Tar header value exceeds its field.');
|
||||
target.set(bytes, offset);
|
||||
}
|
||||
|
||||
function writeOctal(target, offset, length, value) {
|
||||
const text = value.toString(8).padStart(length - 1, '0');
|
||||
if (text.length !== length - 1) throw new Error('Tar numeric field overflow.');
|
||||
writeString(target, offset, length, `${text}\0`);
|
||||
}
|
||||
|
||||
function tarHeader(path, size) {
|
||||
const header = new Uint8Array(512);
|
||||
const { name, prefix } = splitTarPath(path);
|
||||
writeString(header, 0, 100, name);
|
||||
writeOctal(header, 100, 8, 0o444);
|
||||
writeOctal(header, 108, 8, 0);
|
||||
writeOctal(header, 116, 8, 0);
|
||||
writeOctal(header, 124, 12, size);
|
||||
writeOctal(header, 136, 12, 0);
|
||||
header.fill(0x20, 148, 156);
|
||||
writeString(header, 156, 1, '0');
|
||||
writeString(header, 257, 6, 'ustar\0');
|
||||
writeString(header, 263, 2, '00');
|
||||
writeString(header, 345, 155, prefix);
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
const checksumText = checksum.toString(8).padStart(6, '0');
|
||||
writeString(header, 148, 8, `${checksumText}\0 `);
|
||||
return header;
|
||||
}
|
||||
|
||||
export function createDeterministicTar(files) {
|
||||
const ordered = [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
|
||||
const seen = new Set();
|
||||
const chunks = [];
|
||||
for (const file of ordered) {
|
||||
validatePublicPath(file.path);
|
||||
if (seen.has(file.path)) throw new Error(`Duplicate release archive path: ${file.path}`);
|
||||
seen.add(file.path);
|
||||
const bytes = new Uint8Array(file.bytes);
|
||||
chunks.push(tarHeader(file.path, bytes.length), bytes);
|
||||
const remainder = bytes.length % 512;
|
||||
if (remainder !== 0) chunks.push(new Uint8Array(512 - remainder));
|
||||
}
|
||||
chunks.push(new Uint8Array(1024));
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
}
|
||||
|
||||
function readString(bytes, start, length) {
|
||||
const end = bytes.indexOf(0, start);
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start, end === -1 || end > start + length ? start + length : end));
|
||||
}
|
||||
|
||||
function readOctal(bytes, start, length, label) {
|
||||
const text = readString(bytes, start, length).trim();
|
||||
if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`);
|
||||
const value = Number.parseInt(text, 8);
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid tar ${label}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseDeterministicTar(source) {
|
||||
const bytes = new Uint8Array(source);
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
let offset = 0;
|
||||
let zeroBlocks = 0;
|
||||
while (offset + 512 <= bytes.length) {
|
||||
const header = bytes.subarray(offset, offset + 512);
|
||||
offset += 512;
|
||||
if (header.every((byte) => byte === 0)) {
|
||||
zeroBlocks += 1;
|
||||
if (zeroBlocks === 2) break;
|
||||
continue;
|
||||
}
|
||||
if (zeroBlocks !== 0) throw new Error('Malformed tar end marker.');
|
||||
const expectedChecksum = readOctal(header, 148, 8, 'checksum');
|
||||
const checksumHeader = new Uint8Array(header);
|
||||
checksumHeader.fill(0x20, 148, 156);
|
||||
if (checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum) throw new Error('Tar checksum mismatch.');
|
||||
if (readString(header, 257, 6) !== 'ustar' || String.fromCharCode(header[156] ?? 0) !== '0') throw new Error('Only deterministic regular ustar entries are accepted.');
|
||||
const name = readString(header, 0, 100);
|
||||
const prefix = readString(header, 345, 155);
|
||||
const path = prefix === '' ? name : `${prefix}/${name}`;
|
||||
validatePublicPath(path);
|
||||
if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`);
|
||||
seen.add(path);
|
||||
const size = readOctal(header, 124, 12, 'size');
|
||||
if (offset + size > bytes.length) throw new Error('Truncated tar entry.');
|
||||
files.push({ path, bytes: bytes.slice(offset, offset + size) });
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
if (zeroBlocks !== 2 || offset !== bytes.length) throw new Error('Tar archive has trailing or missing data.');
|
||||
return files;
|
||||
}
|
||||
|
||||
export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) {
|
||||
const artifacts = [...manifest.artifacts].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
|
||||
return {
|
||||
schema: 'portfolio-release-package/v1',
|
||||
buildId: manifest.buildId,
|
||||
releaseId: `${manifest.buildId}-${admission.manifestDigest}`,
|
||||
manifestDigest: admission.manifestDigest,
|
||||
predecessorManifestDigest: admission.predecessorManifestDigest,
|
||||
expiresAt: admission.expiresAt,
|
||||
expectedFence: admission.expectedFence,
|
||||
latestLedgerHeadHash: admission.latestLedgerHeadHash,
|
||||
latestLedgerRevision: admission.latestLedgerRevision,
|
||||
signedManifest: { path: '.release/signed-release-manifest.json', sha256: byteDigest(signedBytes), bytes: signedBytes.length },
|
||||
sbom: { path: '.release/sbom.cdx.json', sha256: byteDigest(sbomBytes), bytes: sbomBytes.length },
|
||||
artifacts,
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePackageFiles(files) {
|
||||
const byPath = new Map(files.map((file) => [file.path, file]));
|
||||
const indexFile = byPath.get('.release/package-index.json');
|
||||
if (indexFile === undefined) throw new Error('Release package index is missing.');
|
||||
const index = exactObject(parseCanonicalJson(indexFile.bytes), [
|
||||
'schema', 'buildId', 'releaseId', 'manifestDigest', 'predecessorManifestDigest', 'expiresAt', 'expectedFence',
|
||||
'latestLedgerHeadHash', 'latestLedgerRevision', 'signedManifest', 'sbom', 'artifacts',
|
||||
], 'release package index');
|
||||
if (index.schema !== 'portfolio-release-package/v1') throw new Error('Release package schema is invalid.');
|
||||
assertToken(index.buildId, 'package buildId');
|
||||
assertToken(index.releaseId, 'package releaseId');
|
||||
assertDigest(index.manifestDigest, 'package manifest digest');
|
||||
assertTimestamp(index.expiresAt, 'package expiry');
|
||||
if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) throw new Error('Package fence is invalid.');
|
||||
const metadata = [index.signedManifest, index.sbom];
|
||||
for (const record of metadata) {
|
||||
exactObject(record, ['path', 'sha256', 'bytes'], 'package metadata record');
|
||||
const file = byPath.get(record.path);
|
||||
if (file === undefined || byteDigest(file.bytes) !== record.sha256 || file.bytes.length !== record.bytes) throw new Error('Package metadata hash or size mismatch.');
|
||||
}
|
||||
if (!Array.isArray(index.artifacts) || index.artifacts.length === 0) throw new Error('Package artifact closure is empty.');
|
||||
const expectedPaths = new Set(['.release/package-index.json', '.release/signed-release-manifest.json', '.release/sbom.cdx.json']);
|
||||
for (const artifact of index.artifacts) {
|
||||
exactObject(artifact, ['path', 'sha256', 'bytes', 'encoding'], 'package artifact');
|
||||
if (typeof artifact.path !== 'string' || artifact.path.startsWith(reservedPrefix)) throw new Error('Package artifact uses a reserved path.');
|
||||
const file = byPath.get(artifact.path);
|
||||
if (file === undefined || byteDigest(file.bytes) !== artifact.sha256 || file.bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
|
||||
expectedPaths.add(artifact.path);
|
||||
}
|
||||
if (expectedPaths.size !== byPath.size || [...byPath.keys()].some((path) => !expectedPaths.has(path))) throw new Error('Release package files do not exactly match the signed closure.');
|
||||
return { index, byPath };
|
||||
}
|
||||
|
||||
export async function writeArchiveFiles(files, targetRoot) {
|
||||
await mkdir(targetRoot, { recursive: false, mode: 0o700 });
|
||||
const root = resolve(targetRoot);
|
||||
for (const file of files) {
|
||||
const destination = resolve(root, file.path);
|
||||
if (relative(root, destination).startsWith(`..${sep}`) || destination === root) throw new Error('Archive entry escapes staging root.');
|
||||
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
||||
await writeFile(destination, file.bytes, { flag: 'wx', mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeImmutable(root, uid, gid) {
|
||||
if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Release owner UID/GID is invalid.');
|
||||
async function visit(path) {
|
||||
const info = await lstat(path);
|
||||
if (info.isSymbolicLink()) throw new Error('Symlink found in immutable release.');
|
||||
if (info.uid !== uid || info.gid !== gid) throw new Error(`Release ownership mismatch at ${path}.`);
|
||||
if (info.isDirectory()) {
|
||||
for (const entry of await readdir(path)) await visit(join(path, entry));
|
||||
await chmod(path, 0o555);
|
||||
} else if (info.isFile()) await chmod(path, 0o444);
|
||||
else throw new Error('Immutable release contains a non-file entry.');
|
||||
}
|
||||
await visit(root);
|
||||
}
|
||||
|
||||
export async function assertReleaseRoot(rootPath) {
|
||||
if (typeof rootPath !== 'string' || !isAbsolute(rootPath)) throw new Error('Release root must be an explicit absolute path.');
|
||||
const normalized = resolve(rootPath);
|
||||
if (normalized === '/' || normalized.split('/').filter(Boolean).length < 2) throw new Error('Broad release root is forbidden.');
|
||||
const info = await lstat(normalized);
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Release root must be a real dedicated directory.');
|
||||
const marker = await readFile(join(normalized, '.portfolio-release-root'), 'utf8').catch((error) => {
|
||||
if (error?.code === 'ENOENT') throw new Error('Release root sentinel is missing or invalid.');
|
||||
throw error;
|
||||
});
|
||||
if (marker !== 'portfolio-release-root/v1\n') throw new Error('Release root sentinel is missing or invalid.');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function atomicCanonicalWrite(path, value, mode = 0o600) {
|
||||
const temporary = `${path}.next-${process.pid}`;
|
||||
const handle = await open(temporary, 'wx', mode);
|
||||
try {
|
||||
await handle.writeFile(canonicalValueBytes(value));
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, path);
|
||||
}
|
||||
|
||||
export async function safeRemoveStage(root, stageName) {
|
||||
assertToken(stageName, 'stage name');
|
||||
const staging = join(root, 'staging');
|
||||
const target = join(staging, stageName);
|
||||
const rel = relative(staging, target);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('Stage cleanup escaped its root.');
|
||||
await rm(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function digestBytes(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
34
scripts/deploy/lib/release-transaction.d.mts
Normal file
34
scripts/deploy/lib/release-transaction.d.mts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface TransactionInput {
|
||||
root: string;
|
||||
archivePath: string;
|
||||
transactionId: string;
|
||||
verifierModulePath: string;
|
||||
trustPolicyPath: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
now?: string;
|
||||
clock?: () => string;
|
||||
verifySignedReleaseEnvelope?: (input: any) => any;
|
||||
smokeCheck?: (input: { releaseDir: string; manifestDigest: string }) => Promise<Uint8Array>;
|
||||
smokeCommand?: string;
|
||||
expectedCurrentManifestDigest?: string | null;
|
||||
expectedFence?: number;
|
||||
crashPoint?: 'before-swap' | 'after-swap';
|
||||
targetReleaseId?: string;
|
||||
}
|
||||
|
||||
export interface ControlState {
|
||||
schema: 'portfolio-release-control-state/v1';
|
||||
currentManifestDigest: string | null;
|
||||
currentReleaseId: string | null;
|
||||
publisherFence: number;
|
||||
latestLedgerHeadHash: string;
|
||||
latestLedgerRevision: number;
|
||||
activeTransaction: null | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function readControlState(root: string): Promise<ControlState>;
|
||||
export function prepareRelease(input: TransactionInput): Promise<Record<string, unknown>>;
|
||||
export function activateRelease(input: TransactionInput): Promise<Record<string, any>>;
|
||||
export function rollbackRelease(input: TransactionInput): Promise<Record<string, any>>;
|
||||
export function cleanupOrphanStages(root: string): Promise<string[]>;
|
||||
299
scripts/deploy/lib/release-transaction.mjs
Normal file
299
scripts/deploy/lib/release-transaction.mjs
Normal file
@@ -0,0 +1,299 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, lstat, mkdir, open, readFile, readlink, readdir, rename, symlink, unlink } from 'node:fs/promises';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
import { byteDigest, canonicalJsonDigest, canonicalValueBytes, parseCanonicalJson } from '../../../src/security/canonical-digest.ts';
|
||||
import {
|
||||
assertDigest, assertReleaseRoot, assertTimestamp, assertToken, atomicCanonicalWrite, exactObject, makeImmutable,
|
||||
safeRemoveStage, verifySignedRelease, writeArchiveFiles,
|
||||
} from './release-common.mjs';
|
||||
import { verifyStagedRelease } from '../verify-staged-release.mjs';
|
||||
|
||||
const stateFields = ['schema', 'currentManifestDigest', 'currentReleaseId', 'publisherFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'activeTransaction'];
|
||||
const transactionFields = ['transactionId', 'manifestDigest', 'releaseId', 'expectedFence', 'phase', 'readinessReceiptDigest'];
|
||||
const readinessFields = ['schema', 'transactionId', 'manifestDigest', 'releaseId', 'predecessorManifestDigest', 'expectedFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'expiresAt', 'packageDigest', 'smokeEvidenceDigest', 'createdAt'];
|
||||
|
||||
function nowFrom(input) {
|
||||
const value = typeof input === 'function' ? input() : input ?? new Date().toISOString();
|
||||
return assertTimestamp(value, 'trusted current time');
|
||||
}
|
||||
|
||||
function normalizeExpectedDigest(value) {
|
||||
if (value === null || value === 'none') return null;
|
||||
return assertDigest(value, 'expected current manifest digest');
|
||||
}
|
||||
|
||||
export function validateControlState(value) {
|
||||
const state = exactObject(value, stateFields, 'release control state');
|
||||
if (state.schema !== 'portfolio-release-control-state/v1') throw new Error('Release control state schema is invalid.');
|
||||
if (state.currentManifestDigest !== null) assertDigest(state.currentManifestDigest, 'current manifest digest');
|
||||
if (state.currentReleaseId !== null) assertToken(state.currentReleaseId, 'current release ID');
|
||||
if (!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 || !Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0) throw new Error('Release control counters are invalid.');
|
||||
assertDigest(state.latestLedgerHeadHash, 'live ledger head');
|
||||
if ((state.currentManifestDigest === null) !== (state.currentReleaseId === null)) throw new Error('Current release pointer state is incoherent.');
|
||||
if (state.activeTransaction !== null) {
|
||||
const tx = exactObject(state.activeTransaction, transactionFields, 'active release transaction');
|
||||
assertToken(tx.transactionId, 'transaction ID'); assertDigest(tx.manifestDigest, 'transaction manifest digest'); assertToken(tx.releaseId, 'transaction release ID');
|
||||
if (!Number.isSafeInteger(tx.expectedFence) || tx.expectedFence !== state.publisherFence + 1 || !['preparing', 'ready', 'committing'].includes(tx.phase)) throw new Error('Active transaction fence or phase is invalid.');
|
||||
if (tx.readinessReceiptDigest !== null) assertDigest(tx.readinessReceiptDigest, 'readiness receipt digest');
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function readControlState(root) {
|
||||
return validateControlState(parseCanonicalJson(await readFile(join(root, 'control/state.json'))));
|
||||
}
|
||||
|
||||
async function withExclusiveLock(root, callback) {
|
||||
const lockPath = join(root, 'control', 'transaction.lock');
|
||||
let handle;
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
try {
|
||||
handle = await open(lockPath, 'wx', 0o600);
|
||||
await handle.writeFile(`${process.pid}\n`);
|
||||
await handle.sync();
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
const owner = await readFile(lockPath, 'utf8').catch(() => '');
|
||||
if (!/^\d+\n$/u.test(owner)) throw new Error('Release transaction lock is malformed; protected recovery is required.');
|
||||
try { process.kill(Number(owner.trim()), 0); } catch (probeError) {
|
||||
if (probeError?.code !== 'ESRCH') throw probeError;
|
||||
await unlink(lockPath).catch((unlinkError) => { if (unlinkError?.code !== 'ENOENT') throw unlinkError; });
|
||||
continue;
|
||||
}
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5));
|
||||
}
|
||||
}
|
||||
if (handle === undefined) throw new Error('Could not obtain the exclusive release transaction lock.');
|
||||
try { return await callback(); } finally { await handle.close(); await unlink(lockPath).catch(() => undefined); }
|
||||
}
|
||||
|
||||
async function runSmokeCommand(command, releaseDir, manifestDigest) {
|
||||
if (typeof command !== 'string' || !isAbsolute(command)) throw new Error('Smoke command must be an absolute executable path.');
|
||||
const info = await lstat(command);
|
||||
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o111) === 0) throw new Error('Smoke command is not an executable regular file.');
|
||||
return await new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(command, ['--release-dir', releaseDir, '--manifest-digest', manifestDigest], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const stdout = []; const stderr = [];
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Staged smoke check timed out.')); }, 15_000);
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk)); child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error); });
|
||||
child.once('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) reject(new Error(`Staged smoke check failed: ${Buffer.concat(stderr).toString('utf8').slice(0, 200)}`));
|
||||
else resolvePromise(Buffer.concat(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareUnlocked(input) {
|
||||
const root = await assertReleaseRoot(input.root);
|
||||
const transactionId = assertToken(input.transactionId, 'transaction ID');
|
||||
const statePath = join(root, 'control/state.json');
|
||||
const state = await readControlState(root);
|
||||
if (state.activeTransaction !== null) throw new Error('Another release transaction is already active.');
|
||||
const firstNow = nowFrom(input.clock ?? input.now);
|
||||
const verified = await verifyStagedRelease({
|
||||
archivePath: resolve(input.archivePath), verifierModulePath: input.verifierModulePath,
|
||||
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
|
||||
protectedState: {
|
||||
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
|
||||
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
|
||||
}, now: firstNow,
|
||||
});
|
||||
const { index, admission, files } = verified;
|
||||
if (input.rollbackTargetReleaseId !== undefined) {
|
||||
const targetId = assertToken(input.rollbackTargetReleaseId, 'rollback target release ID');
|
||||
const targetIndex = parseCanonicalJson(await readFile(join(root, 'releases', targetId, '.release/package-index.json')));
|
||||
if (targetIndex.manifestDigest === index.manifestDigest) throw new Error('Rollback must use a newly signed manifest, not replay an old one.');
|
||||
const left = canonicalValueBytes(targetIndex.artifacts);
|
||||
const right = canonicalValueBytes(index.artifacts);
|
||||
if (!Buffer.from(left).equals(Buffer.from(right))) throw new Error('Rollback package does not bind the selected immutable release artifact closure.');
|
||||
}
|
||||
const activeTransaction = {
|
||||
transactionId, manifestDigest: admission.manifestDigest, releaseId: index.releaseId, expectedFence: admission.expectedFence,
|
||||
phase: 'preparing', readinessReceiptDigest: null,
|
||||
};
|
||||
await atomicCanonicalWrite(statePath, { ...state, activeTransaction });
|
||||
const partialName = `${transactionId}.partial`;
|
||||
const partialDir = join(root, 'staging', partialName);
|
||||
const releaseDir = join(root, 'releases', index.releaseId);
|
||||
try {
|
||||
await mkdir(join(root, 'staging'), { recursive: true, mode: 0o750 });
|
||||
await mkdir(join(root, 'releases'), { recursive: true, mode: 0o750 });
|
||||
await mkdir(join(root, 'receipts', 'readiness'), { recursive: true, mode: 0o750 });
|
||||
await safeRemoveStage(root, partialName);
|
||||
await writeArchiveFiles(files.map((file) => file.path.startsWith('.release/')
|
||||
? file
|
||||
: { ...file, path: `public/${file.path}` }), partialDir);
|
||||
await makeImmutable(partialDir, input.uid, input.gid);
|
||||
const publicDir = join(partialDir, 'public');
|
||||
const smokeBytes = input.smokeCheck !== undefined
|
||||
? await input.smokeCheck({ releaseDir: publicDir, manifestDigest: admission.manifestDigest })
|
||||
: await runSmokeCommand(input.smokeCommand, publicDir, admission.manifestDigest);
|
||||
const secondNow = nowFrom(input.clock ?? input.now);
|
||||
if (Date.parse(secondNow) >= Date.parse(admission.expiresAt)) throw new Error('Release expired while staging or smoke-checking.');
|
||||
const stateAfterSmoke = await readControlState(root);
|
||||
if (stateAfterSmoke.latestLedgerHeadHash !== admission.latestLedgerHeadHash || stateAfterSmoke.latestLedgerRevision !== admission.latestLedgerRevision ||
|
||||
stateAfterSmoke.currentManifestDigest !== admission.predecessorManifestDigest || stateAfterSmoke.publisherFence + 1 !== admission.expectedFence) {
|
||||
throw new Error('Protected state changed while staging the release.');
|
||||
}
|
||||
await chmod(partialDir, 0o755);
|
||||
await rename(partialDir, releaseDir).catch((error) => {
|
||||
if (error?.code === 'EEXIST') throw new Error('Immutable release ID already exists.');
|
||||
throw error;
|
||||
});
|
||||
await chmod(releaseDir, 0o555);
|
||||
const receipt = {
|
||||
schema: 'portfolio-release-readiness-receipt/v1', transactionId, manifestDigest: admission.manifestDigest,
|
||||
releaseId: index.releaseId, predecessorManifestDigest: admission.predecessorManifestDigest, expectedFence: admission.expectedFence,
|
||||
latestLedgerHeadHash: admission.latestLedgerHeadHash, latestLedgerRevision: admission.latestLedgerRevision,
|
||||
expiresAt: admission.expiresAt, packageDigest: byteDigest(await readFile(input.archivePath)),
|
||||
smokeEvidenceDigest: byteDigest(smokeBytes instanceof Uint8Array ? smokeBytes : Buffer.from(String(smokeBytes))), createdAt: secondNow,
|
||||
};
|
||||
const receiptDigest = canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt);
|
||||
const receiptPath = join(root, 'receipts', 'readiness', `${transactionId}.json`);
|
||||
await atomicCanonicalWrite(receiptPath, receipt, 0o440);
|
||||
await chmod(receiptPath, 0o440);
|
||||
await atomicCanonicalWrite(statePath, { ...stateAfterSmoke, activeTransaction: { ...activeTransaction, phase: 'ready', readinessReceiptDigest: receiptDigest } });
|
||||
return { receipt, receiptDigest, releaseDir };
|
||||
} catch (error) {
|
||||
await makeWritable(partialDir).catch(() => undefined);
|
||||
await safeRemoveStage(root, partialName).catch(() => undefined);
|
||||
const latest = await readControlState(root).catch(() => null);
|
||||
if (latest?.activeTransaction?.transactionId === transactionId && latest.activeTransaction.phase !== 'committing') {
|
||||
await atomicCanonicalWrite(statePath, { ...latest, activeTransaction: null }).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareRelease(input) {
|
||||
const root = await assertReleaseRoot(input.root);
|
||||
return input.lock === false ? prepareUnlocked({ ...input, root }) : withExclusiveLock(root, () => prepareUnlocked({ ...input, root }));
|
||||
}
|
||||
|
||||
function validateReadiness(value) {
|
||||
const receipt = exactObject(value, readinessFields, 'readiness receipt');
|
||||
if (receipt.schema !== 'portfolio-release-readiness-receipt/v1') throw new Error('Readiness receipt schema is invalid.');
|
||||
assertToken(receipt.transactionId, 'readiness transaction ID'); assertDigest(receipt.manifestDigest, 'readiness manifest digest');
|
||||
assertToken(receipt.releaseId, 'readiness release ID'); assertTimestamp(receipt.expiresAt, 'readiness expiry');
|
||||
assertDigest(receipt.latestLedgerHeadHash, 'readiness ledger head'); assertDigest(receipt.packageDigest, 'readiness package digest'); assertDigest(receipt.smokeEvidenceDigest, 'smoke evidence digest');
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async function signedManifestFromRelease(root, releaseId) {
|
||||
return join(root, 'releases', releaseId, '.release/signed-release-manifest.json');
|
||||
}
|
||||
|
||||
async function currentTarget(root) {
|
||||
try { return await readlink(join(root, 'current')); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
||||
}
|
||||
|
||||
async function finalizeActivation(root, state, tx, receipt, committedAt, recovered) {
|
||||
const newState = { ...state, currentManifestDigest: tx.manifestDigest, currentReleaseId: tx.releaseId, publisherFence: tx.expectedFence, activeTransaction: null };
|
||||
await mkdir(join(root, 'receipts', 'activation'), { recursive: true, mode: 0o750 });
|
||||
const activationReceipt = {
|
||||
schema: 'portfolio-release-activation-receipt/v1', transactionId: tx.transactionId, predecessorManifestDigest: receipt.predecessorManifestDigest,
|
||||
manifestDigest: tx.manifestDigest, releaseId: tx.releaseId, fence: tx.expectedFence,
|
||||
readinessReceiptDigest: canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt), committedAt, recoveredAfterPointerSwap: recovered,
|
||||
};
|
||||
const path = join(root, 'receipts', 'activation', `${tx.transactionId}.json`);
|
||||
await atomicCanonicalWrite(path, activationReceipt, 0o440);
|
||||
await chmod(path, 0o440);
|
||||
await atomicCanonicalWrite(join(root, 'control/state.json'), newState);
|
||||
return activationReceipt;
|
||||
}
|
||||
|
||||
async function activateUnlocked(input) {
|
||||
const root = await assertReleaseRoot(input.root);
|
||||
const transactionId = assertToken(input.transactionId, 'transaction ID');
|
||||
const expectedCurrent = normalizeExpectedDigest(input.expectedCurrentManifestDigest);
|
||||
if (!Number.isSafeInteger(input.expectedFence) || input.expectedFence < 1) throw new Error('Expected fence is invalid.');
|
||||
let state = await readControlState(root);
|
||||
const tx = state.activeTransaction;
|
||||
if (tx === null || tx.transactionId !== transactionId || !['ready', 'committing'].includes(tx.phase)) throw new Error('Named transaction is not ready for activation.');
|
||||
const receipt = validateReadiness(parseCanonicalJson(await readFile(join(root, 'receipts', 'readiness', `${transactionId}.json`))));
|
||||
if (tx.readinessReceiptDigest !== canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt)) throw new Error('Readiness receipt digest does not match the protected transaction.');
|
||||
const target = `releases/${tx.releaseId}/public`;
|
||||
const actualCurrent = await currentTarget(root);
|
||||
if (tx.phase === 'committing' && actualCurrent === target) {
|
||||
return finalizeActivation(root, state, tx, receipt, nowFrom(input.clock ?? input.now), true);
|
||||
}
|
||||
if (state.currentManifestDigest !== expectedCurrent || tx.expectedFence !== input.expectedFence || state.publisherFence + 1 !== input.expectedFence ||
|
||||
receipt.predecessorManifestDigest !== expectedCurrent || receipt.expectedFence !== input.expectedFence) throw new Error('Activation predecessor or fence is stale.');
|
||||
const activationNow = nowFrom(input.clock ?? input.now);
|
||||
if (Date.parse(activationNow) >= Date.parse(receipt.expiresAt)) throw new Error('Release expired before activation.');
|
||||
const protectedState = {
|
||||
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
|
||||
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
|
||||
};
|
||||
const verified = await verifySignedRelease({
|
||||
signedManifestPath: await signedManifestFromRelease(root, tx.releaseId), verifierModulePath: input.verifierModulePath,
|
||||
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
|
||||
protectedState, now: activationNow,
|
||||
});
|
||||
if (verified.admission.manifestDigest !== tx.manifestDigest || verified.admission.expectedFence !== tx.expectedFence ||
|
||||
verified.admission.latestLedgerHeadHash !== receipt.latestLedgerHeadHash || verified.admission.latestLedgerRevision !== receipt.latestLedgerRevision) {
|
||||
throw new Error('Activation signature admission does not match readiness.');
|
||||
}
|
||||
state = await readControlState(root);
|
||||
if (state.currentManifestDigest !== expectedCurrent || state.publisherFence + 1 !== input.expectedFence || state.activeTransaction?.transactionId !== transactionId) {
|
||||
throw new Error('Protected state changed before activation commit.');
|
||||
}
|
||||
await atomicCanonicalWrite(join(root, 'control/state.json'), { ...state, activeTransaction: { ...tx, phase: 'committing' } });
|
||||
const nextPath = join(root, 'current.next');
|
||||
const nextInfo = await lstat(nextPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
|
||||
if (nextInfo !== null) {
|
||||
if (!nextInfo.isSymbolicLink()) throw new Error('current.next is not a symlink and will not be overwritten.');
|
||||
await unlink(nextPath);
|
||||
}
|
||||
await symlink(target, nextPath);
|
||||
if (input.crashPoint === 'before-swap') throw new Error('Injected crash before pointer swap.');
|
||||
await rename(nextPath, join(root, 'current'));
|
||||
if (input.crashPoint === 'after-swap') throw new Error('Injected crash after pointer swap.');
|
||||
return finalizeActivation(root, state, tx, receipt, activationNow, false);
|
||||
}
|
||||
|
||||
export async function activateRelease(input) {
|
||||
const root = await assertReleaseRoot(input.root);
|
||||
return input.lock === false ? activateUnlocked({ ...input, root }) : withExclusiveLock(root, () => activateUnlocked({ ...input, root }));
|
||||
}
|
||||
|
||||
export async function rollbackRelease(input) {
|
||||
const root = await assertReleaseRoot(input.root);
|
||||
return withExclusiveLock(root, async () => {
|
||||
await prepareUnlocked({ ...input, root, rollbackTargetReleaseId: input.targetReleaseId, lock: false });
|
||||
return activateUnlocked({ ...input, root, lock: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function makeWritable(path) {
|
||||
const info = await lstat(path);
|
||||
if (info.isDirectory()) {
|
||||
await chmod(path, 0o700);
|
||||
for (const entry of await readdir(path)) await makeWritable(join(path, entry));
|
||||
} else if (info.isFile()) await chmod(path, 0o600);
|
||||
else throw new Error('Orphan staging contains a non-file entry.');
|
||||
}
|
||||
|
||||
export async function cleanupOrphanStages(rootPath) {
|
||||
const root = await assertReleaseRoot(rootPath);
|
||||
const staging = join(root, 'staging');
|
||||
await mkdir(staging, { recursive: true, mode: 0o750 });
|
||||
const state = await readControlState(root);
|
||||
const activePrefix = state.activeTransaction === null ? null : `${state.activeTransaction.transactionId}.`;
|
||||
const removed = [];
|
||||
for (const entry of await readdir(staging, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.partial$/u.test(entry.name)) {
|
||||
throw new Error('Unexpected staging entry blocks orphan cleanup.');
|
||||
}
|
||||
if (activePrefix !== null && entry.name.startsWith(activePrefix)) continue;
|
||||
const path = join(staging, entry.name);
|
||||
await makeWritable(path);
|
||||
await safeRemoveStage(root, entry.name);
|
||||
removed.push(entry.name);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
19
scripts/deploy/prepare-release.mjs
Executable file
19
scripts/deploy/prepare-release.mjs
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { cleanupOrphanStages, prepareRelease } from './lib/release-transaction.mjs';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 2 && args[1] === '--cleanup-orphans') {
|
||||
const removed = await cleanupOrphanStages(resolve(args[0]));
|
||||
process.stdout.write(`${JSON.stringify({ removed })}\n`); return;
|
||||
}
|
||||
if (args.length !== 9) throw new Error('Usage: prepare-release.mjs <root> <archive> <transaction-id> <verifier-module> <trust-policy> <smoke-command> <uid> <gid> <trusted-now>');
|
||||
const [root, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, now] = args;
|
||||
const result = await prepareRelease({ root: resolve(root), archivePath: resolve(archivePath), transactionId,
|
||||
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
|
||||
uid: Number(uid), gid: Number(gid), now });
|
||||
process.stdout.write(`${JSON.stringify({ manifestDigest: result.receipt.manifestDigest, releaseId: result.receipt.releaseId })}\n`);
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release preparation failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
15
scripts/deploy/prepare-release.sh
Executable file
15
scripts/deploy/prepare-release.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo 'Usage: prepare-release.sh <dedicated-root> ...' >&2
|
||||
exit 64
|
||||
fi
|
||||
release_root=$1
|
||||
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
|
||||
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
|
||||
exit 65
|
||||
fi
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
exec node "$script_dir/prepare-release.mjs" "$@"
|
||||
15
scripts/deploy/rollback-release.mjs
Executable file
15
scripts/deploy/rollback-release.mjs
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { rollbackRelease } from './lib/release-transaction.mjs';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 12) throw new Error('Usage: rollback-release.mjs <root> <target-release-id> <new-release-archive> <transaction-id> <verifier-module> <trust-policy> <smoke-command> <uid> <gid> <expected-current> <expected-fence> <trusted-now>');
|
||||
const [root, targetReleaseId, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, expectedCurrentManifestDigest, expectedFence, now] = args;
|
||||
const receipt = await rollbackRelease({ root: resolve(root), targetReleaseId, archivePath: resolve(archivePath), transactionId,
|
||||
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
|
||||
uid: Number(uid), gid: Number(gid), expectedCurrentManifestDigest, expectedFence: Number(expectedFence), now });
|
||||
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release rollback failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
15
scripts/deploy/rollback-release.sh
Executable file
15
scripts/deploy/rollback-release.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo 'Usage: rollback-release.sh <dedicated-root> ...' >&2
|
||||
exit 64
|
||||
fi
|
||||
release_root=$1
|
||||
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
|
||||
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
|
||||
exit 65
|
||||
fi
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
exec node "$script_dir/rollback-release.mjs" "$@"
|
||||
39
scripts/deploy/verify-staged-release.mjs
Executable file
39
scripts/deploy/verify-staged-release.mjs
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdtemp, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
||||
import { parseDeterministicTar, validatePackageFiles, verifySignedRelease } from './lib/release-common.mjs';
|
||||
|
||||
export async function verifyStagedRelease({ archivePath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now }) {
|
||||
const files = parseDeterministicTar(await readFile(archivePath));
|
||||
const { index, byPath } = validatePackageFiles(files);
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), 'portfolio-release-verify-'));
|
||||
const signedPath = join(temporaryRoot, 'signed-release.json');
|
||||
const { writeFile, rm } = await import('node:fs/promises');
|
||||
await writeFile(signedPath, byPath.get('.release/signed-release-manifest.json').bytes, { flag: 'wx', mode: 0o400 });
|
||||
try {
|
||||
const verified = await verifySignedRelease({ signedManifestPath: signedPath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now });
|
||||
if (verified.admission.manifestDigest !== index.manifestDigest || verified.manifest.buildId !== index.buildId ||
|
||||
`${verified.manifest.buildId}-${verified.admission.manifestDigest}` !== index.releaseId ||
|
||||
verified.admission.expectedFence !== index.expectedFence || verified.admission.expiresAt !== index.expiresAt) {
|
||||
throw new Error('Package index does not bind the verified signed release admission.');
|
||||
}
|
||||
return { files, index, ...verified };
|
||||
} finally {
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [archivePath, verifierModulePath, trustPolicyPath, protectedStateJson, now] = process.argv.slice(2);
|
||||
if (!archivePath || !verifierModulePath || !trustPolicyPath || !protectedStateJson || !now || process.argv.length !== 7) throw new Error('Usage: verify-staged-release.mjs <archive> <verifier-module> <trust-policy> <protected-state-json> <trusted-now>');
|
||||
const result = await verifyStagedRelease({ archivePath: resolve(archivePath), verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), protectedState: JSON.parse(protectedStateJson), now });
|
||||
process.stdout.write(Buffer.from(canonicalValueBytes(result.admission))); process.stdout.write('\n');
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => { process.stderr.write(`Staged release verification failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
}
|
||||
Reference in New Issue
Block a user