mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
3724 lines
113 KiB
JavaScript
3724 lines
113 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawn, spawnSync } = require('node:child_process');
|
|
const yaml = require('js-yaml');
|
|
const semver = require('semver');
|
|
|
|
const COMMAND_SCHEMA = 'qinglong/kubernetes-deployment-command@v2';
|
|
const LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v2';
|
|
const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
|
|
const PREFLIGHT_SCHEMA = 'qinglong/kubernetes-deployment-preflight@v2';
|
|
const RECEIPT_SCHEMA = 'qinglong/kubernetes-deployment-receipt@v2';
|
|
const RETIREMENT_PREFLIGHT_SCHEMA =
|
|
'qinglong/kubernetes-resource-retirement-preflight@v1';
|
|
const RETIREMENT_RECEIPT_SCHEMA =
|
|
'qinglong/kubernetes-resource-retirement-receipt@v1';
|
|
const HEAD_SCHEMA = 'qinglong/kubernetes-deployment-head@v2';
|
|
const HEAD_NAME = 'qinglong3-deployment-head';
|
|
const HEAD_DATA_KEY = 'head.json';
|
|
const FIELD_MANAGER = 'qinglong3-catalog-lock';
|
|
const MAX_COMMAND_BYTES = 64 * 1024;
|
|
const MAX_MANIFEST_BYTES = 8 * 1024 * 1024;
|
|
const MAX_REPORT_BYTES = 1024 * 1024;
|
|
const MAX_KUBECONFIG_BYTES = 1024 * 1024;
|
|
const MAX_EXECUTABLE_BYTES = 256 * 1024 * 1024;
|
|
const MAX_PROCESS_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
const MAX_RESOURCE_COUNT = 2048;
|
|
const MAX_RETIREMENT_COUNT = 64;
|
|
const MAX_STRUCTURE_DEPTH = 64;
|
|
const PROCESS_TIMEOUT_MS = 60_000;
|
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
const UUID_PATTERN =
|
|
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
const VERSION_PATTERN =
|
|
/^3\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
|
const CONTEXT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,252})$/u;
|
|
const REPOSITORY_PATTERN =
|
|
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
|
const ROLE_ORDER = Object.freeze(['control', 'control-ai', 'admin', 'worker']);
|
|
const TRANSITION_KINDS = Object.freeze(['install', 'upgrade', 'rollback']);
|
|
const HEAD_TRANSITION_KINDS = Object.freeze([...TRANSITION_KINDS, 'retire']);
|
|
const FORBIDDEN_RETIREMENT_IDENTITIES = Object.freeze([
|
|
'v1\u0000PersistentVolumeClaim',
|
|
'v1\u0000Secret',
|
|
'v1\u0000ServiceAccount',
|
|
]);
|
|
const IMAGE_NAMES = Object.freeze({
|
|
control: 'qinglong3-cluster-control',
|
|
'control-ai': 'qinglong3-cluster-control-ai',
|
|
admin: 'qinglong3-cluster-admin',
|
|
worker: 'qinglong3-worker',
|
|
});
|
|
const ANNOTATION_KEYS = Object.freeze({
|
|
releaseSet: 'qinglong.io/release-set-digest',
|
|
catalogManifest: 'qinglong.io/release-catalog-manifest-digest',
|
|
catalogReport: 'qinglong.io/release-catalog-report-digest',
|
|
sourceRevision: 'qinglong.io/release-source-revision',
|
|
version: 'qinglong.io/release-version',
|
|
});
|
|
|
|
class QingLong3KubernetesDeploymentCeremonyError extends Error {
|
|
constructor(message) {
|
|
super(`QingLong 3 Kubernetes deployment ceremony failed: ${message}`);
|
|
this.name = 'QingLong3KubernetesDeploymentCeremonyError';
|
|
this.code = 'QL3_KUBERNETES_DEPLOYMENT_CEREMONY_FAILED';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new QingLong3KubernetesDeploymentCeremonyError(message);
|
|
}
|
|
|
|
function sha256(value) {
|
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
|
|
function canonicalJson(value) {
|
|
return `${JSON.stringify(value)}\n`;
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
|
|
function exactKeys(value, expected) {
|
|
return (
|
|
isPlainObject(value) &&
|
|
JSON.stringify(Object.keys(value).sort()) ===
|
|
JSON.stringify([...expected].sort())
|
|
);
|
|
}
|
|
|
|
function resolveCanonicalAbsolute(input, label) {
|
|
if (typeof input !== 'string' || !path.isAbsolute(input)) {
|
|
fail(`${label} path must be absolute`);
|
|
}
|
|
const resolved = path.resolve(input);
|
|
if (resolved !== input) fail(`${label} path must be normalized`);
|
|
return resolved;
|
|
}
|
|
|
|
function validatePrivateParent(filePath, label, uid = process.getuid()) {
|
|
const parent = path.dirname(filePath);
|
|
let status;
|
|
try {
|
|
status = fs.lstatSync(parent);
|
|
} catch {
|
|
fail(`${label} parent is unavailable`);
|
|
}
|
|
if (
|
|
!status.isDirectory() ||
|
|
status.isSymbolicLink() ||
|
|
status.uid !== uid ||
|
|
(status.mode & 0o777) !== 0o700 ||
|
|
fs.realpathSync(parent) !== parent
|
|
) {
|
|
fail(`${label} parent must be one current-owner canonical 0700 directory`);
|
|
}
|
|
return parent;
|
|
}
|
|
|
|
function descriptorIdentity(status) {
|
|
return Object.freeze({
|
|
dev: status.dev,
|
|
ino: status.ino,
|
|
size: status.size,
|
|
mtimeMs: status.mtimeMs,
|
|
ctimeMs: status.ctimeMs,
|
|
});
|
|
}
|
|
|
|
function sameIdentity(left, right) {
|
|
return (
|
|
left.dev === right.dev &&
|
|
left.ino === right.ino &&
|
|
left.size === right.size &&
|
|
left.mtimeMs === right.mtimeMs &&
|
|
left.ctimeMs === right.ctimeMs
|
|
);
|
|
}
|
|
|
|
function readStableFile(
|
|
input,
|
|
label,
|
|
maximumBytes,
|
|
{ privateFile = true, executable = false } = {},
|
|
) {
|
|
const target = resolveCanonicalAbsolute(input, label);
|
|
if (privateFile) validatePrivateParent(target, label);
|
|
let lexical;
|
|
try {
|
|
lexical = fs.lstatSync(target);
|
|
} catch {
|
|
fail(`${label} is unavailable`);
|
|
}
|
|
const uid = process.getuid();
|
|
const allowedOwner = privateFile
|
|
? lexical.uid === uid
|
|
: lexical.uid === uid || lexical.uid === 0;
|
|
const validMode = privateFile
|
|
? (lexical.mode & 0o777) === 0o600
|
|
: (lexical.mode & 0o022) === 0 && (lexical.mode & 0o111) !== 0;
|
|
if (
|
|
!lexical.isFile() ||
|
|
lexical.isSymbolicLink() ||
|
|
lexical.nlink !== 1 ||
|
|
!allowedOwner ||
|
|
!validMode ||
|
|
lexical.size < 1 ||
|
|
lexical.size > maximumBytes ||
|
|
fs.realpathSync(target) !== target ||
|
|
(executable && (lexical.mode & 0o111) === 0)
|
|
) {
|
|
fail(`${label} file authority is invalid`);
|
|
}
|
|
const flags =
|
|
fs.constants.O_RDONLY |
|
|
(fs.constants.O_NOFOLLOW ?? 0) |
|
|
(fs.constants.O_CLOEXEC ?? 0);
|
|
let descriptor;
|
|
try {
|
|
descriptor = fs.openSync(target, flags);
|
|
} catch {
|
|
fail(`${label} cannot be opened safely`);
|
|
}
|
|
try {
|
|
const before = fs.fstatSync(descriptor);
|
|
if (
|
|
!before.isFile() ||
|
|
before.dev !== lexical.dev ||
|
|
before.ino !== lexical.ino ||
|
|
before.size !== lexical.size
|
|
) {
|
|
fail(`${label} descriptor identity is invalid`);
|
|
}
|
|
const chunks = [];
|
|
const hash = crypto.createHash('sha256');
|
|
let total = 0;
|
|
while (true) {
|
|
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maximumBytes + 1));
|
|
const read = fs.readSync(descriptor, chunk, 0, chunk.length, null);
|
|
if (read === 0) break;
|
|
total += read;
|
|
if (total > maximumBytes) fail(`${label} exceeds its byte limit`);
|
|
const actual = chunk.subarray(0, read);
|
|
hash.update(actual);
|
|
if (!executable) chunks.push(actual);
|
|
}
|
|
const after = fs.fstatSync(descriptor);
|
|
if (!sameIdentity(descriptorIdentity(before), descriptorIdentity(after))) {
|
|
fail(`${label} changed while it was read`);
|
|
}
|
|
const contents = executable ? undefined : Buffer.concat(chunks);
|
|
return Object.freeze({
|
|
path: target,
|
|
identity: descriptorIdentity(after),
|
|
bytes: total,
|
|
digest: `sha256:${hash.digest('hex')}`,
|
|
...(contents === undefined ? {} : { contents }),
|
|
});
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function utf8(file, label) {
|
|
const contents = file.contents.toString('utf8');
|
|
if (!Buffer.from(contents, 'utf8').equals(file.contents)) {
|
|
fail(`${label} must contain valid UTF-8`);
|
|
}
|
|
return contents;
|
|
}
|
|
|
|
function parseCanonicalJson(contents, label) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(contents);
|
|
} catch {
|
|
fail(`${label} must contain valid JSON`);
|
|
}
|
|
if (canonicalJson(parsed) !== contents) {
|
|
fail(`${label} must use canonical JSON encoding`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function readCanonicalJson(input, label, maximumBytes) {
|
|
const file = readStableFile(input, label, maximumBytes);
|
|
return Object.freeze({
|
|
...file,
|
|
value: parseCanonicalJson(utf8(file, label), label),
|
|
});
|
|
}
|
|
|
|
function artifact(value, label) {
|
|
if (
|
|
!exactKeys(value, ['path', 'expectedDigest']) ||
|
|
typeof value.path !== 'string' ||
|
|
!DIGEST_PATTERN.test(value.expectedDigest || '')
|
|
) {
|
|
fail(`${label} authority is invalid`);
|
|
}
|
|
return Object.freeze({
|
|
path: resolveCanonicalAbsolute(value.path, label),
|
|
expectedDigest: value.expectedDigest,
|
|
});
|
|
}
|
|
|
|
function verifyExpected(file, expectedDigest, label) {
|
|
if (file.digest !== expectedDigest) fail(`${label} digest changed`);
|
|
return file;
|
|
}
|
|
|
|
function outputTarget(input, label) {
|
|
const target = resolveCanonicalAbsolute(input, label);
|
|
validatePrivateParent(target, label);
|
|
if (fs.existsSync(target)) fail(`${label} must be unused`);
|
|
return target;
|
|
}
|
|
|
|
function writeNoReplace(target, value) {
|
|
fs.writeFileSync(target, canonicalJson(value), {
|
|
encoding: 'utf8',
|
|
mode: 0o600,
|
|
flag: 'wx',
|
|
});
|
|
}
|
|
|
|
function expectedHead(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'generation',
|
|
'deploymentDigest',
|
|
'lockDigest',
|
|
'stateDigest',
|
|
]) ||
|
|
!Number.isSafeInteger(value.generation) ||
|
|
value.generation < 0 ||
|
|
(value.generation === 0 &&
|
|
(value.deploymentDigest !== null ||
|
|
value.lockDigest !== null ||
|
|
value.stateDigest !== null)) ||
|
|
(value.generation > 0 &&
|
|
(!DIGEST_PATTERN.test(value.deploymentDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.lockDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.stateDigest || '')))
|
|
) {
|
|
fail('expected deployment head is invalid');
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function transitionRequest(request) {
|
|
if (!TRANSITION_KINDS.includes(request.transitionKind)) {
|
|
fail('deployment transition kind is invalid');
|
|
}
|
|
const head = expectedHead(request.expectedHead);
|
|
if (
|
|
(request.transitionKind === 'install' && head.generation !== 0) ||
|
|
(request.transitionKind !== 'install' && head.generation === 0)
|
|
) {
|
|
fail('deployment transition head is inconsistent');
|
|
}
|
|
return Object.freeze({ transitionKind: request.transitionKind, head });
|
|
}
|
|
|
|
function retirementTargets(value) {
|
|
validateResourceInventory(value);
|
|
if (value.length > MAX_RETIREMENT_COUNT) {
|
|
fail('deployment retirement target count is invalid');
|
|
}
|
|
for (const target of value) {
|
|
if (
|
|
target.namespace === null ||
|
|
FORBIDDEN_RETIREMENT_IDENTITIES.includes(
|
|
`${target.apiVersion}\u0000${target.kind}`,
|
|
) ||
|
|
(target.apiVersion === 'v1' &&
|
|
target.kind === 'ConfigMap' &&
|
|
target.name === HEAD_NAME)
|
|
) {
|
|
fail('deployment retirement target requires a dedicated ceremony');
|
|
}
|
|
}
|
|
return Object.freeze(value.map((target) => Object.freeze({ ...target })));
|
|
}
|
|
|
|
function retirementRequest(request) {
|
|
const head = expectedHead(request.expectedHead);
|
|
if (head.generation === 0) {
|
|
fail('deployment retirement requires a committed head');
|
|
}
|
|
return Object.freeze({
|
|
lockedManifest: artifact(request.lockedManifest, 'locked manifest'),
|
|
lockReport: artifact(request.lockReport, 'lock report'),
|
|
kubectl: artifact(request.kubectl, 'kubectl'),
|
|
curl: artifact(request.curl, 'curl'),
|
|
kubeconfig: artifact(request.kubeconfig, 'kubeconfig'),
|
|
context: request.context,
|
|
expectedClusterUid: request.expectedClusterUid,
|
|
expectedHead: head,
|
|
targets: retirementTargets(request.targets),
|
|
});
|
|
}
|
|
|
|
function parseCommand(value) {
|
|
if (
|
|
!exactKeys(value, ['schemaVersion', 'schema', 'operation', 'request']) ||
|
|
value.schemaVersion !== 1 ||
|
|
value.schema !== COMMAND_SCHEMA
|
|
) {
|
|
fail('command shape is invalid');
|
|
}
|
|
const request = value.request;
|
|
if (value.operation === 'cluster.deployment.preflight') {
|
|
if (
|
|
!exactKeys(request, [
|
|
'preflightId',
|
|
'lockedManifest',
|
|
'lockReport',
|
|
'kubectl',
|
|
'kubeconfig',
|
|
'context',
|
|
'expectedClusterUid',
|
|
'transitionKind',
|
|
'expectedHead',
|
|
'output',
|
|
]) ||
|
|
!UUID_PATTERN.test(request.preflightId || '') ||
|
|
!CONTEXT_PATTERN.test(request.context || '') ||
|
|
!UUID_PATTERN.test(request.expectedClusterUid || '') ||
|
|
typeof request.output !== 'string'
|
|
) {
|
|
fail('preflight command is invalid');
|
|
}
|
|
const transition = transitionRequest(request);
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: value.operation,
|
|
request: Object.freeze({
|
|
preflightId: request.preflightId,
|
|
lockedManifest: artifact(request.lockedManifest, 'locked manifest'),
|
|
lockReport: artifact(request.lockReport, 'lock report'),
|
|
kubectl: artifact(request.kubectl, 'kubectl'),
|
|
kubeconfig: artifact(request.kubeconfig, 'kubeconfig'),
|
|
context: request.context,
|
|
expectedClusterUid: request.expectedClusterUid,
|
|
transitionKind: transition.transitionKind,
|
|
expectedHead: transition.head,
|
|
output: resolveCanonicalAbsolute(request.output, 'preflight output'),
|
|
}),
|
|
});
|
|
}
|
|
if (value.operation === 'cluster.deployment.apply') {
|
|
if (
|
|
!exactKeys(request, [
|
|
'mutationId',
|
|
'preflight',
|
|
'lockedManifest',
|
|
'lockReport',
|
|
'kubectl',
|
|
'kubeconfig',
|
|
'context',
|
|
'expectedClusterUid',
|
|
'transitionKind',
|
|
'expectedHead',
|
|
'output',
|
|
]) ||
|
|
!UUID_PATTERN.test(request.mutationId || '') ||
|
|
!CONTEXT_PATTERN.test(request.context || '') ||
|
|
!UUID_PATTERN.test(request.expectedClusterUid || '') ||
|
|
typeof request.output !== 'string'
|
|
) {
|
|
fail('apply command is invalid');
|
|
}
|
|
const transition = transitionRequest(request);
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: value.operation,
|
|
request: Object.freeze({
|
|
mutationId: request.mutationId,
|
|
preflight: artifact(request.preflight, 'preflight report'),
|
|
lockedManifest: artifact(request.lockedManifest, 'locked manifest'),
|
|
lockReport: artifact(request.lockReport, 'lock report'),
|
|
kubectl: artifact(request.kubectl, 'kubectl'),
|
|
kubeconfig: artifact(request.kubeconfig, 'kubeconfig'),
|
|
context: request.context,
|
|
expectedClusterUid: request.expectedClusterUid,
|
|
transitionKind: transition.transitionKind,
|
|
expectedHead: transition.head,
|
|
output: resolveCanonicalAbsolute(
|
|
request.output,
|
|
'apply receipt output',
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
if (value.operation === 'cluster.deployment.retirement.preflight') {
|
|
if (
|
|
!exactKeys(request, [
|
|
'preflightId',
|
|
'lockedManifest',
|
|
'lockReport',
|
|
'kubectl',
|
|
'curl',
|
|
'kubeconfig',
|
|
'context',
|
|
'expectedClusterUid',
|
|
'expectedHead',
|
|
'targets',
|
|
'output',
|
|
]) ||
|
|
!UUID_PATTERN.test(request.preflightId || '') ||
|
|
!CONTEXT_PATTERN.test(request.context || '') ||
|
|
!UUID_PATTERN.test(request.expectedClusterUid || '') ||
|
|
typeof request.output !== 'string'
|
|
) {
|
|
fail('retirement preflight command is invalid');
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: value.operation,
|
|
request: Object.freeze({
|
|
preflightId: request.preflightId,
|
|
...retirementRequest(request),
|
|
output: resolveCanonicalAbsolute(
|
|
request.output,
|
|
'retirement preflight output',
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
if (value.operation === 'cluster.deployment.retirement.apply') {
|
|
if (
|
|
!exactKeys(request, [
|
|
'mutationId',
|
|
'preflight',
|
|
'lockedManifest',
|
|
'lockReport',
|
|
'kubectl',
|
|
'curl',
|
|
'kubeconfig',
|
|
'context',
|
|
'expectedClusterUid',
|
|
'expectedHead',
|
|
'targets',
|
|
'output',
|
|
]) ||
|
|
!UUID_PATTERN.test(request.mutationId || '') ||
|
|
!CONTEXT_PATTERN.test(request.context || '') ||
|
|
!UUID_PATTERN.test(request.expectedClusterUid || '') ||
|
|
typeof request.output !== 'string'
|
|
) {
|
|
fail('retirement apply command is invalid');
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: value.operation,
|
|
request: Object.freeze({
|
|
mutationId: request.mutationId,
|
|
preflight: artifact(request.preflight, 'retirement preflight report'),
|
|
...retirementRequest(request),
|
|
output: resolveCanonicalAbsolute(
|
|
request.output,
|
|
'retirement receipt output',
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
if (value.operation === 'cluster.deployment.retirement.receipt.audit') {
|
|
if (!exactKeys(request, ['applyCommand', 'receipt'])) {
|
|
fail('retirement receipt audit command is invalid');
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: value.operation,
|
|
request: Object.freeze({
|
|
applyCommand: artifact(
|
|
request.applyCommand,
|
|
'retirement apply command',
|
|
),
|
|
receipt: artifact(request.receipt, 'retirement receipt'),
|
|
}),
|
|
});
|
|
}
|
|
if (value.operation === 'cluster.deployment.receipt.audit') {
|
|
if (!exactKeys(request, ['applyCommand', 'receipt'])) {
|
|
fail('receipt audit command is invalid');
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: value.operation,
|
|
request: Object.freeze({
|
|
applyCommand: artifact(request.applyCommand, 'apply command'),
|
|
receipt: artifact(request.receipt, 'deployment receipt'),
|
|
}),
|
|
});
|
|
}
|
|
fail('command operation is invalid');
|
|
}
|
|
|
|
function readCommand(input) {
|
|
const commandFile = readCanonicalJson(
|
|
input,
|
|
'deployment command',
|
|
MAX_COMMAND_BYTES,
|
|
);
|
|
return Object.freeze({
|
|
file: commandFile,
|
|
command: parseCommand(commandFile.value),
|
|
commandDigest: commandFile.digest,
|
|
});
|
|
}
|
|
|
|
function validateRelease(release) {
|
|
if (
|
|
!exactKeys(release, ['version', 'sourceRevision', 'sourceRef', 'scope']) ||
|
|
!VERSION_PATTERN.test(release.version || '') ||
|
|
!/^[a-f0-9]{40}$/u.test(release.sourceRevision || '') ||
|
|
release.sourceRef !== `refs/tags/v${release.version}` ||
|
|
!['cluster', 'all'].includes(release.scope)
|
|
) {
|
|
fail('deployment lock release identity is invalid');
|
|
}
|
|
}
|
|
|
|
function validateCatalog(catalog, release, releaseSetDigest) {
|
|
if (
|
|
!exactKeys(catalog, [
|
|
'schema',
|
|
'sourceRepository',
|
|
'workflowIdentity',
|
|
'immutableReference',
|
|
'manifestDigest',
|
|
'consumptionReportDigest',
|
|
'releaseSetDigest',
|
|
'discoveryTagAuthority',
|
|
]) ||
|
|
catalog.schema !== CATALOG_SCHEMA ||
|
|
!REPOSITORY_PATTERN.test(catalog.sourceRepository || '') ||
|
|
catalog.workflowIdentity !==
|
|
`https://github.com/${catalog.sourceRepository}/.github/workflows/ql3-image-release.yml@${release.sourceRef}` ||
|
|
!DIGEST_PATTERN.test(catalog.manifestDigest || '') ||
|
|
!DIGEST_PATTERN.test(catalog.consumptionReportDigest || '') ||
|
|
catalog.releaseSetDigest !== releaseSetDigest ||
|
|
catalog.discoveryTagAuthority !== 'none'
|
|
) {
|
|
fail('deployment lock catalog authority is invalid');
|
|
}
|
|
const match = new RegExp(
|
|
`^ghcr\\.io/([a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?)/qinglong3-release-catalog@${catalog.manifestDigest}$`,
|
|
'u',
|
|
).exec(catalog.immutableReference || '');
|
|
if (!match) fail('deployment lock catalog reference is invalid');
|
|
return match[1];
|
|
}
|
|
|
|
function validateLockReport(report) {
|
|
if (
|
|
!exactKeys(report, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'release',
|
|
'releaseSetDigest',
|
|
'catalog',
|
|
'deploymentFamily',
|
|
'requiredImages',
|
|
'imageOccurrences',
|
|
'manifest',
|
|
'verification',
|
|
'lockDigest',
|
|
]) ||
|
|
report.schemaVersion !== 1 ||
|
|
report.schema !== LOCK_SCHEMA ||
|
|
report.deploymentFamily !== 'cluster' ||
|
|
!DIGEST_PATTERN.test(report.releaseSetDigest || '') ||
|
|
!DIGEST_PATTERN.test(report.lockDigest || '')
|
|
) {
|
|
fail('deployment lock report shape is invalid');
|
|
}
|
|
validateRelease(report.release);
|
|
const repositoryOwner = validateCatalog(
|
|
report.catalog,
|
|
report.release,
|
|
report.releaseSetDigest,
|
|
);
|
|
if (
|
|
!Array.isArray(report.requiredImages) ||
|
|
report.requiredImages.length < 1 ||
|
|
JSON.stringify(report.requiredImages) !==
|
|
JSON.stringify(
|
|
ROLE_ORDER.filter((role) => report.requiredImages.includes(role)),
|
|
) ||
|
|
!Array.isArray(report.imageOccurrences) ||
|
|
report.imageOccurrences.length !== ROLE_ORDER.length
|
|
) {
|
|
fail('deployment lock role closure is invalid');
|
|
}
|
|
const references = {};
|
|
const counts = {};
|
|
for (let index = 0; index < ROLE_ORDER.length; index += 1) {
|
|
const role = ROLE_ORDER[index];
|
|
const occurrence = report.imageOccurrences[index];
|
|
const repository = IMAGE_NAMES[role];
|
|
if (
|
|
!exactKeys(occurrence, ['name', 'reference', 'count']) ||
|
|
occurrence.name !== role ||
|
|
!new RegExp(
|
|
`^ghcr\\.io/${repositoryOwner}/${repository}@sha256:[a-f0-9]{64}$`,
|
|
'u',
|
|
).test(occurrence.reference || '') ||
|
|
!Number.isSafeInteger(occurrence.count) ||
|
|
occurrence.count < 0 ||
|
|
occurrence.count > MAX_RESOURCE_COUNT * 16 ||
|
|
(report.requiredImages.includes(role) && occurrence.count < 1)
|
|
) {
|
|
fail(`deployment lock image occurrence is invalid: ${role}`);
|
|
}
|
|
references[role] = occurrence.reference;
|
|
counts[role] = occurrence.count;
|
|
}
|
|
if (
|
|
!exactKeys(report.manifest, [
|
|
'inputDigest',
|
|
'outputDigest',
|
|
'resources',
|
|
'changedResources',
|
|
'admissionAuthorityCount',
|
|
]) ||
|
|
!DIGEST_PATTERN.test(report.manifest.inputDigest || '') ||
|
|
!DIGEST_PATTERN.test(report.manifest.outputDigest || '') ||
|
|
!Number.isSafeInteger(report.manifest.resources) ||
|
|
report.manifest.resources < 1 ||
|
|
report.manifest.resources > MAX_RESOURCE_COUNT ||
|
|
!Number.isSafeInteger(report.manifest.changedResources) ||
|
|
report.manifest.changedResources < 1 ||
|
|
report.manifest.changedResources > report.manifest.resources ||
|
|
!Number.isSafeInteger(report.manifest.admissionAuthorityCount) ||
|
|
report.manifest.admissionAuthorityCount < 0 ||
|
|
report.manifest.admissionAuthorityCount > report.manifest.resources ||
|
|
!exactKeys(report.verification, [
|
|
'releaseSet',
|
|
'sourceRecordsReplayed',
|
|
'catalogConsumption',
|
|
'externalToolResultsReplayed',
|
|
'unknownImageAuthorities',
|
|
'mutableQingLongImages',
|
|
'networkAccess',
|
|
'kubernetesMutation',
|
|
]) ||
|
|
JSON.stringify(report.verification) !==
|
|
JSON.stringify({
|
|
releaseSet: 'standalone_structure_identity_and_self_digest',
|
|
sourceRecordsReplayed: false,
|
|
catalogConsumption: 'offline_reconstructed',
|
|
externalToolResultsReplayed: false,
|
|
unknownImageAuthorities: 0,
|
|
mutableQingLongImages: 0,
|
|
networkAccess: false,
|
|
kubernetesMutation: false,
|
|
})
|
|
) {
|
|
fail('deployment lock verification is invalid');
|
|
}
|
|
const { lockDigest, ...unsigned } = report;
|
|
if (lockDigest !== sha256(JSON.stringify(unsigned))) {
|
|
fail('deployment lock self digest is invalid');
|
|
}
|
|
return Object.freeze({
|
|
report,
|
|
repositoryOwner,
|
|
references: Object.freeze(references),
|
|
counts: Object.freeze(counts),
|
|
});
|
|
}
|
|
|
|
function validateStructure(value, state, depth = 0) {
|
|
if (depth > MAX_STRUCTURE_DEPTH) fail('locked manifest is too deep');
|
|
if (value === null || typeof value !== 'object') return;
|
|
if (state.seen.has(value)) fail('locked manifest aliases are not allowed');
|
|
state.seen.add(value);
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) validateStructure(entry, state, depth + 1);
|
|
return;
|
|
}
|
|
if (!isPlainObject(value)) fail('locked manifest value is invalid');
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
if (!key || /[\u0000-\u001f\u007f]/u.test(key)) {
|
|
fail('locked manifest key is invalid');
|
|
}
|
|
validateStructure(entry, state, depth + 1);
|
|
}
|
|
}
|
|
|
|
function parseManifest(contents) {
|
|
const resources = [];
|
|
try {
|
|
yaml.loadAll(
|
|
contents,
|
|
(resource) => {
|
|
if (resource !== undefined && resource !== null)
|
|
resources.push(resource);
|
|
},
|
|
{ json: false },
|
|
);
|
|
} catch {
|
|
fail('locked manifest must be duplicate-free YAML');
|
|
}
|
|
if (resources.length < 1 || resources.length > MAX_RESOURCE_COUNT) {
|
|
fail('locked manifest resource count is invalid');
|
|
}
|
|
const state = { seen: new WeakSet() };
|
|
for (const resource of resources) {
|
|
if (!isPlainObject(resource)) fail('locked manifest resource is invalid');
|
|
validateStructure(resource, state);
|
|
if (
|
|
typeof resource.apiVersion !== 'string' ||
|
|
typeof resource.kind !== 'string' ||
|
|
!isPlainObject(resource.metadata) ||
|
|
typeof resource.metadata.name !== 'string'
|
|
) {
|
|
fail('locked manifest resource identity is invalid');
|
|
}
|
|
}
|
|
return resources;
|
|
}
|
|
|
|
function roleFromImage(value) {
|
|
if (typeof value !== 'string') return null;
|
|
for (const role of ROLE_ORDER) {
|
|
if (
|
|
new RegExp(
|
|
`^(?:[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]+)?/)*${IMAGE_NAMES[role]}(?::[^@\\s]+|@sha256:[a-f0-9]{64})$`,
|
|
'u',
|
|
).test(value)
|
|
) {
|
|
return role;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mentionsRoleImageName(value) {
|
|
return (
|
|
typeof value === 'string' &&
|
|
ROLE_ORDER.some((role) => value.includes(IMAGE_NAMES[role]))
|
|
);
|
|
}
|
|
|
|
function podTemplateFor(resource) {
|
|
if (
|
|
['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet'].includes(
|
|
resource.kind,
|
|
)
|
|
) {
|
|
return resource.spec?.template;
|
|
}
|
|
if (resource.kind === 'Job') return resource.spec?.template;
|
|
if (resource.kind === 'CronJob') {
|
|
return resource.spec?.jobTemplate?.spec?.template;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function podSpecFor(resource) {
|
|
if (resource.kind === 'Pod') return resource.spec;
|
|
return podTemplateFor(resource)?.spec;
|
|
}
|
|
|
|
function expectedAnnotations(report) {
|
|
return Object.freeze({
|
|
[ANNOTATION_KEYS.releaseSet]: report.releaseSetDigest,
|
|
[ANNOTATION_KEYS.catalogManifest]: report.catalog.manifestDigest,
|
|
[ANNOTATION_KEYS.catalogReport]: report.catalog.consumptionReportDigest,
|
|
[ANNOTATION_KEYS.sourceRevision]: report.release.sourceRevision,
|
|
[ANNOTATION_KEYS.version]: report.release.version,
|
|
});
|
|
}
|
|
|
|
function assertAnnotations(target, expected, label) {
|
|
const annotations = target?.metadata?.annotations;
|
|
if (!isPlainObject(annotations)) fail(`${label} annotations are invalid`);
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
if (annotations[key] !== value) fail(`${label} annotations drifted`);
|
|
}
|
|
}
|
|
|
|
function inspectContainerList(list, authority, handled, counters) {
|
|
if (list === undefined) return 0;
|
|
if (!Array.isArray(list)) fail('locked manifest container list is invalid');
|
|
let matched = 0;
|
|
for (const container of list) {
|
|
if (!isPlainObject(container)) fail('locked manifest container is invalid');
|
|
const role = roleFromImage(container.image);
|
|
if (role === null) {
|
|
if (mentionsRoleImageName(container.image)) {
|
|
fail('locked manifest container image is malformed');
|
|
}
|
|
continue;
|
|
}
|
|
if (container.image !== authority.references[role]) {
|
|
fail(`locked manifest image authority drifted: ${role}`);
|
|
}
|
|
handled.add(container);
|
|
counters[role] += 1;
|
|
matched += 1;
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
function scanUnhandled(value, authority, handled, parent, parentKey) {
|
|
if (typeof value === 'string') {
|
|
const role = roleFromImage(value);
|
|
if (
|
|
role !== null &&
|
|
(value !== authority.references[role] ||
|
|
parentKey !== 'image' ||
|
|
!handled.has(parent))
|
|
) {
|
|
fail('locked manifest contains an unhandled QingLong image authority');
|
|
}
|
|
if (role === null && mentionsRoleImageName(value)) {
|
|
fail('locked manifest contains a malformed QingLong image authority');
|
|
}
|
|
return;
|
|
}
|
|
if (value === null || typeof value !== 'object') return;
|
|
if (Array.isArray(value)) {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
scanUnhandled(value[index], authority, handled, value, String(index));
|
|
}
|
|
return;
|
|
}
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
scanUnhandled(entry, authority, handled, value, key);
|
|
}
|
|
}
|
|
|
|
function inspectResourceAuthorities(resources, authority, label) {
|
|
const report = authority.report;
|
|
const counters = Object.fromEntries(ROLE_ORDER.map((role) => [role, 0]));
|
|
const handled = new WeakSet();
|
|
const annotations = expectedAnnotations(report);
|
|
let changedResources = 0;
|
|
let admissionAuthorityCount = 0;
|
|
for (const resource of resources) {
|
|
const podSpec = podSpecFor(resource);
|
|
let matched = 0;
|
|
if (podSpec !== undefined) {
|
|
if (!isPlainObject(podSpec)) fail(`${label} Pod spec is invalid`);
|
|
for (const key of [
|
|
'initContainers',
|
|
'containers',
|
|
'ephemeralContainers',
|
|
]) {
|
|
matched += inspectContainerList(
|
|
podSpec[key],
|
|
authority,
|
|
handled,
|
|
counters,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
resource.kind === 'ConfigMap' &&
|
|
resource.metadata.name === 'ql3-plugin-package-secret-action-admission'
|
|
) {
|
|
const role = roleFromImage(resource.data?.image);
|
|
if (
|
|
role !== 'admin' ||
|
|
resource.data.image !== authority.references.admin
|
|
) {
|
|
fail('locked manifest admission authority is invalid');
|
|
}
|
|
handled.add(resource.data);
|
|
counters.admin += 1;
|
|
matched += 1;
|
|
admissionAuthorityCount += 1;
|
|
}
|
|
if (matched > 0) {
|
|
if (
|
|
typeof resource.metadata.namespace !== 'string' ||
|
|
!CONTEXT_PATTERN.test(resource.metadata.namespace)
|
|
) {
|
|
fail(`${label} resource namespace must be explicit`);
|
|
}
|
|
assertAnnotations(resource, annotations, `${label} resource`);
|
|
const template = podTemplateFor(resource);
|
|
if (template !== undefined) {
|
|
assertAnnotations(template, annotations, `${label} Pod template`);
|
|
}
|
|
changedResources += 1;
|
|
}
|
|
}
|
|
for (const resource of resources) {
|
|
scanUnhandled(resource, authority, handled, undefined, undefined);
|
|
}
|
|
if (
|
|
JSON.stringify(counters) !== JSON.stringify(authority.counts) ||
|
|
changedResources !== report.manifest.changedResources ||
|
|
admissionAuthorityCount !== report.manifest.admissionAuthorityCount
|
|
) {
|
|
fail('locked manifest authority counts changed');
|
|
}
|
|
return Object.freeze({
|
|
resourceCount: resources.length,
|
|
changedResources,
|
|
admissionAuthorityCount,
|
|
counts: Object.freeze({ ...counters }),
|
|
});
|
|
}
|
|
|
|
function inspectLockedManifest(contents, authority) {
|
|
const report = authority.report;
|
|
if (sha256(Buffer.from(contents, 'utf8')) !== report.manifest.outputDigest) {
|
|
fail('locked manifest digest changed');
|
|
}
|
|
const resources = parseManifest(contents);
|
|
if (resources.length !== report.manifest.resources) {
|
|
fail('locked manifest resource count changed');
|
|
}
|
|
inspectResourceAuthorities(resources, authority, 'locked manifest');
|
|
return Object.freeze({ resources: Object.freeze(resources) });
|
|
}
|
|
|
|
function resourceIdentity(resource) {
|
|
const namespace = resource.metadata?.namespace ?? '';
|
|
return `${resource.apiVersion}\u0000${resource.kind}\u0000${namespace}\u0000${
|
|
resource.metadata?.name ?? ''
|
|
}`;
|
|
}
|
|
|
|
function resourceInventory(resources) {
|
|
const inventory = resources
|
|
.map((resource) => {
|
|
const namespace = resource.metadata?.namespace ?? null;
|
|
const item = {
|
|
apiVersion: resource.apiVersion,
|
|
kind: resource.kind,
|
|
namespace,
|
|
name: resource.metadata?.name,
|
|
};
|
|
if (
|
|
Object.values(item).some(
|
|
(entry) => entry !== null && (typeof entry !== 'string' || !entry),
|
|
)
|
|
) {
|
|
fail('deployment resource inventory is invalid');
|
|
}
|
|
return Object.freeze(item);
|
|
})
|
|
.sort((left, right) => {
|
|
const leftIdentity = JSON.stringify(left);
|
|
const rightIdentity = JSON.stringify(right);
|
|
return leftIdentity < rightIdentity
|
|
? -1
|
|
: leftIdentity > rightIdentity
|
|
? 1
|
|
: 0;
|
|
});
|
|
const identities = inventory.map((item) => JSON.stringify(item));
|
|
if (new Set(identities).size !== identities.length) {
|
|
fail('deployment resource inventory is duplicated');
|
|
}
|
|
return Object.freeze(inventory);
|
|
}
|
|
|
|
function containsExactString(value, expected, depth = 0) {
|
|
if (depth > MAX_STRUCTURE_DEPTH) fail('locked manifest is too deep');
|
|
if (typeof value === 'string') return value === expected;
|
|
if (value === null || typeof value !== 'object') return false;
|
|
if (Array.isArray(value)) {
|
|
return value.some((entry) =>
|
|
containsExactString(entry, expected, depth + 1),
|
|
);
|
|
}
|
|
return Object.values(value).some((entry) =>
|
|
containsExactString(entry, expected, depth + 1),
|
|
);
|
|
}
|
|
|
|
function deploymentHeadNamespace(resources, authority) {
|
|
const namespaces = new Set(
|
|
resources
|
|
.filter((resource) =>
|
|
containsExactString(resource, authority.references.control),
|
|
)
|
|
.map((resource) => resource.metadata?.namespace)
|
|
.filter((namespace) => typeof namespace === 'string'),
|
|
);
|
|
if (namespaces.size !== 1) {
|
|
fail('deployment control namespace authority is invalid');
|
|
}
|
|
return [...namespaces][0];
|
|
}
|
|
|
|
function containsQingLongAuthority(value, references) {
|
|
if (typeof value === 'string') {
|
|
const role = roleFromImage(value);
|
|
return role !== null && value === references[role];
|
|
}
|
|
if (value === null || typeof value !== 'object') return false;
|
|
if (Array.isArray(value)) {
|
|
return value.some((entry) => containsQingLongAuthority(entry, references));
|
|
}
|
|
return Object.values(value).some((entry) =>
|
|
containsQingLongAuthority(entry, references),
|
|
);
|
|
}
|
|
|
|
function assertDesiredSubset(desired, actual, depth = 0) {
|
|
if (depth > MAX_STRUCTURE_DEPTH) fail('live Kubernetes object is too deep');
|
|
if (desired === null || typeof desired !== 'object') {
|
|
if (desired !== actual) fail('live Kubernetes object drifted');
|
|
return;
|
|
}
|
|
if (Array.isArray(desired)) {
|
|
if (!Array.isArray(actual) || actual.length !== desired.length) {
|
|
fail('live Kubernetes array drifted');
|
|
}
|
|
for (let index = 0; index < desired.length; index += 1) {
|
|
assertDesiredSubset(desired[index], actual[index], depth + 1);
|
|
}
|
|
return;
|
|
}
|
|
if (!isPlainObject(actual)) fail('live Kubernetes object is invalid');
|
|
for (const [key, value] of Object.entries(desired)) {
|
|
if (!Object.hasOwn(actual, key)) fail('live Kubernetes field is missing');
|
|
assertDesiredSubset(value, actual[key], depth + 1);
|
|
}
|
|
}
|
|
|
|
function inspectConvergenceOutput(contents, inputs) {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(contents);
|
|
} catch {
|
|
fail('Kubernetes convergence response is invalid');
|
|
}
|
|
const items = value?.kind === 'List' ? value.items : undefined;
|
|
if (
|
|
!Array.isArray(items) ||
|
|
items.length !== inputs.authority.report.manifest.resources ||
|
|
items.some((item) => !isPlainObject(item))
|
|
) {
|
|
fail('Kubernetes convergence resource count changed');
|
|
}
|
|
const liveByIdentity = new Map();
|
|
for (const item of items) {
|
|
const identity = resourceIdentity(item);
|
|
if (liveByIdentity.has(identity)) {
|
|
fail('Kubernetes convergence identity is duplicated');
|
|
}
|
|
liveByIdentity.set(identity, item);
|
|
}
|
|
const desiredResources = inspectLockedManifest(
|
|
inputs.manifestContents,
|
|
inputs.authority,
|
|
).resources;
|
|
for (const desired of desiredResources) {
|
|
const live = liveByIdentity.get(resourceIdentity(desired));
|
|
if (
|
|
live === undefined ||
|
|
typeof live.metadata?.uid !== 'string' ||
|
|
typeof live.metadata?.resourceVersion !== 'string'
|
|
) {
|
|
fail('Kubernetes convergence identity is unavailable');
|
|
}
|
|
if (
|
|
containsQingLongAuthority(desired, inputs.authority.references) &&
|
|
(!Array.isArray(live.metadata.managedFields) ||
|
|
!live.metadata.managedFields.some(
|
|
(entry) =>
|
|
entry?.manager === FIELD_MANAGER && entry?.operation === 'Apply',
|
|
))
|
|
) {
|
|
fail('Kubernetes convergence field authority is unavailable');
|
|
}
|
|
assertDesiredSubset(desired, live);
|
|
}
|
|
inspectResourceAuthorities(items, inputs.authority, 'live Kubernetes');
|
|
}
|
|
|
|
function validateKubeconfig(contents, context) {
|
|
let config;
|
|
try {
|
|
config = yaml.load(contents, { json: false });
|
|
} catch {
|
|
fail('kubeconfig is invalid');
|
|
}
|
|
if (
|
|
!isPlainObject(config) ||
|
|
!Array.isArray(config.contexts) ||
|
|
!config.contexts.some(
|
|
(entry) =>
|
|
isPlainObject(entry) &&
|
|
entry.name === context &&
|
|
isPlainObject(entry.context) &&
|
|
typeof entry.context.cluster === 'string',
|
|
) ||
|
|
!Array.isArray(config.users)
|
|
) {
|
|
fail('kubeconfig context is unavailable');
|
|
}
|
|
for (const entry of config.users) {
|
|
const user = entry?.user;
|
|
if (
|
|
!isPlainObject(entry) ||
|
|
!isPlainObject(user) ||
|
|
Object.hasOwn(user, 'exec') ||
|
|
Object.hasOwn(user, 'auth-provider')
|
|
) {
|
|
fail('kubeconfig executable authentication is forbidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectInputs(request) {
|
|
const manifest = verifyExpected(
|
|
readStableFile(
|
|
request.lockedManifest.path,
|
|
'locked manifest',
|
|
MAX_MANIFEST_BYTES,
|
|
),
|
|
request.lockedManifest.expectedDigest,
|
|
'locked manifest',
|
|
);
|
|
const reportFile = readCanonicalJson(
|
|
request.lockReport.path,
|
|
'lock report',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const authority = validateLockReport(reportFile.value);
|
|
if (authority.report.lockDigest !== request.lockReport.expectedDigest) {
|
|
fail('lock report digest changed');
|
|
}
|
|
const manifestContents = utf8(manifest, 'locked manifest');
|
|
const inspection = inspectLockedManifest(manifestContents, authority);
|
|
if (manifest.digest !== authority.report.manifest.outputDigest) {
|
|
fail('locked manifest and report digest differ');
|
|
}
|
|
if (
|
|
JSON.stringify(authority.report.requiredImages) !==
|
|
JSON.stringify(ROLE_ORDER)
|
|
) {
|
|
fail('cluster deployment requires the complete role surface');
|
|
}
|
|
const headNamespace = deploymentHeadNamespace(
|
|
inspection.resources,
|
|
authority,
|
|
);
|
|
if (
|
|
inspection.resources.some(
|
|
(resource) =>
|
|
resource.apiVersion === 'v1' &&
|
|
resource.kind === 'ConfigMap' &&
|
|
resource.metadata?.namespace === headNamespace &&
|
|
resource.metadata?.name === HEAD_NAME,
|
|
)
|
|
) {
|
|
fail('locked manifest cannot own the deployment head');
|
|
}
|
|
const executable = verifyExpected(
|
|
readStableFile(request.kubectl.path, 'kubectl', MAX_EXECUTABLE_BYTES, {
|
|
privateFile: false,
|
|
executable: true,
|
|
}),
|
|
request.kubectl.expectedDigest,
|
|
'kubectl',
|
|
);
|
|
const kubeconfig = verifyExpected(
|
|
readStableFile(request.kubeconfig.path, 'kubeconfig', MAX_KUBECONFIG_BYTES),
|
|
request.kubeconfig.expectedDigest,
|
|
'kubeconfig',
|
|
);
|
|
validateKubeconfig(utf8(kubeconfig, 'kubeconfig'), request.context);
|
|
return Object.freeze({
|
|
manifest,
|
|
manifestContents,
|
|
reportFile,
|
|
authority,
|
|
resources: inspection.resources,
|
|
resourceInventory: resourceInventory(inspection.resources),
|
|
headNamespace,
|
|
executable,
|
|
kubeconfig,
|
|
});
|
|
}
|
|
|
|
function inspectRetirementInputs(request) {
|
|
const inputs = inspectInputs(request);
|
|
const curl = verifyExpected(
|
|
readStableFile(request.curl.path, 'curl', MAX_EXECUTABLE_BYTES, {
|
|
privateFile: false,
|
|
executable: true,
|
|
}),
|
|
request.curl.expectedDigest,
|
|
'curl',
|
|
);
|
|
return Object.freeze({ ...inputs, curl });
|
|
}
|
|
|
|
function defaultRunProcess(executable, args, input) {
|
|
const privateHome = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ql3-kubectl-home-'),
|
|
);
|
|
fs.chmodSync(privateHome, 0o700);
|
|
try {
|
|
return spawnSync(executable, args, {
|
|
input,
|
|
encoding: 'utf8',
|
|
timeout: PROCESS_TIMEOUT_MS,
|
|
maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
|
|
shell: false,
|
|
windowsHide: true,
|
|
env: Object.freeze({
|
|
HOME: privateHome,
|
|
XDG_CACHE_HOME: privateHome,
|
|
TMPDIR: privateHome,
|
|
LANG: 'C',
|
|
LC_ALL: 'C',
|
|
NO_COLOR: '1',
|
|
}),
|
|
});
|
|
} finally {
|
|
fs.rmSync(privateHome, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function waitForProxySocket(socketPath, child) {
|
|
const deadline = Date.now() + 5_000;
|
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
if (fs.lstatSync(socketPath).isSocket()) return;
|
|
} catch {}
|
|
if (child.exitCode !== null) break;
|
|
Atomics.wait(sleeper, 0, 0, 25);
|
|
}
|
|
fail('private Kubernetes API proxy did not become ready');
|
|
}
|
|
|
|
function curlUnixRequest(inputs, socketPath, method, requestPath, body) {
|
|
const args = [
|
|
'--silent',
|
|
'--show-error',
|
|
'--unix-socket',
|
|
socketPath,
|
|
'--request',
|
|
method,
|
|
'--header',
|
|
'Accept: application/json',
|
|
'--max-time',
|
|
'60',
|
|
'--output',
|
|
'-',
|
|
'--write-out',
|
|
'\n%{http_code}',
|
|
];
|
|
let input = '';
|
|
if (body !== null) {
|
|
args.push(
|
|
'--header',
|
|
'Content-Type: application/json',
|
|
'--data-binary',
|
|
'@-',
|
|
);
|
|
input = JSON.stringify(body);
|
|
}
|
|
args.push(`http://localhost${requestPath}`);
|
|
const result = spawnSync(inputs.curl.path, args, {
|
|
input,
|
|
encoding: 'utf8',
|
|
timeout: PROCESS_TIMEOUT_MS,
|
|
maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
|
|
shell: false,
|
|
windowsHide: true,
|
|
});
|
|
if (
|
|
result.error ||
|
|
result.status !== 0 ||
|
|
typeof result.stdout !== 'string'
|
|
) {
|
|
fail('private Kubernetes API request could not complete safely');
|
|
}
|
|
const boundary = result.stdout.lastIndexOf('\n');
|
|
const statusText = result.stdout.slice(boundary + 1);
|
|
const responseBody = result.stdout.slice(0, boundary);
|
|
if (
|
|
boundary < 0 ||
|
|
!/^\d{3}$/u.test(statusText) ||
|
|
Buffer.byteLength(responseBody, 'utf8') > MAX_PROCESS_OUTPUT_BYTES
|
|
) {
|
|
fail('private Kubernetes API response is invalid');
|
|
}
|
|
return Object.freeze({ status: Number(statusText), body: responseBody });
|
|
}
|
|
|
|
function defaultRetirementApi(inputs, request, callback) {
|
|
const privateHome = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ql3-kubectl-retirement-'),
|
|
);
|
|
fs.chmodSync(privateHome, 0o700);
|
|
const socketPath = path.join(privateHome, 'api.sock');
|
|
const args = [
|
|
`--kubeconfig=${inputs.kubeconfig.path}`,
|
|
`--context=${request.context}`,
|
|
'--request-timeout=60s',
|
|
'proxy',
|
|
`--unix-socket=${socketPath}`,
|
|
'--api-prefix=/',
|
|
'--www=',
|
|
'--accept-paths=^/(api|apis)/.*$',
|
|
'--reject-methods=^(POST|PUT|PATCH|CONNECT|OPTIONS|TRACE)$',
|
|
'--keepalive=0s',
|
|
];
|
|
let child;
|
|
try {
|
|
child = spawn(inputs.executable.path, args, {
|
|
stdio: 'ignore',
|
|
shell: false,
|
|
windowsHide: true,
|
|
env: Object.freeze({
|
|
HOME: privateHome,
|
|
XDG_CACHE_HOME: privateHome,
|
|
TMPDIR: privateHome,
|
|
LANG: 'C',
|
|
LC_ALL: 'C',
|
|
NO_COLOR: '1',
|
|
}),
|
|
});
|
|
waitForProxySocket(socketPath, child);
|
|
return callback(
|
|
Object.freeze({
|
|
request(method, requestPath, body = null) {
|
|
return curlUnixRequest(inputs, socketPath, method, requestPath, body);
|
|
},
|
|
}),
|
|
);
|
|
} finally {
|
|
if (child?.pid) child.kill('SIGTERM');
|
|
try {
|
|
fs.rmSync(privateHome, { recursive: true, force: true });
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function withRetirementApi(inputs, request, dependencies, callback) {
|
|
if (dependencies.retirementApi !== undefined) {
|
|
if (typeof dependencies.retirementApi?.request !== 'function') {
|
|
fail('retirement API dependency is invalid');
|
|
}
|
|
return callback(dependencies.retirementApi);
|
|
}
|
|
return defaultRetirementApi(inputs, request, callback);
|
|
}
|
|
|
|
function recordedApiRequest(records, api, name, method, requestPath, body) {
|
|
const response = api.request(method, requestPath, body);
|
|
if (
|
|
!Number.isInteger(response?.status) ||
|
|
response.status < 100 ||
|
|
response.status > 599 ||
|
|
typeof response.body !== 'string' ||
|
|
Buffer.byteLength(response.body, 'utf8') > MAX_PROCESS_OUTPUT_BYTES
|
|
) {
|
|
fail('retirement API dependency response is invalid');
|
|
}
|
|
records.push(
|
|
Object.freeze({
|
|
name,
|
|
method,
|
|
requestPathDigest: sha256(Buffer.from(requestPath, 'utf8')),
|
|
requestDigest: sha256(
|
|
Buffer.from(body === null ? '' : JSON.stringify(body), 'utf8'),
|
|
),
|
|
status: response.status,
|
|
responseDigest: sha256(Buffer.from(response.body, 'utf8')),
|
|
responseBytes: Buffer.byteLength(response.body, 'utf8'),
|
|
}),
|
|
);
|
|
return response;
|
|
}
|
|
|
|
function aggregateRequestStep(name, records) {
|
|
const stdoutBytes = records.reduce(
|
|
(total, record) => total + record.responseBytes,
|
|
0,
|
|
);
|
|
if (records.length < 1 || stdoutBytes > MAX_PROCESS_OUTPUT_BYTES) {
|
|
fail('retirement API transcript exceeds its bound');
|
|
}
|
|
return Object.freeze({
|
|
name,
|
|
status: 0,
|
|
argvDigest: sha256(
|
|
Buffer.from(
|
|
JSON.stringify(
|
|
records.map((record) => ({
|
|
name: record.name,
|
|
method: record.method,
|
|
requestPathDigest: record.requestPathDigest,
|
|
requestDigest: record.requestDigest,
|
|
status: record.status,
|
|
})),
|
|
),
|
|
'utf8',
|
|
),
|
|
),
|
|
stdoutDigest: sha256(
|
|
Buffer.from(
|
|
JSON.stringify(records.map((record) => record.responseDigest)),
|
|
'utf8',
|
|
),
|
|
),
|
|
stdoutBytes,
|
|
stderrDigest: sha256(Buffer.alloc(0)),
|
|
stderrBytes: 0,
|
|
});
|
|
}
|
|
|
|
function runStep(
|
|
inputs,
|
|
request,
|
|
name,
|
|
argumentsAfterTarget,
|
|
runner,
|
|
input = inputs.manifestContents,
|
|
) {
|
|
const args = [
|
|
`--kubeconfig=${inputs.kubeconfig.path}`,
|
|
`--context=${request.context}`,
|
|
'--request-timeout=60s',
|
|
...argumentsAfterTarget,
|
|
];
|
|
const result = runner(inputs.executable.path, args, input);
|
|
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
const stderr = typeof result.stderr === 'string' ? result.stderr : '';
|
|
if (
|
|
result.error ||
|
|
!Number.isInteger(result.status) ||
|
|
Buffer.byteLength(stdout, 'utf8') > MAX_PROCESS_OUTPUT_BYTES ||
|
|
Buffer.byteLength(stderr, 'utf8') > MAX_PROCESS_OUTPUT_BYTES
|
|
) {
|
|
fail(`kubectl ${name} could not complete safely`);
|
|
}
|
|
return Object.freeze({
|
|
name,
|
|
status: result.status,
|
|
argvDigest: sha256(Buffer.from(JSON.stringify(args), 'utf8')),
|
|
stdoutDigest: sha256(Buffer.from(stdout, 'utf8')),
|
|
stdoutBytes: Buffer.byteLength(stdout, 'utf8'),
|
|
stderrDigest: sha256(Buffer.from(stderr, 'utf8')),
|
|
stderrBytes: Buffer.byteLength(stderr, 'utf8'),
|
|
stdout,
|
|
});
|
|
}
|
|
|
|
function identityStep(inputs, request, runner, name) {
|
|
const step = runStep(
|
|
inputs,
|
|
request,
|
|
name,
|
|
['get', 'namespace', 'kube-system', '-o=jsonpath={.metadata.uid}'],
|
|
runner,
|
|
);
|
|
if (step.status !== 0 || step.stdout.trim() !== request.expectedClusterUid) {
|
|
fail('Kubernetes cluster identity changed');
|
|
}
|
|
const { stdout, ...record } = step;
|
|
return Object.freeze(record);
|
|
}
|
|
|
|
function dryRunStep(inputs, request, runner, name = 'server_side_dry_run') {
|
|
const step = runStep(
|
|
inputs,
|
|
request,
|
|
name,
|
|
[
|
|
'apply',
|
|
'--server-side',
|
|
'--dry-run=server',
|
|
`--field-manager=${FIELD_MANAGER}`,
|
|
'--validate=strict',
|
|
'-f=-',
|
|
'-o=name',
|
|
],
|
|
runner,
|
|
);
|
|
if (step.status !== 0) fail('Kubernetes server-side dry-run was rejected');
|
|
const { stdout, ...record } = step;
|
|
return Object.freeze(record);
|
|
}
|
|
|
|
function convergenceReadStep(inputs, request, runner) {
|
|
const step = runStep(
|
|
inputs,
|
|
request,
|
|
'server_side_convergence_read',
|
|
['get', '-f=-', '-o=json', '--show-managed-fields=true'],
|
|
runner,
|
|
);
|
|
if (step.status !== 0) fail('Kubernetes convergence read failed');
|
|
inspectConvergenceOutput(step.stdout, inputs);
|
|
const { stdout, ...record } = step;
|
|
return Object.freeze(record);
|
|
}
|
|
|
|
function revalidateStableInputs(inputs, request) {
|
|
for (const [label, previous, maximumBytes, options] of [
|
|
['locked manifest', inputs.manifest, MAX_MANIFEST_BYTES, {}],
|
|
['lock report', inputs.reportFile, MAX_REPORT_BYTES, {}],
|
|
[
|
|
'kubectl',
|
|
inputs.executable,
|
|
MAX_EXECUTABLE_BYTES,
|
|
{ privateFile: false, executable: true },
|
|
],
|
|
['kubeconfig', inputs.kubeconfig, MAX_KUBECONFIG_BYTES, {}],
|
|
]) {
|
|
const current = readStableFile(previous.path, label, maximumBytes, options);
|
|
if (
|
|
current.digest !== previous.digest ||
|
|
!sameIdentity(current.identity, previous.identity)
|
|
) {
|
|
fail(`${label} changed during the ceremony`);
|
|
}
|
|
}
|
|
if (
|
|
inputs.manifest.digest !== request.lockedManifest.expectedDigest ||
|
|
inputs.authority.report.lockDigest !== request.lockReport.expectedDigest ||
|
|
inputs.executable.digest !== request.kubectl.expectedDigest ||
|
|
inputs.kubeconfig.digest !== request.kubeconfig.expectedDigest
|
|
) {
|
|
fail('deployment input authority changed');
|
|
}
|
|
}
|
|
|
|
function revalidateRetirementInputs(inputs, request) {
|
|
revalidateStableInputs(inputs, request);
|
|
const current = readStableFile(
|
|
inputs.curl.path,
|
|
'curl',
|
|
MAX_EXECUTABLE_BYTES,
|
|
{ privateFile: false, executable: true },
|
|
);
|
|
if (
|
|
current.digest !== inputs.curl.digest ||
|
|
!sameIdentity(current.identity, inputs.curl.identity) ||
|
|
current.digest !== request.curl.expectedDigest
|
|
) {
|
|
fail('curl changed during the retirement ceremony');
|
|
}
|
|
}
|
|
|
|
function lockSummary(inputs) {
|
|
const report = inputs.authority.report;
|
|
return Object.freeze({
|
|
lockDigest: report.lockDigest,
|
|
manifestDigest: inputs.manifest.digest,
|
|
lockReportDigest: inputs.reportFile.digest,
|
|
releaseSetDigest: report.releaseSetDigest,
|
|
catalogManifestDigest: report.catalog.manifestDigest,
|
|
catalogReportDigest: report.catalog.consumptionReportDigest,
|
|
catalogImmutableReference: report.catalog.immutableReference,
|
|
release: Object.freeze({ ...report.release }),
|
|
requiredImages: Object.freeze([...report.requiredImages]),
|
|
});
|
|
}
|
|
|
|
function targetSummary(inputs, request) {
|
|
return Object.freeze({
|
|
context: request.context,
|
|
clusterUid: request.expectedClusterUid,
|
|
kubeconfigDigest: inputs.kubeconfig.digest,
|
|
fieldManager: FIELD_MANAGER,
|
|
});
|
|
}
|
|
|
|
function toolSummary(inputs) {
|
|
return Object.freeze({
|
|
name: 'kubectl',
|
|
executableDigest: inputs.executable.digest,
|
|
});
|
|
}
|
|
|
|
function retirementToolSummary(inputs) {
|
|
return Object.freeze({
|
|
kubectl: toolSummary(inputs),
|
|
curl: Object.freeze({
|
|
name: 'curl',
|
|
executableDigest: inputs.curl.digest,
|
|
}),
|
|
transport: 'owner_private_unix_socket_proxy',
|
|
});
|
|
}
|
|
|
|
function validateRetirementToolSummary(value) {
|
|
if (
|
|
!exactKeys(value, ['kubectl', 'curl', 'transport']) ||
|
|
value.transport !== 'owner_private_unix_socket_proxy' ||
|
|
!exactKeys(value.curl, ['name', 'executableDigest']) ||
|
|
value.curl.name !== 'curl' ||
|
|
!DIGEST_PATTERN.test(value.curl.executableDigest || '')
|
|
) {
|
|
fail('retirement tool summary is invalid');
|
|
}
|
|
validateToolSummary(value.kubectl);
|
|
}
|
|
|
|
function inventoryKey(value) {
|
|
return JSON.stringify({
|
|
apiVersion: value.apiVersion,
|
|
kind: value.kind,
|
|
namespace: value.namespace ?? value.metadata?.namespace ?? null,
|
|
name: value.name ?? value.metadata?.name,
|
|
});
|
|
}
|
|
|
|
function retirementPlan(inputs, current, targets) {
|
|
if (current.state === null || current.state.phase !== 'committed') {
|
|
fail('deployment retirement requires a committed head');
|
|
}
|
|
const currentDeployment = current.state.deployment;
|
|
if (
|
|
JSON.stringify(currentDeployment.lock) !==
|
|
JSON.stringify(lockSummary(inputs))
|
|
) {
|
|
fail('deployment retirement lock is not the active lock');
|
|
}
|
|
const manifestByIdentity = new Map(
|
|
inputs.resources.map((resource) => [inventoryKey(resource), resource]),
|
|
);
|
|
const activeKeys = new Set(
|
|
currentDeployment.resources.map((resource) => inventoryKey(resource)),
|
|
);
|
|
if (
|
|
activeKeys.size !== currentDeployment.resources.length ||
|
|
[...activeKeys].some((identity) => !manifestByIdentity.has(identity))
|
|
) {
|
|
fail('active deployment inventory is not backed by its lock');
|
|
}
|
|
const retiredKeys = new Set(targets.map((target) => inventoryKey(target)));
|
|
if ([...retiredKeys].some((identity) => !activeKeys.has(identity))) {
|
|
fail('deployment retirement target is not active');
|
|
}
|
|
const survivors = Object.freeze(
|
|
currentDeployment.resources.filter(
|
|
(resource) => !retiredKeys.has(inventoryKey(resource)),
|
|
),
|
|
);
|
|
if (survivors.length < 1) {
|
|
fail('deployment retirement cannot empty the active inventory');
|
|
}
|
|
const survivorResources = survivors.map((item) =>
|
|
manifestByIdentity.get(inventoryKey(item)),
|
|
);
|
|
for (const role of ROLE_ORDER) {
|
|
if (
|
|
!survivorResources.some((resource) =>
|
|
containsExactString(resource, inputs.authority.references[role]),
|
|
)
|
|
) {
|
|
fail(`deployment retirement would remove the last ${role} authority`);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
active: currentDeployment.resources,
|
|
survivors,
|
|
resources: Object.freeze(
|
|
targets.map((target) => manifestByIdentity.get(inventoryKey(target))),
|
|
),
|
|
});
|
|
}
|
|
|
|
function apiVersionBase(apiVersion) {
|
|
if (apiVersion === 'v1') return '/api/v1';
|
|
const segments = apiVersion.split('/');
|
|
if (
|
|
segments.length !== 2 ||
|
|
segments.some(
|
|
(segment) => !segment || /[\u0000-\u001f\u007f]/u.test(segment),
|
|
)
|
|
) {
|
|
fail('retirement API version is invalid');
|
|
}
|
|
return `/apis/${encodeURIComponent(segments[0])}/${encodeURIComponent(
|
|
segments[1],
|
|
)}`;
|
|
}
|
|
|
|
function apiResourcePath(api, item, cache, records) {
|
|
const base = apiVersionBase(item.apiVersion);
|
|
let resources = cache.get(base);
|
|
if (resources === undefined) {
|
|
const response = recordedApiRequest(
|
|
records,
|
|
api,
|
|
'api_discovery',
|
|
'GET',
|
|
base,
|
|
null,
|
|
);
|
|
if (response.status !== 200) fail('Kubernetes API discovery failed');
|
|
let discovery;
|
|
try {
|
|
discovery = JSON.parse(response.body);
|
|
} catch {
|
|
fail('Kubernetes API discovery response is invalid');
|
|
}
|
|
if (!Array.isArray(discovery?.resources)) {
|
|
fail('Kubernetes API discovery response is invalid');
|
|
}
|
|
resources = discovery.resources;
|
|
cache.set(base, resources);
|
|
}
|
|
const candidates = resources.filter(
|
|
(resource) =>
|
|
resource?.kind === item.kind &&
|
|
typeof resource?.name === 'string' &&
|
|
!resource.name.includes('/') &&
|
|
Array.isArray(resource.verbs) &&
|
|
resource.verbs.includes('get') &&
|
|
resource.verbs.includes('delete') &&
|
|
resource.namespaced === (item.namespace !== null),
|
|
);
|
|
if (
|
|
candidates.length !== 1 ||
|
|
!/^[a-z0-9](?:[a-z0-9.-]{0,252})$/u.test(candidates[0].name)
|
|
) {
|
|
fail('retirement API resource mapping is ambiguous');
|
|
}
|
|
const collection =
|
|
item.namespace === null
|
|
? `${base}/${candidates[0].name}`
|
|
: `${base}/namespaces/${encodeURIComponent(item.namespace)}/${
|
|
candidates[0].name
|
|
}`;
|
|
return `${collection}/${encodeURIComponent(item.name)}`;
|
|
}
|
|
|
|
function parseRetirementObject(response, desired, expected = null) {
|
|
let live;
|
|
try {
|
|
live = JSON.parse(response.body);
|
|
} catch {
|
|
fail('retirement target response is invalid');
|
|
}
|
|
if (
|
|
response.status !== 200 ||
|
|
inventoryKey(live) !== inventoryKey(desired) ||
|
|
!UUID_PATTERN.test(live.metadata?.uid || '') ||
|
|
typeof live.metadata?.resourceVersion !== 'string' ||
|
|
!live.metadata.resourceVersion ||
|
|
live.metadata.deletionTimestamp !== undefined ||
|
|
!Array.isArray(live.metadata.managedFields) ||
|
|
!live.metadata.managedFields.some(
|
|
(entry) =>
|
|
entry?.manager === FIELD_MANAGER && entry?.operation === 'Apply',
|
|
)
|
|
) {
|
|
fail('retirement target authority is invalid');
|
|
}
|
|
assertDesiredSubset(desired, live);
|
|
const observation = Object.freeze({
|
|
apiVersion: desired.apiVersion,
|
|
kind: desired.kind,
|
|
namespace: desired.metadata.namespace,
|
|
name: desired.metadata.name,
|
|
uid: live.metadata.uid,
|
|
resourceVersion: live.metadata.resourceVersion,
|
|
});
|
|
if (
|
|
expected !== null &&
|
|
JSON.stringify(observation) !== JSON.stringify(expected)
|
|
) {
|
|
fail('retirement target changed after preflight');
|
|
}
|
|
return observation;
|
|
}
|
|
|
|
function readRetirementTargets(api, resources, records) {
|
|
const cache = new Map();
|
|
return Object.freeze(
|
|
resources.map((desired) => {
|
|
const item = {
|
|
apiVersion: desired.apiVersion,
|
|
kind: desired.kind,
|
|
namespace: desired.metadata?.namespace ?? null,
|
|
name: desired.metadata?.name,
|
|
};
|
|
const requestPath = apiResourcePath(api, item, cache, records);
|
|
const response = recordedApiRequest(
|
|
records,
|
|
api,
|
|
'retirement_target_get',
|
|
'GET',
|
|
requestPath,
|
|
null,
|
|
);
|
|
return Object.freeze({
|
|
observation: parseRetirementObject(response, desired),
|
|
requestPath,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
function deleteOptions(observation, dryRun) {
|
|
return Object.freeze({
|
|
apiVersion: 'v1',
|
|
kind: 'DeleteOptions',
|
|
...(dryRun ? { dryRun: Object.freeze(['All']) } : {}),
|
|
propagationPolicy: 'Background',
|
|
preconditions: Object.freeze({
|
|
uid: observation.uid,
|
|
resourceVersion: observation.resourceVersion,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function validateResourceInventory(value) {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.length < 1 ||
|
|
value.length > MAX_RESOURCE_COUNT
|
|
) {
|
|
fail('deployment resource inventory is invalid');
|
|
}
|
|
const serialized = [];
|
|
for (const item of value) {
|
|
if (
|
|
!exactKeys(item, ['apiVersion', 'kind', 'namespace', 'name']) ||
|
|
typeof item.apiVersion !== 'string' ||
|
|
!item.apiVersion ||
|
|
typeof item.kind !== 'string' ||
|
|
!item.kind ||
|
|
(item.namespace !== null &&
|
|
(typeof item.namespace !== 'string' || !item.namespace)) ||
|
|
typeof item.name !== 'string' ||
|
|
!item.name
|
|
) {
|
|
fail('deployment resource inventory is invalid');
|
|
}
|
|
serialized.push(JSON.stringify(item));
|
|
}
|
|
if (
|
|
new Set(serialized).size !== serialized.length ||
|
|
JSON.stringify([...serialized].sort()) !== JSON.stringify(serialized)
|
|
) {
|
|
fail('deployment resource inventory ordering is invalid');
|
|
}
|
|
}
|
|
|
|
function validateRetirementObservations(value) {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.length < 1 ||
|
|
value.length > MAX_RETIREMENT_COUNT
|
|
) {
|
|
fail('deployment retirement observations are invalid');
|
|
}
|
|
const identities = [];
|
|
for (const item of value) {
|
|
if (
|
|
!exactKeys(item, [
|
|
'apiVersion',
|
|
'kind',
|
|
'namespace',
|
|
'name',
|
|
'uid',
|
|
'resourceVersion',
|
|
]) ||
|
|
item.namespace === null ||
|
|
!UUID_PATTERN.test(item.uid || '') ||
|
|
typeof item.resourceVersion !== 'string' ||
|
|
!item.resourceVersion
|
|
) {
|
|
fail('deployment retirement observation is invalid');
|
|
}
|
|
retirementTargets([
|
|
{
|
|
apiVersion: item.apiVersion,
|
|
kind: item.kind,
|
|
namespace: item.namespace,
|
|
name: item.name,
|
|
},
|
|
]);
|
|
identities.push(inventoryKey(item));
|
|
}
|
|
if (
|
|
new Set(identities).size !== identities.length ||
|
|
JSON.stringify([...identities].sort()) !== JSON.stringify(identities)
|
|
) {
|
|
fail('deployment retirement observation ordering is invalid');
|
|
}
|
|
}
|
|
|
|
function deploymentPlan(inputs) {
|
|
return Object.freeze({
|
|
lock: lockSummary(inputs),
|
|
resources: inputs.resourceInventory,
|
|
});
|
|
}
|
|
|
|
function validateDeploymentPlan(value) {
|
|
if (!exactKeys(value, ['lock', 'resources'])) {
|
|
fail('deployment plan is invalid');
|
|
}
|
|
validateLockSummary(value.lock);
|
|
validateResourceInventory(value.resources);
|
|
}
|
|
|
|
function validateDeploymentSummary(value) {
|
|
if (!exactKeys(value, ['deploymentDigest', 'lock', 'resources'])) {
|
|
fail('deployment summary is invalid');
|
|
}
|
|
if (!DIGEST_PATTERN.test(value.deploymentDigest || '')) {
|
|
fail('deployment summary digest is invalid');
|
|
}
|
|
validateLockSummary(value.lock);
|
|
validateResourceInventory(value.resources);
|
|
}
|
|
|
|
function headSnapshot(namespace, state) {
|
|
if (state === null) {
|
|
return Object.freeze({
|
|
namespace,
|
|
name: HEAD_NAME,
|
|
generation: 0,
|
|
deploymentDigest: null,
|
|
lockDigest: null,
|
|
stateDigest: null,
|
|
});
|
|
}
|
|
if (state.phase !== 'committed') {
|
|
fail('deployment head is not committed');
|
|
}
|
|
return Object.freeze({
|
|
namespace,
|
|
name: HEAD_NAME,
|
|
generation: state.generation,
|
|
deploymentDigest: state.deployment.deploymentDigest,
|
|
lockDigest: state.deployment.lock.lockDigest,
|
|
stateDigest: state.stateDigest,
|
|
});
|
|
}
|
|
|
|
function validateHeadSnapshot(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'namespace',
|
|
'name',
|
|
'generation',
|
|
'deploymentDigest',
|
|
'lockDigest',
|
|
'stateDigest',
|
|
]) ||
|
|
!CONTEXT_PATTERN.test(value.namespace || '') ||
|
|
value.name !== HEAD_NAME
|
|
) {
|
|
fail('deployment head snapshot is invalid');
|
|
}
|
|
expectedHead({
|
|
generation: value.generation,
|
|
deploymentDigest: value.deploymentDigest,
|
|
lockDigest: value.lockDigest,
|
|
stateDigest: value.stateDigest,
|
|
});
|
|
}
|
|
|
|
function expectedSnapshot(request, namespace) {
|
|
return Object.freeze({
|
|
namespace,
|
|
name: HEAD_NAME,
|
|
...request.expectedHead,
|
|
});
|
|
}
|
|
|
|
function validateTransition(current, request, planned) {
|
|
const expected = expectedSnapshot(request, current.namespace);
|
|
if (JSON.stringify(expected) !== JSON.stringify(current.snapshot)) {
|
|
fail('deployment head compare-and-swap authority changed');
|
|
}
|
|
if (request.transitionKind === 'install') {
|
|
if (current.state !== null)
|
|
fail('deployment install requires an empty head');
|
|
return;
|
|
}
|
|
if (current.state === null || current.state.phase !== 'committed') {
|
|
fail('deployment transition requires a committed head');
|
|
}
|
|
const active = current.state.deployment;
|
|
const activeVersion = active.lock.release.version;
|
|
const plannedVersion = planned.lock.release.version;
|
|
if (request.transitionKind === 'upgrade') {
|
|
if (!semver.gt(plannedVersion, activeVersion)) {
|
|
fail('deployment upgrade version must increase');
|
|
}
|
|
const plannedResources = new Set(
|
|
planned.resources.map((item) => JSON.stringify(item)),
|
|
);
|
|
if (
|
|
active.resources.some(
|
|
(item) => !plannedResources.has(JSON.stringify(item)),
|
|
)
|
|
) {
|
|
fail('deployment upgrade cannot retire resources implicitly');
|
|
}
|
|
return;
|
|
}
|
|
const previous = current.state.previous;
|
|
const versionRollback = semver.lt(plannedVersion, activeVersion);
|
|
const retirementRestore =
|
|
current.state.transition.kind === 'retire' &&
|
|
plannedVersion === activeVersion &&
|
|
previous !== null &&
|
|
previous.lock.release.version === activeVersion;
|
|
if (
|
|
previous === null ||
|
|
(!versionRollback && !retirementRestore) ||
|
|
JSON.stringify(planned.lock) !== JSON.stringify(previous.lock) ||
|
|
JSON.stringify(planned.resources) !== JSON.stringify(previous.resources)
|
|
) {
|
|
fail('deployment rollback must restore the exact previous resource set');
|
|
}
|
|
}
|
|
|
|
function validateHeadTransition(value) {
|
|
const retirement = value?.kind === 'retire';
|
|
if (
|
|
!exactKeys(value, [
|
|
'kind',
|
|
'mutationId',
|
|
'commandDigest',
|
|
'preflightDigest',
|
|
'expectedHead',
|
|
...(retirement ? ['retirements', 'curlExecutableDigest'] : []),
|
|
]) ||
|
|
!HEAD_TRANSITION_KINDS.includes(value.kind) ||
|
|
!UUID_PATTERN.test(value.mutationId || '') ||
|
|
!DIGEST_PATTERN.test(value.commandDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.preflightDigest || '') ||
|
|
(retirement && !DIGEST_PATTERN.test(value.curlExecutableDigest || ''))
|
|
) {
|
|
fail('deployment head transition is invalid');
|
|
}
|
|
validateHeadSnapshot(value.expectedHead);
|
|
if (retirement) validateRetirementObservations(value.retirements);
|
|
}
|
|
|
|
function deploymentEvidence(state) {
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
schema: 'qinglong/kubernetes-deployment-evidence@v1',
|
|
generation: state.generation,
|
|
transition: state.transition,
|
|
previous: state.previous,
|
|
planned: state.planned,
|
|
target: state.target,
|
|
tool: state.tool,
|
|
steps: state.steps,
|
|
});
|
|
}
|
|
|
|
function validateHeadState(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'phase',
|
|
'generation',
|
|
'transition',
|
|
'previous',
|
|
'planned',
|
|
'target',
|
|
'tool',
|
|
'steps',
|
|
'deployment',
|
|
'stateDigest',
|
|
]) ||
|
|
value.schemaVersion !== 1 ||
|
|
value.schema !== HEAD_SCHEMA ||
|
|
!['applying', 'committed'].includes(value.phase) ||
|
|
!Number.isSafeInteger(value.generation) ||
|
|
value.generation < 1 ||
|
|
!DIGEST_PATTERN.test(value.stateDigest || '') ||
|
|
!Array.isArray(value.steps)
|
|
) {
|
|
fail('deployment head state is invalid');
|
|
}
|
|
validateHeadTransition(value.transition);
|
|
if (value.previous !== null) validateDeploymentSummary(value.previous);
|
|
validateDeploymentPlan(value.planned);
|
|
validateTargetSummary(value.target);
|
|
validateToolSummary(value.tool);
|
|
for (const step of value.steps) validateStepRecord(step);
|
|
if (value.steps.some((step) => step.status !== 0)) {
|
|
fail('deployment head contains a failed step');
|
|
}
|
|
if (value.phase === 'applying') {
|
|
if (value.steps.length !== 0 || value.deployment !== null) {
|
|
fail('applying deployment head is invalid');
|
|
}
|
|
} else {
|
|
const expectedStepNames =
|
|
value.transition.kind === 'retire'
|
|
? [
|
|
'cluster_identity_before',
|
|
'resource_precondition_delete',
|
|
'retirement_absence_read',
|
|
'cluster_identity_after',
|
|
]
|
|
: [
|
|
'cluster_identity_before',
|
|
'server_side_dry_run',
|
|
'server_side_apply',
|
|
'server_side_convergence_read',
|
|
'cluster_identity_after',
|
|
];
|
|
if (
|
|
value.steps.length !== expectedStepNames.length ||
|
|
JSON.stringify(value.steps.map((step) => step.name)) !==
|
|
JSON.stringify(expectedStepNames) ||
|
|
value.deployment === null ||
|
|
JSON.stringify(value.deployment.lock) !==
|
|
JSON.stringify(value.planned.lock) ||
|
|
JSON.stringify(value.deployment.resources) !==
|
|
JSON.stringify(value.planned.resources)
|
|
) {
|
|
fail('committed deployment head is invalid');
|
|
}
|
|
validateDeploymentSummary(value.deployment);
|
|
const expectedDigest = sha256(
|
|
Buffer.from(JSON.stringify(deploymentEvidence(value)), 'utf8'),
|
|
);
|
|
if (value.deployment.deploymentDigest !== expectedDigest) {
|
|
fail('deployment evidence digest is invalid');
|
|
}
|
|
}
|
|
const { stateDigest, ...unsigned } = value;
|
|
if (stateDigest !== sha256(Buffer.from(JSON.stringify(unsigned), 'utf8'))) {
|
|
fail('deployment head self digest is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function headReadStep(inputs, request, runner) {
|
|
const step = runStep(
|
|
inputs,
|
|
request,
|
|
'deployment_head_read',
|
|
[
|
|
'get',
|
|
'configmap',
|
|
HEAD_NAME,
|
|
`--namespace=${inputs.headNamespace}`,
|
|
'--ignore-not-found=true',
|
|
'-o=json',
|
|
],
|
|
runner,
|
|
'',
|
|
);
|
|
if (step.status !== 0) fail('deployment head read failed');
|
|
const { stdout: _stdout, ...record } = step;
|
|
if (!step.stdout.trim()) {
|
|
return Object.freeze({
|
|
state: null,
|
|
resourceVersion: null,
|
|
namespace: inputs.headNamespace,
|
|
snapshot: headSnapshot(inputs.headNamespace, null),
|
|
record: Object.freeze(record),
|
|
});
|
|
}
|
|
let configMap;
|
|
let state;
|
|
try {
|
|
configMap = JSON.parse(step.stdout);
|
|
state = JSON.parse(configMap?.data?.[HEAD_DATA_KEY]);
|
|
} catch {
|
|
fail('deployment head response is invalid');
|
|
}
|
|
if (
|
|
configMap?.apiVersion !== 'v1' ||
|
|
configMap?.kind !== 'ConfigMap' ||
|
|
configMap?.metadata?.name !== HEAD_NAME ||
|
|
configMap?.metadata?.namespace !== inputs.headNamespace ||
|
|
typeof configMap?.metadata?.resourceVersion !== 'string' ||
|
|
!configMap.metadata.resourceVersion
|
|
) {
|
|
fail('deployment head identity is invalid');
|
|
}
|
|
validateHeadState(state);
|
|
return Object.freeze({
|
|
state,
|
|
resourceVersion: configMap.metadata.resourceVersion,
|
|
namespace: inputs.headNamespace,
|
|
snapshot:
|
|
state.phase === 'committed'
|
|
? headSnapshot(inputs.headNamespace, state)
|
|
: null,
|
|
record: Object.freeze(record),
|
|
});
|
|
}
|
|
|
|
function headConfigMap(inputs, state, resourceVersion) {
|
|
return JSON.stringify({
|
|
apiVersion: 'v1',
|
|
kind: 'ConfigMap',
|
|
metadata: {
|
|
name: HEAD_NAME,
|
|
namespace: inputs.headNamespace,
|
|
labels: { 'app.kubernetes.io/managed-by': FIELD_MANAGER },
|
|
...(resourceVersion === null ? {} : { resourceVersion }),
|
|
},
|
|
data: { [HEAD_DATA_KEY]: JSON.stringify(state) },
|
|
});
|
|
}
|
|
|
|
function writeHead(inputs, request, runner, state, resourceVersion, name) {
|
|
const operation = resourceVersion === null ? 'create' : 'replace';
|
|
const step = runStep(
|
|
inputs,
|
|
request,
|
|
name,
|
|
[operation, '--validate=strict', '-f=-', '-o=json'],
|
|
runner,
|
|
headConfigMap(inputs, state, resourceVersion),
|
|
);
|
|
if (step.status !== 0) fail(`deployment head ${operation} was rejected`);
|
|
let written;
|
|
try {
|
|
written = JSON.parse(step.stdout);
|
|
} catch {
|
|
fail('deployment head write response is invalid');
|
|
}
|
|
if (
|
|
typeof written?.metadata?.resourceVersion !== 'string' ||
|
|
!written.metadata.resourceVersion ||
|
|
written?.data?.[HEAD_DATA_KEY] !== JSON.stringify(state)
|
|
) {
|
|
fail('deployment head write did not converge');
|
|
}
|
|
const { stdout, ...record } = step;
|
|
return Object.freeze({
|
|
record: Object.freeze(record),
|
|
resourceVersion: written.metadata.resourceVersion,
|
|
});
|
|
}
|
|
|
|
function applyingHead(commandState, preflight, inputs, current) {
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: HEAD_SCHEMA,
|
|
phase: 'applying',
|
|
generation: current.state === null ? 1 : current.state.generation + 1,
|
|
transition: {
|
|
kind: commandState.command.request.transitionKind,
|
|
mutationId: commandState.command.request.mutationId,
|
|
commandDigest: commandState.commandDigest,
|
|
preflightDigest: preflight.report.preflightDigest,
|
|
expectedHead: current.snapshot,
|
|
},
|
|
previous:
|
|
current.state === null
|
|
? null
|
|
: Object.freeze({ ...current.state.deployment }),
|
|
planned: deploymentPlan(inputs),
|
|
target: targetSummary(inputs, commandState.command.request),
|
|
tool: toolSummary(inputs),
|
|
steps: [],
|
|
deployment: null,
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
stateDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
}
|
|
|
|
function committedHead(applying, steps) {
|
|
const evidenceState = { ...applying, phase: 'committed', steps };
|
|
delete evidenceState.stateDigest;
|
|
const deployment = Object.freeze({
|
|
deploymentDigest: sha256(
|
|
Buffer.from(JSON.stringify(deploymentEvidence(evidenceState)), 'utf8'),
|
|
),
|
|
lock: applying.planned.lock,
|
|
resources: applying.planned.resources,
|
|
});
|
|
const unsigned = { ...evidenceState, deployment };
|
|
return Object.freeze({
|
|
...unsigned,
|
|
stateDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
}
|
|
|
|
function validatePreflight(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'preflightId',
|
|
'commandDigest',
|
|
'transitionKind',
|
|
'lock',
|
|
'resourceInventory',
|
|
'deploymentHead',
|
|
'target',
|
|
'tool',
|
|
'steps',
|
|
'verification',
|
|
'preflightDigest',
|
|
]) ||
|
|
value.schemaVersion !== 1 ||
|
|
value.schema !== PREFLIGHT_SCHEMA ||
|
|
!UUID_PATTERN.test(value.preflightId || '') ||
|
|
!DIGEST_PATTERN.test(value.commandDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.preflightDigest || '') ||
|
|
!TRANSITION_KINDS.includes(value.transitionKind) ||
|
|
!Array.isArray(value.steps) ||
|
|
value.steps.length !== 3 ||
|
|
value.steps[0]?.name !== 'cluster_identity_before' ||
|
|
value.steps[1]?.name !== 'deployment_head_read' ||
|
|
value.steps[2]?.name !== 'server_side_dry_run' ||
|
|
!exactKeys(value.verification, [
|
|
'catalogBoundLock',
|
|
'clusterIdentityBound',
|
|
'serverSideDryRun',
|
|
'deploymentHeadCompared',
|
|
'resourceInventoryClosed',
|
|
'networkAccess',
|
|
'kubernetesMutation',
|
|
'externalResultsReplayed',
|
|
'ambientHome',
|
|
'ephemeralCache',
|
|
]) ||
|
|
JSON.stringify(value.verification) !==
|
|
JSON.stringify({
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
serverSideDryRun: true,
|
|
deploymentHeadCompared: true,
|
|
resourceInventoryClosed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: false,
|
|
externalResultsReplayed: false,
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
})
|
|
) {
|
|
fail('deployment preflight report is invalid');
|
|
}
|
|
validateLockSummary(value.lock);
|
|
validateResourceInventory(value.resourceInventory);
|
|
validateHeadSnapshot(value.deploymentHead);
|
|
validateTargetSummary(value.target);
|
|
validateToolSummary(value.tool);
|
|
for (const step of value.steps) validateStepRecord(step);
|
|
if (value.steps.some((step) => step.status !== 0)) {
|
|
fail('deployment preflight contains a failed step');
|
|
}
|
|
const { preflightDigest, ...unsigned } = value;
|
|
if (
|
|
preflightDigest !== sha256(Buffer.from(JSON.stringify(unsigned), 'utf8'))
|
|
) {
|
|
fail('deployment preflight self digest is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateLockSummary(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'lockDigest',
|
|
'manifestDigest',
|
|
'lockReportDigest',
|
|
'releaseSetDigest',
|
|
'catalogManifestDigest',
|
|
'catalogReportDigest',
|
|
'catalogImmutableReference',
|
|
'release',
|
|
'requiredImages',
|
|
]) ||
|
|
!DIGEST_PATTERN.test(value.lockDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.manifestDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.lockReportDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.releaseSetDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.catalogManifestDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.catalogReportDigest || '') ||
|
|
!Array.isArray(value.requiredImages) ||
|
|
value.requiredImages.length < 1 ||
|
|
JSON.stringify(value.requiredImages) !==
|
|
JSON.stringify(
|
|
ROLE_ORDER.filter((role) => value.requiredImages.includes(role)),
|
|
)
|
|
) {
|
|
fail('deployment lock summary is invalid');
|
|
}
|
|
validateRelease(value.release);
|
|
if (
|
|
!new RegExp(
|
|
`^ghcr\\.io/[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?/qinglong3-release-catalog@${value.catalogManifestDigest}$`,
|
|
'u',
|
|
).test(value.catalogImmutableReference || '')
|
|
) {
|
|
fail('deployment lock summary catalog reference is invalid');
|
|
}
|
|
}
|
|
|
|
function validateTargetSummary(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'context',
|
|
'clusterUid',
|
|
'kubeconfigDigest',
|
|
'fieldManager',
|
|
]) ||
|
|
value.fieldManager !== FIELD_MANAGER ||
|
|
!CONTEXT_PATTERN.test(value.context || '') ||
|
|
!UUID_PATTERN.test(value.clusterUid || '') ||
|
|
!DIGEST_PATTERN.test(value.kubeconfigDigest || '')
|
|
) {
|
|
fail('deployment target summary is invalid');
|
|
}
|
|
}
|
|
|
|
function validateToolSummary(value) {
|
|
if (
|
|
!exactKeys(value, ['name', 'executableDigest']) ||
|
|
value.name !== 'kubectl' ||
|
|
!DIGEST_PATTERN.test(value.executableDigest || '')
|
|
) {
|
|
fail('deployment tool summary is invalid');
|
|
}
|
|
}
|
|
|
|
function validateStepRecord(step) {
|
|
if (
|
|
!exactKeys(step, [
|
|
'name',
|
|
'status',
|
|
'argvDigest',
|
|
'stdoutDigest',
|
|
'stdoutBytes',
|
|
'stderrDigest',
|
|
'stderrBytes',
|
|
]) ||
|
|
typeof step.name !== 'string' ||
|
|
!Number.isInteger(step.status) ||
|
|
!DIGEST_PATTERN.test(step.argvDigest || '') ||
|
|
!DIGEST_PATTERN.test(step.stdoutDigest || '') ||
|
|
!Number.isSafeInteger(step.stdoutBytes) ||
|
|
step.stdoutBytes < 0 ||
|
|
step.stdoutBytes > MAX_PROCESS_OUTPUT_BYTES ||
|
|
!DIGEST_PATTERN.test(step.stderrDigest || '') ||
|
|
!Number.isSafeInteger(step.stderrBytes) ||
|
|
step.stderrBytes < 0 ||
|
|
step.stderrBytes > MAX_PROCESS_OUTPUT_BYTES
|
|
) {
|
|
fail('deployment command step is invalid');
|
|
}
|
|
}
|
|
|
|
function createPreflight(commandState, dependencies = {}) {
|
|
const request = commandState.command.request;
|
|
const output = outputTarget(request.output, 'preflight output');
|
|
const inputs = inspectInputs(request);
|
|
const runner = dependencies.runProcess ?? defaultRunProcess;
|
|
const identity = identityStep(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
'cluster_identity_before',
|
|
);
|
|
const current = headReadStep(inputs, request, runner);
|
|
if (current.state !== null && current.state.phase !== 'committed') {
|
|
fail('deployment head contains an unfinished transition');
|
|
}
|
|
validateTransition(current, request, deploymentPlan(inputs));
|
|
const steps = [identity, current.record, dryRunStep(inputs, request, runner)];
|
|
revalidateStableInputs(inputs, request);
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: PREFLIGHT_SCHEMA,
|
|
preflightId: request.preflightId,
|
|
commandDigest: commandState.commandDigest,
|
|
transitionKind: request.transitionKind,
|
|
lock: lockSummary(inputs),
|
|
resourceInventory: inputs.resourceInventory,
|
|
deploymentHead: current.snapshot,
|
|
target: targetSummary(inputs, request),
|
|
tool: toolSummary(inputs),
|
|
steps,
|
|
verification: {
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
serverSideDryRun: true,
|
|
deploymentHeadCompared: true,
|
|
resourceInventoryClosed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: false,
|
|
externalResultsReplayed: false,
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
},
|
|
};
|
|
const result = Object.freeze({
|
|
...unsigned,
|
|
preflightDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
writeNoReplace(output, result);
|
|
return result;
|
|
}
|
|
|
|
function readAndValidatePreflight(request, inputs) {
|
|
const file = readCanonicalJson(
|
|
request.preflight.path,
|
|
'preflight report',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const report = validatePreflight(file.value);
|
|
if (
|
|
report.preflightDigest !== request.preflight.expectedDigest ||
|
|
report.transitionKind !== request.transitionKind ||
|
|
JSON.stringify(report.lock) !== JSON.stringify(lockSummary(inputs)) ||
|
|
JSON.stringify(report.resourceInventory) !==
|
|
JSON.stringify(inputs.resourceInventory) ||
|
|
JSON.stringify(report.deploymentHead) !==
|
|
JSON.stringify(expectedSnapshot(request, inputs.headNamespace)) ||
|
|
JSON.stringify(report.target) !==
|
|
JSON.stringify(targetSummary(inputs, request)) ||
|
|
JSON.stringify(report.tool) !== JSON.stringify(toolSummary(inputs))
|
|
) {
|
|
fail('deployment preflight binding changed');
|
|
}
|
|
return Object.freeze({ file, report });
|
|
}
|
|
|
|
function receiptFromCommitted(state, namespace) {
|
|
if (state.phase !== 'committed' || state.transition.kind === 'retire') {
|
|
fail('deployment receipt requires a committed head');
|
|
}
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: RECEIPT_SCHEMA,
|
|
mutationId: state.transition.mutationId,
|
|
commandDigest: state.transition.commandDigest,
|
|
preflightDigest: state.transition.preflightDigest,
|
|
transitionKind: state.transition.kind,
|
|
lock: state.deployment.lock,
|
|
resourceInventory: state.deployment.resources,
|
|
deploymentHead: headSnapshot(namespace, state),
|
|
target: state.target,
|
|
tool: state.tool,
|
|
steps: state.steps,
|
|
verification: {
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
serverSideDryRun: true,
|
|
serverSideApply: true,
|
|
convergenceRead: true,
|
|
deploymentHeadCas: true,
|
|
resourceInventoryClosed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: true,
|
|
crossResourceAtomicity: false,
|
|
externalResultsReplayed: false,
|
|
recovery: 'resume_exact_transition_from_target_head',
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
},
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
receiptDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
}
|
|
|
|
function validateReceipt(value, expectedCommandDigest) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'mutationId',
|
|
'commandDigest',
|
|
'preflightDigest',
|
|
'transitionKind',
|
|
'lock',
|
|
'resourceInventory',
|
|
'deploymentHead',
|
|
'target',
|
|
'tool',
|
|
'steps',
|
|
'verification',
|
|
'receiptDigest',
|
|
]) ||
|
|
value.schemaVersion !== 1 ||
|
|
value.schema !== RECEIPT_SCHEMA ||
|
|
!UUID_PATTERN.test(value.mutationId || '') ||
|
|
value.commandDigest !== expectedCommandDigest ||
|
|
!DIGEST_PATTERN.test(value.preflightDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.receiptDigest || '') ||
|
|
!TRANSITION_KINDS.includes(value.transitionKind) ||
|
|
!Array.isArray(value.steps) ||
|
|
JSON.stringify(value.steps.map((step) => step.name)) !==
|
|
JSON.stringify([
|
|
'cluster_identity_before',
|
|
'server_side_dry_run',
|
|
'server_side_apply',
|
|
'server_side_convergence_read',
|
|
'cluster_identity_after',
|
|
]) ||
|
|
!exactKeys(value.verification, [
|
|
'catalogBoundLock',
|
|
'clusterIdentityBound',
|
|
'serverSideDryRun',
|
|
'serverSideApply',
|
|
'convergenceRead',
|
|
'deploymentHeadCas',
|
|
'resourceInventoryClosed',
|
|
'networkAccess',
|
|
'kubernetesMutation',
|
|
'crossResourceAtomicity',
|
|
'externalResultsReplayed',
|
|
'recovery',
|
|
'ambientHome',
|
|
'ephemeralCache',
|
|
]) ||
|
|
JSON.stringify(value.verification) !==
|
|
JSON.stringify({
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
serverSideDryRun: true,
|
|
serverSideApply: true,
|
|
convergenceRead: true,
|
|
deploymentHeadCas: true,
|
|
resourceInventoryClosed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: true,
|
|
crossResourceAtomicity: false,
|
|
externalResultsReplayed: false,
|
|
recovery: 'resume_exact_transition_from_target_head',
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
})
|
|
) {
|
|
fail('deployment receipt is invalid');
|
|
}
|
|
validateLockSummary(value.lock);
|
|
validateResourceInventory(value.resourceInventory);
|
|
validateHeadSnapshot(value.deploymentHead);
|
|
validateTargetSummary(value.target);
|
|
validateToolSummary(value.tool);
|
|
for (const step of value.steps) validateStepRecord(step);
|
|
if (value.steps.some((step) => step.status !== 0)) {
|
|
fail('deployment receipt contains a failed step');
|
|
}
|
|
const { receiptDigest, ...unsigned } = value;
|
|
if (receiptDigest !== sha256(Buffer.from(JSON.stringify(unsigned), 'utf8'))) {
|
|
fail('deployment receipt self digest is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function retirementDeleteDryRun(api, targets) {
|
|
const dryRunRecords = [];
|
|
for (const target of targets) {
|
|
const response = recordedApiRequest(
|
|
dryRunRecords,
|
|
api,
|
|
'retirement_delete_dry_run',
|
|
'DELETE',
|
|
target.requestPath,
|
|
deleteOptions(target.observation, true),
|
|
);
|
|
if (![200, 201, 202].includes(response.status)) {
|
|
fail('Kubernetes retirement dry-run was rejected');
|
|
}
|
|
}
|
|
return aggregateRequestStep('server_side_delete_dry_run', dryRunRecords);
|
|
}
|
|
|
|
function retirementRecheck(api, resources, targets) {
|
|
const records = [];
|
|
const cache = new Map();
|
|
for (let index = 0; index < resources.length; index += 1) {
|
|
const desired = resources[index];
|
|
const observation = targets[index].observation;
|
|
const requestPath = apiResourcePath(api, observation, cache, records);
|
|
const response = recordedApiRequest(
|
|
records,
|
|
api,
|
|
'retirement_target_recheck',
|
|
'GET',
|
|
requestPath,
|
|
null,
|
|
);
|
|
parseRetirementObject(response, desired, observation);
|
|
}
|
|
return aggregateRequestStep('retirement_target_recheck', records);
|
|
}
|
|
|
|
function validateRetirementPreflight(value) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'preflightId',
|
|
'commandDigest',
|
|
'lock',
|
|
'activeResourceInventory',
|
|
'survivorResourceInventory',
|
|
'retirementTargets',
|
|
'deploymentHead',
|
|
'target',
|
|
'tools',
|
|
'steps',
|
|
'verification',
|
|
'preflightDigest',
|
|
]) ||
|
|
value.schemaVersion !== 1 ||
|
|
value.schema !== RETIREMENT_PREFLIGHT_SCHEMA ||
|
|
!UUID_PATTERN.test(value.preflightId || '') ||
|
|
!DIGEST_PATTERN.test(value.commandDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.preflightDigest || '') ||
|
|
!Array.isArray(value.steps) ||
|
|
JSON.stringify(value.steps.map((step) => step.name)) !==
|
|
JSON.stringify([
|
|
'cluster_identity_before',
|
|
'deployment_head_read',
|
|
'retirement_target_read',
|
|
'server_side_delete_dry_run',
|
|
'retirement_target_recheck',
|
|
]) ||
|
|
!exactKeys(value.verification, [
|
|
'catalogBoundLock',
|
|
'clusterIdentityBound',
|
|
'deploymentHeadCompared',
|
|
'uidResourceVersionObserved',
|
|
'serverSideDeleteDryRun',
|
|
'survivorRoleClosure',
|
|
'resourceInventoryClosed',
|
|
'networkAccess',
|
|
'kubernetesMutation',
|
|
'unixSocketProxy',
|
|
'ambientHome',
|
|
'ephemeralCache',
|
|
]) ||
|
|
JSON.stringify(value.verification) !==
|
|
JSON.stringify({
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
deploymentHeadCompared: true,
|
|
uidResourceVersionObserved: true,
|
|
serverSideDeleteDryRun: true,
|
|
survivorRoleClosure: true,
|
|
resourceInventoryClosed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: false,
|
|
unixSocketProxy: true,
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
})
|
|
) {
|
|
fail('retirement preflight report is invalid');
|
|
}
|
|
validateLockSummary(value.lock);
|
|
validateResourceInventory(value.activeResourceInventory);
|
|
validateResourceInventory(value.survivorResourceInventory);
|
|
validateRetirementObservations(value.retirementTargets);
|
|
validateHeadSnapshot(value.deploymentHead);
|
|
validateTargetSummary(value.target);
|
|
validateRetirementToolSummary(value.tools);
|
|
for (const step of value.steps) validateStepRecord(step);
|
|
if (value.steps.some((step) => step.status !== 0)) {
|
|
fail('retirement preflight contains a failed step');
|
|
}
|
|
const retired = new Set(
|
|
value.retirementTargets.map((target) => inventoryKey(target)),
|
|
);
|
|
if (
|
|
JSON.stringify(
|
|
value.activeResourceInventory.filter(
|
|
(resource) => !retired.has(inventoryKey(resource)),
|
|
),
|
|
) !== JSON.stringify(value.survivorResourceInventory)
|
|
) {
|
|
fail('retirement preflight inventory closure is invalid');
|
|
}
|
|
const { preflightDigest, ...unsigned } = value;
|
|
if (
|
|
preflightDigest !== sha256(Buffer.from(JSON.stringify(unsigned), 'utf8'))
|
|
) {
|
|
fail('retirement preflight self digest is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function createRetirementPreflight(commandState, dependencies = {}) {
|
|
const request = commandState.command.request;
|
|
const output = outputTarget(request.output, 'retirement preflight output');
|
|
const inputs = inspectRetirementInputs(request);
|
|
const runner = dependencies.runProcess ?? defaultRunProcess;
|
|
const identity = identityStep(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
'cluster_identity_before',
|
|
);
|
|
const current = headReadStep(inputs, request, runner);
|
|
if (
|
|
current.state === null ||
|
|
current.state.phase !== 'committed' ||
|
|
JSON.stringify(current.snapshot) !==
|
|
JSON.stringify(expectedSnapshot(request, inputs.headNamespace))
|
|
) {
|
|
fail('deployment retirement head authority changed');
|
|
}
|
|
const plan = retirementPlan(inputs, current, request.targets);
|
|
const apiEvidence = withRetirementApi(
|
|
inputs,
|
|
request,
|
|
dependencies,
|
|
(api) => {
|
|
const readRecords = [];
|
|
const targets = readRetirementTargets(api, plan.resources, readRecords);
|
|
return Object.freeze({
|
|
targets,
|
|
read: aggregateRequestStep('retirement_target_read', readRecords),
|
|
dryRun: retirementDeleteDryRun(api, targets),
|
|
recheck: retirementRecheck(api, plan.resources, targets),
|
|
});
|
|
},
|
|
);
|
|
revalidateRetirementInputs(inputs, request);
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: RETIREMENT_PREFLIGHT_SCHEMA,
|
|
preflightId: request.preflightId,
|
|
commandDigest: commandState.commandDigest,
|
|
lock: lockSummary(inputs),
|
|
activeResourceInventory: plan.active,
|
|
survivorResourceInventory: plan.survivors,
|
|
retirementTargets: Object.freeze(
|
|
apiEvidence.targets.map((target) => target.observation),
|
|
),
|
|
deploymentHead: current.snapshot,
|
|
target: targetSummary(inputs, request),
|
|
tools: retirementToolSummary(inputs),
|
|
steps: Object.freeze([
|
|
identity,
|
|
current.record,
|
|
apiEvidence.read,
|
|
apiEvidence.dryRun,
|
|
apiEvidence.recheck,
|
|
]),
|
|
verification: {
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
deploymentHeadCompared: true,
|
|
uidResourceVersionObserved: true,
|
|
serverSideDeleteDryRun: true,
|
|
survivorRoleClosure: true,
|
|
resourceInventoryClosed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: false,
|
|
unixSocketProxy: true,
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
},
|
|
};
|
|
const result = Object.freeze({
|
|
...unsigned,
|
|
preflightDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
writeNoReplace(output, result);
|
|
return result;
|
|
}
|
|
|
|
function readAndValidateRetirementPreflight(request, inputs) {
|
|
const file = readCanonicalJson(
|
|
request.preflight.path,
|
|
'retirement preflight report',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const report = validateRetirementPreflight(file.value);
|
|
if (
|
|
report.preflightDigest !== request.preflight.expectedDigest ||
|
|
JSON.stringify(report.lock) !== JSON.stringify(lockSummary(inputs)) ||
|
|
JSON.stringify(report.deploymentHead) !==
|
|
JSON.stringify(expectedSnapshot(request, inputs.headNamespace)) ||
|
|
JSON.stringify(
|
|
report.retirementTargets.map(
|
|
({ uid: _uid, resourceVersion: _resourceVersion, ...item }) => item,
|
|
),
|
|
) !== JSON.stringify(request.targets) ||
|
|
JSON.stringify(report.target) !==
|
|
JSON.stringify(targetSummary(inputs, request)) ||
|
|
JSON.stringify(report.tools) !==
|
|
JSON.stringify(retirementToolSummary(inputs))
|
|
) {
|
|
fail('retirement preflight binding changed');
|
|
}
|
|
return Object.freeze({ file, report });
|
|
}
|
|
|
|
function retirementApplyingHead(commandState, preflight, inputs, current) {
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: HEAD_SCHEMA,
|
|
phase: 'applying',
|
|
generation: current.state.generation + 1,
|
|
transition: {
|
|
kind: 'retire',
|
|
mutationId: commandState.command.request.mutationId,
|
|
commandDigest: commandState.commandDigest,
|
|
preflightDigest: preflight.report.preflightDigest,
|
|
expectedHead: current.snapshot,
|
|
retirements: preflight.report.retirementTargets,
|
|
curlExecutableDigest: inputs.curl.digest,
|
|
},
|
|
previous: Object.freeze({ ...current.state.deployment }),
|
|
planned: Object.freeze({
|
|
lock: current.state.deployment.lock,
|
|
resources: preflight.report.survivorResourceInventory,
|
|
}),
|
|
target: targetSummary(inputs, commandState.command.request),
|
|
tool: toolSummary(inputs),
|
|
steps: [],
|
|
deployment: null,
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
stateDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
}
|
|
|
|
function assertRetirementBinding(state, commandState, preflight, inputs) {
|
|
const request = commandState.command.request;
|
|
if (
|
|
state.transition.kind !== 'retire' ||
|
|
state.transition.mutationId !== request.mutationId ||
|
|
state.transition.commandDigest !== commandState.commandDigest ||
|
|
state.transition.preflightDigest !== preflight.report.preflightDigest ||
|
|
state.transition.curlExecutableDigest !== inputs.curl.digest ||
|
|
JSON.stringify(state.transition.expectedHead) !==
|
|
JSON.stringify(preflight.report.deploymentHead) ||
|
|
JSON.stringify(state.transition.retirements) !==
|
|
JSON.stringify(preflight.report.retirementTargets) ||
|
|
JSON.stringify(state.planned) !==
|
|
JSON.stringify({
|
|
lock: preflight.report.lock,
|
|
resources: preflight.report.survivorResourceInventory,
|
|
}) ||
|
|
JSON.stringify(state.target) !==
|
|
JSON.stringify(targetSummary(inputs, request)) ||
|
|
JSON.stringify(state.tool) !== JSON.stringify(toolSummary(inputs))
|
|
) {
|
|
fail('retirement head transition binding changed');
|
|
}
|
|
}
|
|
|
|
function retirementObjectState(response, desired, expected) {
|
|
if (response.status === 404) return Object.freeze({ kind: 'absent' });
|
|
if (response.status !== 200) {
|
|
fail('retirement target inspection failed');
|
|
}
|
|
let live;
|
|
try {
|
|
live = JSON.parse(response.body);
|
|
} catch {
|
|
fail('retirement target response is invalid');
|
|
}
|
|
if (
|
|
inventoryKey(live) !== inventoryKey(expected) ||
|
|
typeof live.metadata?.uid !== 'string' ||
|
|
typeof live.metadata?.resourceVersion !== 'string'
|
|
) {
|
|
fail('retirement target identity changed');
|
|
}
|
|
if (live.metadata.uid !== expected.uid) {
|
|
fail('retirement target was replaced by a different UID');
|
|
}
|
|
assertDesiredSubset(desired, live);
|
|
if (live.metadata.deletionTimestamp !== undefined) {
|
|
return Object.freeze({ kind: 'terminating' });
|
|
}
|
|
if (live.metadata.resourceVersion !== expected.resourceVersion) {
|
|
fail('retirement target resourceVersion changed');
|
|
}
|
|
return Object.freeze({ kind: 'current' });
|
|
}
|
|
|
|
function recoveredDeleteRecord(observation, requestPath) {
|
|
const body = deleteOptions(observation, false);
|
|
return Object.freeze({
|
|
name: 'retirement_delete_recovered',
|
|
method: 'DELETE',
|
|
requestPathDigest: sha256(Buffer.from(requestPath, 'utf8')),
|
|
requestDigest: sha256(Buffer.from(JSON.stringify(body), 'utf8')),
|
|
status: 404,
|
|
responseDigest: sha256(Buffer.from('recovered_absent', 'utf8')),
|
|
responseBytes: 0,
|
|
});
|
|
}
|
|
|
|
function executeRetirementDeletes(api, inputs, preflight, resumed) {
|
|
const desiredByIdentity = new Map(
|
|
inputs.resources.map((resource) => [inventoryKey(resource), resource]),
|
|
);
|
|
const cache = new Map();
|
|
const deleteRecords = [];
|
|
const absenceRecords = [];
|
|
for (const observation of preflight.retirementTargets) {
|
|
const desired = desiredByIdentity.get(inventoryKey(observation));
|
|
if (desired === undefined) {
|
|
fail('retirement target is no longer in the locked manifest');
|
|
}
|
|
const requestPath = apiResourcePath(api, observation, cache, deleteRecords);
|
|
const before = recordedApiRequest(
|
|
deleteRecords,
|
|
api,
|
|
'retirement_target_before_delete',
|
|
'GET',
|
|
requestPath,
|
|
null,
|
|
);
|
|
const beforeState = retirementObjectState(before, desired, observation);
|
|
if (beforeState.kind === 'absent') {
|
|
if (!resumed) fail('retirement target disappeared before deletion');
|
|
deleteRecords.push(recoveredDeleteRecord(observation, requestPath));
|
|
} else if (beforeState.kind === 'terminating') {
|
|
fail('retirement target deletion is not complete');
|
|
} else {
|
|
const response = recordedApiRequest(
|
|
deleteRecords,
|
|
api,
|
|
'retirement_target_delete',
|
|
'DELETE',
|
|
requestPath,
|
|
deleteOptions(observation, false),
|
|
);
|
|
if (![200, 201, 202].includes(response.status)) {
|
|
fail('Kubernetes retirement delete was rejected');
|
|
}
|
|
}
|
|
const after = recordedApiRequest(
|
|
absenceRecords,
|
|
api,
|
|
'retirement_target_absence',
|
|
'GET',
|
|
requestPath,
|
|
null,
|
|
);
|
|
const afterState = retirementObjectState(after, desired, observation);
|
|
if (afterState.kind === 'terminating') {
|
|
fail('retirement target deletion is not complete');
|
|
}
|
|
if (afterState.kind !== 'absent') {
|
|
fail('retirement target remains after deletion');
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
deletion: aggregateRequestStep(
|
|
'resource_precondition_delete',
|
|
deleteRecords,
|
|
),
|
|
absence: aggregateRequestStep('retirement_absence_read', absenceRecords),
|
|
});
|
|
}
|
|
|
|
function retirementReceiptFromCommitted(state, namespace, inputs) {
|
|
if (state.phase !== 'committed' || state.transition.kind !== 'retire') {
|
|
fail('retirement receipt requires a committed retirement head');
|
|
}
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: RETIREMENT_RECEIPT_SCHEMA,
|
|
mutationId: state.transition.mutationId,
|
|
commandDigest: state.transition.commandDigest,
|
|
preflightDigest: state.transition.preflightDigest,
|
|
lock: state.deployment.lock,
|
|
retiredResources: state.transition.retirements,
|
|
resourceInventory: state.deployment.resources,
|
|
deploymentHead: headSnapshot(namespace, state),
|
|
target: state.target,
|
|
tools: retirementToolSummary(inputs),
|
|
steps: state.steps,
|
|
verification: {
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
uidResourceVersionDeletePreconditions: true,
|
|
deploymentHeadCas: true,
|
|
resourceInventoryClosed: true,
|
|
survivorRoleClosure: true,
|
|
deletionAbsenceConfirmed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: true,
|
|
crossResourceAtomicity: false,
|
|
externalResultsReplayed: false,
|
|
recovery: 'resume_exact_retirement_from_target_head',
|
|
unixSocketProxy: true,
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
},
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
receiptDigest: sha256(Buffer.from(JSON.stringify(unsigned), 'utf8')),
|
|
});
|
|
}
|
|
|
|
function validateRetirementReceipt(value, expectedCommandDigest) {
|
|
if (
|
|
!exactKeys(value, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'mutationId',
|
|
'commandDigest',
|
|
'preflightDigest',
|
|
'lock',
|
|
'retiredResources',
|
|
'resourceInventory',
|
|
'deploymentHead',
|
|
'target',
|
|
'tools',
|
|
'steps',
|
|
'verification',
|
|
'receiptDigest',
|
|
]) ||
|
|
value.schemaVersion !== 1 ||
|
|
value.schema !== RETIREMENT_RECEIPT_SCHEMA ||
|
|
!UUID_PATTERN.test(value.mutationId || '') ||
|
|
value.commandDigest !== expectedCommandDigest ||
|
|
!DIGEST_PATTERN.test(value.preflightDigest || '') ||
|
|
!DIGEST_PATTERN.test(value.receiptDigest || '') ||
|
|
!Array.isArray(value.steps) ||
|
|
JSON.stringify(value.steps.map((step) => step.name)) !==
|
|
JSON.stringify([
|
|
'cluster_identity_before',
|
|
'resource_precondition_delete',
|
|
'retirement_absence_read',
|
|
'cluster_identity_after',
|
|
]) ||
|
|
!exactKeys(value.verification, [
|
|
'catalogBoundLock',
|
|
'clusterIdentityBound',
|
|
'uidResourceVersionDeletePreconditions',
|
|
'deploymentHeadCas',
|
|
'resourceInventoryClosed',
|
|
'survivorRoleClosure',
|
|
'deletionAbsenceConfirmed',
|
|
'networkAccess',
|
|
'kubernetesMutation',
|
|
'crossResourceAtomicity',
|
|
'externalResultsReplayed',
|
|
'recovery',
|
|
'unixSocketProxy',
|
|
'ambientHome',
|
|
'ephemeralCache',
|
|
]) ||
|
|
JSON.stringify(value.verification) !==
|
|
JSON.stringify({
|
|
catalogBoundLock: true,
|
|
clusterIdentityBound: true,
|
|
uidResourceVersionDeletePreconditions: true,
|
|
deploymentHeadCas: true,
|
|
resourceInventoryClosed: true,
|
|
survivorRoleClosure: true,
|
|
deletionAbsenceConfirmed: true,
|
|
networkAccess: true,
|
|
kubernetesMutation: true,
|
|
crossResourceAtomicity: false,
|
|
externalResultsReplayed: false,
|
|
recovery: 'resume_exact_retirement_from_target_head',
|
|
unixSocketProxy: true,
|
|
ambientHome: false,
|
|
ephemeralCache: true,
|
|
})
|
|
) {
|
|
fail('retirement receipt is invalid');
|
|
}
|
|
validateLockSummary(value.lock);
|
|
validateRetirementObservations(value.retiredResources);
|
|
validateResourceInventory(value.resourceInventory);
|
|
validateHeadSnapshot(value.deploymentHead);
|
|
validateTargetSummary(value.target);
|
|
validateRetirementToolSummary(value.tools);
|
|
for (const step of value.steps) validateStepRecord(step);
|
|
if (value.steps.some((step) => step.status !== 0)) {
|
|
fail('retirement receipt contains a failed step');
|
|
}
|
|
const { receiptDigest, ...unsigned } = value;
|
|
if (receiptDigest !== sha256(Buffer.from(JSON.stringify(unsigned), 'utf8'))) {
|
|
fail('retirement receipt self digest is invalid');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function existingRetirementReceipt(request, commandDigest) {
|
|
if (!fs.existsSync(request.output)) return null;
|
|
const file = readCanonicalJson(
|
|
request.output,
|
|
'existing retirement receipt',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const receipt = validateRetirementReceipt(file.value, commandDigest);
|
|
if (
|
|
receipt.mutationId !== request.mutationId ||
|
|
receipt.preflightDigest !== request.preflight.expectedDigest
|
|
) {
|
|
fail('existing retirement receipt binding changed');
|
|
}
|
|
return receipt;
|
|
}
|
|
|
|
function applyRetirement(commandState, dependencies = {}) {
|
|
const request = commandState.command.request;
|
|
const resumedReceipt = existingRetirementReceipt(
|
|
request,
|
|
commandState.commandDigest,
|
|
);
|
|
const output =
|
|
resumedReceipt === null
|
|
? outputTarget(request.output, 'retirement receipt output')
|
|
: request.output;
|
|
const inputs = inspectRetirementInputs(request);
|
|
const preflight = readAndValidateRetirementPreflight(request, inputs);
|
|
const runner = dependencies.runProcess ?? defaultRunProcess;
|
|
const current = headReadStep(inputs, request, runner);
|
|
|
|
if (resumedReceipt !== null) {
|
|
if (
|
|
current.state === null ||
|
|
current.state.phase !== 'committed' ||
|
|
resumedReceipt.preflightDigest !== preflight.report.preflightDigest ||
|
|
JSON.stringify(resumedReceipt.lock) !==
|
|
JSON.stringify(preflight.report.lock) ||
|
|
JSON.stringify(resumedReceipt.resourceInventory) !==
|
|
JSON.stringify(preflight.report.survivorResourceInventory) ||
|
|
JSON.stringify(resumedReceipt.deploymentHead) !==
|
|
JSON.stringify(headSnapshot(inputs.headNamespace, current.state)) ||
|
|
JSON.stringify(resumedReceipt.target) !==
|
|
JSON.stringify(targetSummary(inputs, request)) ||
|
|
JSON.stringify(resumedReceipt.tools) !==
|
|
JSON.stringify(retirementToolSummary(inputs))
|
|
) {
|
|
fail('existing retirement receipt authority changed');
|
|
}
|
|
assertRetirementBinding(current.state, commandState, preflight, inputs);
|
|
revalidateRetirementInputs(inputs, request);
|
|
return resumedReceipt;
|
|
}
|
|
|
|
if (
|
|
current.state !== null &&
|
|
current.state.phase === 'committed' &&
|
|
current.state.transition.kind === 'retire' &&
|
|
current.state.transition.commandDigest === commandState.commandDigest
|
|
) {
|
|
assertRetirementBinding(current.state, commandState, preflight, inputs);
|
|
const recovered = retirementReceiptFromCommitted(
|
|
current.state,
|
|
inputs.headNamespace,
|
|
inputs,
|
|
);
|
|
writeNoReplace(output, recovered);
|
|
revalidateRetirementInputs(inputs, request);
|
|
return recovered;
|
|
}
|
|
|
|
let applying;
|
|
let headResourceVersion;
|
|
let resumedHead = false;
|
|
if (current.state !== null && current.state.phase === 'applying') {
|
|
assertRetirementBinding(current.state, commandState, preflight, inputs);
|
|
applying = current.state;
|
|
headResourceVersion = current.resourceVersion;
|
|
resumedHead = true;
|
|
} else {
|
|
if (
|
|
JSON.stringify(current.snapshot) !==
|
|
JSON.stringify(preflight.report.deploymentHead)
|
|
) {
|
|
fail('deployment retirement head changed after preflight');
|
|
}
|
|
const plan = retirementPlan(inputs, current, request.targets);
|
|
if (
|
|
JSON.stringify(plan.active) !==
|
|
JSON.stringify(preflight.report.activeResourceInventory) ||
|
|
JSON.stringify(plan.survivors) !==
|
|
JSON.stringify(preflight.report.survivorResourceInventory)
|
|
) {
|
|
fail('deployment retirement inventory changed after preflight');
|
|
}
|
|
withRetirementApi(inputs, request, dependencies, (api) => {
|
|
const records = [];
|
|
const observations = readRetirementTargets(
|
|
api,
|
|
plan.resources,
|
|
records,
|
|
).map((target) => target.observation);
|
|
if (
|
|
JSON.stringify(observations) !==
|
|
JSON.stringify(preflight.report.retirementTargets)
|
|
) {
|
|
fail('deployment retirement target changed after preflight');
|
|
}
|
|
});
|
|
applying = retirementApplyingHead(commandState, preflight, inputs, current);
|
|
const acquired = writeHead(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
applying,
|
|
current.resourceVersion,
|
|
'deployment_head_acquire',
|
|
);
|
|
headResourceVersion = acquired.resourceVersion;
|
|
}
|
|
|
|
const beforeIdentity = identityStep(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
'cluster_identity_before',
|
|
);
|
|
const deletion = withRetirementApi(inputs, request, dependencies, (api) =>
|
|
executeRetirementDeletes(api, inputs, preflight.report, resumedHead),
|
|
);
|
|
const afterIdentity = identityStep(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
'cluster_identity_after',
|
|
);
|
|
revalidateRetirementInputs(inputs, request);
|
|
const committed = committedHead(
|
|
applying,
|
|
Object.freeze([
|
|
beforeIdentity,
|
|
deletion.deletion,
|
|
deletion.absence,
|
|
afterIdentity,
|
|
]),
|
|
);
|
|
writeHead(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
committed,
|
|
headResourceVersion,
|
|
'deployment_head_commit',
|
|
);
|
|
const receipt = retirementReceiptFromCommitted(
|
|
committed,
|
|
inputs.headNamespace,
|
|
inputs,
|
|
);
|
|
writeNoReplace(output, receipt);
|
|
return receipt;
|
|
}
|
|
|
|
function existingReceipt(request, commandDigest) {
|
|
if (!fs.existsSync(request.output)) return null;
|
|
const file = readCanonicalJson(
|
|
request.output,
|
|
'existing deployment receipt',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const receipt = validateReceipt(file.value, commandDigest);
|
|
if (
|
|
receipt.mutationId !== request.mutationId ||
|
|
receipt.preflightDigest !== request.preflight.expectedDigest
|
|
) {
|
|
fail('existing deployment receipt binding changed');
|
|
}
|
|
return receipt;
|
|
}
|
|
|
|
function assertTransitionBinding(state, commandState, preflight, inputs) {
|
|
const request = commandState.command.request;
|
|
if (
|
|
state.transition.kind !== request.transitionKind ||
|
|
state.transition.mutationId !== request.mutationId ||
|
|
state.transition.commandDigest !== commandState.commandDigest ||
|
|
state.transition.preflightDigest !== preflight.report.preflightDigest ||
|
|
JSON.stringify(state.transition.expectedHead) !==
|
|
JSON.stringify(preflight.report.deploymentHead) ||
|
|
JSON.stringify(state.planned) !== JSON.stringify(deploymentPlan(inputs)) ||
|
|
JSON.stringify(state.target) !==
|
|
JSON.stringify(targetSummary(inputs, request)) ||
|
|
JSON.stringify(state.tool) !== JSON.stringify(toolSummary(inputs))
|
|
) {
|
|
fail('deployment head transition binding changed');
|
|
}
|
|
}
|
|
|
|
function applyDeployment(commandState, dependencies = {}) {
|
|
const request = commandState.command.request;
|
|
const resumed = existingReceipt(request, commandState.commandDigest);
|
|
const output =
|
|
resumed === null
|
|
? outputTarget(request.output, 'apply receipt output')
|
|
: request.output;
|
|
const inputs = inspectInputs(request);
|
|
const preflight = readAndValidatePreflight(request, inputs);
|
|
const runner = dependencies.runProcess ?? defaultRunProcess;
|
|
const current = headReadStep(inputs, request, runner);
|
|
if (resumed !== null) {
|
|
if (
|
|
current.state === null ||
|
|
current.state.phase !== 'committed' ||
|
|
resumed.preflightDigest !== preflight.report.preflightDigest ||
|
|
JSON.stringify(resumed.lock) !== JSON.stringify(lockSummary(inputs)) ||
|
|
JSON.stringify(resumed.resourceInventory) !==
|
|
JSON.stringify(inputs.resourceInventory) ||
|
|
JSON.stringify(resumed.deploymentHead) !==
|
|
JSON.stringify(headSnapshot(inputs.headNamespace, current.state)) ||
|
|
JSON.stringify(resumed.target) !==
|
|
JSON.stringify(targetSummary(inputs, request)) ||
|
|
JSON.stringify(resumed.tool) !== JSON.stringify(toolSummary(inputs))
|
|
) {
|
|
fail('existing deployment receipt authority changed');
|
|
}
|
|
assertTransitionBinding(current.state, commandState, preflight, inputs);
|
|
revalidateStableInputs(inputs, request);
|
|
return resumed;
|
|
}
|
|
if (
|
|
current.state !== null &&
|
|
current.state.phase === 'committed' &&
|
|
current.state.transition.commandDigest === commandState.commandDigest
|
|
) {
|
|
assertTransitionBinding(current.state, commandState, preflight, inputs);
|
|
const recovered = receiptFromCommitted(current.state, inputs.headNamespace);
|
|
writeNoReplace(output, recovered);
|
|
revalidateStableInputs(inputs, request);
|
|
return recovered;
|
|
}
|
|
let applying;
|
|
let headResourceVersion;
|
|
if (current.state !== null && current.state.phase === 'applying') {
|
|
assertTransitionBinding(current.state, commandState, preflight, inputs);
|
|
applying = current.state;
|
|
headResourceVersion = current.resourceVersion;
|
|
} else {
|
|
if (
|
|
JSON.stringify(current.snapshot) !==
|
|
JSON.stringify(preflight.report.deploymentHead)
|
|
) {
|
|
fail('deployment head changed after preflight');
|
|
}
|
|
validateTransition(current, request, deploymentPlan(inputs));
|
|
applying = applyingHead(commandState, preflight, inputs, current);
|
|
const acquired = writeHead(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
applying,
|
|
current.resourceVersion,
|
|
'deployment_head_acquire',
|
|
);
|
|
headResourceVersion = acquired.resourceVersion;
|
|
}
|
|
const steps = [
|
|
identityStep(inputs, request, runner, 'cluster_identity_before'),
|
|
dryRunStep(inputs, request, runner),
|
|
];
|
|
const applied = runStep(
|
|
inputs,
|
|
request,
|
|
'server_side_apply',
|
|
[
|
|
'apply',
|
|
'--server-side',
|
|
`--field-manager=${FIELD_MANAGER}`,
|
|
'--validate=strict',
|
|
'-f=-',
|
|
'-o=name',
|
|
],
|
|
runner,
|
|
);
|
|
if (applied.status !== 0) fail('Kubernetes server-side apply failed');
|
|
const { stdout: _appliedOutput, ...appliedRecord } = applied;
|
|
steps.push(Object.freeze(appliedRecord));
|
|
steps.push(convergenceReadStep(inputs, request, runner));
|
|
steps.push(identityStep(inputs, request, runner, 'cluster_identity_after'));
|
|
revalidateStableInputs(inputs, request);
|
|
const committed = committedHead(applying, Object.freeze(steps));
|
|
writeHead(
|
|
inputs,
|
|
request,
|
|
runner,
|
|
committed,
|
|
headResourceVersion,
|
|
'deployment_head_commit',
|
|
);
|
|
const receipt = receiptFromCommitted(committed, inputs.headNamespace);
|
|
writeNoReplace(output, receipt);
|
|
return receipt;
|
|
}
|
|
|
|
function auditReceipt(command) {
|
|
const applyFile = verifyExpected(
|
|
readCanonicalJson(
|
|
command.request.applyCommand.path,
|
|
'apply command',
|
|
MAX_COMMAND_BYTES,
|
|
),
|
|
command.request.applyCommand.expectedDigest,
|
|
'apply command',
|
|
);
|
|
const applyCommand = parseCommand(applyFile.value);
|
|
if (applyCommand.operation !== 'cluster.deployment.apply') {
|
|
fail('receipt audit requires an apply command');
|
|
}
|
|
const receiptFile = readCanonicalJson(
|
|
command.request.receipt.path,
|
|
'deployment receipt',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const receipt = validateReceipt(receiptFile.value, applyFile.digest);
|
|
if (
|
|
receipt.receiptDigest !== command.request.receipt.expectedDigest ||
|
|
receipt.mutationId !== applyCommand.request.mutationId ||
|
|
receipt.preflightDigest !== applyCommand.request.preflight.expectedDigest ||
|
|
receipt.transitionKind !== applyCommand.request.transitionKind ||
|
|
applyCommand.request.output !== command.request.receipt.path
|
|
) {
|
|
fail('deployment receipt command binding changed');
|
|
}
|
|
return Object.freeze({
|
|
compatible: true,
|
|
deploymentFamily: 'cluster',
|
|
mutationId: receipt.mutationId,
|
|
receiptDigest: receipt.receiptDigest,
|
|
preflightDigest: receipt.preflightDigest,
|
|
transitionKind: receipt.transitionKind,
|
|
lockDigest: receipt.lock.lockDigest,
|
|
manifestDigest: receipt.lock.manifestDigest,
|
|
deploymentGeneration: receipt.deploymentHead.generation,
|
|
deploymentDigest: receipt.deploymentHead.deploymentDigest,
|
|
deploymentHeadStateDigest: receipt.deploymentHead.stateDigest,
|
|
resourceCount: receipt.resourceInventory.length,
|
|
clusterUid: receipt.target.clusterUid,
|
|
externalResultsReplayed: false,
|
|
kubernetesMutation: false,
|
|
});
|
|
}
|
|
|
|
function auditRetirementReceipt(command) {
|
|
const applyFile = verifyExpected(
|
|
readCanonicalJson(
|
|
command.request.applyCommand.path,
|
|
'retirement apply command',
|
|
MAX_COMMAND_BYTES,
|
|
),
|
|
command.request.applyCommand.expectedDigest,
|
|
'retirement apply command',
|
|
);
|
|
const applyCommand = parseCommand(applyFile.value);
|
|
if (applyCommand.operation !== 'cluster.deployment.retirement.apply') {
|
|
fail('retirement receipt audit requires a retirement apply command');
|
|
}
|
|
const receiptFile = readCanonicalJson(
|
|
command.request.receipt.path,
|
|
'retirement receipt',
|
|
MAX_REPORT_BYTES,
|
|
);
|
|
const receipt = validateRetirementReceipt(
|
|
receiptFile.value,
|
|
applyFile.digest,
|
|
);
|
|
if (
|
|
receipt.receiptDigest !== command.request.receipt.expectedDigest ||
|
|
receipt.mutationId !== applyCommand.request.mutationId ||
|
|
receipt.preflightDigest !== applyCommand.request.preflight.expectedDigest ||
|
|
applyCommand.request.output !== command.request.receipt.path
|
|
) {
|
|
fail('retirement receipt command binding changed');
|
|
}
|
|
return Object.freeze({
|
|
compatible: true,
|
|
deploymentFamily: 'cluster',
|
|
mutationId: receipt.mutationId,
|
|
receiptDigest: receipt.receiptDigest,
|
|
preflightDigest: receipt.preflightDigest,
|
|
transitionKind: 'retire',
|
|
lockDigest: receipt.lock.lockDigest,
|
|
manifestDigest: receipt.lock.manifestDigest,
|
|
deploymentGeneration: receipt.deploymentHead.generation,
|
|
deploymentDigest: receipt.deploymentHead.deploymentDigest,
|
|
deploymentHeadStateDigest: receipt.deploymentHead.stateDigest,
|
|
retiredResourceCount: receipt.retiredResources.length,
|
|
resourceCount: receipt.resourceInventory.length,
|
|
clusterUid: receipt.target.clusterUid,
|
|
externalResultsReplayed: false,
|
|
kubernetesMutation: false,
|
|
});
|
|
}
|
|
|
|
function executeCommand(commandFile, dependencies = {}) {
|
|
const commandState = readCommand(commandFile);
|
|
if (commandState.command.operation === 'cluster.deployment.preflight') {
|
|
return createPreflight(commandState, dependencies);
|
|
}
|
|
if (commandState.command.operation === 'cluster.deployment.apply') {
|
|
return applyDeployment(commandState, dependencies);
|
|
}
|
|
if (
|
|
commandState.command.operation === 'cluster.deployment.retirement.preflight'
|
|
) {
|
|
return createRetirementPreflight(commandState, dependencies);
|
|
}
|
|
if (
|
|
commandState.command.operation === 'cluster.deployment.retirement.apply'
|
|
) {
|
|
return applyRetirement(commandState, dependencies);
|
|
}
|
|
if (
|
|
commandState.command.operation ===
|
|
'cluster.deployment.retirement.receipt.audit'
|
|
) {
|
|
return auditRetirementReceipt(commandState.command);
|
|
}
|
|
return auditReceipt(commandState.command);
|
|
}
|
|
|
|
module.exports = Object.freeze({
|
|
COMMAND_SCHEMA,
|
|
FIELD_MANAGER,
|
|
HEAD_DATA_KEY,
|
|
HEAD_NAME,
|
|
HEAD_SCHEMA,
|
|
LOCK_SCHEMA,
|
|
PREFLIGHT_SCHEMA,
|
|
RECEIPT_SCHEMA,
|
|
RETIREMENT_PREFLIGHT_SCHEMA,
|
|
RETIREMENT_RECEIPT_SCHEMA,
|
|
QingLong3KubernetesDeploymentCeremonyError,
|
|
canonicalJson,
|
|
executeCommand,
|
|
parseCommand,
|
|
validateLockReport,
|
|
validatePreflight,
|
|
validateReceipt,
|
|
validateRetirementPreflight,
|
|
validateRetirementReceipt,
|
|
});
|