mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add catalog-bound cluster deployment ceremony
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
QingLong3KubernetesDeploymentCeremonyError,
|
||||
canonicalJson,
|
||||
executeCommand,
|
||||
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
|
||||
|
||||
function commandFile(argv) {
|
||||
if (argv.length !== 1) throw new Error('arguments are invalid');
|
||||
const match = /^--command-file=(.+)$/u.exec(argv[0]);
|
||||
if (!match) throw new Error('arguments are invalid');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function lowSensitivityFailure() {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-kubernetes-deployment-ceremony',
|
||||
code: 'QL3_KUBERNETES_DEPLOYMENT_CEREMONY_FAILED',
|
||||
message: 'QingLong 3 Kubernetes deployment ceremony failed',
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, output = process.stdout, errorOutput = process.stderr) {
|
||||
try {
|
||||
const result = executeCommand(commandFile(argv));
|
||||
output.write(canonicalJson(result));
|
||||
return result;
|
||||
} catch (error) {
|
||||
errorOutput.write(canonicalJson(lowSensitivityFailure()));
|
||||
if (
|
||||
!(error instanceof QingLong3KubernetesDeploymentCeremonyError) &&
|
||||
process.env.QL3_DEBUG_DEPLOYMENT_CEREMONY === 'true'
|
||||
) {
|
||||
errorOutput.write(
|
||||
`${error instanceof Error ? error.message : 'error'}\n`,
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) runCli(process.argv.slice(2));
|
||||
|
||||
module.exports = Object.freeze({ commandFile, runCli });
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/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,
|
||||
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 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,
|
||||
'/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('d341-live-release-set');
|
||||
const catalogManifestDigest = digest('d341-live-catalog-manifest');
|
||||
const catalogReportDigest = digest('d341-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 },
|
||||
},
|
||||
];
|
||||
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('d341-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 writeCommand(directory, name, operation, request) {
|
||||
return privateFile(
|
||||
directory,
|
||||
name,
|
||||
canonicalJson({
|
||||
schemaVersion: 1,
|
||||
schema: COMMAND_SCHEMA,
|
||||
operation,
|
||||
request,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fixture = new K3sDockerLiveFixture({
|
||||
prefix: 'ql3-deploy-live',
|
||||
kubectl: process.env.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 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 = lockedArtifacts();
|
||||
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,
|
||||
};
|
||||
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 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 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,
|
||||
},
|
||||
preflightDigest: preflight.preflightDigest,
|
||||
receiptDigest: receipt.receiptDigest,
|
||||
receiptAuditCompatible: audit.compatible,
|
||||
serverSideDryRun: preflight.verification.serverSideDryRun,
|
||||
serverSideApply: receipt.verification.serverSideApply,
|
||||
convergenceRead: receipt.verification.convergenceRead,
|
||||
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({ lockedArtifacts, main });
|
||||
Reference in New Issue
Block a user