feat: add dev deploy apply plan report
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user