fix: add deploy desired-state plan
This commit is contained in:
@@ -0,0 +1,743 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
const deployPath = "deploy/deploy.json";
|
||||
const catalogPath = "deploy/artifact-catalog.dev.json";
|
||||
const workloadsPath = "deploy/k8s/base/workloads.yaml";
|
||||
const artifactReportPath = "reports/dev-gate/dev-artifacts.json";
|
||||
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
|
||||
const mirrorEnvNames = [
|
||||
"HWLAB_COMMIT_ID",
|
||||
"HWLAB_IMAGE",
|
||||
"HWLAB_IMAGE_TAG",
|
||||
"HWLAB_SKILLS_COMMIT_ID",
|
||||
"HWLAB_IMAGE_DIGEST"
|
||||
];
|
||||
const commitMirrorEnvNames = new Set(["HWLAB_COMMIT_ID", "HWLAB_SKILLS_COMMIT_ID"]);
|
||||
const imageMirrorEnvNames = new Set(["HWLAB_IMAGE"]);
|
||||
const tagMirrorEnvNames = new Set(["HWLAB_IMAGE_TAG"]);
|
||||
const digestMirrorEnvNames = new Set(["HWLAB_IMAGE_DIGEST"]);
|
||||
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
||||
const commitPattern = /^[a-f0-9]{7,40}$/;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { check: false, pretty: false, targetRef: null, targetTag: null, help: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--check") {
|
||||
args.check = true;
|
||||
} else if (arg === "--pretty") {
|
||||
args.pretty = true;
|
||||
} else if (arg === "--target-ref") {
|
||||
args.targetRef = requireValue(argv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg === "--target-tag") {
|
||||
args.targetTag = requireValue(argv, index, arg);
|
||||
index += 1;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function requireValue(argv, index, arg) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${arg} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"usage: node scripts/deploy-desired-state-plan.mjs [--check] [--pretty] [--target-ref REF] [--target-tag TAG]",
|
||||
"",
|
||||
"Read-only DEV desired-state commit/image planner.",
|
||||
"",
|
||||
"Options:",
|
||||
" --check exit non-zero only when desired-state sources are missing, invalid, or partially drifted",
|
||||
" --pretty print indented JSON",
|
||||
" --target-ref REF resolve REF with git and review promotion to its short commit tag",
|
||||
" --target-tag TAG review promotion to an explicit image tag without proving registry existence",
|
||||
"",
|
||||
"This command reads repository files only. It does not build, pull, push, kubectl apply, restart services, or touch PROD."
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function readJson(repoRoot, relativePath) {
|
||||
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function readOptionalJson(repoRoot, relativePath) {
|
||||
try {
|
||||
return await readJson(repoRoot, relativePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseImageRef(image) {
|
||||
if (typeof image !== "string" || image.length === 0) {
|
||||
return { valid: false, repository: null, tag: null, reason: "image must be a non-empty string" };
|
||||
}
|
||||
if (image.includes("@")) {
|
||||
return { valid: false, repository: null, tag: null, reason: "digest-qualified image refs are not desired-state tags" };
|
||||
}
|
||||
const slashIndex = image.lastIndexOf("/");
|
||||
const colonIndex = image.lastIndexOf(":");
|
||||
if (colonIndex <= slashIndex) {
|
||||
return { valid: false, repository: image, tag: null, reason: "image ref is missing a tag" };
|
||||
}
|
||||
const repository = image.slice(0, colonIndex);
|
||||
const tag = image.slice(colonIndex + 1);
|
||||
if (!repository || !tag) {
|
||||
return { valid: false, repository, tag, reason: "image repository and tag must be non-empty" };
|
||||
}
|
||||
return { valid: true, repository, tag, reason: null };
|
||||
}
|
||||
|
||||
function replaceImageTag(image, targetTag) {
|
||||
const parsed = parseImageRef(image);
|
||||
return parsed.valid ? `${parsed.repository}:${targetTag}` : null;
|
||||
}
|
||||
|
||||
function shortCommit(value) {
|
||||
return typeof value === "string" && value.length >= 7 ? value.slice(0, 7) : value;
|
||||
}
|
||||
|
||||
function commitEquivalent(actual, expected) {
|
||||
if (typeof actual !== "string" || typeof expected !== "string") return false;
|
||||
return actual === expected || actual === expected.slice(0, 7) || expected === actual.slice(0, 7);
|
||||
}
|
||||
|
||||
function listItems(document) {
|
||||
if (!document) return [];
|
||||
return document.kind === "List" ? document.items ?? [] : [document];
|
||||
}
|
||||
|
||||
function serviceIdForWorkload(item, container) {
|
||||
return (
|
||||
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
||||
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
||||
container?.name ||
|
||||
item?.metadata?.name
|
||||
);
|
||||
}
|
||||
|
||||
function workloadContainers(item) {
|
||||
return item?.spec?.template?.spec?.containers ?? [];
|
||||
}
|
||||
|
||||
function envListToObject(envList) {
|
||||
const env = {};
|
||||
if (!Array.isArray(envList)) return env;
|
||||
for (const entry of envList) {
|
||||
if (!entry?.name) continue;
|
||||
if (Object.hasOwn(entry, "value")) {
|
||||
env[entry.name] = entry.value;
|
||||
} else if (entry.valueFrom) {
|
||||
env[entry.name] = { valueFrom: entry.valueFrom };
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function addDiagnostic(ctx, diagnostic) {
|
||||
ctx.diagnostics.push({
|
||||
severity: "blocker",
|
||||
...diagnostic
|
||||
});
|
||||
}
|
||||
|
||||
function addMismatch(ctx, code, sourcePath, message, expected, actual, extra = {}) {
|
||||
addDiagnostic(ctx, {
|
||||
code,
|
||||
path: sourcePath,
|
||||
message,
|
||||
expected,
|
||||
actual,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
function recordObservation(ctx, observation) {
|
||||
ctx.observations.push(observation);
|
||||
}
|
||||
|
||||
function expectedEnvValue(name, service, desiredCommitId) {
|
||||
if (name === "HWLAB_COMMIT_ID" || name === "HWLAB_SKILLS_COMMIT_ID") return desiredCommitId;
|
||||
if (name === "HWLAB_IMAGE") return service?.image ?? null;
|
||||
if (name === "HWLAB_IMAGE_TAG") return service?.imageTag ?? null;
|
||||
if (name === "HWLAB_IMAGE_DIGEST") return service?.digest ?? "not_published";
|
||||
return null;
|
||||
}
|
||||
|
||||
function observationKindForEnv(name) {
|
||||
if (commitMirrorEnvNames.has(name)) return "commit";
|
||||
if (imageMirrorEnvNames.has(name)) return "image";
|
||||
if (tagMirrorEnvNames.has(name)) return "tag";
|
||||
if (digestMirrorEnvNames.has(name)) return "digest";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function valueMatchesExpected(kind, actual, expected) {
|
||||
if (expected === null || expected === undefined) return true;
|
||||
if (kind === "commit") return commitEquivalent(actual, expected);
|
||||
return Object.is(actual, expected);
|
||||
}
|
||||
|
||||
function inspectEnvMirrors(ctx, { source, basePath, serviceId, env, service, desiredCommitId }) {
|
||||
const present = {};
|
||||
const missing = [];
|
||||
const diagnostics = [];
|
||||
|
||||
for (const name of mirrorEnvNames) {
|
||||
if (!Object.hasOwn(env ?? {}, name)) {
|
||||
missing.push(name);
|
||||
continue;
|
||||
}
|
||||
const actual = env[name];
|
||||
const kind = observationKindForEnv(name);
|
||||
const expected = expectedEnvValue(name, service, desiredCommitId);
|
||||
const record = {
|
||||
name,
|
||||
value: actual,
|
||||
expected,
|
||||
status: valueMatchesExpected(kind, actual, expected) ? "match" : "drift"
|
||||
};
|
||||
present[name] = record;
|
||||
recordObservation(ctx, {
|
||||
source,
|
||||
serviceId,
|
||||
path: `${basePath}.${name}`,
|
||||
field: name,
|
||||
kind,
|
||||
value: actual,
|
||||
expected
|
||||
});
|
||||
if (record.status === "drift") {
|
||||
const message = `${source} ${serviceId} ${name} mirror does not match desired ${kind}`;
|
||||
const diagnostic = {
|
||||
code: "mirror_mismatch",
|
||||
path: `${basePath}.${name}`,
|
||||
message,
|
||||
expected,
|
||||
actual
|
||||
};
|
||||
diagnostics.push(diagnostic);
|
||||
addDiagnostic(ctx, diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
return { present, missing, diagnostics };
|
||||
}
|
||||
|
||||
function indexWorkloads(workloads) {
|
||||
const byService = new Map();
|
||||
for (const item of listItems(workloads)) {
|
||||
for (const [containerIndex, container] of workloadContainers(item).entries()) {
|
||||
const serviceId = serviceIdForWorkload(item, container);
|
||||
if (!serviceId) continue;
|
||||
const record = {
|
||||
kind: item?.kind ?? "unknown",
|
||||
name: item?.metadata?.name ?? "unknown",
|
||||
namespace: item?.metadata?.namespace ?? null,
|
||||
containerIndex,
|
||||
containerName: container?.name ?? "unknown",
|
||||
image: container?.image ?? null,
|
||||
env: envListToObject(container?.env),
|
||||
path: `${workloadsPath}.${item?.kind ?? "Workload"}.${item?.metadata?.name ?? serviceId}.containers[${containerIndex}]`
|
||||
};
|
||||
const list = byService.get(serviceId) ?? [];
|
||||
list.push(record);
|
||||
byService.set(serviceId, list);
|
||||
}
|
||||
}
|
||||
return byService;
|
||||
}
|
||||
|
||||
function serviceIdsFrom(...sources) {
|
||||
const ids = [];
|
||||
for (const source of sources) {
|
||||
for (const id of source) {
|
||||
if (typeof id === "string" && id.length > 0 && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function assertTargetTag(ctx, tag, pathName) {
|
||||
if (!tagPattern.test(tag)) {
|
||||
addMismatch(ctx, "invalid_target_tag", pathName, "target tag is not a valid Docker tag", "Docker tag syntax", tag);
|
||||
}
|
||||
if (mutableTags.has(tag)) {
|
||||
addMismatch(ctx, "mutable_target_tag", pathName, `target tag ${tag} is mutable and forbidden for desired-state pinning`, "immutable tag", tag);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) {
|
||||
if (!targetRef && !targetTag) return null;
|
||||
let commitId = null;
|
||||
let shortCommitId = null;
|
||||
if (targetRef) {
|
||||
try {
|
||||
const result = await execFileAsync("git", ["rev-parse", "--verify", `${targetRef}^{commit}`], { cwd: repoRoot });
|
||||
commitId = result.stdout.trim();
|
||||
shortCommitId = commitId.slice(0, 7);
|
||||
} catch (error) {
|
||||
addDiagnostic(ctx, {
|
||||
code: "target_ref_unresolved",
|
||||
path: "--target-ref",
|
||||
message: `target ref ${targetRef} could not be resolved with git`,
|
||||
expected: "local git ref",
|
||||
actual: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
const tag = targetTag ?? shortCommitId;
|
||||
if (tag) assertTargetTag(ctx, tag, "--target-tag");
|
||||
return {
|
||||
targetRef,
|
||||
commitId,
|
||||
shortCommitId,
|
||||
tag,
|
||||
tagSource: targetTag ? "target-tag" : "target-ref-short-commit"
|
||||
};
|
||||
}
|
||||
|
||||
function observationMatchesTarget(observation, target) {
|
||||
if (!target) return null;
|
||||
if (observation.kind === "commit") {
|
||||
return target.commitId ? commitEquivalent(observation.value, target.commitId) : null;
|
||||
}
|
||||
if (observation.kind === "tag") {
|
||||
return target.tag ? observation.value === target.tag : null;
|
||||
}
|
||||
if (observation.kind === "image") {
|
||||
return target.tag ? parseImageRef(observation.value).tag === target.tag : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTargetConvergence(ctx, target) {
|
||||
if (!target) return null;
|
||||
if (target.targetRef && !target.commitId) {
|
||||
return {
|
||||
state: "target_unresolved",
|
||||
comparableFields: 0,
|
||||
matchingTargetFields: 0,
|
||||
pendingTargetFields: 0,
|
||||
matchingPaths: [],
|
||||
pendingPaths: []
|
||||
};
|
||||
}
|
||||
const comparable = ctx.observations
|
||||
.map((observation) => ({
|
||||
...observation,
|
||||
matchesTarget: observationMatchesTarget(observation, target)
|
||||
}))
|
||||
.filter((observation) => observation.matchesTarget !== null);
|
||||
const matching = comparable.filter((observation) => observation.matchesTarget === true);
|
||||
const pending = comparable.filter((observation) => observation.matchesTarget === false);
|
||||
const partial = matching.length > 0 && pending.length > 0;
|
||||
|
||||
if (partial) {
|
||||
addDiagnostic(ctx, {
|
||||
code: "partial_target_drift",
|
||||
path: "desired-state",
|
||||
message: "desired-state fields partially match the target while other authoritative fields still point elsewhere",
|
||||
expected: target.tag,
|
||||
actual: {
|
||||
matching: matching.length,
|
||||
pending: pending.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const state = partial
|
||||
? "partial_drift"
|
||||
: pending.length === 0
|
||||
? "already_promoted"
|
||||
: "promotion_pending";
|
||||
return {
|
||||
state,
|
||||
comparableFields: comparable.length,
|
||||
matchingTargetFields: matching.length,
|
||||
pendingTargetFields: pending.length,
|
||||
matchingPaths: matching.map((item) => item.path),
|
||||
pendingPaths: pending.map((item) => item.path)
|
||||
};
|
||||
}
|
||||
|
||||
function reportHints(report) {
|
||||
if (!report) {
|
||||
return {
|
||||
path: artifactReportPath,
|
||||
present: false,
|
||||
authoritative: false,
|
||||
note: "optional report snapshot is absent; desired-state review still uses deploy files only"
|
||||
};
|
||||
}
|
||||
const artifactPublish = report.artifactPublish ?? {};
|
||||
return {
|
||||
path: artifactReportPath,
|
||||
present: true,
|
||||
authoritative: false,
|
||||
note: "report snapshots are contextual evidence only; deploy desired-state remains authoritative in deploy/, not reports/",
|
||||
commitId: report.commitId ?? null,
|
||||
artifactPublish: {
|
||||
status: artifactPublish.status ?? null,
|
||||
sourceCommitId: artifactPublish.sourceCommitId ?? null,
|
||||
publishedCount: artifactPublish.publishedCount ?? null,
|
||||
serviceCount: artifactPublish.serviceCount ?? null,
|
||||
registryVerified: artifactPublish.registryVerified ?? null,
|
||||
registryCapabilitiesClassification: artifactPublish.registryCapabilities?.classification ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function servicePromotion(service, target) {
|
||||
if (!target?.tag) return null;
|
||||
return {
|
||||
targetCommitId: target.commitId,
|
||||
targetImageTag: target.tag,
|
||||
deployImage: service.deploy?.image ? replaceImageTag(service.deploy.image, target.tag) : null,
|
||||
catalogImage: service.catalog?.image ? replaceImageTag(service.catalog.image, target.tag) : null,
|
||||
workloadImages: (service.workloads ?? []).map((workload) => ({
|
||||
workload: workload.name,
|
||||
container: workload.container,
|
||||
image: workload.image ? replaceImageTag(workload.image, target.tag) : null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function targetCommands(target) {
|
||||
if (!target?.targetRef) return [];
|
||||
return [
|
||||
`node scripts/deploy-desired-state-plan.mjs --target-ref ${target.targetRef} --pretty`,
|
||||
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --blocked`,
|
||||
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --publish-report ${artifactReportPath}`
|
||||
];
|
||||
}
|
||||
|
||||
export async function buildDesiredStatePlan(options = {}) {
|
||||
const repoRoot = options.repoRoot ?? defaultRepoRoot;
|
||||
const ctx = { diagnostics: [], observations: [] };
|
||||
const [deploy, catalog, workloads, artifactReport] = await Promise.all([
|
||||
readJson(repoRoot, deployPath),
|
||||
readJson(repoRoot, catalogPath),
|
||||
readJson(repoRoot, workloadsPath),
|
||||
readOptionalJson(repoRoot, artifactReportPath)
|
||||
]);
|
||||
const target = await resolveTarget(repoRoot, {
|
||||
targetRef: options.targetRef ?? null,
|
||||
targetTag: options.targetTag ?? null
|
||||
}, ctx);
|
||||
|
||||
const deployServices = deploy.services ?? [];
|
||||
const catalogServices = catalog.services ?? [];
|
||||
const deployByService = new Map(deployServices.map((service) => [service.serviceId, service]));
|
||||
const catalogByService = new Map(catalogServices.map((service) => [service.serviceId, service]));
|
||||
const workloadByService = indexWorkloads(workloads);
|
||||
const desiredCommitId = deploy.commitId;
|
||||
const desiredImageTag = shortCommit(desiredCommitId);
|
||||
|
||||
recordObservation(ctx, {
|
||||
source: "deploy",
|
||||
path: `${deployPath}.commitId`,
|
||||
field: "commitId",
|
||||
kind: "commit",
|
||||
value: deploy.commitId,
|
||||
expected: desiredCommitId
|
||||
});
|
||||
recordObservation(ctx, {
|
||||
source: "catalog",
|
||||
path: `${catalogPath}.commitId`,
|
||||
field: "commitId",
|
||||
kind: "commit",
|
||||
value: catalog.commitId,
|
||||
expected: desiredCommitId
|
||||
});
|
||||
|
||||
if (!commitPattern.test(String(deploy.commitId ?? ""))) {
|
||||
addMismatch(ctx, "invalid_commit", `${deployPath}.commitId`, "deploy commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", deploy.commitId);
|
||||
}
|
||||
if (!commitEquivalent(catalog.commitId, desiredCommitId)) {
|
||||
addMismatch(ctx, "commit_mismatch", `${catalogPath}.commitId`, "artifact catalog commitId must match deploy commitId", desiredCommitId, catalog.commitId);
|
||||
}
|
||||
|
||||
const serviceIds = serviceIdsFrom(
|
||||
deployServices.map((service) => service.serviceId),
|
||||
catalogServices.map((service) => service.serviceId),
|
||||
[...workloadByService.keys()]
|
||||
);
|
||||
|
||||
const services = [];
|
||||
for (const serviceId of serviceIds) {
|
||||
const deployService = deployByService.get(serviceId);
|
||||
const catalogService = catalogByService.get(serviceId);
|
||||
const workloadsForService = workloadByService.get(serviceId) ?? [];
|
||||
const service = {
|
||||
serviceId,
|
||||
deploy: null,
|
||||
catalog: null,
|
||||
workloads: [],
|
||||
promotion: null
|
||||
};
|
||||
|
||||
if (!deployService) {
|
||||
addMismatch(ctx, "missing_service", `${deployPath}.services.${serviceId}`, `${serviceId} missing from deploy manifest`, "service entry", null, { serviceId });
|
||||
}
|
||||
if (!catalogService) {
|
||||
addMismatch(ctx, "missing_service", `${catalogPath}.services.${serviceId}`, `${serviceId} missing from artifact catalog`, "service entry", null, { serviceId });
|
||||
}
|
||||
if (!workloadsForService.length) {
|
||||
addMismatch(ctx, "missing_workload", `${workloadsPath}.${serviceId}`, `${serviceId} missing from k8s workloads`, "workload container", null, { serviceId });
|
||||
}
|
||||
|
||||
const deployImage = parseImageRef(deployService?.image);
|
||||
const catalogImage = parseImageRef(catalogService?.image);
|
||||
const serviceImageTag = catalogService?.imageTag ?? deployImage.tag;
|
||||
const expectedService = {
|
||||
image: deployService?.image ?? catalogService?.image ?? null,
|
||||
imageTag: desiredImageTag,
|
||||
digest: catalogService?.digest ?? "not_published"
|
||||
};
|
||||
|
||||
if (deployService) {
|
||||
const deployPathBase = `${deployPath}.services.${serviceId}`;
|
||||
recordObservation(ctx, {
|
||||
source: "deploy",
|
||||
serviceId,
|
||||
path: `${deployPathBase}.image`,
|
||||
field: "image",
|
||||
kind: "image",
|
||||
value: deployService.image,
|
||||
expected: catalogService?.image ?? deployService.image
|
||||
});
|
||||
if (!deployImage.valid) {
|
||||
addMismatch(ctx, "invalid_image", `${deployPathBase}.image`, deployImage.reason, "tagged image ref", deployService.image, { serviceId });
|
||||
} else if (deployImage.tag !== desiredImageTag) {
|
||||
addMismatch(ctx, "image_tag_mismatch", `${deployPathBase}.image`, `${serviceId} deploy image tag must match deploy commit tag`, desiredImageTag, deployImage.tag, { serviceId });
|
||||
}
|
||||
service.deploy = {
|
||||
image: deployService.image,
|
||||
imageTag: deployImage.tag,
|
||||
envMirrors: inspectEnvMirrors(ctx, {
|
||||
source: "deploy",
|
||||
basePath: `${deployPathBase}.env`,
|
||||
serviceId,
|
||||
env: deployService.env ?? {},
|
||||
service: expectedService,
|
||||
desiredCommitId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (catalogService) {
|
||||
const catalogPathBase = `${catalogPath}.services.${serviceId}`;
|
||||
recordObservation(ctx, {
|
||||
source: "catalog",
|
||||
serviceId,
|
||||
path: `${catalogPathBase}.commitId`,
|
||||
field: "commitId",
|
||||
kind: "commit",
|
||||
value: catalogService.commitId,
|
||||
expected: desiredCommitId
|
||||
});
|
||||
recordObservation(ctx, {
|
||||
source: "catalog",
|
||||
serviceId,
|
||||
path: `${catalogPathBase}.image`,
|
||||
field: "image",
|
||||
kind: "image",
|
||||
value: catalogService.image,
|
||||
expected: deployService?.image ?? catalogService.image
|
||||
});
|
||||
recordObservation(ctx, {
|
||||
source: "catalog",
|
||||
serviceId,
|
||||
path: `${catalogPathBase}.imageTag`,
|
||||
field: "imageTag",
|
||||
kind: "tag",
|
||||
value: catalogService.imageTag,
|
||||
expected: desiredImageTag
|
||||
});
|
||||
if (!commitEquivalent(catalogService.commitId, desiredCommitId)) {
|
||||
addMismatch(ctx, "commit_mismatch", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must match deploy commitId`, desiredCommitId, catalogService.commitId, { serviceId });
|
||||
}
|
||||
if (!catalogImage.valid) {
|
||||
addMismatch(ctx, "invalid_image", `${catalogPathBase}.image`, catalogImage.reason, "tagged image ref", catalogService.image, { serviceId });
|
||||
}
|
||||
if (catalogService.imageTag !== desiredImageTag) {
|
||||
addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.imageTag`, `${serviceId} catalog imageTag must match deploy commit tag`, desiredImageTag, catalogService.imageTag, { serviceId });
|
||||
}
|
||||
if (catalogImage.valid && catalogImage.tag !== catalogService.imageTag) {
|
||||
addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image tag must match catalog imageTag`, catalogService.imageTag, catalogImage.tag, { serviceId });
|
||||
}
|
||||
if (deployService?.image && catalogService.image !== deployService.image) {
|
||||
addMismatch(ctx, "image_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image must match deploy image`, deployService.image, catalogService.image, { serviceId });
|
||||
}
|
||||
service.catalog = {
|
||||
commitId: catalogService.commitId,
|
||||
image: catalogService.image,
|
||||
imageTag: catalogService.imageTag,
|
||||
digest: catalogService.digest,
|
||||
publishState: catalogService.publishState,
|
||||
artifactRequired: catalogService.artifactRequired
|
||||
};
|
||||
}
|
||||
|
||||
for (const workload of workloadsForService) {
|
||||
const workloadImage = parseImageRef(workload.image);
|
||||
recordObservation(ctx, {
|
||||
source: "workload",
|
||||
serviceId,
|
||||
path: `${workload.path}.image`,
|
||||
field: "image",
|
||||
kind: "image",
|
||||
value: workload.image,
|
||||
expected: deployService?.image ?? catalogService?.image ?? null
|
||||
});
|
||||
if (!workloadImage.valid) {
|
||||
addMismatch(ctx, "invalid_image", `${workload.path}.image`, workloadImage.reason, "tagged image ref", workload.image, { serviceId });
|
||||
} else if (workloadImage.tag !== desiredImageTag) {
|
||||
addMismatch(ctx, "image_tag_mismatch", `${workload.path}.image`, `${serviceId} workload image tag must match deploy commit tag`, desiredImageTag, workloadImage.tag, { serviceId });
|
||||
}
|
||||
if (deployService?.image && workload.image !== deployService.image) {
|
||||
addMismatch(ctx, "image_mismatch", `${workload.path}.image`, `${serviceId} workload image must match deploy image`, deployService.image, workload.image, { serviceId });
|
||||
}
|
||||
service.workloads.push({
|
||||
kind: workload.kind,
|
||||
name: workload.name,
|
||||
namespace: workload.namespace,
|
||||
container: workload.containerName,
|
||||
image: workload.image,
|
||||
imageTag: workloadImage.tag,
|
||||
envMirrors: inspectEnvMirrors(ctx, {
|
||||
source: "workload",
|
||||
basePath: `${workload.path}.env`,
|
||||
serviceId,
|
||||
env: workload.env,
|
||||
service: expectedService,
|
||||
desiredCommitId
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
service.promotion = servicePromotion(service, target);
|
||||
services.push(service);
|
||||
}
|
||||
|
||||
const targetConvergence = buildTargetConvergence(ctx, target);
|
||||
const blockers = ctx.diagnostics.filter((diagnostic) => diagnostic.severity === "blocker");
|
||||
const status = blockers.length > 0
|
||||
? "blocked"
|
||||
: targetConvergence?.state === "promotion_pending"
|
||||
? "planned"
|
||||
: "pass";
|
||||
|
||||
const presentMirrorCount = services.reduce((count, service) => {
|
||||
const deployCount = service.deploy ? Object.keys(service.deploy.envMirrors.present).length : 0;
|
||||
const workloadCount = service.workloads.reduce((inner, workload) => inner + Object.keys(workload.envMirrors.present).length, 0);
|
||||
return count + deployCount + workloadCount;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
kind: "hwlab-deploy-desired-state-plan",
|
||||
mode: "read-only-plan",
|
||||
status,
|
||||
source: {
|
||||
deploy: deployPath,
|
||||
artifactCatalog: catalogPath,
|
||||
workloads: workloadsPath,
|
||||
optionalReport: artifactReportPath
|
||||
},
|
||||
safety: {
|
||||
readOnly: true,
|
||||
sourceOrDryRunSupportOnly: true,
|
||||
kubectlApply: false,
|
||||
registryPull: false,
|
||||
registryPush: false,
|
||||
imageBuild: false,
|
||||
serviceRestart: false,
|
||||
prod: false,
|
||||
devLiveClaim: false
|
||||
},
|
||||
checkSemantics: "--check exits non-zero only for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, or partial target drift. A uniform older desired-state under --target-ref is a read-only promotion plan, not a check failure.",
|
||||
summary: {
|
||||
desiredCommitId,
|
||||
desiredImageTag,
|
||||
artifactState: catalog.artifactState,
|
||||
ciPublished: catalog.publish?.ciPublished === true,
|
||||
registryVerified: catalog.publish?.registryVerified === true,
|
||||
services: services.length,
|
||||
workloadContainers: [...workloadByService.values()].reduce((sum, entries) => sum + entries.length, 0),
|
||||
presentMirrorCount,
|
||||
diagnostics: ctx.diagnostics.length,
|
||||
blockers: blockers.length,
|
||||
targetConvergence: targetConvergence?.state ?? "not_requested"
|
||||
},
|
||||
target: target ? {
|
||||
...target,
|
||||
convergence: targetConvergence,
|
||||
commands: targetCommands(target)
|
||||
} : null,
|
||||
promotionBoundary: {
|
||||
writes: [],
|
||||
authoritativeDesiredState: [deployPath, catalogPath, workloadsPath],
|
||||
nonAuthoritativeEvidence: [artifactReportPath],
|
||||
note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
|
||||
},
|
||||
reportHints: reportHints(artifactReport),
|
||||
services,
|
||||
diagnostics: ctx.diagnostics
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDeployDesiredStatePlanCli(argv = process.argv.slice(2), options = {}) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.help) {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
return;
|
||||
}
|
||||
const plan = await buildDesiredStatePlan({
|
||||
repoRoot: options.repoRoot,
|
||||
targetRef: args.targetRef,
|
||||
targetTag: args.targetTag
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(plan, null, args.pretty ? 2 : 0)}\n`);
|
||||
if (args.check && plan.status === "blocked") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDeployDesiredStatePlanError(error) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
kind: "hwlab-deploy-desired-state-plan",
|
||||
mode: "read-only-plan",
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
safety: {
|
||||
readOnly: true,
|
||||
kubectlApply: false,
|
||||
registryPull: false,
|
||||
registryPush: false,
|
||||
imageBuild: false,
|
||||
serviceRestart: false,
|
||||
prod: false,
|
||||
devLiveClaim: false
|
||||
}
|
||||
})}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user