Files
qinglong/scripts/ql3-kubernetes-deployment-live-contract.cjs

715 lines
22 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
COMMAND_SCHEMA,
FIELD_MANAGER,
HEAD_DATA_KEY,
HEAD_NAME,
canonicalJson,
executeCommand,
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
const { K3sDockerLiveFixture } = require('./lib/ql3-k3s-docker-live.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const { createKubernetesLock } = require('./ql3-deployment-lock-contract.cjs');
const {
auditCeremonyBundle,
} = require('./ql3-release-catalog-consumption-ceremony.cjs');
const ROOT = path.resolve(__dirname, '..');
const VERSION = readReleaseIdentity(ROOT).version;
const SOURCE_REVISION = 'd'.repeat(40);
const NAMESPACE = 'ql3-deployment-live';
const CONTEXT = 'default';
const OWNER = 'qinglong-release';
const ROLE_ORDER = Object.freeze(['control', 'control-ai', 'admin', 'worker']);
const IMAGE_NAMES = Object.freeze({
control: 'qinglong3-cluster-control',
'control-ai': 'qinglong3-cluster-control-ai',
admin: 'qinglong3-cluster-admin',
worker: 'qinglong3-worker',
});
function fail(message) {
throw new Error(
`QingLong Kubernetes deployment live contract failed: ${message}`,
);
}
function digest(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function privateFile(directory, name, contents) {
const target = path.join(directory, name);
fs.writeFileSync(target, contents, { mode: 0o600, flag: 'wx' });
return target;
}
function executablePath(input) {
const candidates = [
input,
...(input === 'kubectl'
? ['/Applications/Docker.app/Contents/Resources/bin/kubectl']
: []),
...(process.env.PATH ?? '')
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.join(directory, input)),
];
for (const candidate of candidates) {
try {
const resolved = fs.realpathSync(candidate);
if (fs.lstatSync(resolved).isFile()) return resolved;
} catch {}
}
fail('kubectl executable is unavailable');
}
function references() {
return Object.fromEntries(
ROLE_ORDER.map((role, index) => [
role,
`ghcr.io/${OWNER}/${IMAGE_NAMES[role]}@sha256:${String(index + 1).repeat(
64,
)}`,
]),
);
}
function lockedArtifacts() {
const releaseSetDigest = digest('d343-live-release-set');
const catalogManifestDigest = digest('d343-live-catalog-manifest');
const catalogReportDigest = digest('d343-live-catalog-report');
const imageReferences = references();
const annotations = {
'qinglong.io/release-set-digest': releaseSetDigest,
'qinglong.io/release-catalog-manifest-digest': catalogManifestDigest,
'qinglong.io/release-catalog-report-digest': catalogReportDigest,
'qinglong.io/release-source-revision': SOURCE_REVISION,
'qinglong.io/release-version': VERSION,
};
const deployment = (role) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: `ql3-${role.replace('control-ai', 'control-ai')}`,
namespace: NAMESPACE,
annotations,
},
spec: {
replicas: 0,
selector: { matchLabels: { 'app.kubernetes.io/name': `ql3-${role}` } },
template: {
metadata: {
labels: { 'app.kubernetes.io/name': `ql3-${role}` },
annotations,
},
spec: {
containers: [
{
name: role,
image: imageReferences[role],
command: ['/bin/false'],
},
],
},
},
},
});
const resources = [
{
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: NAMESPACE },
},
...ROLE_ORDER.map(deployment),
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'ql3-plugin-package-secret-action-admission',
namespace: NAMESPACE,
annotations,
},
data: { image: imageReferences.admin },
},
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'ql3-retirement-live-target',
namespace: NAMESPACE,
annotations,
},
data: { purpose: 'uid-resource-version-retirement-live-contract' },
},
];
const manifest = `${resources
.map((resource) => JSON.stringify(resource))
.join('\n---\n')}\n`;
const unsigned = {
schemaVersion: 1,
schema: 'qinglong/kubernetes-deployment-lock@v2',
release: {
version: VERSION,
sourceRevision: SOURCE_REVISION,
sourceRef: `refs/tags/v${VERSION}`,
scope: 'cluster',
},
releaseSetDigest,
catalog: {
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
sourceRepository: `${OWNER}/qinglong`,
workflowIdentity: `https://github.com/${OWNER}/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`,
immutableReference: `ghcr.io/${OWNER}/qinglong3-release-catalog@${catalogManifestDigest}`,
manifestDigest: catalogManifestDigest,
consumptionReportDigest: catalogReportDigest,
releaseSetDigest,
discoveryTagAuthority: 'none',
},
deploymentFamily: 'cluster',
requiredImages: [...ROLE_ORDER],
imageOccurrences: ROLE_ORDER.map((role) => ({
name: role,
reference: imageReferences[role],
count: role === 'admin' ? 2 : 1,
})),
manifest: {
inputDigest: digest('d343-live-source-render'),
outputDigest: digest(manifest),
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 Object.freeze({
manifest,
report: Object.freeze({
...unsigned,
lockDigest: digest(JSON.stringify(unsigned)),
}),
imageReferences: Object.freeze(imageReferences),
});
}
function catalogSourceManifest(imageReferences) {
const annotations = {
'qinglong.io/live-contract': 'verified-release-catalog',
};
const deployment = (role) => ({
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: `ql3-${role}`,
namespace: NAMESPACE,
annotations,
},
spec: {
replicas: 0,
selector: { matchLabels: { 'app.kubernetes.io/name': `ql3-${role}` } },
template: {
metadata: {
labels: { 'app.kubernetes.io/name': `ql3-${role}` },
annotations,
},
spec: {
containers: [
{
name: role,
image: imageReferences[role],
command: ['/bin/false'],
},
],
},
},
},
});
return `${[
{
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: NAMESPACE },
},
...ROLE_ORDER.map(deployment),
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'ql3-plugin-package-secret-action-admission',
namespace: NAMESPACE,
annotations,
},
data: { image: imageReferences.admin },
},
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'ql3-retirement-live-target',
namespace: NAMESPACE,
annotations,
},
data: { purpose: 'catalog-bound-retirement-live-contract' },
},
]
.map((resource) => JSON.stringify(resource))
.join('\n---\n')}\n`;
}
function catalogLockedArtifacts(options, dependencies = {}) {
const audit = dependencies.auditCeremonyBundle ?? auditCeremonyBundle;
const materialize = dependencies.createKubernetesLock ?? createKubernetesLock;
const identity = Object.freeze({
version: VERSION,
sourceRevision: options?.sourceRevision,
sourceRef: options?.sourceRef,
releaseScope: options?.releaseScope,
repositoryOwner: options?.repositoryOwner,
sourceRepository: options?.sourceRepository,
});
if (!['cluster', 'all'].includes(identity.releaseScope)) {
fail('release catalog live deployment requires cluster or all scope');
}
const consumption = audit({
...identity,
outputDirectory: options?.consumptionBundle,
});
const imageReferences = Object.freeze(
Object.fromEntries(
ROLE_ORDER.map((role) => {
const images = consumption.releaseSet?.images?.filter(
(image) => image.name === role,
);
if (images?.length !== 1) {
fail(`verified release catalog is missing one ${role} image`);
}
return [role, images[0].reference];
}),
),
);
const sourceManifest = catalogSourceManifest(imageReferences);
const created = materialize(consumption.releaseSet, sourceManifest, {
...identity,
consumption,
requiredImages: ROLE_ORDER.join(','),
});
return Object.freeze({
manifest: created.outputManifest,
report: created.report,
imageReferences,
releaseAuthority: Object.freeze({
mode: 'verified_release_catalog',
version: VERSION,
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
scope: identity.releaseScope,
releaseSetDigest: consumption.releaseSetDigest,
catalogManifestDigest: consumption.catalogManifestDigest,
catalogConsumptionDigest: consumption.contentDigest,
immutableReference: consumption.immutableReference,
}),
});
}
function deploymentArtifacts(environment = process.env, dependencies = {}) {
const configuration = {
consumptionBundle: environment.QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE,
sourceRevision: environment.QL3_RELEASE_SOURCE_REVISION,
sourceRef: environment.QL3_RELEASE_SOURCE_REF,
releaseScope: environment.QL3_RELEASE_SCOPE,
repositoryOwner: environment.QL3_RELEASE_REPOSITORY_OWNER,
sourceRepository: environment.QL3_RELEASE_SOURCE_REPOSITORY,
};
const values = Object.values(configuration);
const configured = values.filter(
(value) => typeof value === 'string' && value.length > 0,
).length;
if (configured === 0) {
const artifacts = lockedArtifacts();
return Object.freeze({
...artifacts,
releaseAuthority: Object.freeze({
mode: 'synthetic_live_fixture',
version: artifacts.report.release.version,
sourceRevision: artifacts.report.release.sourceRevision,
sourceRef: artifacts.report.release.sourceRef,
scope: artifacts.report.release.scope,
releaseSetDigest: artifacts.report.releaseSetDigest,
catalogManifestDigest: artifacts.report.catalog.manifestDigest,
catalogConsumptionDigest:
artifacts.report.catalog.consumptionReportDigest,
immutableReference: artifacts.report.catalog.immutableReference,
}),
});
}
if (configured !== values.length) {
fail('release catalog live deployment configuration is incomplete');
}
return catalogLockedArtifacts(configuration, dependencies);
}
function writeCommand(directory, name, operation, request) {
return privateFile(
directory,
name,
canonicalJson({
schemaVersion: 1,
schema: COMMAND_SCHEMA,
operation,
request,
}),
);
}
async function main(environment = process.env) {
const fixture = new K3sDockerLiveFixture({
prefix: 'ql3-deploy-live',
kubectl: environment.QL3_KUBECTL_BIN,
});
let evidence;
let cleanupComplete = false;
try {
const nodes = await fixture.start();
fs.chmodSync(fixture.temporary, 0o700);
const ceremonyDirectory = fs.realpathSync(fixture.temporary);
const kubeconfig = fs.realpathSync(fixture.kubeconfig);
const kubectl = executablePath(fixture.kubectlBinary);
const curl = executablePath('curl');
const clusterUid = fixture
.kubectl(
['get', 'namespace', 'kube-system', '-o=jsonpath={.metadata.uid}'],
{ capture: true, quiet: true },
)
.stdout.trim();
fixture.apply({
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: NAMESPACE },
});
const artifacts = deploymentArtifacts(environment);
const manifestPath = privateFile(
ceremonyDirectory,
'locked.yaml',
artifacts.manifest,
);
const reportPath = privateFile(
ceremonyDirectory,
'lock.json',
canonicalJson(artifacts.report),
);
const common = {
lockedManifest: {
path: manifestPath,
expectedDigest: artifacts.report.manifest.outputDigest,
},
lockReport: {
path: reportPath,
expectedDigest: artifacts.report.lockDigest,
},
kubectl: {
path: kubectl,
expectedDigest: digest(fs.readFileSync(kubectl)),
},
kubeconfig: {
path: kubeconfig,
expectedDigest: digest(fs.readFileSync(kubeconfig)),
},
context: CONTEXT,
expectedClusterUid: clusterUid,
transitionKind: 'install',
expectedHead: {
generation: 0,
deploymentDigest: null,
lockDigest: null,
stateDigest: null,
},
};
const preflightPath = path.join(ceremonyDirectory, 'preflight.json');
const preflightCommand = writeCommand(
ceremonyDirectory,
'preflight-command.json',
'cluster.deployment.preflight',
{
preflightId: crypto.randomUUID(),
...common,
output: preflightPath,
},
);
const preflight = executeCommand(preflightCommand);
const receiptPath = path.join(ceremonyDirectory, 'receipt.json');
const applyCommand = writeCommand(
ceremonyDirectory,
'apply-command.json',
'cluster.deployment.apply',
{
mutationId: crypto.randomUUID(),
preflight: {
path: preflightPath,
expectedDigest: preflight.preflightDigest,
},
...common,
output: receiptPath,
},
);
const receipt = executeCommand(applyCommand);
const auditCommand = writeCommand(
ceremonyDirectory,
'audit-command.json',
'cluster.deployment.receipt.audit',
{
applyCommand: {
path: applyCommand,
expectedDigest: digest(fs.readFileSync(applyCommand)),
},
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
},
);
const audit = executeCommand(auditCommand);
const installedHeadConfigMap = fixture.kubectlJson([
'get',
'configmap',
HEAD_NAME,
'-n',
NAMESPACE,
]);
const installedHead = JSON.parse(
installedHeadConfigMap.data?.[HEAD_DATA_KEY],
);
if (
installedHead.phase !== 'committed' ||
installedHead.generation !== 1 ||
installedHead.stateDigest !== receipt.deploymentHead.stateDigest ||
installedHead.deployment?.deploymentDigest !==
receipt.deploymentHead.deploymentDigest
) {
fail('committed deployment head is invalid');
}
const deployments = fixture.kubectlJson([
'get',
'deployments',
'-n',
NAMESPACE,
'--show-managed-fields=true',
]).items;
if (deployments.length !== ROLE_ORDER.length) {
fail('applied deployment count is invalid');
}
for (const deployment of deployments) {
const container = deployment.spec?.template?.spec?.containers?.[0];
if (
!Object.values(artifacts.imageReferences).includes(container?.image)
) {
fail('applied immutable image authority is invalid');
}
if (
!deployment.metadata?.managedFields?.some(
(entry) => entry.manager === FIELD_MANAGER,
)
) {
fail('server-side apply field manager is unavailable');
}
}
const retirementTarget = {
apiVersion: 'v1',
kind: 'ConfigMap',
namespace: NAMESPACE,
name: 'ql3-retirement-live-target',
};
const retirementCommon = {
lockedManifest: common.lockedManifest,
lockReport: common.lockReport,
kubectl: common.kubectl,
curl: {
path: curl,
expectedDigest: digest(fs.readFileSync(curl)),
},
kubeconfig: common.kubeconfig,
context: CONTEXT,
expectedClusterUid: clusterUid,
expectedHead: {
generation: receipt.deploymentHead.generation,
deploymentDigest: receipt.deploymentHead.deploymentDigest,
lockDigest: receipt.deploymentHead.lockDigest,
stateDigest: receipt.deploymentHead.stateDigest,
},
targets: [retirementTarget],
};
const retirementPreflightPath = path.join(
ceremonyDirectory,
'retirement-preflight.json',
);
const retirementPreflightCommand = writeCommand(
ceremonyDirectory,
'retirement-preflight-command.json',
'cluster.deployment.retirement.preflight',
{
preflightId: crypto.randomUUID(),
...retirementCommon,
output: retirementPreflightPath,
},
);
const retirementPreflight = executeCommand(retirementPreflightCommand);
const retirementReceiptPath = path.join(
ceremonyDirectory,
'retirement-receipt.json',
);
const retirementApplyCommand = writeCommand(
ceremonyDirectory,
'retirement-apply-command.json',
'cluster.deployment.retirement.apply',
{
mutationId: crypto.randomUUID(),
preflight: {
path: retirementPreflightPath,
expectedDigest: retirementPreflight.preflightDigest,
},
...retirementCommon,
output: retirementReceiptPath,
},
);
const retirementReceipt = executeCommand(retirementApplyCommand);
const retirementAuditCommand = writeCommand(
ceremonyDirectory,
'retirement-audit-command.json',
'cluster.deployment.retirement.receipt.audit',
{
applyCommand: {
path: retirementApplyCommand,
expectedDigest: digest(fs.readFileSync(retirementApplyCommand)),
},
receipt: {
path: retirementReceiptPath,
expectedDigest: retirementReceipt.receiptDigest,
},
},
);
const retirementAudit = executeCommand(retirementAuditCommand);
const retiredName = fixture
.kubectl(
[
'get',
'configmap',
retirementTarget.name,
'-n',
NAMESPACE,
'--ignore-not-found=true',
'-o=name',
],
{ capture: true, quiet: true },
)
.stdout.trim();
const deploymentHeadConfigMap = fixture.kubectlJson([
'get',
'configmap',
HEAD_NAME,
'-n',
NAMESPACE,
]);
const deploymentHead = JSON.parse(
deploymentHeadConfigMap.data?.[HEAD_DATA_KEY],
);
if (
retiredName !== '' ||
deploymentHead.phase !== 'committed' ||
deploymentHead.generation !== 2 ||
deploymentHead.transition.kind !== 'retire' ||
deploymentHead.stateDigest !==
retirementReceipt.deploymentHead.stateDigest ||
deploymentHead.deployment.resources.length !==
artifacts.report.manifest.resources - 1
) {
fail('committed resource retirement head is invalid');
}
const version = JSON.parse(
fixture.kubectl(['version', '-o=json'], {
capture: true,
quiet: true,
}).stdout,
);
evidence = Object.freeze({
schemaVersion: 1,
schema: 'qinglong/kubernetes-deployment-live-evidence@v1',
kubernetes: {
serverVersion: version.serverVersion.gitVersion,
architecture: version.serverVersion.platform,
nodeCount: nodes.length,
clusterUid,
},
deployment: {
namespace: NAMESPACE,
resourceCount: artifacts.report.manifest.resources,
deploymentCount: deployments.length,
replicas: 0,
fieldManager: FIELD_MANAGER,
immutableImages: true,
headName: HEAD_NAME,
headPhase: deploymentHead.phase,
headGeneration: deploymentHead.generation,
deploymentDigest: deploymentHead.deployment.deploymentDigest,
resourceInventoryCount: deploymentHead.deployment.resources.length,
},
releaseAuthority: artifacts.releaseAuthority,
preflightDigest: preflight.preflightDigest,
receiptDigest: receipt.receiptDigest,
receiptAuditCompatible: audit.compatible,
retirement: {
preflightDigest: retirementPreflight.preflightDigest,
receiptDigest: retirementReceipt.receiptDigest,
receiptAuditCompatible: retirementAudit.compatible,
targetCount: retirementReceipt.retiredResources.length,
targetAbsent: retiredName === '',
uidResourceVersionDeletePreconditions:
retirementReceipt.verification.uidResourceVersionDeletePreconditions,
deploymentHeadCas: retirementReceipt.verification.deploymentHeadCas,
inventoryCount: retirementReceipt.resourceInventory.length,
unixSocketProxy: retirementReceipt.verification.unixSocketProxy,
},
serverSideDryRun: preflight.verification.serverSideDryRun,
serverSideApply: receipt.verification.serverSideApply,
convergenceRead: receipt.verification.convergenceRead,
deploymentHeadCas: receipt.verification.deploymentHeadCas,
resourceInventoryClosed: receipt.verification.resourceInventoryClosed,
crossResourceAtomicity: receipt.verification.crossResourceAtomicity,
});
} finally {
await fixture.cleanup().catch(() => undefined);
cleanupComplete = true;
}
process.stdout.write(canonicalJson({ ...evidence, cleanupComplete }));
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(
`${
error instanceof Error
? error.message
: 'deployment live contract failed'
}\n`,
);
process.exitCode = 1;
});
}
module.exports = Object.freeze({
catalogLockedArtifacts,
deploymentArtifacts,
lockedArtifacts,
main,
});