1038 lines
40 KiB
JavaScript
1038 lines
40 KiB
JavaScript
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";
|
|
|
|
import {
|
|
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
|
inspectCodeAgentProviderManifestRefs
|
|
} from "../../internal/cloud/code-agent-contract.ts";
|
|
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
|
|
import { tempReportPath } from "./report-paths.mjs";
|
|
import { readStructuredFile } from "./structured-config.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
|
|
const deployPath = "deploy/deploy.yaml";
|
|
const catalogPath = "deploy/artifact-catalog.dev.json";
|
|
const workloadsPath = "deploy/k8s/base/workloads.yaml";
|
|
const artifactReportPath = tempReportPath("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 forbiddenDeployArtifactFields = new Set(["commitId", "image", "imageTag", "digest", "sourceCommitId", "repositoryDigest", "publishState"]);
|
|
const forbiddenDeployArtifactEnv = new Set(mirrorEnvNames.concat(["HWLAB_REVISION", "HWLAB_BUILD_CREATED_AT", "HWLAB_BUILD_SOURCE"]));
|
|
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,
|
|
plan: false,
|
|
pretty: false,
|
|
targetRef: null,
|
|
targetTag: null,
|
|
promotionCommit: 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 === "--plan") {
|
|
args.plan = 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 === "--promotion-commit") {
|
|
args.promotionCommit = 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 [--plan] [--check] [--pretty] [--target-ref REF] [--target-tag TAG] [--promotion-commit SHA]",
|
|
"",
|
|
"Read-only DEV desired-state commit/image planner.",
|
|
"",
|
|
"Options:",
|
|
" --plan explicitly request the default read-only JSON plan",
|
|
" --check exit non-zero when desired-state sources are invalid, drifted, or do not match an explicit target",
|
|
" --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",
|
|
" --promotion-commit SHA",
|
|
" require every authoritative desired-state commit/image/tag field to match this promotion commit",
|
|
"",
|
|
"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) {
|
|
return readStructuredFile(repoRoot, relativePath);
|
|
}
|
|
|
|
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 service?.commitId ?? service?.imageTag ?? 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, requiredMirrorNames = [] }) {
|
|
const present = {};
|
|
const missing = [];
|
|
const diagnostics = [];
|
|
const required = new Set(requiredMirrorNames);
|
|
|
|
for (const name of mirrorEnvNames) {
|
|
if (!Object.hasOwn(env ?? {}, name)) {
|
|
missing.push(name);
|
|
if (required.has(name)) {
|
|
const kind = observationKindForEnv(name);
|
|
const expected = expectedEnvValue(name, service, desiredCommitId);
|
|
const diagnostic = {
|
|
code: "missing_mirror",
|
|
path: `${basePath}.${name}`,
|
|
message: `${source} ${serviceId} must include ${name} so DEV applies own runtime ${kind} identity`,
|
|
expected,
|
|
actual: null
|
|
};
|
|
diagnostics.push(diagnostic);
|
|
addDiagnostic(ctx, diagnostic);
|
|
}
|
|
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 inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecords) {
|
|
const workloadRecord = workloadRecords.find((workload) => workload.containerName === "hwlab-cloud-api") ?? workloadRecords[0] ?? null;
|
|
const inspection = inspectCodeAgentProviderManifestRefs({
|
|
deployEnv: deployService?.env ?? {},
|
|
workloadEnv: workloadRecord?.env ?? {}
|
|
});
|
|
if (!inspection.ready) {
|
|
addDiagnostic(ctx, {
|
|
code: "code_agent_provider_contract_mismatch",
|
|
path: "deploy.codeAgentProvider",
|
|
message: "hwlab-cloud-api desired state must preserve Code Agent provider Secret ref and DEV egress proxy env",
|
|
expected: {
|
|
provider: DEV_CODE_AGENT_PROVIDER_CONTRACT.codeAgentProvider,
|
|
secretRef: `${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretName}/${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretKey}`,
|
|
egressTarget: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target
|
|
},
|
|
actual: {
|
|
missingDeployEnv: inspection.missingDeployEnv,
|
|
missingWorkloadEnv: inspection.missingWorkloadEnv,
|
|
deployMismatches: inspection.deployMismatches.map((entry) => entry.name),
|
|
workloadMismatches: inspection.workloadMismatches.map((entry) => entry.name),
|
|
missingSecretRefs: inspection.missingSecretRefs,
|
|
missingEgressContract: inspection.missingEgressContract,
|
|
missingCodexHomeContract: inspection.missingCodexHomeContract,
|
|
missingWorkspaceContract: inspection.missingWorkspaceContract,
|
|
missingNoProxyContract: inspection.missingNoProxyContract,
|
|
missingForwarderContract: inspection.missingForwarderContract
|
|
},
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
recordObservation(ctx, {
|
|
source: "deploy",
|
|
serviceId: "hwlab-cloud-api",
|
|
path: `${deployPath}.services.hwlab-cloud-api.env.OPENAI_API_KEY`,
|
|
field: "OPENAI_API_KEY",
|
|
kind: "secret-ref",
|
|
value: inspection.secretRef.present ? "secretRef:declared" : "missing_or_mismatched",
|
|
expected: "secretRef:hwlab-code-agent-provider/openai-api-key"
|
|
});
|
|
recordObservation(ctx, {
|
|
source: "workload",
|
|
serviceId: "hwlab-cloud-api",
|
|
path: `${workloadsPath}.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL`,
|
|
field: "HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
|
kind: "codex-api-forwarder",
|
|
value: inspection.egress.workloadMatchesDevProxy ? "pod-local-codex-api-forwarder" : "missing_or_mismatched",
|
|
expected: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target
|
|
});
|
|
return inspection;
|
|
}
|
|
|
|
function dbSecretRefPlaceholder() {
|
|
const ref = DEV_DB_ENV_CONTRACT.secretRefs[0];
|
|
return `secretRef:${ref.secretName}/${ref.secretKey}`;
|
|
}
|
|
|
|
function secretRefMatches(entry, ref) {
|
|
return (
|
|
entry?.valueFrom?.secretKeyRef?.name === ref.secretName &&
|
|
entry?.valueFrom?.secretKeyRef?.key === ref.secretKey
|
|
);
|
|
}
|
|
|
|
function inspectCloudApiDbDesiredState(ctx, deployService, workloadRecords) {
|
|
const workloadRecord = workloadRecords.find((workload) => workload.containerName === "hwlab-cloud-api") ?? workloadRecords[0] ?? null;
|
|
const deployEnv = deployService?.env ?? {};
|
|
const workloadEnv = workloadRecord?.env ?? {};
|
|
const secretRef = DEV_DB_ENV_CONTRACT.secretRefs[0];
|
|
const expectedSecretRef = dbSecretRefPlaceholder();
|
|
const expectedSslMode = DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE;
|
|
const deploySecretMatches = deployEnv.HWLAB_CLOUD_DB_URL === expectedSecretRef;
|
|
const workloadSecretMatches = secretRefMatches(workloadEnv.HWLAB_CLOUD_DB_URL, secretRef);
|
|
const deploySslMatches = deployEnv.HWLAB_CLOUD_DB_SSL_MODE === expectedSslMode;
|
|
const workloadSslMatches = workloadEnv.HWLAB_CLOUD_DB_SSL_MODE === expectedSslMode;
|
|
const ready = deploySecretMatches && workloadSecretMatches && deploySslMatches && workloadSslMatches;
|
|
|
|
if (!ready) {
|
|
addDiagnostic(ctx, {
|
|
code: "cloud_api_db_contract_mismatch",
|
|
path: "deploy.cloudApiDb",
|
|
message: "hwlab-cloud-api desired state must preserve DEV DB Secret ref and non-SSL runtime mode",
|
|
expected: {
|
|
secretRef: `${secretRef.secretName}/${secretRef.secretKey}`,
|
|
sslMode: expectedSslMode
|
|
},
|
|
actual: {
|
|
deploySecretRef: deploySecretMatches ? "secretRef:declared" : "missing_or_mismatched",
|
|
workloadSecretRef: workloadSecretMatches ? "secretRef:declared" : "missing_or_mismatched",
|
|
deploySslMode: deployEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null,
|
|
workloadSslMode: workloadEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null
|
|
},
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
|
|
recordObservation(ctx, {
|
|
source: "deploy",
|
|
serviceId: "hwlab-cloud-api",
|
|
path: `${deployPath}.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE`,
|
|
field: "HWLAB_CLOUD_DB_SSL_MODE",
|
|
kind: "runtime-config",
|
|
value: deployEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null,
|
|
expected: expectedSslMode
|
|
});
|
|
recordObservation(ctx, {
|
|
source: "workload",
|
|
serviceId: "hwlab-cloud-api",
|
|
path: `${workloadsPath}.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE`,
|
|
field: "HWLAB_CLOUD_DB_SSL_MODE",
|
|
kind: "runtime-config",
|
|
value: workloadEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null,
|
|
expected: expectedSslMode
|
|
});
|
|
|
|
return {
|
|
contractVersion: DEV_DB_ENV_CONTRACT.contractVersion,
|
|
environment: DEV_DB_ENV_CONTRACT.environment,
|
|
status: ready ? "pass" : "blocked",
|
|
ready,
|
|
secretRef: {
|
|
env: secretRef.env,
|
|
secretName: secretRef.secretName,
|
|
secretKey: secretRef.secretKey,
|
|
deployPlaceholderPresent: deploySecretMatches,
|
|
workloadSecretRefPresent: workloadSecretMatches,
|
|
present: deploySecretMatches && workloadSecretMatches,
|
|
redacted: true
|
|
},
|
|
sslMode: {
|
|
env: "HWLAB_CLOUD_DB_SSL_MODE",
|
|
value: deployEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null,
|
|
workloadValue: workloadEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null,
|
|
expected: expectedSslMode,
|
|
deployMatches: deploySslMatches,
|
|
workloadMatches: workloadSslMatches,
|
|
secret: false
|
|
},
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true,
|
|
liveDbEvidence: false,
|
|
fixtureEvidence: false
|
|
};
|
|
}
|
|
|
|
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),
|
|
__volumeMounts: Array.isArray(container?.volumeMounts) ? container.volumeMounts : [],
|
|
__volumes: Array.isArray(item?.spec?.template?.spec?.volumes) ? item.spec.template.spec.volumes : [],
|
|
__podContainers: Array.isArray(item?.spec?.template?.spec?.containers) ? item.spec.template.spec.containers : [],
|
|
__initContainers: Array.isArray(item?.spec?.template?.spec?.initContainers) ? item.spec.template.spec.initContainers : [],
|
|
__deploymentStrategy: item?.spec?.strategy ?? null
|
|
},
|
|
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, promotionCommit, requireTargetConvergence = false }, ctx) {
|
|
if (!targetRef && !targetTag && !promotionCommit) return null;
|
|
let commitId = null;
|
|
let shortCommitId = null;
|
|
let acceptedCurrentMain = 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)
|
|
});
|
|
}
|
|
}
|
|
if (promotionCommit) {
|
|
if (!commitPattern.test(String(promotionCommit))) {
|
|
addMismatch(ctx, "invalid_promotion_commit", "--promotion-commit", "promotion commit must be a short or full lowercase Git SHA", "7-40 lowercase hex", promotionCommit);
|
|
} else {
|
|
const promotionShortCommitId = promotionCommit.slice(0, 7);
|
|
if (commitId && !commitEquivalent(promotionCommit, commitId)) {
|
|
addMismatch(ctx, "promotion_commit_ref_mismatch", "--promotion-commit", "promotion commit must match the resolved target ref", commitId, promotionCommit);
|
|
}
|
|
commitId = commitId ?? promotionCommit;
|
|
shortCommitId = shortCommitId ?? promotionShortCommitId;
|
|
}
|
|
}
|
|
const tag = targetTag ?? shortCommitId;
|
|
if (tag) assertTargetTag(ctx, tag, "--target-tag");
|
|
if (promotionCommit && commitPattern.test(String(promotionCommit)) && targetTag && targetTag !== promotionCommit.slice(0, 7)) {
|
|
addMismatch(ctx, "promotion_tag_mismatch", "--target-tag", "promotion target tag must be the short promotion commit", promotionCommit.slice(0, 7), targetTag);
|
|
}
|
|
const requireConvergence = Boolean(promotionCommit || requireTargetConvergence);
|
|
if (targetRef && commitId && requireConvergence && !promotionCommit && !targetTag) {
|
|
acceptedCurrentMain = await resolveAcceptedCurrentMain(repoRoot, commitId);
|
|
}
|
|
const comparisonCommitId = acceptedCurrentMain?.commitId ?? commitId;
|
|
const comparisonTag = acceptedCurrentMain?.tag ?? tag;
|
|
return {
|
|
targetRef,
|
|
promotionCommit,
|
|
commitId,
|
|
shortCommitId,
|
|
tag,
|
|
tagSource: targetTag ? "target-tag" : promotionCommit ? "promotion-commit-short" : "target-ref-short-commit",
|
|
acceptedCurrentMain,
|
|
comparisonCommitId,
|
|
comparisonTag,
|
|
requireConvergence,
|
|
convergenceRequirement: promotionCommit ? "promotion-commit" : requireTargetConvergence ? "target-check" : "plan-only"
|
|
};
|
|
}
|
|
|
|
async function resolveAcceptedCurrentMain(repoRoot, targetCommitId) {
|
|
try {
|
|
const [{ stdout: headStdout }, { stdout: parentsStdout }] = await Promise.all([
|
|
execFileAsync("git", ["rev-parse", "--verify", "HEAD^{commit}"], { cwd: repoRoot }),
|
|
execFileAsync("git", ["rev-list", "--parents", "-n", "1", targetCommitId], { cwd: repoRoot })
|
|
]);
|
|
const headCommitId = headStdout.trim();
|
|
if (headCommitId !== targetCommitId) return null;
|
|
|
|
const [commitId, firstParentId, secondParentId] = parentsStdout.trim().split(/\s+/u);
|
|
if (commitId !== targetCommitId || !firstParentId || !secondParentId) return null;
|
|
|
|
return {
|
|
mode: "target-ref-head-merge-first-parent",
|
|
reason: "A merged main commit cannot contain desired-state files pinned to its own content hash; compare against the accepted first-parent main commit.",
|
|
commitId: firstParentId,
|
|
shortCommitId: firstParentId.slice(0, 7),
|
|
tag: firstParentId.slice(0, 7)
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function targetLabel(target) {
|
|
if (target.acceptedCurrentMain) return `${target.targetRef} accepted first parent (${target.acceptedCurrentMain.tag})`;
|
|
if (target.targetRef && target.tag) return `${target.targetRef} (${target.tag})`;
|
|
if (target.promotionCommit) return target.promotionCommit;
|
|
if (target.tag) return `tag ${target.tag}`;
|
|
return "target";
|
|
}
|
|
|
|
function observationMatchesTarget(observation, target) {
|
|
if (!target) return null;
|
|
if (observation.kind === "commit") {
|
|
return target.comparisonCommitId ? commitEquivalent(observation.value, target.comparisonCommitId) : null;
|
|
}
|
|
if (observation.kind === "tag") {
|
|
return target.comparisonTag ? observation.value === target.comparisonTag : null;
|
|
}
|
|
if (observation.kind === "image") {
|
|
return target.comparisonTag ? parseImageRef(observation.value).tag === target.comparisonTag : 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;
|
|
const promotionRequired = target.requireConvergence === true;
|
|
|
|
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
|
|
}
|
|
});
|
|
} else if (promotionRequired && pending.length > 0) {
|
|
const promotionCommitRequired = Boolean(target.promotionCommit);
|
|
addDiagnostic(ctx, {
|
|
code: promotionCommitRequired ? "promotion_commit_mismatch" : "target_desired_state_mismatch",
|
|
path: "desired-state",
|
|
message: promotionCommitRequired
|
|
? "desired-state fields do not match the required promotion commit/tag"
|
|
: `stale artifact catalog is not pinned to ${targetLabel(target)}; explicit target checks require current-main/commit-pinned catalog identity`,
|
|
expected: {
|
|
commitId: target.comparisonCommitId,
|
|
imageTag: target.comparisonTag
|
|
},
|
|
actual: {
|
|
pending: pending.length,
|
|
samplePendingPaths: pending.slice(0, 8).map((item) => item.path)
|
|
}
|
|
});
|
|
}
|
|
|
|
const state = partial
|
|
? "partial_drift"
|
|
: pending.length === 0
|
|
? target.acceptedCurrentMain
|
|
? "accepted_main_promoted"
|
|
: "already_promoted"
|
|
: promotionRequired
|
|
? target.promotionCommit
|
|
? "promotion_mismatch"
|
|
: "target_mismatch"
|
|
: "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, desiredCommitId) {
|
|
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 ?? {};
|
|
const reportCommitId = report.commitId ?? null;
|
|
const artifactSourceCommitId = artifactPublish.sourceCommitId ?? null;
|
|
return {
|
|
path: artifactReportPath,
|
|
present: true,
|
|
authoritative: false,
|
|
note: "temporary publish JSON is contextual evidence only; deploy desired-state remains authoritative in deploy/",
|
|
commitId: reportCommitId,
|
|
matchesDesiredState: reportCommitId ? commitEquivalent(reportCommitId, desiredCommitId) : null,
|
|
artifactPublish: {
|
|
status: artifactPublish.status ?? null,
|
|
sourceCommitId: artifactSourceCommitId,
|
|
sourceMatchesDesiredState: artifactSourceCommitId ? commitEquivalent(artifactSourceCommitId, desiredCommitId) : null,
|
|
publishedCount: artifactPublish.publishedCount ?? null,
|
|
serviceCount: artifactPublish.serviceCount ?? null,
|
|
registryVerified: artifactPublish.registryVerified ?? null,
|
|
registryCapabilitiesClassification: artifactPublish.registryCapabilities?.classification ?? null
|
|
}
|
|
};
|
|
}
|
|
|
|
function servicePromotion(service, target) {
|
|
const targetTag = target?.comparisonTag ?? target?.tag;
|
|
if (!targetTag) return null;
|
|
return {
|
|
targetCommitId: target.comparisonCommitId ?? target.commitId,
|
|
targetImageTag: targetTag,
|
|
deployImage: null,
|
|
catalogImage: service.catalog?.image ? replaceImageTag(service.catalog.image, targetTag) : null,
|
|
workloadImages: (service.workloads ?? []).map((workload) => ({
|
|
workload: workload.name,
|
|
container: workload.container,
|
|
image: workload.image ? replaceImageTag(workload.image, targetTag) : null
|
|
}))
|
|
};
|
|
}
|
|
|
|
function targetCommands(target) {
|
|
if (!target?.targetRef && !target?.promotionCommit) return [];
|
|
const commands = [];
|
|
if (target.promotionCommit) {
|
|
commands.push(`node scripts/deploy-desired-state-plan.mjs --promotion-commit ${target.promotionCommit} --check --pretty`);
|
|
}
|
|
if (target.targetRef) {
|
|
commands.push(
|
|
`node scripts/deploy-desired-state-plan.mjs --target-ref ${target.targetRef} --pretty`,
|
|
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --blocked --no-write`,
|
|
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --publish-report ${artifactReportPath} --no-write`
|
|
);
|
|
}
|
|
return commands;
|
|
}
|
|
|
|
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,
|
|
promotionCommit: options.promotionCommit ?? null,
|
|
requireTargetConvergence: options.requireTargetConvergence === true
|
|
}, 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 = catalog.commitId;
|
|
const desiredImageTag = shortCommit(desiredCommitId);
|
|
|
|
recordObservation(ctx, {
|
|
source: "catalog",
|
|
path: `${catalogPath}.commitId`,
|
|
field: "commitId",
|
|
kind: "commit",
|
|
value: catalog.commitId,
|
|
expected: desiredCommitId
|
|
});
|
|
|
|
if (Object.hasOwn(deploy, "commitId")) {
|
|
addMismatch(ctx, "generated_artifact_identity", `${deployPath}.commitId`, "deploy.yaml is human-authored config; generated commit identity must stay in artifact catalog", "absent", deploy.commitId);
|
|
}
|
|
if (!commitPattern.test(String(catalog.commitId ?? ""))) {
|
|
addMismatch(ctx, "invalid_commit", `${catalogPath}.commitId`, "artifact catalog commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", catalog.commitId);
|
|
}
|
|
|
|
const serviceIds = serviceIdsFrom(
|
|
deployServices.map((service) => service.serviceId),
|
|
catalogServices.map((service) => service.serviceId),
|
|
[...workloadByService.keys()]
|
|
);
|
|
|
|
const services = [];
|
|
let codeAgentProvider = null;
|
|
let cloudApiDb = null;
|
|
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 catalogImage = parseImageRef(catalogService?.image);
|
|
const serviceImageTag = catalogService?.imageTag ?? catalogImage.tag ?? desiredImageTag;
|
|
const serviceCommitId = catalogService?.commitId ?? serviceImageTag;
|
|
if (deployService) {
|
|
const deployPathBase = `${deployPath}.services.${serviceId}`;
|
|
for (const field of Object.keys(deployService)) {
|
|
if (forbiddenDeployArtifactFields.has(field)) {
|
|
addMismatch(ctx, "generated_artifact_identity", `${deployPathBase}.${field}`, `${serviceId} deploy service field is generated artifact identity and must stay in artifact catalog`, "absent", deployService[field], { serviceId });
|
|
}
|
|
}
|
|
for (const envName of Object.keys(deployService.env ?? {})) {
|
|
if (forbiddenDeployArtifactEnv.has(envName)) {
|
|
addMismatch(ctx, "generated_artifact_identity", `${deployPathBase}.env.${envName}`, `${serviceId} deploy env is generated artifact identity and must be rendered from artifact catalog`, "absent", deployService.env[envName], { serviceId });
|
|
}
|
|
}
|
|
service.deploy = {
|
|
artifactIdentity: "human-authored-config",
|
|
envMirrors: inspectEnvMirrors(ctx, {
|
|
source: "deploy",
|
|
basePath: `${deployPathBase}.env`,
|
|
serviceId,
|
|
env: deployService.env ?? {},
|
|
service: null,
|
|
desiredCommitId: null,
|
|
requiredMirrorNames: []
|
|
})
|
|
};
|
|
}
|
|
|
|
if (catalogService) {
|
|
const catalogPathBase = `${catalogPath}.services.${serviceId}`;
|
|
recordObservation(ctx, {
|
|
source: "catalog",
|
|
serviceId,
|
|
path: `${catalogPathBase}.commitId`,
|
|
field: "commitId",
|
|
kind: "commit",
|
|
value: catalogService.commitId,
|
|
expected: serviceCommitId
|
|
});
|
|
recordObservation(ctx, {
|
|
source: "catalog",
|
|
serviceId,
|
|
path: `${catalogPathBase}.image`,
|
|
field: "image",
|
|
kind: "image",
|
|
value: catalogService.image,
|
|
expected: catalogService.image
|
|
});
|
|
recordObservation(ctx, {
|
|
source: "catalog",
|
|
serviceId,
|
|
path: `${catalogPathBase}.imageTag`,
|
|
field: "imageTag",
|
|
kind: "tag",
|
|
value: catalogService.imageTag,
|
|
expected: serviceImageTag
|
|
});
|
|
if (!commitPattern.test(String(catalogService.commitId ?? ""))) {
|
|
addMismatch(ctx, "invalid_commit", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must be a short or full lowercase Git SHA`, "7-40 lowercase hex", catalogService.commitId, { serviceId });
|
|
} else if (!commitEquivalent(catalogService.commitId, serviceImageTag)) {
|
|
addMismatch(ctx, "commit_mismatch", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must match service artifact tag`, serviceImageTag, catalogService.commitId, { serviceId });
|
|
}
|
|
if (!catalogImage.valid) {
|
|
addMismatch(ctx, "invalid_image", `${catalogPathBase}.image`, catalogImage.reason, "tagged image ref", catalogService.image, { serviceId });
|
|
}
|
|
if (catalogService.imageTag !== serviceImageTag) {
|
|
addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.imageTag`, `${serviceId} catalog imageTag must match service artifact tag`, serviceImageTag, 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 });
|
|
}
|
|
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: catalogService?.image ?? null
|
|
});
|
|
if (!workloadImage.valid) {
|
|
addMismatch(ctx, "invalid_image", `${workload.path}.image`, workloadImage.reason, "tagged image ref", 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: null,
|
|
desiredCommitId: null,
|
|
requiredMirrorNames: []
|
|
})
|
|
});
|
|
}
|
|
|
|
service.promotion = servicePromotion(service, target);
|
|
services.push(service);
|
|
|
|
if (serviceId === "hwlab-cloud-api") {
|
|
cloudApiDb = inspectCloudApiDbDesiredState(ctx, deployService, workloadsForService);
|
|
codeAgentProvider = inspectCodeAgentProviderDesiredState(ctx, deployService, workloadsForService);
|
|
}
|
|
}
|
|
|
|
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 for missing/invalid desired-state sources, generated artifact identity leaking into deploy.yaml, invalid target tags, partial target drift, or catalog identity mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older catalog under --target-ref/--target-tag is a read-only promotion plan.",
|
|
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],
|
|
humanAuthoredTruth: [deployPath, workloadsPath],
|
|
artifactIdentityTruth: [catalogPath],
|
|
nonAuthoritativeEvidence: [artifactReportPath],
|
|
note: "This planner is read-only. deploy.yaml is human-authored config; artifact-catalog carries generated image identity and is written by G14 Tekton promotion only to G14-gitops. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
|
|
},
|
|
cloudApiDb: cloudApiDb ?? {
|
|
status: "blocked",
|
|
ready: false,
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true,
|
|
liveDbEvidence: false
|
|
},
|
|
codeAgentProvider: codeAgentProvider ?? {
|
|
status: "blocked",
|
|
ready: false,
|
|
missingDeployEnv: ["hwlab-cloud-api"],
|
|
missingWorkloadEnv: ["hwlab-cloud-api"],
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true
|
|
},
|
|
reportHints: reportHints(artifactReport, desiredCommitId),
|
|
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,
|
|
promotionCommit: args.promotionCommit,
|
|
requireTargetConvergence: args.check && Boolean(args.targetRef || 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;
|
|
}
|