mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
1515 lines
46 KiB
JavaScript
1515 lines
46 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const test = require('node:test');
|
|
const yaml = require('js-yaml');
|
|
const {
|
|
COMMAND_SCHEMA,
|
|
FIELD_MANAGER,
|
|
HEAD_DATA_KEY,
|
|
HEAD_NAME,
|
|
PREFLIGHT_SCHEMA,
|
|
RECEIPT_SCHEMA,
|
|
RETIREMENT_PREFLIGHT_SCHEMA,
|
|
RETIREMENT_RECEIPT_SCHEMA,
|
|
canonicalJson,
|
|
executeCommand,
|
|
parseCommand,
|
|
validateLockReport,
|
|
} = require('../../scripts/lib/ql3-kubernetes-deployment-ceremony.cjs');
|
|
const {
|
|
commandFile,
|
|
} = require('../../scripts/ql3-kubernetes-deployment-ceremony.cjs');
|
|
|
|
const CLUSTER_UID = '123e4567-e89b-42d3-a456-426614174000';
|
|
const RELEASE_SET_DIGEST = digest('release-set');
|
|
const CATALOG_MANIFEST_DIGEST = digest('catalog-manifest');
|
|
const CATALOG_REPORT_DIGEST = digest('catalog-report');
|
|
const SOURCE_REVISION = 'd'.repeat(40);
|
|
const VERSION = '3.0.0-alpha.0';
|
|
const CONTEXT = 'qinglong-production';
|
|
const REFERENCES = Object.freeze({
|
|
control: image('qinglong3-cluster-control', '1'),
|
|
'control-ai': image('qinglong3-cluster-control-ai', '2'),
|
|
admin: image('qinglong3-cluster-admin', '3'),
|
|
worker: image('qinglong3-worker', '4'),
|
|
});
|
|
|
|
function digest(value) {
|
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
|
|
function image(repository, digit) {
|
|
return `ghcr.io/qinglong-release/${repository}@sha256:${digit.repeat(64)}`;
|
|
}
|
|
|
|
function annotations(version = VERSION) {
|
|
return {
|
|
'qinglong.io/release-set-digest': RELEASE_SET_DIGEST,
|
|
'qinglong.io/release-catalog-manifest-digest': CATALOG_MANIFEST_DIGEST,
|
|
'qinglong.io/release-catalog-report-digest': CATALOG_REPORT_DIGEST,
|
|
'qinglong.io/release-source-revision': SOURCE_REVISION,
|
|
'qinglong.io/release-version': version,
|
|
};
|
|
}
|
|
|
|
function manifest({ version = VERSION, extraResource = false } = {}) {
|
|
const metadata = (name) => ({
|
|
name,
|
|
namespace: 'qinglong-system',
|
|
annotations: annotations(version),
|
|
});
|
|
const deployment = (name, containerName, reference) => ({
|
|
apiVersion: 'apps/v1',
|
|
kind: 'Deployment',
|
|
metadata: metadata(name),
|
|
spec: {
|
|
selector: { matchLabels: { app: name } },
|
|
template: {
|
|
metadata: {
|
|
labels: { app: name },
|
|
annotations: annotations(version),
|
|
},
|
|
spec: { containers: [{ name: containerName, image: reference }] },
|
|
},
|
|
},
|
|
});
|
|
const resources = [
|
|
deployment('ql3-control', 'control', REFERENCES.control),
|
|
deployment('ql3-control-ai', 'control-ai', REFERENCES['control-ai']),
|
|
{
|
|
apiVersion: 'batch/v1',
|
|
kind: 'Job',
|
|
metadata: metadata('ql3-admin'),
|
|
spec: {
|
|
template: {
|
|
metadata: { annotations: annotations(version) },
|
|
spec: {
|
|
restartPolicy: 'Never',
|
|
containers: [{ name: 'admin', image: REFERENCES.admin }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
deployment('ql3-worker', 'worker', REFERENCES.worker),
|
|
{
|
|
apiVersion: 'v1',
|
|
kind: 'ConfigMap',
|
|
metadata: metadata('ql3-plugin-package-secret-action-admission'),
|
|
data: { image: REFERENCES.admin },
|
|
},
|
|
];
|
|
if (extraResource) {
|
|
resources.push({
|
|
apiVersion: 'v1',
|
|
kind: 'ConfigMap',
|
|
metadata: metadata('ql3-extra-release-resource'),
|
|
data: { purpose: 'resource-inventory-closure-test' },
|
|
});
|
|
}
|
|
return `${resources
|
|
.map((resource) => JSON.stringify(resource))
|
|
.join('\n---\n')}\n`;
|
|
}
|
|
|
|
function lockReport(manifestContents = manifest(), version = VERSION) {
|
|
const resources = [];
|
|
yaml.loadAll(manifestContents, (resource) => resources.push(resource));
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: 'qinglong/kubernetes-deployment-lock@v2',
|
|
release: {
|
|
version,
|
|
sourceRevision: SOURCE_REVISION,
|
|
sourceRef: `refs/tags/v${version}`,
|
|
scope: 'cluster',
|
|
},
|
|
releaseSetDigest: RELEASE_SET_DIGEST,
|
|
catalog: {
|
|
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
|
sourceRepository: 'qinglong-release/qinglong',
|
|
workflowIdentity: `https://github.com/qinglong-release/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v${version}`,
|
|
immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${CATALOG_MANIFEST_DIGEST}`,
|
|
manifestDigest: CATALOG_MANIFEST_DIGEST,
|
|
consumptionReportDigest: CATALOG_REPORT_DIGEST,
|
|
releaseSetDigest: RELEASE_SET_DIGEST,
|
|
discoveryTagAuthority: 'none',
|
|
},
|
|
deploymentFamily: 'cluster',
|
|
requiredImages: ['control', 'control-ai', 'admin', 'worker'],
|
|
imageOccurrences: [
|
|
{ name: 'control', reference: REFERENCES.control, count: 1 },
|
|
{
|
|
name: 'control-ai',
|
|
reference: REFERENCES['control-ai'],
|
|
count: 1,
|
|
},
|
|
{ name: 'admin', reference: REFERENCES.admin, count: 2 },
|
|
{ name: 'worker', reference: REFERENCES.worker, count: 1 },
|
|
],
|
|
manifest: {
|
|
inputDigest: digest('source-render'),
|
|
outputDigest: digest(manifestContents),
|
|
resources: resources.length,
|
|
changedResources: 5,
|
|
admissionAuthorityCount: 1,
|
|
},
|
|
verification: {
|
|
releaseSet: 'standalone_structure_identity_and_self_digest',
|
|
sourceRecordsReplayed: false,
|
|
catalogConsumption: 'offline_reconstructed',
|
|
externalToolResultsReplayed: false,
|
|
unknownImageAuthorities: 0,
|
|
mutableQingLongImages: 0,
|
|
networkAccess: false,
|
|
kubernetesMutation: false,
|
|
},
|
|
};
|
|
return {
|
|
...unsigned,
|
|
lockDigest: digest(JSON.stringify(unsigned)),
|
|
};
|
|
}
|
|
|
|
function fixture(t, options = {}) {
|
|
const directory = fs.realpathSync(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-kubernetes-deployment-')),
|
|
);
|
|
fs.chmodSync(directory, 0o700);
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const manifestContents = manifest(options);
|
|
const lockedManifest = privateFile(
|
|
directory,
|
|
'locked.yaml',
|
|
manifestContents,
|
|
);
|
|
const report = lockReport(manifestContents, options.version ?? VERSION);
|
|
const lockReportPath = privateFile(
|
|
directory,
|
|
'lock.json',
|
|
canonicalJson(report),
|
|
);
|
|
const kubectl = privateFile(
|
|
directory,
|
|
'kubectl',
|
|
'#!/bin/sh\nexit 97\n',
|
|
0o700,
|
|
);
|
|
const curl = privateFile(directory, 'curl', '#!/bin/sh\nexit 96\n', 0o700);
|
|
const kubeconfig = privateFile(
|
|
directory,
|
|
'kubeconfig.yaml',
|
|
`apiVersion: v1
|
|
kind: Config
|
|
current-context: ${CONTEXT}
|
|
clusters:
|
|
- name: ql3
|
|
cluster:
|
|
server: https://cluster.example.test
|
|
certificate-authority-data: Y2E=
|
|
contexts:
|
|
- name: ${CONTEXT}
|
|
context:
|
|
cluster: ql3
|
|
user: operator
|
|
users:
|
|
- name: operator
|
|
user:
|
|
token: bounded-test-token
|
|
`,
|
|
);
|
|
return {
|
|
directory,
|
|
lockedManifest,
|
|
lockReportPath,
|
|
kubectl,
|
|
curl,
|
|
kubeconfig,
|
|
report,
|
|
manifestContents,
|
|
};
|
|
}
|
|
|
|
function privateFile(directory, name, contents, mode = 0o600) {
|
|
const target = path.join(directory, name);
|
|
fs.writeFileSync(target, contents, { mode });
|
|
return target;
|
|
}
|
|
|
|
function fileDigest(filePath) {
|
|
return digest(fs.readFileSync(filePath));
|
|
}
|
|
|
|
function writeCommand(directory, name, operation, request) {
|
|
const value = {
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation,
|
|
request,
|
|
};
|
|
return privateFile(directory, name, canonicalJson(value));
|
|
}
|
|
|
|
function commonRequest(fixtureValue) {
|
|
return {
|
|
lockedManifest: {
|
|
path: fixtureValue.lockedManifest,
|
|
expectedDigest: fixtureValue.report.manifest.outputDigest,
|
|
},
|
|
lockReport: {
|
|
path: fixtureValue.lockReportPath,
|
|
expectedDigest: fixtureValue.report.lockDigest,
|
|
},
|
|
kubectl: {
|
|
path: fixtureValue.kubectl,
|
|
expectedDigest: fileDigest(fixtureValue.kubectl),
|
|
},
|
|
kubeconfig: {
|
|
path: fixtureValue.kubeconfig,
|
|
expectedDigest: fileDigest(fixtureValue.kubeconfig),
|
|
},
|
|
context: CONTEXT,
|
|
expectedClusterUid: CLUSTER_UID,
|
|
transitionKind: 'install',
|
|
expectedHead: {
|
|
generation: 0,
|
|
deploymentDigest: null,
|
|
lockDigest: null,
|
|
stateDigest: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function successfulRunner(calls, server = {}, manifestContents = manifest()) {
|
|
server.head ??= null;
|
|
server.resourceVersion ??= 0;
|
|
const runner = (_executable, args, input) => {
|
|
calls.push({ args: [...args], input });
|
|
if (args.includes('get') && args.includes('-f=-')) {
|
|
return {
|
|
status: 0,
|
|
stdout: convergenceList(manifestContents),
|
|
stderr: '',
|
|
};
|
|
}
|
|
if (args.includes('get') && args.includes(HEAD_NAME)) {
|
|
return {
|
|
status: 0,
|
|
stdout: server.head === null ? '' : JSON.stringify(server.head),
|
|
stderr: '',
|
|
};
|
|
}
|
|
if (args.includes('get') && args.includes('kube-system')) {
|
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
|
}
|
|
if (args.includes('--dry-run=server')) {
|
|
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
|
|
}
|
|
if (args.includes('create') || args.includes('replace')) {
|
|
const configMap = JSON.parse(input);
|
|
if (
|
|
(args.includes('create') && server.head !== null) ||
|
|
(args.includes('replace') &&
|
|
(server.head === null ||
|
|
configMap.metadata.resourceVersion !==
|
|
server.head.metadata.resourceVersion))
|
|
) {
|
|
return { status: 1, stdout: '', stderr: 'conflict' };
|
|
}
|
|
server.resourceVersion += 1;
|
|
server.head = {
|
|
...configMap,
|
|
metadata: {
|
|
...configMap.metadata,
|
|
resourceVersion: String(server.resourceVersion),
|
|
uid: '123e4567-e89b-42d3-a456-426614174099',
|
|
},
|
|
};
|
|
return { status: 0, stdout: JSON.stringify(server.head), stderr: '' };
|
|
}
|
|
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
|
|
};
|
|
runner.server = server;
|
|
return runner;
|
|
}
|
|
|
|
function convergenceList(manifestContents = manifest()) {
|
|
const resources = [];
|
|
yaml.loadAll(manifestContents, (resource) => resources.push(resource));
|
|
return JSON.stringify({
|
|
apiVersion: 'v1',
|
|
kind: 'List',
|
|
items: resources.map((resource, index) => ({
|
|
...resource,
|
|
metadata: {
|
|
...resource.metadata,
|
|
uid: `123e4567-e89b-42d3-a456-4266141741${String(index).padStart(
|
|
2,
|
|
'0',
|
|
)}`,
|
|
resourceVersion: String(index + 1),
|
|
managedFields: [
|
|
{
|
|
manager: FIELD_MANAGER,
|
|
operation: 'Apply',
|
|
apiVersion: resource.apiVersion,
|
|
},
|
|
],
|
|
},
|
|
})),
|
|
});
|
|
}
|
|
|
|
function prepare(t) {
|
|
const value = fixture(t);
|
|
const output = path.join(value.directory, 'preflight.json');
|
|
const command = writeCommand(
|
|
value.directory,
|
|
'preflight-command.json',
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId: '123e4567-e89b-42d3-a456-426614174001',
|
|
...commonRequest(value),
|
|
output,
|
|
},
|
|
);
|
|
const calls = [];
|
|
const runner = successfulRunner(calls, {}, value.manifestContents);
|
|
const report = executeCommand(command, {
|
|
runProcess: runner,
|
|
});
|
|
return {
|
|
...value,
|
|
preflightCommand: command,
|
|
preflightPath: output,
|
|
preflight: report,
|
|
calls,
|
|
runner,
|
|
};
|
|
}
|
|
|
|
function expectedFromReceipt(receipt) {
|
|
return {
|
|
generation: receipt.deploymentHead.generation,
|
|
deploymentDigest: receipt.deploymentHead.deploymentDigest,
|
|
lockDigest: receipt.deploymentHead.lockDigest,
|
|
stateDigest: receipt.deploymentHead.stateDigest,
|
|
};
|
|
}
|
|
|
|
function performTransition(
|
|
value,
|
|
runner,
|
|
transitionKind,
|
|
expectedHead,
|
|
suffix,
|
|
) {
|
|
const preflightPath = path.join(value.directory, `${suffix}-preflight.json`);
|
|
const preflightCommand = writeCommand(
|
|
value.directory,
|
|
`${suffix}-preflight-command.json`,
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId: crypto.randomUUID(),
|
|
...commonRequest(value),
|
|
transitionKind,
|
|
expectedHead,
|
|
output: preflightPath,
|
|
},
|
|
);
|
|
const preflight = executeCommand(preflightCommand, { runProcess: runner });
|
|
const receiptPath = path.join(value.directory, `${suffix}-receipt.json`);
|
|
const applyCommand = writeCommand(
|
|
value.directory,
|
|
`${suffix}-apply-command.json`,
|
|
'cluster.deployment.apply',
|
|
{
|
|
mutationId: crypto.randomUUID(),
|
|
preflight: {
|
|
path: preflightPath,
|
|
expectedDigest: preflight.preflightDigest,
|
|
},
|
|
...commonRequest(value),
|
|
transitionKind,
|
|
expectedHead,
|
|
output: receiptPath,
|
|
},
|
|
);
|
|
return {
|
|
preflight,
|
|
preflightPath,
|
|
applyCommand,
|
|
receiptPath,
|
|
receipt: executeCommand(applyCommand, { runProcess: runner }),
|
|
};
|
|
}
|
|
|
|
const RETIREMENT_TARGET = Object.freeze({
|
|
apiVersion: 'v1',
|
|
kind: 'ConfigMap',
|
|
namespace: 'qinglong-system',
|
|
name: 'ql3-extra-release-resource',
|
|
});
|
|
|
|
function retirementRequest(value, expectedHead) {
|
|
return {
|
|
lockedManifest: {
|
|
path: value.lockedManifest,
|
|
expectedDigest: value.report.manifest.outputDigest,
|
|
},
|
|
lockReport: {
|
|
path: value.lockReportPath,
|
|
expectedDigest: value.report.lockDigest,
|
|
},
|
|
kubectl: {
|
|
path: value.kubectl,
|
|
expectedDigest: fileDigest(value.kubectl),
|
|
},
|
|
curl: {
|
|
path: value.curl,
|
|
expectedDigest: fileDigest(value.curl),
|
|
},
|
|
kubeconfig: {
|
|
path: value.kubeconfig,
|
|
expectedDigest: fileDigest(value.kubeconfig),
|
|
},
|
|
context: CONTEXT,
|
|
expectedClusterUid: CLUSTER_UID,
|
|
expectedHead,
|
|
targets: [RETIREMENT_TARGET],
|
|
};
|
|
}
|
|
|
|
function apiObjectPath(resource) {
|
|
const namespace = encodeURIComponent(resource.metadata.namespace);
|
|
const name = encodeURIComponent(resource.metadata.name);
|
|
if (resource.apiVersion === 'v1' && resource.kind === 'ConfigMap') {
|
|
return `/api/v1/namespaces/${namespace}/configmaps/${name}`;
|
|
}
|
|
throw new Error('unsupported fake API resource');
|
|
}
|
|
|
|
class FakeRetirementApi {
|
|
constructor(manifestContents) {
|
|
this.objects = new Map();
|
|
this.requests = [];
|
|
this.loseNextDeleteResponse = false;
|
|
const list = JSON.parse(convergenceList(manifestContents));
|
|
for (const resource of list.items) {
|
|
if (resource.apiVersion === 'v1' && resource.kind === 'ConfigMap') {
|
|
this.objects.set(apiObjectPath(resource), resource);
|
|
}
|
|
}
|
|
}
|
|
|
|
request(method, requestPath, body) {
|
|
this.requests.push({ method, requestPath, body });
|
|
if (method === 'GET' && requestPath === '/api/v1') {
|
|
return {
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
groupVersion: 'v1',
|
|
resources: [
|
|
{
|
|
name: 'configmaps',
|
|
namespaced: true,
|
|
kind: 'ConfigMap',
|
|
verbs: ['delete', 'get', 'list'],
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
}
|
|
const live = this.objects.get(requestPath);
|
|
if (method === 'GET') {
|
|
return live === undefined
|
|
? { status: 404, body: JSON.stringify({ kind: 'Status' }) }
|
|
: { status: 200, body: JSON.stringify(live) };
|
|
}
|
|
if (method !== 'DELETE') throw new Error('unexpected fake API method');
|
|
if (live === undefined) {
|
|
return { status: 404, body: JSON.stringify({ kind: 'Status' }) };
|
|
}
|
|
if (
|
|
body?.preconditions?.uid !== live.metadata.uid ||
|
|
body?.preconditions?.resourceVersion !== live.metadata.resourceVersion
|
|
) {
|
|
return { status: 409, body: JSON.stringify({ kind: 'Status' }) };
|
|
}
|
|
if (body.dryRun?.includes('All')) {
|
|
return { status: 200, body: JSON.stringify({ kind: 'Status' }) };
|
|
}
|
|
this.objects.delete(requestPath);
|
|
if (this.loseNextDeleteResponse) {
|
|
this.loseNextDeleteResponse = false;
|
|
throw new Error('simulated response loss');
|
|
}
|
|
return { status: 200, body: JSON.stringify({ kind: 'Status' }) };
|
|
}
|
|
}
|
|
|
|
function prepareRetirement(t) {
|
|
const value = fixture(t, { extraResource: true });
|
|
const runner = successfulRunner([], {}, value.manifestContents);
|
|
const installed = performTransition(
|
|
value,
|
|
runner,
|
|
'install',
|
|
commonRequest(value).expectedHead,
|
|
'retirement-install',
|
|
);
|
|
const expectedHead = expectedFromReceipt(installed.receipt);
|
|
const api = new FakeRetirementApi(value.manifestContents);
|
|
const preflightPath = path.join(value.directory, 'retirement-preflight.json');
|
|
const preflightCommand = writeCommand(
|
|
value.directory,
|
|
'retirement-preflight-command.json',
|
|
'cluster.deployment.retirement.preflight',
|
|
{
|
|
preflightId: crypto.randomUUID(),
|
|
...retirementRequest(value, expectedHead),
|
|
output: preflightPath,
|
|
},
|
|
);
|
|
const preflight = executeCommand(preflightCommand, {
|
|
runProcess: runner,
|
|
retirementApi: api,
|
|
});
|
|
return {
|
|
...value,
|
|
runner,
|
|
installed,
|
|
expectedHead,
|
|
api,
|
|
preflight,
|
|
preflightPath,
|
|
preflightCommand,
|
|
};
|
|
}
|
|
|
|
function retirementApplyCommand(value, suffix = 'retirement') {
|
|
const receiptPath = path.join(value.directory, `${suffix}-receipt.json`);
|
|
const applyCommand = writeCommand(
|
|
value.directory,
|
|
`${suffix}-apply-command.json`,
|
|
'cluster.deployment.retirement.apply',
|
|
{
|
|
mutationId: crypto.randomUUID(),
|
|
preflight: {
|
|
path: value.preflightPath,
|
|
expectedDigest: value.preflight.preflightDigest,
|
|
},
|
|
...retirementRequest(value, value.expectedHead),
|
|
output: receiptPath,
|
|
},
|
|
);
|
|
return { applyCommand, receiptPath };
|
|
}
|
|
|
|
test('preflight binds one catalog lock to one Kubernetes target without mutation', (t) => {
|
|
const value = prepare(t);
|
|
assert.equal(value.preflight.schema, PREFLIGHT_SCHEMA);
|
|
assert.equal(value.preflight.lock.lockDigest, value.report.lockDigest);
|
|
assert.equal(
|
|
value.preflight.lock.manifestDigest,
|
|
value.report.manifest.outputDigest,
|
|
);
|
|
assert.equal(value.preflight.target.clusterUid, CLUSTER_UID);
|
|
assert.equal(value.preflight.target.fieldManager, FIELD_MANAGER);
|
|
assert.equal(value.preflight.verification.serverSideDryRun, true);
|
|
assert.equal(value.preflight.verification.kubernetesMutation, false);
|
|
assert.deepEqual(
|
|
value.preflight.steps.map(({ name }) => name),
|
|
['cluster_identity_before', 'deployment_head_read', 'server_side_dry_run'],
|
|
);
|
|
assert.equal(fs.statSync(value.preflightPath).mode & 0o777, 0o600);
|
|
assert.equal(value.calls.length, 3);
|
|
assert.equal(value.calls[0].args.includes('get'), true);
|
|
assert.equal(value.calls[1].args.includes(HEAD_NAME), true);
|
|
assert.equal(value.calls[2].args.includes('--dry-run=server'), true);
|
|
assert.equal(
|
|
value.calls[2].args.includes(`--field-manager=${FIELD_MANAGER}`),
|
|
true,
|
|
);
|
|
assert.equal(value.calls[2].input, manifest());
|
|
});
|
|
|
|
test('preflight rejects lock, manifest and annotation drift before network access', (t) => {
|
|
const cases = [
|
|
(target) => fs.appendFileSync(target.lockedManifest, '\n'),
|
|
(target) => {
|
|
const drifted = { ...target.report, lockDigest: digest('forged') };
|
|
fs.writeFileSync(target.lockReportPath, canonicalJson(drifted));
|
|
},
|
|
(target) => {
|
|
const driftedManifest = manifest().replace(
|
|
CATALOG_REPORT_DIGEST,
|
|
digest('different-catalog-report'),
|
|
);
|
|
fs.writeFileSync(target.lockedManifest, driftedManifest);
|
|
const report = lockReport(driftedManifest);
|
|
fs.writeFileSync(target.lockReportPath, canonicalJson(report));
|
|
target.report = report;
|
|
},
|
|
(target) => {
|
|
const implicitNamespaceManifest = manifest().replace(
|
|
'"namespace":"qinglong-system",',
|
|
'',
|
|
);
|
|
fs.writeFileSync(target.lockedManifest, implicitNamespaceManifest);
|
|
const report = lockReport(implicitNamespaceManifest);
|
|
fs.writeFileSync(target.lockReportPath, canonicalJson(report));
|
|
target.report = report;
|
|
},
|
|
];
|
|
for (const [index, mutate] of cases.entries()) {
|
|
const fresh = fixture(t);
|
|
mutate(fresh);
|
|
const output = path.join(fresh.directory, `rejected-${index}.json`);
|
|
const request = commonRequest(fresh);
|
|
request.lockedManifest.expectedDigest = fresh.report.manifest.outputDigest;
|
|
request.lockReport.expectedDigest = fresh.report.lockDigest;
|
|
const command = writeCommand(
|
|
fresh.directory,
|
|
`rejected-command-${index}.json`,
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId: `123e4567-e89b-42d3-a456-42661417400${index + 2}`,
|
|
...request,
|
|
output,
|
|
},
|
|
);
|
|
let calls = 0;
|
|
assert.throws(
|
|
() =>
|
|
executeCommand(command, {
|
|
runProcess() {
|
|
calls += 1;
|
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
|
},
|
|
}),
|
|
/deployment ceremony failed/,
|
|
);
|
|
assert.equal(calls, 0);
|
|
assert.equal(fs.existsSync(output), false);
|
|
}
|
|
});
|
|
|
|
test('preflight rejects the wrong cluster and rejected server dry-run', (t) => {
|
|
for (const failure of ['identity', 'dry-run']) {
|
|
const value = fixture(t);
|
|
const output = path.join(value.directory, `${failure}.json`);
|
|
const command = writeCommand(
|
|
value.directory,
|
|
`${failure}-command.json`,
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId:
|
|
failure === 'identity'
|
|
? '123e4567-e89b-42d3-a456-426614174010'
|
|
: '123e4567-e89b-42d3-a456-426614174011',
|
|
...commonRequest(value),
|
|
output,
|
|
},
|
|
);
|
|
const base = successfulRunner([]);
|
|
assert.throws(() =>
|
|
executeCommand(command, {
|
|
runProcess(executable, args, input) {
|
|
if (args.includes('get') && args.includes('kube-system')) {
|
|
return {
|
|
status: 0,
|
|
stdout:
|
|
failure === 'identity' ? crypto.randomUUID() : CLUSTER_UID,
|
|
stderr: '',
|
|
};
|
|
}
|
|
if (failure === 'dry-run' && args.includes('--dry-run=server')) {
|
|
return {
|
|
status: 1,
|
|
stdout: '',
|
|
stderr: 'redacted admission error',
|
|
};
|
|
}
|
|
return base(executable, args, input);
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(fs.existsSync(output), false);
|
|
}
|
|
});
|
|
|
|
test('kubeconfig executable authentication and weak private files fail closed', (t) => {
|
|
for (const mode of ['exec', 'public']) {
|
|
const value = fixture(t);
|
|
if (mode === 'exec') {
|
|
fs.writeFileSync(
|
|
value.kubeconfig,
|
|
fs
|
|
.readFileSync(value.kubeconfig, 'utf8')
|
|
.replace(
|
|
'token: bounded-test-token',
|
|
'exec:\n command: owned',
|
|
),
|
|
);
|
|
} else {
|
|
fs.chmodSync(value.kubeconfig, 0o644);
|
|
}
|
|
const output = path.join(value.directory, `${mode}.json`);
|
|
const command = writeCommand(
|
|
value.directory,
|
|
`${mode}-command.json`,
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId:
|
|
mode === 'exec'
|
|
? '123e4567-e89b-42d3-a456-426614174020'
|
|
: '123e4567-e89b-42d3-a456-426614174021',
|
|
...commonRequest(value),
|
|
output,
|
|
},
|
|
);
|
|
let calls = 0;
|
|
assert.throws(() =>
|
|
executeCommand(command, {
|
|
runProcess() {
|
|
calls += 1;
|
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(calls, 0);
|
|
assert.equal(fs.existsSync(output), false);
|
|
}
|
|
});
|
|
|
|
test('apply revalidates preflight, mutates explicitly and proves convergence', (t) => {
|
|
const value = prepare(t);
|
|
const receiptPath = path.join(value.directory, 'receipt.json');
|
|
const applyCommand = writeCommand(
|
|
value.directory,
|
|
'apply-command.json',
|
|
'cluster.deployment.apply',
|
|
{
|
|
mutationId: '123e4567-e89b-42d3-a456-426614174030',
|
|
preflight: {
|
|
path: value.preflightPath,
|
|
expectedDigest: value.preflight.preflightDigest,
|
|
},
|
|
...commonRequest(value),
|
|
output: receiptPath,
|
|
},
|
|
);
|
|
const calls = [];
|
|
value.calls.length = 0;
|
|
const receipt = executeCommand(applyCommand, {
|
|
runProcess(executable, args, input) {
|
|
calls.push({ args: [...args], input });
|
|
return value.runner(executable, args, input);
|
|
},
|
|
});
|
|
assert.equal(receipt.schema, RECEIPT_SCHEMA);
|
|
assert.equal(receipt.preflightDigest, value.preflight.preflightDigest);
|
|
assert.equal(receipt.verification.kubernetesMutation, true);
|
|
assert.equal(receipt.verification.crossResourceAtomicity, false);
|
|
assert.equal(
|
|
receipt.verification.recovery,
|
|
'resume_exact_transition_from_target_head',
|
|
);
|
|
assert.deepEqual(
|
|
receipt.steps.map(({ name }) => name),
|
|
[
|
|
'cluster_identity_before',
|
|
'server_side_dry_run',
|
|
'server_side_apply',
|
|
'server_side_convergence_read',
|
|
'cluster_identity_after',
|
|
],
|
|
);
|
|
assert.equal(calls.length, 8);
|
|
assert.equal(fs.statSync(receiptPath).mode & 0o777, 0o600);
|
|
|
|
const auditCommand = writeCommand(
|
|
value.directory,
|
|
'audit-command.json',
|
|
'cluster.deployment.receipt.audit',
|
|
{
|
|
applyCommand: {
|
|
path: applyCommand,
|
|
expectedDigest: fileDigest(applyCommand),
|
|
},
|
|
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
|
|
},
|
|
);
|
|
assert.deepEqual(executeCommand(auditCommand), {
|
|
compatible: true,
|
|
deploymentFamily: 'cluster',
|
|
mutationId: receipt.mutationId,
|
|
receiptDigest: receipt.receiptDigest,
|
|
preflightDigest: receipt.preflightDigest,
|
|
transitionKind: 'install',
|
|
lockDigest: value.report.lockDigest,
|
|
manifestDigest: value.report.manifest.outputDigest,
|
|
deploymentGeneration: 1,
|
|
deploymentDigest: receipt.deploymentHead.deploymentDigest,
|
|
deploymentHeadStateDigest: receipt.deploymentHead.stateDigest,
|
|
resourceCount: 5,
|
|
clusterUid: CLUSTER_UID,
|
|
externalResultsReplayed: false,
|
|
kubernetesMutation: false,
|
|
});
|
|
|
|
let replayCalls = 0;
|
|
assert.equal(
|
|
executeCommand(applyCommand, {
|
|
runProcess(executable, args, input) {
|
|
replayCalls += 1;
|
|
return value.runner(executable, args, input);
|
|
},
|
|
}).receiptDigest,
|
|
receipt.receiptDigest,
|
|
);
|
|
assert.equal(replayCalls, 1);
|
|
});
|
|
|
|
test('apply failure or post-apply drift never publishes a success receipt', (t) => {
|
|
for (const failure of ['apply', 'convergence', 'identity-after']) {
|
|
const value = prepare(t);
|
|
const receiptPath = path.join(value.directory, `${failure}-receipt.json`);
|
|
const applyCommand = writeCommand(
|
|
value.directory,
|
|
`${failure}-apply-command.json`,
|
|
'cluster.deployment.apply',
|
|
{
|
|
mutationId:
|
|
failure === 'apply'
|
|
? '123e4567-e89b-42d3-a456-426614174040'
|
|
: failure === 'convergence'
|
|
? '123e4567-e89b-42d3-a456-426614174041'
|
|
: '123e4567-e89b-42d3-a456-426614174042',
|
|
preflight: {
|
|
path: value.preflightPath,
|
|
expectedDigest: value.preflight.preflightDigest,
|
|
},
|
|
...commonRequest(value),
|
|
output: receiptPath,
|
|
},
|
|
);
|
|
let identityCount = 0;
|
|
const base = value.runner;
|
|
assert.throws(() =>
|
|
executeCommand(applyCommand, {
|
|
runProcess(executable, args, input) {
|
|
if (args.includes('get') && args.includes('-f=-')) {
|
|
return {
|
|
status: failure === 'convergence' ? 1 : 0,
|
|
stdout: failure === 'convergence' ? '' : convergenceList(),
|
|
stderr:
|
|
failure === 'convergence' ? 'redacted convergence error' : '',
|
|
};
|
|
}
|
|
if (args.includes('get') && args.includes('kube-system')) {
|
|
identityCount += 1;
|
|
return {
|
|
status: 0,
|
|
stdout:
|
|
failure === 'identity-after' && identityCount === 2
|
|
? crypto.randomUUID()
|
|
: CLUSTER_UID,
|
|
stderr: '',
|
|
};
|
|
}
|
|
if (args.includes('--dry-run=server')) {
|
|
return { status: 0, stdout: 'dry-run', stderr: '' };
|
|
}
|
|
if (failure === 'apply' && args.includes('apply')) {
|
|
return {
|
|
status: 1,
|
|
stdout: '',
|
|
stderr: 'redacted apply error',
|
|
};
|
|
}
|
|
return base(executable, args, input);
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(fs.existsSync(receiptPath), false);
|
|
}
|
|
});
|
|
|
|
test('apply resumes the exact applying intent from the target head', (t) => {
|
|
const value = prepare(t);
|
|
const receiptPath = path.join(value.directory, 'resumed-receipt.json');
|
|
const applyCommand = writeCommand(
|
|
value.directory,
|
|
'resumed-apply-command.json',
|
|
'cluster.deployment.apply',
|
|
{
|
|
mutationId: '123e4567-e89b-42d3-a456-426614174043',
|
|
preflight: {
|
|
path: value.preflightPath,
|
|
expectedDigest: value.preflight.preflightDigest,
|
|
},
|
|
...commonRequest(value),
|
|
output: receiptPath,
|
|
},
|
|
);
|
|
let rejected = false;
|
|
assert.throws(() =>
|
|
executeCommand(applyCommand, {
|
|
runProcess(executable, args, input) {
|
|
if (
|
|
!rejected &&
|
|
args.includes('apply') &&
|
|
!args.includes('--dry-run=server')
|
|
) {
|
|
rejected = true;
|
|
return { status: 1, stdout: '', stderr: 'transient rejection' };
|
|
}
|
|
return value.runner(executable, args, input);
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(fs.existsSync(receiptPath), false);
|
|
assert.equal(
|
|
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).phase,
|
|
'applying',
|
|
);
|
|
const receipt = executeCommand(applyCommand, {
|
|
runProcess: value.runner,
|
|
});
|
|
assert.equal(receipt.deploymentHead.generation, 1);
|
|
assert.equal(
|
|
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).phase,
|
|
'committed',
|
|
);
|
|
});
|
|
|
|
test('a stale preflight cannot overtake a committed deployment intent', (t) => {
|
|
const value = prepare(t);
|
|
const competingPreflightPath = path.join(
|
|
value.directory,
|
|
'competing-preflight.json',
|
|
);
|
|
const competingPreflightCommand = writeCommand(
|
|
value.directory,
|
|
'competing-preflight-command.json',
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId: '123e4567-e89b-42d3-a456-426614174044',
|
|
...commonRequest(value),
|
|
output: competingPreflightPath,
|
|
},
|
|
);
|
|
const competingPreflight = executeCommand(competingPreflightCommand, {
|
|
runProcess: value.runner,
|
|
});
|
|
const winner = performTransition(
|
|
value,
|
|
value.runner,
|
|
'install',
|
|
commonRequest(value).expectedHead,
|
|
'winner',
|
|
);
|
|
assert.equal(winner.receipt.deploymentHead.generation, 1);
|
|
const staleReceipt = path.join(value.directory, 'stale-receipt.json');
|
|
const staleApply = writeCommand(
|
|
value.directory,
|
|
'stale-apply-command.json',
|
|
'cluster.deployment.apply',
|
|
{
|
|
mutationId: '123e4567-e89b-42d3-a456-426614174045',
|
|
preflight: {
|
|
path: competingPreflightPath,
|
|
expectedDigest: competingPreflight.preflightDigest,
|
|
},
|
|
...commonRequest(value),
|
|
output: staleReceipt,
|
|
},
|
|
);
|
|
assert.throws(() => executeCommand(staleApply, { runProcess: value.runner }));
|
|
assert.equal(fs.existsSync(staleReceipt), false);
|
|
});
|
|
|
|
test('upgrade fails closed when its target omits an active resource', (t) => {
|
|
const server = {};
|
|
const installValue = fixture(t, { version: '3.0.0-alpha.0' });
|
|
const installRunner = successfulRunner(
|
|
[],
|
|
server,
|
|
installValue.manifestContents,
|
|
);
|
|
const installed = performTransition(
|
|
installValue,
|
|
installRunner,
|
|
'install',
|
|
commonRequest(installValue).expectedHead,
|
|
'inventory-install',
|
|
);
|
|
const expandedValue = fixture(t, {
|
|
version: '3.0.0-alpha.1',
|
|
extraResource: true,
|
|
});
|
|
const expandedRunner = successfulRunner(
|
|
[],
|
|
server,
|
|
expandedValue.manifestContents,
|
|
);
|
|
const expanded = performTransition(
|
|
expandedValue,
|
|
expandedRunner,
|
|
'upgrade',
|
|
expectedFromReceipt(installed.receipt),
|
|
'inventory-expand',
|
|
);
|
|
assert.equal(expanded.receipt.resourceInventory.length, 6);
|
|
|
|
const reducedValue = fixture(t, { version: '3.0.0-alpha.2' });
|
|
const reducedRunner = successfulRunner(
|
|
[],
|
|
server,
|
|
reducedValue.manifestContents,
|
|
);
|
|
const reducedPreflightPath = path.join(
|
|
reducedValue.directory,
|
|
'inventory-reduce-preflight.json',
|
|
);
|
|
const reducedPreflight = writeCommand(
|
|
reducedValue.directory,
|
|
'inventory-reduce-preflight-command.json',
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId: crypto.randomUUID(),
|
|
...commonRequest(reducedValue),
|
|
transitionKind: 'upgrade',
|
|
expectedHead: expectedFromReceipt(expanded.receipt),
|
|
output: reducedPreflightPath,
|
|
},
|
|
);
|
|
assert.throws(() =>
|
|
executeCommand(reducedPreflight, { runProcess: reducedRunner }),
|
|
);
|
|
assert.equal(fs.existsSync(reducedPreflightPath), false);
|
|
});
|
|
|
|
test('rollback restores only the exact previous lock with unchanged inventory', (t) => {
|
|
const server = {};
|
|
const installValue = fixture(t, { version: '3.0.0-alpha.0' });
|
|
const installRunner = successfulRunner(
|
|
[],
|
|
server,
|
|
installValue.manifestContents,
|
|
);
|
|
const installed = performTransition(
|
|
installValue,
|
|
installRunner,
|
|
'install',
|
|
commonRequest(installValue).expectedHead,
|
|
'rollback-install',
|
|
);
|
|
const upgradeValue = fixture(t, { version: '3.0.0-alpha.1' });
|
|
const upgradeRunner = successfulRunner(
|
|
[],
|
|
server,
|
|
upgradeValue.manifestContents,
|
|
);
|
|
const upgraded = performTransition(
|
|
upgradeValue,
|
|
upgradeRunner,
|
|
'upgrade',
|
|
expectedFromReceipt(installed.receipt),
|
|
'rollback-upgrade',
|
|
);
|
|
const rolledBack = performTransition(
|
|
installValue,
|
|
installRunner,
|
|
'rollback',
|
|
expectedFromReceipt(upgraded.receipt),
|
|
'rollback-restore',
|
|
);
|
|
assert.equal(rolledBack.receipt.transitionKind, 'rollback');
|
|
assert.equal(rolledBack.receipt.deploymentHead.generation, 3);
|
|
assert.equal(
|
|
rolledBack.receipt.lock.lockDigest,
|
|
installed.receipt.lock.lockDigest,
|
|
);
|
|
assert.deepEqual(
|
|
rolledBack.receipt.resourceInventory,
|
|
installed.receipt.resourceInventory,
|
|
);
|
|
});
|
|
|
|
test('receipt audit rejects a different command or recomputed receipt', (t) => {
|
|
const value = prepare(t);
|
|
const receiptPath = path.join(value.directory, 'receipt.json');
|
|
const applyCommand = writeCommand(
|
|
value.directory,
|
|
'apply-command.json',
|
|
'cluster.deployment.apply',
|
|
{
|
|
mutationId: '123e4567-e89b-42d3-a456-426614174050',
|
|
preflight: {
|
|
path: value.preflightPath,
|
|
expectedDigest: value.preflight.preflightDigest,
|
|
},
|
|
...commonRequest(value),
|
|
output: receiptPath,
|
|
},
|
|
);
|
|
const receipt = executeCommand(applyCommand, {
|
|
runProcess: successfulRunner([]),
|
|
});
|
|
const forged = { ...receipt, mutationId: crypto.randomUUID() };
|
|
const { receiptDigest: ignored, ...unsigned } = forged;
|
|
forged.receiptDigest = digest(JSON.stringify(unsigned));
|
|
fs.writeFileSync(receiptPath, canonicalJson(forged));
|
|
const auditCommand = writeCommand(
|
|
value.directory,
|
|
'audit-command.json',
|
|
'cluster.deployment.receipt.audit',
|
|
{
|
|
applyCommand: {
|
|
path: applyCommand,
|
|
expectedDigest: fileDigest(applyCommand),
|
|
},
|
|
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
|
|
},
|
|
);
|
|
assert.throws(() => executeCommand(auditCommand));
|
|
});
|
|
|
|
test('retirement preflight binds exact UID and resourceVersion without mutation', (t) => {
|
|
const value = prepareRetirement(t);
|
|
assert.equal(value.preflight.schema, RETIREMENT_PREFLIGHT_SCHEMA);
|
|
assert.deepEqual(value.preflight.activeResourceInventory.length, 6);
|
|
assert.deepEqual(value.preflight.survivorResourceInventory.length, 5);
|
|
assert.deepEqual(value.preflight.retirementTargets[0], {
|
|
...RETIREMENT_TARGET,
|
|
uid: '123e4567-e89b-42d3-a456-426614174105',
|
|
resourceVersion: '6',
|
|
});
|
|
assert.equal(value.preflight.verification.kubernetesMutation, false);
|
|
const deletes = value.api.requests.filter(
|
|
(request) => request.method === 'DELETE',
|
|
);
|
|
assert.equal(deletes.length, 1);
|
|
assert.deepEqual(deletes[0].body.preconditions, {
|
|
uid: value.preflight.retirementTargets[0].uid,
|
|
resourceVersion: value.preflight.retirementTargets[0].resourceVersion,
|
|
});
|
|
assert.deepEqual(deletes[0].body.dryRun, ['All']);
|
|
assert.equal(
|
|
value.api.objects.has(
|
|
apiObjectPath({
|
|
...RETIREMENT_TARGET,
|
|
metadata: {
|
|
namespace: RETIREMENT_TARGET.namespace,
|
|
name: RETIREMENT_TARGET.name,
|
|
},
|
|
}),
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('retirement applies preconditioned deletion, closes inventory and audits receipt', (t) => {
|
|
const value = prepareRetirement(t);
|
|
const { applyCommand, receiptPath } = retirementApplyCommand(value);
|
|
const receipt = executeCommand(applyCommand, {
|
|
runProcess: value.runner,
|
|
retirementApi: value.api,
|
|
});
|
|
assert.equal(receipt.schema, RETIREMENT_RECEIPT_SCHEMA);
|
|
assert.equal(receipt.resourceInventory.length, 5);
|
|
assert.equal(receipt.retiredResources.length, 1);
|
|
assert.equal(
|
|
receipt.verification.uidResourceVersionDeletePreconditions,
|
|
true,
|
|
);
|
|
assert.equal(receipt.verification.deletionAbsenceConfirmed, true);
|
|
assert.equal(receipt.deploymentHead.generation, 2);
|
|
assert.equal(
|
|
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).transition.kind,
|
|
'retire',
|
|
);
|
|
const actualDelete = value.api.requests.find(
|
|
(request) =>
|
|
request.method === 'DELETE' && request.body?.dryRun === undefined,
|
|
);
|
|
assert.deepEqual(actualDelete.body.preconditions, {
|
|
uid: receipt.retiredResources[0].uid,
|
|
resourceVersion: receipt.retiredResources[0].resourceVersion,
|
|
});
|
|
|
|
const auditCommand = writeCommand(
|
|
value.directory,
|
|
'retirement-audit-command.json',
|
|
'cluster.deployment.retirement.receipt.audit',
|
|
{
|
|
applyCommand: {
|
|
path: applyCommand,
|
|
expectedDigest: fileDigest(applyCommand),
|
|
},
|
|
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
|
|
},
|
|
);
|
|
const audit = executeCommand(auditCommand);
|
|
assert.equal(audit.compatible, true);
|
|
assert.equal(audit.transitionKind, 'retire');
|
|
assert.equal(audit.retiredResourceCount, 1);
|
|
assert.equal(audit.resourceCount, 5);
|
|
assert.equal(audit.kubernetesMutation, false);
|
|
});
|
|
|
|
test('retirement rejects a replacement UID before acquiring the deployment head', (t) => {
|
|
const value = prepareRetirement(t);
|
|
const { applyCommand, receiptPath } = retirementApplyCommand(
|
|
value,
|
|
'replacement',
|
|
);
|
|
const requestPath = [...value.api.objects.keys()].find((candidate) =>
|
|
candidate.endsWith('/ql3-extra-release-resource'),
|
|
);
|
|
const live = value.api.objects.get(requestPath);
|
|
value.api.objects.set(requestPath, {
|
|
...live,
|
|
metadata: { ...live.metadata, uid: crypto.randomUUID() },
|
|
});
|
|
assert.throws(() =>
|
|
executeCommand(applyCommand, {
|
|
runProcess: value.runner,
|
|
retirementApi: value.api,
|
|
}),
|
|
);
|
|
assert.equal(fs.existsSync(receiptPath), false);
|
|
const head = JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]);
|
|
assert.equal(head.phase, 'committed');
|
|
assert.equal(head.generation, 1);
|
|
});
|
|
|
|
test('retirement resumes exact intent after a lost delete response', (t) => {
|
|
const value = prepareRetirement(t);
|
|
const { applyCommand, receiptPath } = retirementApplyCommand(
|
|
value,
|
|
'response-loss',
|
|
);
|
|
value.api.loseNextDeleteResponse = true;
|
|
assert.throws(() =>
|
|
executeCommand(applyCommand, {
|
|
runProcess: value.runner,
|
|
retirementApi: value.api,
|
|
}),
|
|
);
|
|
assert.equal(fs.existsSync(receiptPath), false);
|
|
assert.equal(
|
|
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).phase,
|
|
'applying',
|
|
);
|
|
const receipt = executeCommand(applyCommand, {
|
|
runProcess: value.runner,
|
|
retirementApi: value.api,
|
|
});
|
|
assert.equal(receipt.deploymentHead.generation, 2);
|
|
assert.equal(receipt.resourceInventory.length, 5);
|
|
assert.equal(
|
|
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).phase,
|
|
'committed',
|
|
);
|
|
});
|
|
|
|
test('rollback may restore the exact inventory retired at the same release version', (t) => {
|
|
const value = prepareRetirement(t);
|
|
const { applyCommand } = retirementApplyCommand(value, 'restore-source');
|
|
const retired = executeCommand(applyCommand, {
|
|
runProcess: value.runner,
|
|
retirementApi: value.api,
|
|
});
|
|
const restored = performTransition(
|
|
value,
|
|
value.runner,
|
|
'rollback',
|
|
expectedFromReceipt(retired),
|
|
'retirement-restore',
|
|
);
|
|
assert.equal(restored.receipt.transitionKind, 'rollback');
|
|
assert.equal(restored.receipt.deploymentHead.generation, 3);
|
|
assert.equal(restored.receipt.resourceInventory.length, 6);
|
|
assert.deepEqual(
|
|
restored.receipt.resourceInventory,
|
|
value.installed.receipt.resourceInventory,
|
|
);
|
|
});
|
|
|
|
test('command surface is closed and lock reports require exact canonical identity', () => {
|
|
assert.equal(
|
|
commandFile(['--command-file=/private/command.json']),
|
|
'/private/command.json',
|
|
);
|
|
assert.throws(() => commandFile([]));
|
|
assert.throws(() => commandFile(['--command-file=a', '--extra=b']));
|
|
assert.throws(() =>
|
|
parseCommand({
|
|
schemaVersion: 1,
|
|
schema: COMMAND_SCHEMA,
|
|
operation: 'cluster.deployment.apply-now',
|
|
request: {},
|
|
}),
|
|
);
|
|
const report = lockReport();
|
|
assert.equal(validateLockReport(report).report.lockDigest, report.lockDigest);
|
|
const drifted = JSON.parse(JSON.stringify(report));
|
|
drifted.catalog.discoveryTagAuthority = 'fallback';
|
|
const { lockDigest: ignored, ...unsigned } = drifted;
|
|
drifted.lockDigest = digest(JSON.stringify(unsigned));
|
|
assert.throws(() => validateLockReport(drifted));
|
|
});
|
|
|
|
test('repository exposes the reviewed ceremony and removes the bare apply handoff', () => {
|
|
const root = path.resolve(__dirname, '../..');
|
|
const packageManifest = JSON.parse(
|
|
fs.readFileSync(path.join(root, 'package.json'), 'utf8'),
|
|
);
|
|
assert.equal(
|
|
packageManifest.scripts['cluster-deployment:ql3'],
|
|
'node scripts/ql3-kubernetes-deployment-ceremony.cjs',
|
|
);
|
|
assert.equal(
|
|
packageManifest.scripts['test:cluster-deployment-live:ql3'],
|
|
'node scripts/ql3-kubernetes-deployment-live-contract.cjs',
|
|
);
|
|
const operations = fs.readFileSync(
|
|
path.join(root, 'docs/operations/ql3-release-set-deployment.md'),
|
|
'utf8',
|
|
);
|
|
assert.match(operations, /cluster\.deployment\.preflight/);
|
|
assert.match(operations, /cluster\.deployment\.apply/);
|
|
assert.match(operations, /cluster\.deployment\.receipt\.audit/);
|
|
assert.match(operations, /qinglong3-catalog-lock/);
|
|
assert.equal(
|
|
operations.includes('才由有权限的独立步骤执行 `kubectl apply'),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('CI runs the isolated deployment and retirement live contract', () => {
|
|
const root = path.resolve(__dirname, '../..');
|
|
const workflowPath = path.join(
|
|
root,
|
|
'.github/workflows/ql3-kubernetes-deployment-live.yml',
|
|
);
|
|
const workflowSource = fs.readFileSync(workflowPath, 'utf8');
|
|
const workflow = yaml.load(workflowSource);
|
|
const job = workflow.jobs?.['kubernetes-deployment-live'];
|
|
assert.equal(job?.['runs-on'], 'ubuntu-24.04');
|
|
assert.equal(job?.['timeout-minutes'], 25);
|
|
assert.match(workflowSource, /rancher\/k3s:v1\.34\.3-k3s1/u);
|
|
assert.match(workflowSource, /kubectl v1\.34\.3/u);
|
|
assert.match(
|
|
workflowSource,
|
|
/node --test test\/back\/ql3KubernetesDeploymentCeremony\.test\.cjs/u,
|
|
);
|
|
assert.match(
|
|
workflowSource,
|
|
/node scripts\/ql3-kubernetes-deployment-live-contract\.cjs/u,
|
|
);
|
|
assert.match(workflowSource, /uidResourceVersionDeletePreconditions/u);
|
|
assert.match(workflowSource, /ql3-deploy-live-/u);
|
|
});
|
|
|
|
test('live contract never resolves curl through the Docker kubectl fallback', function liveToolResolutionKeepsCurlDistinct() {
|
|
const root = path.resolve(__dirname, '../..');
|
|
const source = fs.readFileSync(
|
|
path.join(root, 'scripts/ql3-kubernetes-deployment-live-contract.cjs'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
source,
|
|
/const candidates = \[\s*input,\s*\.\.\.\(input === 'kubectl'\s*\? \['\/Applications\/Docker\.app\/Contents\/Resources\/bin\/kubectl'\]\s*: \[\]\),\s*\.\.\.\(process\.env\.PATH/u,
|
|
);
|
|
});
|
|
|
|
test('private retirement proxy rejects every unrelated mutation method', function privateRetirementProxyRejectsUnrelatedMutations() {
|
|
const root = path.resolve(__dirname, '../..');
|
|
const source = fs.readFileSync(
|
|
path.join(root, 'scripts/lib/ql3-kubernetes-deployment-ceremony.cjs'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
source,
|
|
/--reject-methods=\^\(POST\|PUT\|PATCH\|CONNECT\|OPTIONS\|TRACE\)\$/u,
|
|
);
|
|
assert.equal(source.includes('--reject-methods=POST,PUT,PATCH'), false);
|
|
});
|
|
|
|
test('thin CLI uses the pinned executable and keeps failures low-sensitive', (t) => {
|
|
const value = fixture(t);
|
|
fs.writeFileSync(
|
|
value.kubectl,
|
|
`#!${process.execPath}
|
|
'use strict';
|
|
const args = process.argv.slice(2);
|
|
if (args.includes('get') && args.includes('-f=-')) process.stdout.write(${JSON.stringify(
|
|
convergenceList(),
|
|
)});
|
|
else if (args.includes('get') && args.includes(${JSON.stringify(
|
|
HEAD_NAME,
|
|
)})) process.stdout.write('');
|
|
else if (args.includes('get')) process.stdout.write(${JSON.stringify(
|
|
CLUSTER_UID,
|
|
)});
|
|
else if (args.includes('--dry-run=server')) process.stdout.write('deployment.apps/ql3-control\\n');
|
|
else process.exitCode = 91;
|
|
`,
|
|
{ mode: 0o700 },
|
|
);
|
|
const output = path.join(value.directory, 'cli-preflight.json');
|
|
const command = writeCommand(
|
|
value.directory,
|
|
'cli-command.json',
|
|
'cluster.deployment.preflight',
|
|
{
|
|
preflightId: '123e4567-e89b-42d3-a456-426614174060',
|
|
...commonRequest(value),
|
|
output,
|
|
},
|
|
);
|
|
const cli = path.resolve(
|
|
__dirname,
|
|
'../../scripts/ql3-kubernetes-deployment-ceremony.cjs',
|
|
);
|
|
const accepted = spawnSync(
|
|
process.execPath,
|
|
[cli, `--command-file=${command}`],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.equal(
|
|
accepted.status,
|
|
0,
|
|
JSON.stringify({ stdout: accepted.stdout, stderr: accepted.stderr }),
|
|
);
|
|
assert.equal(accepted.stderr, '');
|
|
assert.equal(JSON.parse(accepted.stdout).schema, PREFLIGHT_SCHEMA);
|
|
|
|
fs.chmodSync(value.kubeconfig, 0o644);
|
|
const rejected = spawnSync(
|
|
process.execPath,
|
|
[cli, `--command-file=${command}`],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.equal(rejected.status, 1);
|
|
assert.equal(rejected.stdout, '');
|
|
assert.deepEqual(JSON.parse(rejected.stderr), {
|
|
schemaVersion: 1,
|
|
component: 'qinglong3-kubernetes-deployment-ceremony',
|
|
code: 'QL3_KUBERNETES_DEPLOYMENT_CEREMONY_FAILED',
|
|
message: 'QingLong 3 Kubernetes deployment ceremony failed',
|
|
});
|
|
assert.equal(rejected.stderr.includes(value.directory), false);
|
|
assert.equal(rejected.stderr.includes(CONTEXT), false);
|
|
});
|