Files
qinglong/scripts/ql3-deployment-lock-contract.cjs
T

879 lines
26 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const {
SCHEMA: CATALOG_CONSUMPTION_SCHEMA,
auditCeremonyBundle,
catalogIdentity,
} = require('./ql3-release-catalog-consumption-ceremony.cjs');
const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v2';
const KUBERNETES_LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v2';
const MAX_RELEASE_SET_BYTES = 1024 * 1024;
const MAX_MANIFEST_BYTES = 8 * 1024 * 1024;
const MAX_RESOURCE_COUNT = 2048;
const MAX_STRUCTURE_DEPTH = 64;
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',
});
const EXPECTED_SOURCE_SURFACES = Object.freeze({
control: 2,
'control-ai': 1,
admin: 26,
worker: 2,
});
const ADMISSION_CONFIG_NAME = 'ql3-plugin-package-secret-action-admission';
class QingLong3DeploymentLockError extends Error {
constructor(message) {
super(`QingLong 3 deployment lock failed: ${message}`);
this.name = 'QingLong3DeploymentLockError';
}
}
function fail(message) {
throw new QingLong3DeploymentLockError(message);
}
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function canonicalJson(value) {
return `${JSON.stringify(value)}\n`;
}
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 readBoundedFile(filePath, label, maximumBytes) {
const resolved = resolveCanonicalAbsolute(filePath, label);
let stat;
try {
stat = fs.lstatSync(resolved);
} catch {
fail(`${label} is unavailable`);
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 1 ||
stat.size > maximumBytes ||
fs.realpathSync(resolved) !== resolved ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail(`${label} must be one bounded canonical regular file`);
}
const buffer = fs.readFileSync(resolved);
const contents = buffer.toString('utf8');
if (!Buffer.from(contents, 'utf8').equals(buffer)) {
fail(`${label} must contain valid UTF-8`);
}
return Object.freeze({
path: resolved,
contents,
});
}
function parseCanonicalJson(contents, label) {
let value;
try {
value = JSON.parse(contents);
} catch {
fail(`${label} must contain valid JSON`);
}
if (canonicalJson(value) !== contents) {
fail(`${label} must use exact canonical JSON encoding`);
}
return value;
}
function readCanonicalJson(filePath, label, maximumBytes) {
const file = readBoundedFile(filePath, label, maximumBytes);
return Object.freeze({
...file,
value: parseCanonicalJson(file.contents, label),
});
}
function preflightOutput(filePath, label) {
const resolved = resolveCanonicalAbsolute(filePath, label);
if (
fs.existsSync(resolved) ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail(`${label} must be unused in one canonical directory`);
}
return resolved;
}
function writeNoReplace(filePath, contents) {
fs.writeFileSync(filePath, contents, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
}
function inspectInputReleaseSet(releaseSet, options) {
return inspectReleaseSet(releaseSet, {
version: options.version,
sourceRevision: options.sourceRevision,
sourceRef: options.sourceRef,
releaseScope: options.releaseScope,
repositoryOwner: options.repositoryOwner,
});
}
function catalogAuthority(releaseSet, options) {
const consumption = options.consumption;
const manifestDigest = consumption?.catalogManifestDigest;
const expectedCatalog = catalogIdentity(options);
if (
consumption === null ||
typeof consumption !== 'object' ||
consumption.compatible !== true ||
consumption.releaseScope !== options.releaseScope ||
consumption.sourceRepository !== options.sourceRepository ||
consumption.workflowIdentity !== expectedCatalog.workflowIdentity ||
consumption.releaseSetDigest !== releaseSet.releaseSetDigest ||
!/^sha256:[a-f0-9]{64}$/u.test(manifestDigest || '') ||
consumption.immutableReference !==
`${expectedCatalog.repository}@${manifestDigest}` ||
consumption.imageCount !== releaseSet.images.length ||
consumption.discoveryTagAuthority !== 'none' ||
consumption.externalToolResultsReplayed !== false ||
consumption.deploymentMutation !== false ||
!/^sha256:[a-f0-9]{64}$/u.test(consumption.contentDigest || '')
) {
fail('release catalog consumption authority is invalid');
}
return Object.freeze({
schema: CATALOG_CONSUMPTION_SCHEMA,
sourceRepository: consumption.sourceRepository,
workflowIdentity: consumption.workflowIdentity,
immutableReference: consumption.immutableReference,
manifestDigest,
consumptionReportDigest: consumption.contentDigest,
releaseSetDigest: consumption.releaseSetDigest,
discoveryTagAuthority: 'none',
});
}
function imageByName(releaseSet, name) {
const matches = releaseSet.images.filter((entry) => entry.name === name);
if (matches.length !== 1) fail(`release image is unavailable: ${name}`);
return matches[0];
}
function createLocalSelection(releaseSet, options) {
const inspection = inspectInputReleaseSet(releaseSet, options);
const catalog = catalogAuthority(releaseSet, options);
if (!['local', 'all'].includes(options.releaseScope)) {
fail('local selection requires a local or all release set');
}
if (options.allowRootService !== true && options.allowRootService !== false) {
fail('allow-root-service must be an explicit boolean');
}
const local = imageByName(releaseSet, 'local');
const unsigned = {
schemaVersion: 1,
schema: LOCAL_SELECTION_SCHEMA,
release: { ...releaseSet.release },
releaseSetDigest: releaseSet.releaseSetDigest,
catalog,
deploymentFamily: 'local',
service: {
kind: 'compose',
image: local.reference,
allowRootService: options.allowRootService,
},
verification: {
releaseSet: inspection.verification,
sourceRecordsReplayed: inspection.sourceRecordsReplayed,
catalogConsumption: 'offline_reconstructed',
externalToolResultsReplayed: false,
networkAccess: false,
deploymentMutation: false,
},
};
return Object.freeze({
...unsigned,
selectionDigest: sha256(JSON.stringify(unsigned)),
});
}
function auditLocalSelection(actual, releaseSet, options) {
const expected = createLocalSelection(releaseSet, options);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('local selection differs from the verified release set');
}
return Object.freeze({
compatible: true,
deploymentFamily: 'local',
releaseSetDigest: actual.releaseSetDigest,
selectionDigest: actual.selectionDigest,
catalogManifestDigest: actual.catalog.manifestDigest,
immutableReference: actual.catalog.immutableReference,
image: actual.service.image,
networkAccess: false,
deploymentMutation: false,
});
}
function parseRequiredImages(value) {
if (typeof value !== 'string' || value.length === 0) {
fail('required images must be explicit');
}
const names = value.split(',');
const ordered = ROLE_ORDER.filter((name) => names.includes(name));
if (
names.some((name) => !ROLE_ORDER.includes(name)) ||
new Set(names).size !== names.length ||
JSON.stringify(names) !== JSON.stringify(ordered)
) {
fail('required images must be unique and use release order');
}
return Object.freeze(names);
}
function normalizePlain(value, seen = new WeakSet(), depth = 0) {
if (depth > MAX_STRUCTURE_DEPTH) fail('manifest structure is too deep');
if (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value))
) {
return value;
}
if (typeof value !== 'object') fail('manifest contains unsupported data');
if (seen.has(value)) fail('manifest aliases and cycles are not allowed');
seen.add(value);
if (Array.isArray(value)) {
return value.map((entry) => normalizePlain(entry, seen, depth + 1));
}
if (Object.getPrototypeOf(value) !== Object.prototype) {
fail('manifest contains a non-plain object');
}
const normalized = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof key !== 'string' || key.length === 0) {
fail('manifest key is invalid');
}
normalized[key] = normalizePlain(entry, seen, depth + 1);
}
return normalized;
}
function parseManifest(contents) {
const resources = [];
try {
yaml.loadAll(contents, (document) => {
if (document !== undefined && document !== null) resources.push(document);
});
} catch {
fail('input manifest must be valid duplicate-free YAML');
}
if (resources.length === 0 || resources.length > MAX_RESOURCE_COUNT) {
fail('input manifest resource count is invalid');
}
return resources.map((resource) => {
const normalized = normalizePlain(resource);
if (
normalized === null ||
typeof normalized !== 'object' ||
Array.isArray(normalized)
) {
fail('each manifest resource must be one mapping');
}
return normalized;
});
}
function renderManifest(resources) {
return resources
.map((resource) =>
yaml.dump(resource, {
noCompatMode: true,
noRefs: true,
lineWidth: -1,
sortKeys: false,
}),
)
.join('---\n');
}
function roleFromImage(value) {
if (typeof value !== 'string') return null;
for (const role of ROLE_ORDER) {
const imageName = IMAGE_NAMES[role];
if (
new RegExp(
`^(?:[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]+)?/)*${imageName}(?::[^@\\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 podSpecFor(resource) {
const kind = resource.kind;
if (kind === 'Pod') return resource.spec;
if (['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet'].includes(kind)) {
return resource.spec?.template?.spec;
}
if (kind === 'Job') return resource.spec?.template?.spec;
if (kind === 'CronJob')
return resource.spec?.jobTemplate?.spec?.template?.spec;
return undefined;
}
function ensureAnnotations(target, releaseSet, catalog) {
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
fail('rewritten resource metadata is invalid');
}
const metadata = target.metadata ?? {};
if (
metadata === null ||
typeof metadata !== 'object' ||
Array.isArray(metadata) ||
(metadata.annotations !== undefined &&
(metadata.annotations === null ||
typeof metadata.annotations !== 'object' ||
Array.isArray(metadata.annotations)))
) {
fail('rewritten resource annotations are invalid');
}
metadata.annotations = {
...(metadata.annotations ?? {}),
'qinglong.io/release-set-digest': releaseSet.releaseSetDigest,
'qinglong.io/release-catalog-manifest-digest': catalog.manifestDigest,
'qinglong.io/release-catalog-report-digest':
catalog.consumptionReportDigest,
'qinglong.io/release-source-revision': releaseSet.release.sourceRevision,
'qinglong.io/release-version': releaseSet.release.version,
};
target.metadata = metadata;
}
function rewriteContainerList(list, references, counts, handledAuthorities) {
if (list === undefined) return 0;
if (!Array.isArray(list)) fail('container list is invalid');
let rewritten = 0;
for (const container of list) {
if (
container === null ||
typeof container !== 'object' ||
Array.isArray(container)
) {
fail('container is invalid');
}
const role = roleFromImage(container.image);
if (!role) {
if (mentionsRoleImageName(container.image))
fail('container image is malformed');
continue;
}
container.image = references[role];
handledAuthorities.add(container);
counts[role] += 1;
rewritten += 1;
}
return rewritten;
}
function scanForUnhandledRoleImages(
value,
references,
handledAuthorities,
pathSegments = [],
parent,
parentKey,
) {
if (typeof value === 'string') {
const role = roleFromImage(value);
if (
role &&
(value !== references[role] ||
parentKey !== 'image' ||
!handledAuthorities.has(parent))
) {
fail(`unhandled QingLong image authority: ${pathSegments.join('.')}`);
}
return;
}
if (value === null || typeof value !== 'object') return;
if (Array.isArray(value)) {
value.forEach((entry, index) =>
scanForUnhandledRoleImages(
entry,
references,
handledAuthorities,
[...pathSegments, String(index)],
value,
String(index),
),
);
return;
}
for (const [key, entry] of Object.entries(value)) {
scanForUnhandledRoleImages(
entry,
references,
handledAuthorities,
[...pathSegments, key],
value,
key,
);
}
}
function rewriteKubernetesResources(
resources,
releaseSet,
requiredImages,
catalog,
) {
const references = Object.fromEntries(
ROLE_ORDER.map((role) => [role, imageByName(releaseSet, role).reference]),
);
const counts = Object.fromEntries(ROLE_ORDER.map((role) => [role, 0]));
const handledAuthorities = new WeakSet();
let changedResources = 0;
let admissionAuthorityCount = 0;
for (const resource of resources) {
let changed = 0;
const podSpec = podSpecFor(resource);
if (podSpec !== undefined) {
if (
podSpec === null ||
typeof podSpec !== 'object' ||
Array.isArray(podSpec)
) {
fail('pod spec is invalid');
}
for (const key of [
'initContainers',
'containers',
'ephemeralContainers',
]) {
changed += rewriteContainerList(
podSpec[key],
references,
counts,
handledAuthorities,
);
}
}
if (
resource.apiVersion === 'v1' &&
resource.kind === 'ConfigMap' &&
resource.metadata?.name === ADMISSION_CONFIG_NAME
) {
if (
resource.data === null ||
typeof resource.data !== 'object' ||
Array.isArray(resource.data) ||
roleFromImage(resource.data.image) !== 'admin'
) {
fail('plugin-package admission image authority is invalid');
}
resource.data.image = references.admin;
handledAuthorities.add(resource.data);
counts.admin += 1;
admissionAuthorityCount += 1;
changed += 1;
}
if (changed > 0) {
ensureAnnotations(resource, releaseSet, catalog);
const template =
resource.kind === 'CronJob'
? resource.spec?.jobTemplate?.spec?.template
: resource.kind === 'Job'
? resource.spec?.template
: ['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet'].includes(
resource.kind,
)
? resource.spec?.template
: undefined;
if (template) ensureAnnotations(template, releaseSet, catalog);
changedResources += 1;
}
}
for (const resource of resources) {
scanForUnhandledRoleImages(resource, references, handledAuthorities);
}
for (const role of requiredImages) {
if (counts[role] < 1) fail(`required image was not rendered: ${role}`);
}
if (Object.values(counts).every((count) => count === 0)) {
fail('manifest contains no QingLong deployment image');
}
return Object.freeze({
resources,
counts: Object.freeze({ ...counts }),
changedResources,
admissionAuthorityCount,
});
}
function createKubernetesLock(releaseSet, manifestContents, options) {
const inspection = inspectInputReleaseSet(releaseSet, options);
const catalog = catalogAuthority(releaseSet, options);
if (!['cluster', 'all'].includes(options.releaseScope)) {
fail('Kubernetes materialization requires a cluster or all release set');
}
const requiredImages = parseRequiredImages(options.requiredImages);
const parsed = parseManifest(manifestContents);
const rewritten = rewriteKubernetesResources(
parsed,
releaseSet,
requiredImages,
catalog,
);
const outputManifest = renderManifest(rewritten.resources);
if (Buffer.byteLength(outputManifest, 'utf8') > MAX_MANIFEST_BYTES) {
fail('output manifest exceeds the bounded size');
}
const unsigned = {
schemaVersion: 1,
schema: KUBERNETES_LOCK_SCHEMA,
release: { ...releaseSet.release },
releaseSetDigest: releaseSet.releaseSetDigest,
catalog,
deploymentFamily: 'cluster',
requiredImages: [...requiredImages],
imageOccurrences: ROLE_ORDER.map((name) => ({
name,
reference: imageByName(releaseSet, name).reference,
count: rewritten.counts[name],
})),
manifest: {
inputDigest: sha256(manifestContents),
outputDigest: sha256(outputManifest),
resources: parsed.length,
changedResources: rewritten.changedResources,
admissionAuthorityCount: rewritten.admissionAuthorityCount,
},
verification: {
releaseSet: inspection.verification,
sourceRecordsReplayed: inspection.sourceRecordsReplayed,
catalogConsumption: 'offline_reconstructed',
externalToolResultsReplayed: false,
unknownImageAuthorities: 0,
mutableQingLongImages: 0,
networkAccess: false,
kubernetesMutation: false,
},
};
const report = Object.freeze({
...unsigned,
lockDigest: sha256(JSON.stringify(unsigned)),
});
return Object.freeze({ outputManifest, report });
}
function auditKubernetesLock(
actualManifest,
actualReport,
releaseSet,
sourceManifest,
options,
) {
const expected = createKubernetesLock(releaseSet, sourceManifest, options);
if (
actualManifest !== expected.outputManifest ||
JSON.stringify(actualReport) !== JSON.stringify(expected.report)
) {
fail('Kubernetes deployment lock differs from the verified release set');
}
return Object.freeze({
compatible: true,
deploymentFamily: 'cluster',
releaseSetDigest: actualReport.releaseSetDigest,
lockDigest: actualReport.lockDigest,
catalogManifestDigest: actualReport.catalog.manifestDigest,
immutableReference: actualReport.catalog.immutableReference,
outputManifestDigest: actualReport.manifest.outputDigest,
requiredImages: Object.freeze([...actualReport.requiredImages]),
networkAccess: false,
kubernetesMutation: false,
});
}
function listYamlFiles(root) {
const files = [];
const visit = (directory) => {
const stat = fs.lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
fail('deployment source directory is invalid');
}
for (const name of fs.readdirSync(directory).sort()) {
const target = path.join(directory, name);
const child = fs.lstatSync(target);
if (child.isSymbolicLink()) fail('deployment source symlink is invalid');
if (child.isDirectory()) visit(target);
else if (child.isFile() && /\.ya?ml$/u.test(name)) files.push(target);
}
};
visit(root);
return files;
}
function auditDeploymentImageSurfaces(root = DEFAULT_ROOT) {
const deploymentRoots = [
path.join(root, 'deploy/kubernetes/ql3-cluster'),
path.join(root, 'deploy/kubernetes/ql3-worker'),
];
const files = deploymentRoots.flatMap((directory) =>
listYamlFiles(directory),
);
const counts = Object.fromEntries(ROLE_ORDER.map((role) => [role, 0]));
let admissionAuthorityCount = 0;
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
for (const role of ROLE_ORDER) {
const matches = source.match(
new RegExp(`${IMAGE_NAMES[role]}(?=[:@])`, 'gu'),
);
counts[role] += matches?.length ?? 0;
}
admissionAuthorityCount +=
source.match(
/image:\s+[^\n]*qinglong3-cluster-admin@sha256:[a-f0-9]{64}/gu,
)?.length ?? 0;
}
if (
JSON.stringify(counts) !== JSON.stringify(EXPECTED_SOURCE_SURFACES) ||
admissionAuthorityCount !== 2
) {
fail('deployment image surfaces differ from the reviewed post-renderer');
}
return Object.freeze({
schemaVersion: 1,
deploymentYamlFiles: files.length,
imageOccurrences: Object.freeze({ ...counts }),
admissionAuthorityCount,
materialization: 'offline_post_render',
networkAccess: false,
kubernetesMutation: false,
compatible: true,
});
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (values.mode === 'surfaces-audit') {
if (Object.keys(values).length !== 1) fail('arguments are invalid');
return Object.freeze({ mode: values.mode });
}
const identity = [
'mode',
'consumption-bundle',
'release-scope',
'repository-owner',
'source-repository',
'source-ref',
'source-revision',
'version',
];
const expected =
values.mode === 'local-create'
? [...identity, 'allow-root-service', 'output']
: values.mode === 'local-audit'
? [...identity, 'allow-root-service', 'selection']
: values.mode === 'kubernetes-create'
? [
...identity,
'manifest',
'output-manifest',
'output-report',
'required-images',
]
: values.mode === 'kubernetes-audit'
? [
...identity,
'locked-manifest',
'manifest',
'report',
'required-images',
]
: [];
if (
expected.length === 0 ||
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify(expected.sort())
) {
fail('arguments are invalid');
}
return Object.freeze({
mode: values.mode,
version: values.version,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
releaseScope: values['release-scope'],
repositoryOwner: values['repository-owner'],
sourceRepository: values['source-repository'],
consumptionBundle: values['consumption-bundle'],
...(values['allow-root-service']
? {
allowRootService:
values['allow-root-service'] === 'true'
? true
: values['allow-root-service'] === 'false'
? false
: fail('allow-root-service must be true or false'),
}
: {}),
...(values.output ? { output: values.output } : {}),
...(values.selection ? { selection: values.selection } : {}),
...(values.manifest ? { manifest: values.manifest } : {}),
...(values['locked-manifest']
? { lockedManifest: values['locked-manifest'] }
: {}),
...(values['output-manifest']
? { outputManifest: values['output-manifest'] }
: {}),
...(values['output-report']
? { outputReport: values['output-report'] }
: {}),
...(values.report ? { report: values.report } : {}),
...(values['required-images']
? { requiredImages: values['required-images'] }
: {}),
});
}
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
const options = parseArguments(argv);
if (options.mode === 'surfaces-audit') {
const audit = auditDeploymentImageSurfaces(root);
output.write(canonicalJson(audit));
return audit;
}
const consumption = auditCeremonyBundle({
...options,
outputDirectory: options.consumptionBundle,
});
const releaseSet = consumption.releaseSet;
const boundOptions = Object.freeze({ ...options, consumption });
if (options.mode === 'local-create') {
const selection = createLocalSelection(releaseSet, boundOptions);
const target = preflightOutput(options.output, 'output');
writeNoReplace(target, canonicalJson(selection));
output.write(canonicalJson(selection));
return selection;
}
if (options.mode === 'local-audit') {
const selection = readCanonicalJson(
options.selection,
'local selection',
MAX_RELEASE_SET_BYTES,
).value;
const audit = auditLocalSelection(selection, releaseSet, boundOptions);
output.write(canonicalJson(audit));
return audit;
}
const sourceManifest = readBoundedFile(
options.manifest,
'input manifest',
MAX_MANIFEST_BYTES,
).contents;
if (options.mode === 'kubernetes-create') {
const manifestTarget = preflightOutput(
options.outputManifest,
'output manifest',
);
const reportTarget = preflightOutput(options.outputReport, 'output report');
if (manifestTarget === reportTarget) {
fail('output manifest and report paths must differ');
}
const created = createKubernetesLock(
releaseSet,
sourceManifest,
boundOptions,
);
writeNoReplace(manifestTarget, created.outputManifest);
writeNoReplace(reportTarget, canonicalJson(created.report));
output.write(canonicalJson(created.report));
return created;
}
const lockedManifest = readBoundedFile(
options.lockedManifest,
'locked manifest',
MAX_MANIFEST_BYTES,
).contents;
const report = readCanonicalJson(
options.report,
'deployment lock report',
MAX_RELEASE_SET_BYTES,
).value;
const audit = auditKubernetesLock(
lockedManifest,
report,
releaseSet,
sourceManifest,
boundOptions,
);
output.write(canonicalJson(audit));
return audit;
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : 'deployment lock failed'}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
KUBERNETES_LOCK_SCHEMA,
LOCAL_SELECTION_SCHEMA,
QingLong3DeploymentLockError,
auditDeploymentImageSurfaces,
auditKubernetesLock,
auditLocalSelection,
createKubernetesLock,
createLocalSelection,
parseArguments,
runCli,
});