feat: add dev deploy apply plan report

This commit is contained in:
HWLAB Code Queue
2026-05-21 17:45:53 +00:00
parent d5bbb512df
commit a8d27d546b
4 changed files with 951 additions and 8 deletions
+205
View File
@@ -16,8 +16,22 @@ const requiredValidationCommands = [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-deploy-apply.mjs",
"node --check scripts/src/dev-deploy-apply.mjs",
"node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
];
const applyCommand = "node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report";
const dryRunPlanCommand = "node scripts/dev-deploy-apply.mjs --dry-run --write-report";
const blockedDryRunCommand = "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report";
const forbiddenActions = [
"prod-deploy",
"secret-read",
"runtime-substitute",
"unidesk-restart",
"code-queue-restart",
"backend-core-restart",
"heavy-master-e2e",
"force-push"
];
function parseArgs(argv) {
const flags = new Set(argv.filter((arg) => arg.startsWith("--")));
@@ -107,6 +121,173 @@ function serviceIdFor(item, container) {
);
}
function uniqueStrings(values) {
return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
}
function replicaPlan(item) {
if (item?.kind === "Job") {
return item?.spec?.suspend === true ? 0 : 1;
}
return item?.spec?.replicas ?? null;
}
function buildWorkloadPlan(workloads) {
return listItems(workloads).map((item) => {
const containers = containersFor(item).map((container) => ({
name: container.name,
serviceId: serviceIdFor(item, container),
image: container.image
}));
return {
kind: item?.kind ?? "unknown",
name: item?.metadata?.name ?? "unknown",
namespace: item?.metadata?.namespace ?? namespace,
replicas: replicaPlan(item),
serviceIds: uniqueStrings(containers.map((container) => container.serviceId)),
containers
};
});
}
function buildServicePlan(services) {
return listItems(services).map((item) => ({
kind: item?.kind ?? "unknown",
name: item?.metadata?.name ?? "unknown",
namespace: item?.metadata?.namespace ?? namespace,
serviceId: item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ?? item?.metadata?.name ?? "unknown",
type: item?.spec?.type ?? "unknown",
ports: (item?.spec?.ports ?? []).map((port) => ({
name: port.name,
port: port.port,
targetPort: port.targetPort
}))
}));
}
function buildArtifactPlan(deploy, catalog, sourceCommitId, artifactEvidence) {
const serviceCommitIds = uniqueStrings([
...(catalog?.services ?? []).map((service) => service.commitId),
...(artifactEvidence ?? []).map((service) => service.catalogCommitId)
]);
const expectedArtifactCommit = deploy?.commitId ?? catalog?.commitId ?? "unknown";
return {
expectedArtifactCommit,
deployCommitId: deploy?.commitId ?? "unknown",
catalogCommitId: catalog?.commitId ?? "unknown",
sourceCommitId,
serviceCommitIds,
matchesSourceCommit:
deploy?.commitId && catalog?.commitId
? commitMatchesSource(deploy.commitId, sourceCommitId) && commitMatchesSource(catalog.commitId, sourceCommitId)
: false,
published: catalog?.publish?.ciPublished === true,
registryVerified: catalog?.publish?.registryVerified === true,
imageCount: artifactEvidence.length,
unpublishedServices: (catalog?.services ?? [])
.filter((service) => service.publishState === "skeleton-only" || !service.digest?.startsWith("sha256:"))
.map((service) => service.serviceId)
};
}
function blockerHint(blocker) {
if (blocker.scope === "artifact-publish") {
return "Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence.";
}
if (blocker.scope === "artifact-source-commit") {
return "Regenerate deploy/deploy.json and deploy/artifact-catalog.dev.json for the source commit that will be promoted.";
}
if (blocker.scope === "kubectl" || blocker.scope === "kubectl-version") {
return "Provide a kubectl client configured for the D601 DEV k3s context without exposing token or secret values.";
}
if (blocker.scope === "cluster-read") {
return "Restore read-only visibility for pods, services, configmaps, deployments, and jobs in hwlab-dev.";
}
if (blocker.scope === "dev-health" || blocker.scope === "dev-health-identity") {
return "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime.";
}
if (blocker.scope === "dev-health-db" || blocker.scope === "cloud-api-db" || blocker.scope === "cloud-api-db-config") {
return "Configure and verify the DEV cloud-api DB connection through health output, without reading secret values.";
}
return blocker.summary;
}
function buildRemainingBlockers(blockers) {
return blockers.map((blocker) => ({
type: blocker.type,
scope: blocker.scope,
status: blocker.status,
summary: blocker.summary,
unblockHint: oneLine(blockerHint(blocker))
}));
}
function buildRollbackHint(workloads) {
const deploymentNames = buildWorkloadPlan(workloads)
.filter((workload) => workload.kind === "Deployment")
.map((workload) => workload.name);
const jobNames = buildWorkloadPlan(workloads)
.filter((workload) => workload.kind === "Job")
.map((workload) => workload.name);
return {
namespace,
strategy: "DEV-only Kubernetes rollback using deployment revision history; do not touch PROD or UniDesk services.",
captureBeforeApply: [
"kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide",
"kubectl -n hwlab-dev get deployments -o json"
],
deploymentRollbackCommands: deploymentNames.map((name) => `kubectl -n hwlab-dev rollout undo deployment/${name}`),
jobCleanupCommands: jobNames.map((name) => `kubectl -n hwlab-dev delete job ${name} --ignore-not-found`),
postRollbackChecks: [
"kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s",
`curl -fsS ${new URL(healthPath, DEV_ENDPOINT).href}`
]
};
}
function buildManualCommands(status) {
if (status === "blocked") {
return {
status: "blocked",
beforeHumanApproval: [blockedDryRunCommand, "node scripts/validate-dev-gate-report.mjs"],
afterHumanApproval: [],
summary: "No apply command is offered while blockers remain open."
};
}
return {
status: "ready",
beforeHumanApproval: [dryRunPlanCommand, "node scripts/validate-dev-gate-report.mjs"],
afterHumanApproval: [applyCommand],
summary: "Run the dry-run plan and validator immediately before requesting human approval; live apply still requires the explicit DEV confirmation flags."
};
}
function buildApplyBoundary(args, status, applyStep) {
return {
currentMode: args.apply ? "apply-requested" : "dry-run",
defaultNoWrite: !args.apply,
mutationAttempted: applyStep.status === "pass" && args.apply,
mutationAllowed: status === "pass" && args.apply,
applyRequiresFlags: ["--apply", "--confirm-dev", "--confirmed-non-production"],
writeScope: "kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only",
noWriteScope: "source/catalog/k8s validation, optional DEV health probe, kubectl read, and server-side dry-run only",
forbiddenActions
};
}
function buildPlanConclusion(status, blockers) {
return {
status: status === "blocked" ? "blocked" : "ready",
reason:
status === "blocked"
? `${blockers.length} blocker(s) must be cleared before any live DEV apply.`
: "All DEV-only preconditions passed; human approval is still required before apply.",
blockerCount: blockers.length
};
}
function validateArgs(args, blockers) {
for (const forbidden of ["--prod", "--production", "--read-secret", "--force-push", "--heavyweight-e2e"]) {
if (args.flags.has(forbidden)) {
@@ -380,12 +561,19 @@ export async function runDevDeployApply(argv, io = {}) {
const liveProbe = args.skipLiveProbe ? { status: "not_run", reason: "--skip-live-probe" } : await probeDevEndpoint(blockers);
const applyStep = await runApplyStep(args, kubectlPath, blockers);
const status = blockers.length > 0 ? "blocked" : "pass";
const artifactPlan = buildArtifactPlan(deploy, catalog, commitId, artifactEvidence);
const workloadPlan = buildWorkloadPlan(workloads);
const servicePlan = buildServicePlan(services);
const planConclusion = buildPlanConclusion(status, blockers);
const manualCommands = buildManualCommands(status);
const remainingBlockers = buildRemainingBlockers(blockers);
const report = {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: "https://hwlab.pikastech.local/reports/dev-gate/dev-deploy-report.json",
reportVersion: "v1",
issue: "pikasTech/HWLAB#33",
supports: ["pikasTech/HWLAB#50", "pikasTech/HWLAB#7", "pikasTech/HWLAB#33", "pikasTech/HWLAB#17", "pikasTech/HWLAB#32", "pikasTech/HWLAB#30"],
taskId: "dev-deploy-apply",
commitId,
acceptanceLevel: "dev_deploy_apply",
@@ -421,6 +609,9 @@ export async function runDevDeployApply(argv, io = {}) {
],
evidence: [
`artifact services checked: ${artifactEvidence.length}`,
`expected artifact commit: ${artifactPlan.expectedArtifactCommit}`,
`namespace: ${namespace}`,
`workloads planned: ${workloadPlan.length}`,
`kubectl executor: ${kubectlPath ?? "missing"}`,
`live health: ${liveProbe.status}`
],
@@ -438,8 +629,22 @@ export async function runDevDeployApply(argv, io = {}) {
},
devDeployApply: {
mode: args.apply ? "apply" : "dry-run",
conclusion: planConclusion,
applyBoundary: buildApplyBoundary(args, status, applyStep),
mutationAttempted: applyStep.status === "pass" && args.apply,
target: {
environment: ENVIRONMENT_DEV,
namespace,
endpoint: DEV_ENDPOINT,
prodDisabled: true
},
artifactPlan,
workloadPlan,
servicePlan,
applyStep,
manualCommands,
rollbackHint: buildRollbackHint(workloads),
remainingBlockers,
artifactEvidence,
k8sManifest,
clusterObservation,
+158
View File
@@ -30,6 +30,21 @@ const requiredValidationCommands = [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
];
const requiredDevDeployApplySupports = [
"pikasTech/HWLAB#50",
"pikasTech/HWLAB#7",
"pikasTech/HWLAB#33",
"pikasTech/HWLAB#17",
"pikasTech/HWLAB#32",
"pikasTech/HWLAB#30"
];
const requiredDevDeployApplyValidationCommands = [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-deploy-apply.mjs",
"node --check scripts/src/dev-deploy-apply.mjs",
"node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
];
const requiredPreflightValidationCommands = [
"node --check scripts/dev-gate-preflight.mjs",
"node --check scripts/src/dev-gate-preflight.mjs",
@@ -435,6 +450,10 @@ async function validateReport(relativePath) {
});
assertUnique(report.devPreconditions.evidence, `${label}.devPreconditions.evidence`);
}
if (report.taskId === "dev-deploy-apply") {
assertDevDeployApplyReport(report, label);
}
}
async function assertDocumentSet(documentsValue, label, requiredDocs) {
@@ -469,6 +488,145 @@ function assertBlockers(blockers, label) {
}
}
function assertCommandList(value, label, { minLength = 1 } = {}) {
assertStringArray(value, label, { minLength });
assertUnique(value, label);
for (const command of value) {
assert.ok(!command.includes("--prod"), `${label} must not include PROD command`);
assert.ok(!command.includes("--production"), `${label} must not include production command`);
}
}
function assertDevDeployApplyReport(report, label) {
assert.equal(report.issue, issueFamily.DEV_DEPLOY_APPLY, `${label}.issue`);
assertStringArray(report.supports, `${label}.supports`, {
minLength: requiredDevDeployApplySupports.length
});
for (const supportedIssue of requiredDevDeployApplySupports) {
assert.ok(report.supports.includes(supportedIssue), `${label}.supports missing ${supportedIssue}`);
}
for (const requiredCommand of requiredDevDeployApplyValidationCommands) {
assert.ok(
report.validationCommands.includes(requiredCommand),
`${label}.validationCommands missing ${requiredCommand}`
);
}
assertObject(report.devDeployApply, `${label}.devDeployApply`);
const plan = report.devDeployApply;
for (const field of [
"conclusion",
"applyBoundary",
"target",
"artifactPlan",
"workloadPlan",
"servicePlan",
"manualCommands",
"rollbackHint",
"remainingBlockers"
]) {
assert.ok(Object.hasOwn(plan, field), `${label}.devDeployApply missing ${field}`);
}
assertObject(plan.conclusion, `${label}.devDeployApply.conclusion`);
assert.ok(["blocked", "ready"].includes(plan.conclusion.status), `${label}.devDeployApply.conclusion.status`);
assertString(plan.conclusion.reason, `${label}.devDeployApply.conclusion.reason`);
assert.equal(plan.conclusion.blockerCount, report.blockers.length, `${label}.devDeployApply.conclusion.blockerCount`);
assertObject(plan.applyBoundary, `${label}.devDeployApply.applyBoundary`);
assert.equal(plan.applyBoundary.defaultNoWrite, plan.mode !== "apply", `${label}.devDeployApply.applyBoundary.defaultNoWrite`);
assert.equal(plan.applyBoundary.mutationAttempted, report.devDeployApply.mutationAttempted, `${label}.devDeployApply.applyBoundary.mutationAttempted`);
assertStringArray(plan.applyBoundary.applyRequiresFlags, `${label}.devDeployApply.applyBoundary.applyRequiresFlags`, {
minLength: 3
});
for (const flag of ["--apply", "--confirm-dev", "--confirmed-non-production"]) {
assert.ok(plan.applyBoundary.applyRequiresFlags.includes(flag), `${label}.devDeployApply.applyBoundary.applyRequiresFlags missing ${flag}`);
}
assertStringArray(plan.applyBoundary.forbiddenActions, `${label}.devDeployApply.applyBoundary.forbiddenActions`, {
minLength: 6
});
for (const forbiddenAction of ["prod-deploy", "secret-read", "runtime-substitute", "heavy-master-e2e"]) {
assert.ok(plan.applyBoundary.forbiddenActions.includes(forbiddenAction), `${label}.devDeployApply.applyBoundary.forbiddenActions missing ${forbiddenAction}`);
}
assertString(plan.applyBoundary.writeScope, `${label}.devDeployApply.applyBoundary.writeScope`);
assertString(plan.applyBoundary.noWriteScope, `${label}.devDeployApply.applyBoundary.noWriteScope`);
assertObject(plan.target, `${label}.devDeployApply.target`);
assert.equal(plan.target.environment, "dev", `${label}.devDeployApply.target.environment`);
assert.equal(plan.target.namespace, "hwlab-dev", `${label}.devDeployApply.target.namespace`);
assert.equal(plan.target.endpoint, "http://74.48.78.17:6667", `${label}.devDeployApply.target.endpoint`);
assert.equal(plan.target.prodDisabled, true, `${label}.devDeployApply.target.prodDisabled`);
assertObject(plan.artifactPlan, `${label}.devDeployApply.artifactPlan`);
for (const field of ["expectedArtifactCommit", "deployCommitId", "catalogCommitId", "sourceCommitId"]) {
assertString(plan.artifactPlan[field], `${label}.devDeployApply.artifactPlan.${field}`);
}
assertStringArray(plan.artifactPlan.serviceCommitIds, `${label}.devDeployApply.artifactPlan.serviceCommitIds`, {
minLength: 1
});
assert.equal(typeof plan.artifactPlan.matchesSourceCommit, "boolean", `${label}.devDeployApply.artifactPlan.matchesSourceCommit`);
assert.equal(typeof plan.artifactPlan.published, "boolean", `${label}.devDeployApply.artifactPlan.published`);
assert.equal(typeof plan.artifactPlan.registryVerified, "boolean", `${label}.devDeployApply.artifactPlan.registryVerified`);
assert.ok(Number.isInteger(plan.artifactPlan.imageCount), `${label}.devDeployApply.artifactPlan.imageCount`);
assertStringArray(plan.artifactPlan.unpublishedServices, `${label}.devDeployApply.artifactPlan.unpublishedServices`);
assertArray(plan.workloadPlan, `${label}.devDeployApply.workloadPlan`);
assert.ok(plan.workloadPlan.length >= 1, `${label}.devDeployApply.workloadPlan must not be empty`);
assertArray(plan.servicePlan, `${label}.devDeployApply.servicePlan`);
assert.ok(plan.servicePlan.length >= 1, `${label}.devDeployApply.servicePlan must not be empty`);
for (const [index, workload] of plan.workloadPlan.entries()) {
const workloadLabel = `${label}.devDeployApply.workloadPlan[${index}]`;
assertObject(workload, workloadLabel);
for (const field of ["kind", "name", "namespace", "serviceIds", "containers"]) {
assert.ok(Object.hasOwn(workload, field), `${workloadLabel} missing ${field}`);
}
assert.equal(workload.namespace, "hwlab-dev", `${workloadLabel}.namespace`);
assertStringArray(workload.serviceIds, `${workloadLabel}.serviceIds`, { minLength: 1 });
assertArray(workload.containers, `${workloadLabel}.containers`);
}
assertObject(plan.manualCommands, `${label}.devDeployApply.manualCommands`);
assert.ok(["blocked", "ready"].includes(plan.manualCommands.status), `${label}.devDeployApply.manualCommands.status`);
assertCommandList(plan.manualCommands.beforeHumanApproval, `${label}.devDeployApply.manualCommands.beforeHumanApproval`);
assertCommandList(plan.manualCommands.afterHumanApproval, `${label}.devDeployApply.manualCommands.afterHumanApproval`, {
minLength: 0
});
assertString(plan.manualCommands.summary, `${label}.devDeployApply.manualCommands.summary`);
if (plan.conclusion.status === "blocked") {
assert.equal(plan.manualCommands.status, "blocked", `${label}.devDeployApply.manualCommands.status`);
assert.equal(plan.manualCommands.afterHumanApproval.length, 0, `${label}.devDeployApply.manualCommands.afterHumanApproval`);
}
if (plan.conclusion.status === "ready") {
assert.equal(plan.manualCommands.status, "ready", `${label}.devDeployApply.manualCommands.status`);
assert.ok(
plan.manualCommands.afterHumanApproval.includes("node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report"),
`${label}.devDeployApply.manualCommands.afterHumanApproval missing apply command`
);
}
assertObject(plan.rollbackHint, `${label}.devDeployApply.rollbackHint`);
assert.equal(plan.rollbackHint.namespace, "hwlab-dev", `${label}.devDeployApply.rollbackHint.namespace`);
assertString(plan.rollbackHint.strategy, `${label}.devDeployApply.rollbackHint.strategy`);
assertCommandList(plan.rollbackHint.captureBeforeApply, `${label}.devDeployApply.rollbackHint.captureBeforeApply`);
assertCommandList(plan.rollbackHint.deploymentRollbackCommands, `${label}.devDeployApply.rollbackHint.deploymentRollbackCommands`);
assertCommandList(plan.rollbackHint.jobCleanupCommands, `${label}.devDeployApply.rollbackHint.jobCleanupCommands`);
assertCommandList(plan.rollbackHint.postRollbackChecks, `${label}.devDeployApply.rollbackHint.postRollbackChecks`);
assertArray(plan.remainingBlockers, `${label}.devDeployApply.remainingBlockers`);
assert.equal(plan.remainingBlockers.length, report.blockers.length, `${label}.devDeployApply.remainingBlockers.length`);
for (const [index, blocker] of plan.remainingBlockers.entries()) {
const blockerLabel = `${label}.devDeployApply.remainingBlockers[${index}]`;
assertObject(blocker, blockerLabel);
for (const field of ["type", "scope", "status", "summary", "unblockHint"]) {
assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`);
}
assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be known`);
assertString(blocker.unblockHint, `${blockerLabel}.unblockHint`);
}
}
async function validateDevEdgeReport(report, label) {
for (const field of [
"$schema",