import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs"; import { buildKubectlCommandPrefix, redactSensitiveText, resolveDevKubeconfigSelection } from "./dev-deploy-apply.mjs"; const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const defaultNamespace = "hwlab-dev"; const defaultLockName = "hwlab-dev-cd-lock"; const defaultTtlSeconds = 3600; const defaultReportPath = "reports/dev-gate/dev-cd-apply.json"; const artifactReportPath = "reports/dev-gate/dev-artifacts.json"; const runtimeProvisioningReportPath = "reports/dev-gate/dev-runtime-provisioning-report.json"; const runtimeMigrationReportPath = "reports/dev-gate/dev-runtime-migration-report.json"; const runtimePostflightReportPath = "reports/dev-gate/dev-runtime-postflight-report.json"; const runtimeJobReportPrefix = "/tmp/hwlab-dev-runtime-report"; const browserLiveUrl = "http://74.48.78.17:16666/health/live"; const apiLiveUrl = `${DEV_ENDPOINT}/health/live`; const lockAnnotationPrefix = "hwlab.pikastech.local"; const lockAnnotationFields = { promotionCommit: `${lockAnnotationPrefix}/promotionCommit`, deployJsonHash: `${lockAnnotationPrefix}/deployJsonHash`, ownerTaskId: `${lockAnnotationPrefix}/ownerTaskId`, transactionId: `${lockAnnotationPrefix}/transactionId`, phase: `${lockAnnotationPrefix}/phase`, startedAt: `${lockAnnotationPrefix}/startedAt`, updatedAt: `${lockAnnotationPrefix}/updatedAt`, ttlSeconds: `${lockAnnotationPrefix}/ttlSeconds`, liveBefore: `${lockAnnotationPrefix}/liveBefore`, targetNamespace: `${lockAnnotationPrefix}/targetNamespace`, targetRef: `${lockAnnotationPrefix}/targetRef`, lockBackend: `${lockAnnotationPrefix}/lockBackend`, releasedAt: `${lockAnnotationPrefix}/releasedAt`, releaseStatus: `${lockAnnotationPrefix}/releaseStatus`, staleLockBrokenAt: `${lockAnnotationPrefix}/staleLockBrokenAt`, staleLockPrevious: `${lockAnnotationPrefix}/staleLockPrevious` }; class DevCdApplyError extends Error { constructor(message, details = {}) { super(message); this.name = "DevCdApplyError"; Object.assign(this, details); } } export function parseArgs(argv) { const args = { apply: false, confirmDev: false, confirmedNonProduction: false, writeReport: false, reportPath: defaultReportPath, targetRef: "origin/main", kubeconfig: null, kubeconfigSpecified: false, lockName: defaultLockName, targetNamespace: defaultNamespace, ttlSeconds: defaultTtlSeconds, ownerTaskId: null, breakStaleLock: false, skipLiveVerify: false, status: false, dryRun: false, fullOutput: false, help: false, flags: new Set() }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--apply") { args.apply = true; args.flags.add(arg); } else if (arg === "--confirm-dev") { args.confirmDev = true; args.flags.add(arg); } else if (arg === "--confirmed-non-production") { args.confirmedNonProduction = true; args.flags.add(arg); } else if (arg === "--write-report") { args.writeReport = true; args.flags.add(arg); } else if (arg === "--break-stale-lock") { args.breakStaleLock = true; args.flags.add(arg); } else if (arg === "--dry-run" || arg === "--status") { args.status = true; args.dryRun = arg === "--dry-run"; args.flags.add(arg); } else if (arg === "--full-output") { args.fullOutput = true; args.flags.add(arg); } else if (arg === "--skip-live-verify") { args.skipLiveVerify = true; args.flags.add(arg); } else if (arg === "--report") { args.reportPath = readOption(argv, ++index, arg); args.flags.add(arg); } else if (arg === "--target-ref") { args.targetRef = readOption(argv, ++index, arg); args.flags.add(arg); } else if (arg === "--kubeconfig") { args.kubeconfig = readOption(argv, ++index, arg); args.kubeconfigSpecified = true; args.flags.add(arg); } else if (arg.startsWith("--kubeconfig=")) { args.kubeconfig = arg.slice("--kubeconfig=".length); args.kubeconfigSpecified = true; args.flags.add("--kubeconfig"); } else if (arg === "--lock-name") { args.lockName = readOption(argv, ++index, arg); args.flags.add(arg); } else if (arg === "--target-namespace") { args.targetNamespace = readOption(argv, ++index, arg); args.flags.add(arg); } else if (arg === "--ttl-seconds") { args.ttlSeconds = Number.parseInt(readOption(argv, ++index, arg), 10); args.flags.add(arg); } else if (arg === "--owner-task-id") { args.ownerTaskId = readOption(argv, ++index, arg); args.flags.add(arg); } else if (arg === "--help" || arg === "-h") { args.help = true; } else { throw new DevCdApplyError(`unknown argument ${arg}`, { code: "unknown-argument", blockers: [{ type: "safety_blocker", scope: "unknown-argument", status: "open", summary: `${arg} is not a supported DEV CD option. Use --status/--dry-run for read-only observability or --apply with explicit DEV confirmations.` }] }); } } return args; } function readOption(argv, index, name) { const value = argv[index]; if (!value || value.startsWith("--")) { throw new Error(`${name} requires a value`); } return value; } export function usage() { return { command: "node scripts/dev-cd-apply.mjs", summary: "Single DEV CD transaction for latest-main publish, desired-state refresh, apply, and live verify.", defaultMode: "status", examples: [ "node scripts/dev-cd-apply.mjs --status", "node scripts/dev-cd-apply.mjs --dry-run", "node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report" ], options: { "--status": "Read-only status: target ref, deploy.json, current Lease, and public live health.", "--dry-run": "Read-only alias for --status; does not acquire the Lease or run side effects.", "--apply": "Run the DEV CD transaction side effects.", "--confirm-dev": "Required for DEV mutation and explicit stale-lock recovery.", "--confirmed-non-production": "Required for DEV mutation.", "--target-ref REF": "git ref to publish/apply; default: origin/main", "--kubeconfig PATH": "DEV kubeconfig path; also honors HWLAB_DEV_KUBECONFIG/KUBECONFIG", "--ttl-seconds SECONDS": "lock TTL; default: 3600", "--owner-task-id ID": "lock holder; default: Code Queue/env/user derived", "--break-stale-lock": "May take over an expired lock only with --confirm-dev.", "--report PATH": `default: ${defaultReportPath}`, "--write-report": "Write the full report to --report.", "--full-output": "Print the full report to stdout instead of the concise summary.", "--skip-live-verify": "test-only escape hatch; do not use for DEV acceptance" } }; } function nowIso() { return new Date().toISOString(); } function kubernetesMicroTime(value) { const date = value instanceof Date ? value : new Date(value); if (!Number.isFinite(date.getTime())) { throw new DevCdApplyError("invalid Kubernetes Lease timestamp", { code: "invalid-lease-timestamp" }); } return date.toISOString().replace(/\.(\d{3})Z$/u, ".$1000Z"); } function parseJsonMaybe(value) { try { return JSON.parse(value); } catch { return null; } } function oneLine(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); } function sha256(value) { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } function shortCommit(value) { return typeof value === "string" && value.length >= 7 ? value.slice(0, 7) : value; } function commitMatches(actual, expected) { if (typeof actual !== "string" || typeof expected !== "string") return false; return actual === expected || actual.startsWith(expected) || expected.startsWith(actual); } function shellCommand(command, args) { return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); } function defaultOwnerTaskId(env = process.env) { return ( env.CODE_QUEUE_TASK_ID || env.HWLAB_OWNER_TASK_ID || env.GITHUB_RUN_ID || env.USER || "unknown-dev-cd-runner" ); } function defaultRunCommand(command, args, options = {}) { return new Promise((resolve) => { const abortController = options.timeoutMs ? new AbortController() : null; const child = spawn(command, args, { cwd: options.cwd ?? defaultRepoRoot, env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"], signal: abortController?.signal }); let stdout = ""; let stderr = ""; const timeout = options.timeoutMs ? setTimeout(() => abortController.abort(), options.timeoutMs) : null; child.stdin.on("error", () => {}); child.stdin.end(options.input ?? ""); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", (error) => { if (timeout) clearTimeout(timeout); resolve({ code: error.name === "AbortError" ? 124 : 127, signal: null, stdout, stderr: error.message }); }); child.on("close", (code, signal) => { if (timeout) clearTimeout(timeout); resolve({ code: code ?? 1, signal, stdout, stderr }); }); }); } async function readDeployJson(repoRoot) { const relativePath = "deploy/deploy.json"; const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); const manifest = JSON.parse(raw); return { path: relativePath, hash: sha256(raw), commitId: manifest.commitId ?? "unknown", namespace: manifest.namespace ?? defaultNamespace, environment: manifest.environment ?? "unknown", endpoint: manifest.endpoint ?? null, manifest }; } async function resolveTargetRef(ctx, targetRef) { const target = await ctx.runCommand("git", ["rev-parse", "--verify", `${targetRef}^{commit}`], { cwd: ctx.repoRoot, timeoutMs: 10000 }); if (target.code !== 0) { throw new DevCdApplyError(`target ref ${targetRef} could not be resolved`, { code: "target-ref-unresolved", targetRef, stderr: redactSensitiveText(target.stderr) }); } const head = await ctx.runCommand("git", ["rev-parse", "--verify", "HEAD^{commit}"], { cwd: ctx.repoRoot, timeoutMs: 10000 }); if (head.code !== 0) { throw new DevCdApplyError("HEAD could not be resolved", { code: "head-unresolved", stderr: redactSensitiveText(head.stderr) }); } const commitId = target.stdout.trim(); const headCommitId = head.stdout.trim(); return { ref: targetRef, commitId, shortCommitId: shortCommit(commitId), headCommitId, headShortCommitId: shortCommit(headCommitId), headMatchesTarget: commitId === headCommitId }; } function validateArgs(args) { const blockers = []; if (!args.apply && !args.status) { blockers.push({ type: "safety_blocker", scope: "apply-mode", status: "open", summary: "DEV CD side effects require --apply; use --status or --dry-run for read-only observability." }); } if (args.apply && (!args.confirmDev || !args.confirmedNonProduction)) { blockers.push({ type: "safety_blocker", scope: "dev-confirmation", status: "open", summary: "DEV CD apply requires --confirm-dev and --confirmed-non-production." }); } if (args.breakStaleLock && !args.confirmDev) { blockers.push({ type: "safety_blocker", scope: "break-stale-lock-confirmation", status: "open", summary: "Breaking a stale DEV CD lock requires --break-stale-lock and --confirm-dev." }); } if (!Number.isInteger(args.ttlSeconds) || args.ttlSeconds < 60 || args.ttlSeconds > 24 * 60 * 60) { blockers.push({ type: "safety_blocker", scope: "lock-ttl", status: "open", summary: "--ttl-seconds must be an integer between 60 and 86400." }); } for (const forbidden of ["--prod", "--production", "--read-secret", "--force-push"]) { if (args.flags.has(forbidden)) { blockers.push({ type: "safety_blocker", scope: forbidden.slice(2), status: "open", summary: `${forbidden} is forbidden for DEV CD apply.` }); } } if (blockers.length > 0) { throw new DevCdApplyError("invalid DEV CD apply arguments", { code: "invalid-arguments", blockers }); } } function lockAnnotationValue(value) { if (value === undefined || value === null) return ""; if (typeof value === "string") return value; return JSON.stringify(value); } export function buildLockAnnotations(lock) { const annotations = {}; for (const [field, key] of Object.entries(lockAnnotationFields)) { if (Object.hasOwn(lock, field)) { annotations[key] = lockAnnotationValue(lock[field]); } } return annotations; } function readAnnotation(annotations, field) { return annotations?.[lockAnnotationFields[field]] ?? null; } function readJsonAnnotation(annotations, field) { const value = readAnnotation(annotations, field); return value ? parseJsonMaybe(value) : null; } export function parseDeployLock(lease) { if (!lease) return null; const annotations = lease.metadata?.annotations ?? {}; const ttlSeconds = Number.parseInt( readAnnotation(annotations, "ttlSeconds") || lease.spec?.leaseDurationSeconds || `${defaultTtlSeconds}`, 10 ); const updatedAt = readAnnotation(annotations, "updatedAt") || lease.spec?.renewTime || lease.metadata?.creationTimestamp || null; const transactionId = readAnnotation(annotations, "transactionId") || lease.spec?.holderIdentity || null; const ownerTaskId = readAnnotation(annotations, "ownerTaskId") || lease.spec?.holderIdentity || null; return { lockBackend: readAnnotation(annotations, "lockBackend") || "Lease", lockName: lease.metadata?.name ?? defaultLockName, resourceVersion: lease.metadata?.resourceVersion ?? null, holderIdentity: lease.spec?.holderIdentity ?? null, promotionCommit: readAnnotation(annotations, "promotionCommit"), deployJsonHash: readAnnotation(annotations, "deployJsonHash"), ownerTaskId, transactionId, phase: readAnnotation(annotations, "phase") || "unknown", startedAt: readAnnotation(annotations, "startedAt") || lease.spec?.acquireTime || lease.metadata?.creationTimestamp || null, updatedAt, ttlSeconds: Number.isInteger(ttlSeconds) ? ttlSeconds : defaultTtlSeconds, liveBefore: readJsonAnnotation(annotations, "liveBefore"), targetNamespace: readAnnotation(annotations, "targetNamespace") || lease.metadata?.namespace || defaultNamespace, targetRef: readAnnotation(annotations, "targetRef"), releasedAt: readAnnotation(annotations, "releasedAt"), releaseStatus: readAnnotation(annotations, "releaseStatus"), staleLockBrokenAt: readAnnotation(annotations, "staleLockBrokenAt"), staleLockPrevious: readJsonAnnotation(annotations, "staleLockPrevious") }; } export function classifyDeployLock(lock, now = new Date()) { if (!lock || lock.phase === "released") { return { held: false, stale: false, retryAfterSeconds: 0, expiresAt: null }; } const updatedAtMs = Number.isFinite(Date.parse(lock.updatedAt)) ? Date.parse(lock.updatedAt) : 0; const expiresAtMs = updatedAtMs + lock.ttlSeconds * 1000; const retryAfterSeconds = Math.max(0, Math.ceil((expiresAtMs - now.getTime()) / 1000)); return { held: retryAfterSeconds > 0, stale: retryAfterSeconds <= 0, retryAfterSeconds, expiresAt: Number.isFinite(expiresAtMs) ? new Date(expiresAtMs).toISOString() : null }; } export function deployLockHeldFailure(lock, now = new Date()) { const classification = classifyDeployLock(lock, now); return { ok: false, status: "blocked", error: "deploy-lock-held", code: "deploy-lock-held", holder: lock.ownerTaskId || lock.transactionId || lock.holderIdentity || "unknown", promotionCommit: lock.promotionCommit ?? null, deployJsonHash: lock.deployJsonHash ?? null, transactionId: lock.transactionId ?? null, phase: lock.phase ?? "unknown", targetNamespace: lock.targetNamespace ?? defaultNamespace, retryAfterSeconds: classification.retryAfterSeconds, stale: classification.stale, requiresBreakStaleLock: classification.stale, lockName: lock.lockName ?? defaultLockName, lockBackend: lock.lockBackend ?? "Lease", summary: classification.stale ? "A stale DEV CD transaction lock exists; rerun with --break-stale-lock --confirm-dev only after confirming the holder is inactive." : "Another DEV CD transaction is already running; retry after the current holder releases the lock.", mutationAttempted: false, prodTouched: false }; } function isNotFound(result) { return /not\s*found|notfound/i.test(`${result.stderr}\n${result.stdout}`); } function isAlreadyExists(result) { return /already\s*exists|alreadyexists/i.test(`${result.stderr}\n${result.stdout}`); } function isConflict(result) { return /conflict|object has been modified|operation cannot be fulfilled/i.test(`${result.stderr}\n${result.stdout}`); } function compactLockSummary(lock) { if (!lock) return null; return { lockName: lock.lockName ?? defaultLockName, lockBackend: lock.lockBackend ?? "Lease", ownerTaskId: lock.ownerTaskId ?? null, transactionId: lock.transactionId ?? null, holderIdentity: lock.holderIdentity ?? null, phase: lock.phase ?? null, promotionCommit: lock.promotionCommit ?? null, deployJsonHash: lock.deployJsonHash ?? null, updatedAt: lock.updatedAt ?? null, ttlSeconds: lock.ttlSeconds ?? null, targetNamespace: lock.targetNamespace ?? defaultNamespace, targetRef: lock.targetRef ?? null, releasedAt: lock.releasedAt ?? null, releaseStatus: lock.releaseStatus ?? null, staleLockBrokenAt: lock.staleLockBrokenAt ?? null }; } function buildLeaseManifest({ args, target, deployBefore, transactionId, ownerTaskId, startedAt, phase, liveBefore, staleLockPrevious = null }) { const lock = { promotionCommit: target.shortCommitId, deployJsonHash: deployBefore.hash, ownerTaskId, transactionId, phase, startedAt, updatedAt: startedAt, ttlSeconds: args.ttlSeconds, liveBefore, targetNamespace: args.targetNamespace, targetRef: target.ref, lockBackend: "Lease" }; if (staleLockPrevious) { lock.staleLockBrokenAt = startedAt; lock.staleLockPrevious = staleLockPrevious; } return { apiVersion: "coordination.k8s.io/v1", kind: "Lease", metadata: { name: args.lockName, namespace: args.targetNamespace, labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/profile": "dev", "hwlab.pikastech.local/cd-lock": "true" }, annotations: buildLockAnnotations(lock) }, spec: { holderIdentity: `${ownerTaskId}/${transactionId}`, leaseDurationSeconds: args.ttlSeconds, acquireTime: startedAt, renewTime: startedAt } }; } function lockPatch({ args, target, deployBefore, transactionId, ownerTaskId, phase, startedAt, updatedAt, liveBefore, staleLockPrevious = null }) { const lock = { promotionCommit: target.shortCommitId, deployJsonHash: deployBefore.hash, ownerTaskId, transactionId, phase, startedAt, updatedAt, ttlSeconds: args.ttlSeconds, liveBefore, targetNamespace: args.targetNamespace, targetRef: target.ref, lockBackend: "Lease" }; if (staleLockPrevious) { lock.staleLockBrokenAt = updatedAt; lock.staleLockPrevious = staleLockPrevious; } return { metadata: { labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/profile": "dev", "hwlab.pikastech.local/cd-lock": "true" }, annotations: buildLockAnnotations(lock) }, spec: { holderIdentity: `${ownerTaskId}/${transactionId}`, leaseDurationSeconds: args.ttlSeconds, acquireTime: startedAt, renewTime: updatedAt } }; } async function kubectl(ctx, kubectlContext, args, options = {}) { const result = await ctx.runCommand(kubectlContext.executor, args, { cwd: ctx.repoRoot, env: kubectlContext.env, input: options.input, timeoutMs: options.timeoutMs ?? 30000 }); return { ...result, command: shellCommand("kubectl", args), stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; } async function replaceLease(ctx, kubectlContext, args, lease, timeoutMs = 30000) { return kubectl( ctx, kubectlContext, ["-n", args.targetNamespace, "replace", "-f", "-", "-o", "json"], { input: JSON.stringify(lease), timeoutMs } ); } function mergeLease(lease, patch, { resetLockAnnotations = false } = {}) { const baseAnnotations = { ...(lease.metadata?.annotations ?? {}) }; if (resetLockAnnotations) { for (const key of Object.keys(baseAnnotations).filter((name) => name.startsWith(`${lockAnnotationPrefix}/`))) { delete baseAnnotations[key]; } } return { ...lease, metadata: { ...lease.metadata, ...(patch.metadata ?? {}), labels: { ...(lease.metadata?.labels ?? {}), ...(patch.metadata?.labels ?? {}) }, annotations: { ...baseAnnotations, ...(patch.metadata?.annotations ?? {}) } }, spec: { ...(lease.spec ?? {}), ...(patch.spec ?? {}) } }; } async function acquireDeployLock({ ctx, args, kubectlContext, target, deployBefore, transactionId, ownerTaskId, liveBefore, now }) { const startedAt = kubernetesMicroTime(now); const get = await kubectl(ctx, kubectlContext, ["-n", args.targetNamespace, "get", "lease", args.lockName, "-o", "json"]); if (get.code === 0) { const lease = JSON.parse(get.stdout); const existing = parseDeployLock(lease); const classification = classifyDeployLock(existing, now); if (classification.held) { const failure = deployLockHeldFailure(existing, now); throw new DevCdApplyError(failure.summary, { code: "deploy-lock-held", lockFailure: failure }); } if (classification.stale && !args.breakStaleLock) { const failure = deployLockHeldFailure(existing, now); throw new DevCdApplyError(failure.summary, { code: "deploy-lock-held", lockFailure: failure }); } const patch = lockPatch({ args, target, deployBefore, transactionId, ownerTaskId, phase: "publishing", startedAt, updatedAt: startedAt, liveBefore, staleLockPrevious: classification.stale ? existing : null }); const replaced = await replaceLease(ctx, kubectlContext, args, mergeLease(lease, patch, { resetLockAnnotations: true })); if (isConflict(replaced)) { return acquireDeployLock({ ctx, args, kubectlContext, target, deployBefore, transactionId, ownerTaskId, liveBefore, now }); } if (replaced.code !== 0) { throw new DevCdApplyError("failed to acquire DEV CD Lease lock", { code: "lock-acquire-failed", stderr: redactSensitiveText(replaced.stderr || replaced.stdout) }); } return { acquired: true, lock: parseDeployLock(JSON.parse(replaced.stdout)), staleBreak: classification.stale ? existing : null, command: replaced.command }; } if (!isNotFound(get)) { throw new DevCdApplyError("failed to read DEV CD Lease lock", { code: "lock-read-failed", stderr: redactSensitiveText(get.stderr || get.stdout) }); } const manifest = buildLeaseManifest({ args, target, deployBefore, transactionId, ownerTaskId, startedAt, phase: "publishing", liveBefore }); const created = await kubectl(ctx, kubectlContext, ["-n", args.targetNamespace, "create", "-f", "-", "-o", "json"], { input: JSON.stringify(manifest), timeoutMs: 30000 }); if (created.code === 0) { return { acquired: true, lock: parseDeployLock(JSON.parse(created.stdout)), staleBreak: null, command: created.command }; } if (isAlreadyExists(created)) { return acquireDeployLock({ ctx, args, kubectlContext, target, deployBefore, transactionId, ownerTaskId, liveBefore, now }); } throw new DevCdApplyError("failed to create DEV CD Lease lock", { code: "lock-create-failed", stderr: redactSensitiveText(created.stderr || created.stdout) }); } async function readOwnedLease({ ctx, args, kubectlContext, transactionId, timeoutMs = 15000 }) { const get = await kubectl(ctx, kubectlContext, ["-n", args.targetNamespace, "get", "lease", args.lockName, "-o", "json"], { timeoutMs }); if (get.code !== 0) { throw new DevCdApplyError("failed to read DEV CD lock ownership", { code: "lock-owner-read-failed", stderr: redactSensitiveText(get.stderr || get.stdout) }); } const lease = JSON.parse(get.stdout); const lock = parseDeployLock(lease); if (lock.transactionId !== transactionId) { throw new DevCdApplyError("DEV CD lock ownership changed before transaction update", { code: "lock-owner-mismatch", holder: lock.ownerTaskId || lock.transactionId || "unknown", phase: lock.phase }); } return { lease, lock }; } async function patchOwnedLease({ ctx, args, kubectlContext, lockState, transactionId, patch, errorCode, errorMessage }) { const { lease } = await readOwnedLease({ ctx, args, kubectlContext, transactionId }); const result = await replaceLease(ctx, kubectlContext, args, mergeLease(lease, patch)); if (result.code !== 0) { throw new DevCdApplyError(errorMessage, { code: errorCode, stderr: redactSensitiveText(result.stderr || result.stdout) }); } lockState.lock = parseDeployLock(JSON.parse(result.stdout)); return lockState.lock; } async function updateDeployLockPhase({ ctx, args, kubectlContext, transactionId, lockState, phase, status = "running" }) { const updatedAt = kubernetesMicroTime(ctx.now()); const patch = { metadata: { annotations: { [lockAnnotationFields.phase]: phase, [lockAnnotationFields.updatedAt]: updatedAt, [lockAnnotationFields.releaseStatus]: status } }, spec: { renewTime: updatedAt } }; return await patchOwnedLease({ ctx, args, kubectlContext, transactionId, lockState, patch, errorCode: "lock-phase-update-failed", errorMessage: `failed to update DEV CD lock phase to ${phase}` }); } async function updateDeployLockLiveBefore({ ctx, args, kubectlContext, transactionId, lockState, liveBefore }) { const updatedAt = kubernetesMicroTime(ctx.now()); const patch = { metadata: { annotations: { [lockAnnotationFields.liveBefore]: JSON.stringify(liveBefore), [lockAnnotationFields.updatedAt]: updatedAt } }, spec: { renewTime: updatedAt } }; return await patchOwnedLease({ ctx, args, kubectlContext, transactionId, lockState, patch, errorCode: "lock-live-before-update-failed", errorMessage: "failed to update DEV CD lock liveBefore evidence" }); } async function releaseDeployLock({ ctx, args, kubectlContext, transactionId, status }) { const get = await kubectl(ctx, kubectlContext, ["-n", args.targetNamespace, "get", "lease", args.lockName, "-o", "json"], { timeoutMs: 15000 }); if (get.code !== 0) { return { status: "release_read_failed", command: get.command, error: redactSensitiveText(get.stderr || get.stdout) }; } const current = parseDeployLock(JSON.parse(get.stdout)); if (current.transactionId !== transactionId) { return { status: "not_owner", holder: current.ownerTaskId || current.transactionId || "unknown", phase: current.phase }; } const releasedAt = kubernetesMicroTime(ctx.now()); const patch = { metadata: { annotations: { [lockAnnotationFields.phase]: "released", [lockAnnotationFields.updatedAt]: releasedAt, [lockAnnotationFields.releasedAt]: releasedAt, [lockAnnotationFields.releaseStatus]: status } }, spec: { holderIdentity: "", leaseDurationSeconds: 1, renewTime: releasedAt } }; const result = await replaceLease(ctx, kubectlContext, args, mergeLease(JSON.parse(get.stdout), patch), 15000); if (isConflict(result)) { return { status: "release_conflict", command: result.command, error: "Lease changed before release; lock was not overwritten." }; } if (result.code !== 0) { return { status: "release_failed", command: result.command, error: redactSensitiveText(result.stderr || result.stdout) }; } return { status: "released", command: result.command, lock: parseDeployLock(JSON.parse(result.stdout)) }; } async function resolveKubectlContext(args, env, runCommand) { const selection = resolveDevKubeconfigSelection({ flagValue: args.kubeconfig, env }); const which = await runCommand("which", ["kubectl"], { timeoutMs: 5000 }); const executor = which.code === 0 ? which.stdout.trim() : "kubectl"; return { executor, kubeconfig: selection.kubeconfig, kubeconfigSource: selection.source, commandPrefix: buildKubectlCommandPrefix(selection.kubeconfig), env: { ...env, KUBECONFIG: selection.kubeconfig } }; } function commandEnv(ctx, transaction) { return { ...ctx.env, HWLAB_CD_TRANSACTION_ID: transaction.transactionId, HWLAB_CD_TRANSACTION_OWNER: transaction.ownerTaskId, HWLAB_CD_LOCK_NAME: transaction.lockName, HWLAB_CD_LOCK_NAMESPACE: transaction.targetNamespace }; } async function runStep(ctx, transaction, step) { const startedAt = ctx.now().toISOString(); const command = shellCommand(step.command, step.args); const result = await ctx.runCommand(step.command, step.args, { cwd: ctx.repoRoot, env: commandEnv(ctx, transaction), timeoutMs: step.timeoutMs ?? 300000 }); const finishedAt = ctx.now().toISOString(); const stdout = redactSensitiveText(result.stdout ?? ""); const stderr = redactSensitiveText(result.stderr ?? ""); const parsedStdout = parseJsonMaybe(stdout); return { id: step.id, phase: step.phase, status: result.code === 0 ? "pass" : "blocked", command, code: result.code, startedAt, finishedAt, stdoutJson: parsedStdout, stdoutTail: parsedStdout ? null : stdout.trim().slice(-2000), stderrTail: stderr.trim().slice(-2000), reportPath: step.reportPath ?? null }; } async function runRuntimeK8sJobStep(ctx, transaction, step) { const startedAt = ctx.now().toISOString(); const result = await runRuntimeK8sJob(ctx, transaction, step); const finishedAt = ctx.now().toISOString(); return { id: step.id, phase: step.phase, status: result.status, command: result.command, code: result.status === "pass" ? 0 : 1, startedAt, finishedAt, stdoutJson: result.report, stdoutTail: null, stderrTail: result.error ?? "", reportPath: step.reportPath ?? null, k8sJob: result.k8sJob }; } async function runRuntimeK8sJob(ctx, transaction, step) { const jobName = `${step.jobNamePrefix}-${transaction.transactionId.slice(0, 8)}`; const reportFile = `${runtimeJobReportPrefix}-${step.id}.json`; const image = step.image ?? await resolveRuntimeJobImage(ctx); const manifest = buildRuntimeK8sJobManifest({ jobName, namespace: transaction.targetNamespace, image, commandArgs: [...step.commandArgs, "--report", reportFile], transaction }); const kubectl = transaction.kubectlContext; const apply = await kubectlCommandResult(ctx, kubectl, ["-n", transaction.targetNamespace, "apply", "-f", "-"], { input: JSON.stringify(manifest), timeoutMs: 30000 }); if (apply.code !== 0) { return runtimeJobFailure(step, "apply", apply, jobName, image); } const wait = await kubectlCommandResult(ctx, kubectl, [ "-n", transaction.targetNamespace, "wait", "--for=condition=complete", `job/${jobName}`, "--timeout=300s" ], { timeoutMs: step.timeoutMs ?? 6 * 60 * 1000 }); if (wait.code !== 0) { return runtimeJobFailure(step, "wait", wait, jobName, image); } const logs = await kubectlCommandResult(ctx, kubectl, ["-n", transaction.targetNamespace, "logs", `job/${jobName}`], { timeoutMs: 30000 }); const logText = redactSensitiveText(`${logs.stdout}\n${logs.stderr}`); const report = parseLastJsonObject(logText); if (logs.code !== 0 || !report) { return { status: "blocked", command: `${shellCommand("kubectl", ["-n", transaction.targetNamespace, "logs", `job/${jobName}`])}`, error: logText.trim().slice(-2000) || "runtime job did not emit a JSON report", report: null, k8sJob: runtimeJobSummary(jobName, step, "logs", image) }; } const reportStatus = report.conclusion?.status ?? report.summary?.status ?? report.status; return { status: ["ready", "pass"].includes(reportStatus) ? "pass" : "blocked", command: shellCommand("kubectl", ["-n", transaction.targetNamespace, "apply/wait/logs", `job/${jobName}`]), error: null, report, k8sJob: runtimeJobSummary(jobName, step, "complete", image) }; } function runtimeJobFailure(step, phase, result, jobName, image) { return { status: "blocked", command: result.command ?? shellCommand("kubectl", [phase, `job/${jobName}`]), error: redactSensitiveText(`${result.stderr}\n${result.stdout}`).trim().slice(-2000), report: null, k8sJob: runtimeJobSummary(jobName, step, phase, image) }; } function runtimeJobSummary(jobName, step, phase, image = step.image ?? "deploy-current:hwlab-cloud-api") { return { jobName, phase, serviceId: "hwlab-cloud-api", image, secretRefsOnly: true, secretValuesRead: false, secretValuesPrinted: false }; } async function resolveRuntimeJobImage(ctx) { const deploy = await readDeployJson(ctx.repoRoot); const image = deploy.manifest?.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.image; if (typeof image === "string" && image.trim().length > 0) { return image; } if (typeof deploy.commitId === "string" && deploy.commitId !== "unknown") { return `127.0.0.1:5000/hwlab/hwlab-cloud-api:${shortCommit(deploy.commitId)}`; } throw new DevCdApplyError("could not resolve hwlab-cloud-api image for runtime maintenance Job", { code: "runtime-job-image-unresolved" }); } function buildRuntimeK8sJobManifest({ jobName, namespace, image, commandArgs, transaction }) { return { apiVersion: "batch/v1", kind: "Job", metadata: { name: jobName, namespace, labels: { "app.kubernetes.io/part-of": "hwlab", "app.kubernetes.io/name": "hwlab-runtime-maintenance", "hwlab.pikastech.local/profile": "dev", "hwlab.pikastech.local/service-id": "hwlab-cloud-api", "hwlab.pikastech.local/cd-transaction": transaction.transactionId } }, spec: { backoffLimit: 0, ttlSecondsAfterFinished: 600, template: { metadata: { labels: { "app.kubernetes.io/name": "hwlab-runtime-maintenance", "hwlab.pikastech.local/service-id": "hwlab-cloud-api", "hwlab.pikastech.local/cd-transaction": transaction.transactionId } }, spec: { restartPolicy: "Never", containers: [ { name: "runtime-maintenance", image, command: ["node"], args: commandArgs, env: [ { name: "HWLAB_ENVIRONMENT", value: ENVIRONMENT_DEV }, { name: "HWLAB_CLOUD_RUNTIME_ADAPTER", value: "postgres" }, { name: "HWLAB_CLOUD_RUNTIME_DURABLE", value: "true" }, { name: "HWLAB_CLOUD_DB_URL", valueFrom: { secretKeyRef: { name: "hwlab-cloud-api-dev-db", key: "database-url", optional: false } } }, { name: "HWLAB_CLOUD_DB_ADMIN_URL", valueFrom: { secretKeyRef: { name: "hwlab-cloud-api-dev-db-admin", key: "admin-url", optional: false } } }, { name: "HWLAB_CLOUD_DB_SSL_MODE", value: "disable" }, { name: "HWLAB_CD_TRANSACTION_ID", value: transaction.transactionId }, { name: "HWLAB_CD_TRANSACTION_OWNER", value: transaction.ownerTaskId }, { name: "HWLAB_CD_LOCK_NAME", value: transaction.lockName } ] } ] } } } }; } async function kubectlCommandResult(ctx, kubectlContext, args, options = {}) { const result = await ctx.runCommand(kubectlContext.executor, args, { cwd: ctx.repoRoot, env: kubectlContext.env, input: options.input, timeoutMs: options.timeoutMs ?? 30000 }); return { ...result, command: shellCommand("kubectl", args), stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; } function parseLastJsonObject(text) { const lines = String(text ?? "").trim().split(/\r?\n/u).reverse(); for (const line of lines) { const parsed = parseJsonMaybe(line.trim()); if (parsed && typeof parsed === "object") return parsed; } return parseJsonMaybe(text); } function stepBlocker(stepResult) { return { type: "runtime_blocker", scope: stepResult.id, status: "open", summary: `${stepResult.command} exited ${stepResult.code}.` }; } function healthCommit(json) { return ( json?.commit?.id || json?.commitId || json?.revision || json?.image?.tag || null ); } function healthImage(json) { if (typeof json?.image === "string") return json.image; return json?.image?.reference ?? null; } function summarizeHealthJson(json, expectedServiceId, expectedCommit) { const observedCommit = healthCommit(json); return { serviceId: json?.serviceId ?? json?.service?.id ?? null, environment: json?.environment ?? null, applicationStatus: json?.status ?? null, ready: Object.hasOwn(json ?? {}, "ready") ? json.ready : null, observedCommit, image: healthImage(json), imageTag: json?.image?.tag ?? null, observedAt: json?.observedAt ?? null, serviceMatches: (json?.serviceId ?? json?.service?.id) === expectedServiceId, environmentMatches: json?.environment === ENVIRONMENT_DEV, commitMatches: expectedCommit ? commitMatches(observedCommit, expectedCommit) : null, blockerCodes: Array.isArray(json?.blockerCodes) ? json.blockerCodes : [] }; } async function defaultHttpGetJson(url, timeoutMs = 10000) { return new Promise((resolve, reject) => { const request = httpRequest(url, { method: "GET", timeout: timeoutMs }, (response) => { response.setEncoding("utf8"); let body = ""; response.on("data", (chunk) => { body += chunk; }); response.on("end", () => { let json = null; try { json = JSON.parse(body); } catch {} resolve({ statusCode: response.statusCode ?? 0, body, json }); }); }); request.on("timeout", () => { request.destroy(new Error(`timeout after ${timeoutMs}ms`)); }); request.on("error", reject); request.end(); }); } async function probeLiveEndpoint(ctx, { id, url, expectedServiceId, expectedCommit }) { const startedAt = ctx.now().toISOString(); try { const response = await ctx.httpGetJson(url, 10000); const summary = summarizeHealthJson(response.json, expectedServiceId, expectedCommit); const reachable = response.statusCode >= 200 && response.statusCode < 300 && Boolean(response.json); const identityMatches = reachable && summary.serviceMatches && summary.environmentMatches && (expectedCommit ? summary.commitMatches : true); return { id, url, status: identityMatches ? "pass" : "blocked", httpStatus: response.statusCode, reachable, identityMatches, startedAt, finishedAt: ctx.now().toISOString(), expectedServiceId, expectedCommit: expectedCommit ?? null, ...summary }; } catch (error) { return { id, url, status: "blocked", reachable: false, identityMatches: false, startedAt, finishedAt: ctx.now().toISOString(), expectedServiceId, expectedCommit: expectedCommit ?? null, error: oneLine(error.message) }; } } export async function verifyDevLive(ctx, { expectedCommit = null } = {}) { const [cloudWeb, cloudApi] = await Promise.all([ probeLiveEndpoint(ctx, { id: "cloud-web-16666", url: browserLiveUrl, expectedServiceId: "hwlab-cloud-web", expectedCommit }), probeLiveEndpoint(ctx, { id: "cloud-api-16667", url: apiLiveUrl, expectedServiceId: "hwlab-cloud-api", expectedCommit }) ]); const endpoints = [cloudWeb, cloudApi]; return { status: endpoints.every((endpoint) => endpoint.status === "pass") ? "pass" : "blocked", expectedCommit: expectedCommit ?? null, endpoints, summary: { checked: endpoints.length, reachable: endpoints.filter((endpoint) => endpoint.reachable).length, identityMatches: endpoints.filter((endpoint) => endpoint.identityMatches).length, commitMatches: expectedCommit ? endpoints.filter((endpoint) => endpoint.commitMatches === true).length : null, ports: [16666, 16667] } }; } function deployServiceCount(manifest) { const services = manifest?.services; if (Array.isArray(services)) return services.length; if (services && typeof services === "object") return Object.keys(services).length; return null; } function summarizeDeployJson(deploy) { return { path: deploy.path, hash: deploy.hash, commitId: deploy.commitId, namespace: deploy.namespace, environment: deploy.environment, endpoint: deploy.endpoint, serviceCount: deployServiceCount(deploy.manifest) }; } function deployJsonMatchesTarget(deploy, target) { return deploy.commitId === target.shortCommitId || deploy.commitId === target.commitId; } function compactLiveEndpoint(endpoint) { return { id: endpoint.id, url: endpoint.url, status: endpoint.status, httpStatus: endpoint.httpStatus ?? null, reachable: endpoint.reachable, identityMatches: endpoint.identityMatches, serviceId: endpoint.serviceId, environment: endpoint.environment, observedCommit: endpoint.observedCommit, commitMatches: endpoint.commitMatches, blockerCodes: endpoint.blockerCodes ?? [], error: endpoint.error ?? null }; } function compactLiveReport(live) { return { status: live.status, expectedCommit: live.expectedCommit ?? null, summary: live.summary ?? { checked: 0 }, endpoints: (live.endpoints ?? []).map(compactLiveEndpoint) }; } async function readDeployLockStatus({ ctx, args, kubectlContext }) { const get = await kubectl(ctx, kubectlContext, ["-n", args.targetNamespace, "get", "lease", args.lockName, "-o", "json"], { timeoutMs: 15000 }); if (get.code === 0) { return { status: "read", command: get.command, lock: parseDeployLock(JSON.parse(get.stdout)) }; } if (isNotFound(get)) { return { status: "absent", command: get.command, lockName: args.lockName, lock: null }; } return { status: "unavailable", command: get.command, lockName: args.lockName, lock: null, error: redactSensitiveText(get.stderr || get.stdout) }; } function summarizeLockStatus(lockRead, now) { if (lockRead.status === "absent") { return { status: "absent", lockName: lockRead.lockName ?? defaultLockName, current: null, retryAfterSeconds: 0, expiresAt: null }; } if (lockRead.status === "unavailable") { return { status: "unavailable", lockName: lockRead.lockName ?? defaultLockName, current: null, retryAfterSeconds: null, expiresAt: null, reason: "kubectl or DEV kubeconfig unavailable; command output is redacted", redactedReason: oneLine(lockRead.error).slice(0, 500) }; } const classification = classifyDeployLock(lockRead.lock, now); const status = lockRead.lock?.phase === "released" ? "released" : classification.held ? "held" : classification.stale ? "stale" : "unknown"; return { status, lockName: lockRead.lock?.lockName ?? lockRead.lockName ?? defaultLockName, current: compactLockSummary(lockRead.lock), retryAfterSeconds: classification.retryAfterSeconds, expiresAt: classification.expiresAt, allowedLockAction: { breakStale: classification.stale ? "--apply --confirm-dev --confirmed-non-production --break-stale-lock" : null } }; } function buildStatusNextActions({ target, deployBefore, lock, liveVerify }) { const actions = []; if (!target.headMatchesTarget) { actions.push(`Checkout or fast-forward to ${target.ref} ${target.shortCommitId} before apply.`); } if (deployBefore.commitId !== target.shortCommitId && deployBefore.commitId !== target.commitId) { actions.push(`deploy/deploy.json commitId is ${deployBefore.commitId}; target ref is ${target.shortCommitId}.`); } if (lock.status === "held") { actions.push(`Wait for the current DEV CD holder to release the Lease or for the ${lock.retryAfterSeconds}s TTL to expire before retrying apply.`); } else if (lock.status === "stale") { actions.push("Use --break-stale-lock only after confirming the holder is inactive."); } else if (lock.status === "unavailable") { actions.push("Fix kubectl/KUBECONFIG access before applying."); } if (liveVerify.status !== "pass") { actions.push("Inspect live endpoint identity/health before treating DEV as converged."); } if (actions.length === 0) { actions.push("Run --apply --confirm-dev --confirmed-non-production --write-report when ready to publish/apply DEV."); } return actions; } async function runDevCdStatus(args, ctx, stdout) { const generatedAt = ctx.now().toISOString(); const target = await resolveTargetRef(ctx, args.targetRef); const deployBefore = await readDeployJson(ctx.repoRoot); const kubectlContext = await resolveKubectlContext(args, ctx.env, ctx.runCommand); const lockRead = await readDeployLockStatus({ ctx, args, kubectlContext }); const lock = summarizeLockStatus(lockRead, ctx.now()); const liveVerify = args.skipLiveVerify ? { status: "not_run", reason: "--skip-live-verify", endpoints: [], summary: { checked: 0 } } : await verifyDevLive(ctx, { expectedCommit: null }); const deployMatchesTarget = deployJsonMatchesTarget(deployBefore, target); const status = ( lock.status === "unavailable" || liveVerify.status !== "pass" || !target.headMatchesTarget || !deployMatchesTarget ) ? "degraded" : "pass"; const report = { ok: true, status, mode: args.dryRun ? "dry-run" : "status", command: "dev-cd-apply", generatedAt, mutationAttempted: false, prodTouched: false, target: { ref: target.ref, promotionCommit: target.commitId, shortCommitId: target.shortCommitId, headCommitId: target.headCommitId, headMatchesTarget: target.headMatchesTarget, namespace: args.targetNamespace }, deployJson: { ...summarizeDeployJson(deployBefore), matchesTarget: deployMatchesTarget }, kubectl: { kubeconfigSource: kubectlContext.kubeconfigSource, kubeconfig: kubectlContext.kubeconfig ? "" : "", lockReadCommand: lockRead.command }, lock, live: compactLiveReport(liveVerify), nextActions: buildStatusNextActions({ target, deployBefore, lock, liveVerify }), fullOutputHint: "Use --apply --confirm-dev --confirmed-non-production --write-report for the full transaction report; use --full-output only when stdout needs the full JSON." }; stdout.write(`${JSON.stringify(report, null, 2)}\n`); return 0; } function summarizeDevCdApplyReport(report) { const apply = report.devCdApply ?? {}; return { ok: report.status === "pass", status: report.status, mode: apply.mode ?? "apply", command: "dev-cd-apply", commitId: report.commitId, generatedAt: report.generatedAt, mutationAttempted: apply.safety?.mutationAttempted ?? false, prodTouched: false, transaction: { transactionId: report.transaction?.transactionId ?? null, ownerTaskId: report.transaction?.ownerTaskId ?? null, targetNamespace: report.transaction?.targetNamespace ?? defaultNamespace, lockName: report.transaction?.lockName ?? defaultLockName, phases: report.transaction?.phases ?? [], release: report.transaction?.release ?? null }, target: apply.target ?? null, lock: { acquired: Boolean(apply.lock?.acquired), current: compactLockSummary(apply.lock?.current), staleBreak: compactLockSummary(apply.lock?.staleBreak) }, steps: (apply.steps ?? []).map((step) => ({ id: step.id, phase: step.phase, status: step.status, code: step.code, reportPath: step.reportPath })), liveBefore: apply.liveBefore ? compactLiveReport(apply.liveBefore) : null, liveVerify: apply.liveVerify ? compactLiveReport(apply.liveVerify) : null, blockers: report.blockers ?? [], reportPaths: apply.reportPaths ?? {}, fullOutputHint: "Use --full-output to print the full report to stdout. Use --write-report to persist the full report." }; } function buildReport({ args, transaction, target, deployBefore, deployAfter, lockState, steps, blockers, status, startedAt, finishedAt, liveBefore, liveVerify, release }) { return { $schema: "https://hwlab.pikastech.local/schemas/dev-cd-apply.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-cd-apply.json", reportVersion: "v1", issue: "pikasTech/HWLAB#274", taskId: "dev-cd-apply", commitId: target.shortCommitId, acceptanceLevel: "dev_cd_apply", devOnly: true, prodDisabled: true, status, generatedAt: finishedAt, reportLifecycle: activeReportLifecycle("Current DEV CD transaction report; the only DEV side-effect entrypoint is scripts/dev-cd-apply.mjs."), supports: [ "pikasTech/HWLAB#274", "pikasTech/HWLAB#116", "pikasTech/HWLAB#235", "pikasTech/HWLAB#50", "pikasTech/HWLAB#57", "pikasTech/HWLAB#67", "pikasTech/HWLAB#73" ], sourceContract: { status: blockers.length ? "blocked" : "pass", documents: [ "docs/reference/deployment-publish.md", "docs/reference/dev-runtime-boundary.md", "docs/dev-artifact-publish.md", "docs/dev-deploy-apply.md", "docs/artifact-catalog.md" ], summary: "DEV CD publish/apply/verify side effects are serialized by one Kubernetes Lease and use deploy/deploy.json as the apply desired-state source." }, validationCommands: [ "node --check scripts/dev-cd-apply.mjs", "node --check scripts/src/dev-cd-apply.mjs", "node --test scripts/src/dev-cd-apply.test.mjs", "node --check scripts/dev-artifact-publish.mjs", "node --check scripts/src/dev-deploy-apply.mjs", "node --test scripts/src/dev-deploy-apply.test.mjs", "node scripts/deploy-desired-state-plan.mjs --check", "node scripts/validate-artifact-catalog.mjs", "git diff --check" ], localSmoke: { status: "not_run", commands: ["npm run check"], evidence: ["Local checks are listed as validation commands and are not implied by a DEV CD transaction."], summary: "The transaction report records DEV publish/apply/verify evidence; local smoke is a separate gate." }, dryRun: { status: "not_run", commands: ["node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report"], evidence: ["The transaction path owns live side effects; dry-run remains a preflight support mode."], summary: "DEV CD mutation requires --apply and the transaction Lease lock." }, devPreconditions: { status: blockers.length ? "blocked" : "pass", requirements: [ "HEAD must match the requested target ref before publish.", "Lease/hwlab-dev-cd-lock must be acquired before publish/apply.", "Legacy publish/apply side-effect scripts must run with HWLAB_CD_TRANSACTION_ID.", "DEV DB role/database provisioning and runtime migration must run through repo-owned commands before workload apply.", "deploy/deploy.json, artifact catalog, and workloads must converge before apply.", "Public 16666 and 16667 live health plus M3 durable postflight must be recorded in this report." ], summary: blockers.length ? "One or more DEV CD transaction preconditions failed." : "DEV CD transaction preconditions passed." }, transaction: { transactionId: transaction.transactionId, ownerTaskId: transaction.ownerTaskId, targetNamespace: transaction.targetNamespace, lockName: transaction.lockName, lockBackend: "Lease", startedAt, finishedAt, ttlSeconds: args.ttlSeconds, phases: transaction.phases, release }, devCdApply: { mode: args.apply ? "apply" : "dry-run", target: { ref: target.ref, promotionCommit: target.commitId, shortCommitId: target.shortCommitId, headCommitId: target.headCommitId, headMatchesTarget: target.headMatchesTarget, namespace: args.targetNamespace, apiEndpoint: DEV_ENDPOINT, browserEndpoint: "http://74.48.78.17:16666/" }, deployJson: { before: deployBefore, after: deployAfter }, lock: { acquired: Boolean(lockState?.acquired), current: lockState?.lock ?? null, staleBreak: lockState?.staleBreak ?? null }, steps, liveBefore, liveVerify, reportPaths: { transaction: args.writeReport ? args.reportPath : null, artifacts: artifactReportPath, deployApply: "reports/dev-gate/dev-deploy-report.json", runtimeProvisioning: runtimeProvisioningReportPath, runtimeMigration: runtimeMigrationReportPath, runtimePostflight: runtimePostflightReportPath }, safety: { prodTouched: false, secretValuesRead: false, secretValuesPrinted: false, manualDbWrite: false, oldPublicPortsUsed: false, mutationAttempted: steps.some((step) => ["artifact-publish", "dev-deploy-apply"].includes(step.id)), singleLock: "Lease/hwlab-dev/hwlab-dev-cd-lock" } }, blockers, notes: "DEV-only CD transaction evidence. This does not claim M3 PASS or runtime durability." }; } function buildArgumentFailure(error) { const blockers = error.blockers ?? [{ type: "safety_blocker", scope: error.code ?? "dev-cd-apply", status: "open", summary: oneLine(error.message) }]; return { ok: false, status: "blocked", error: error.code ?? "dev-cd-apply-failed", blockers, mutationAttempted: false, prodTouched: false }; } export function formatDevCdApplyFailure(error) { if (error?.lockFailure) return error.lockFailure; if (error instanceof DevCdApplyError) return buildArgumentFailure(error); return { ok: false, status: "failed", error: "dev-cd-apply-failed", summary: error instanceof Error ? error.message : String(error), mutationAttempted: false, prodTouched: false }; } export async function runDevCdApply(argv, io = {}) { const stdout = io.stdout ?? process.stdout; const env = io.env ?? process.env; const ctx = { repoRoot: io.repoRoot ?? defaultRepoRoot, env, runCommand: io.runCommand ?? defaultRunCommand, httpGetJson: io.httpGetJson ?? defaultHttpGetJson, now: io.now ?? (() => new Date()) }; let args; try { args = parseArgs(argv); if (args.help) { stdout.write(`${JSON.stringify({ ok: true, status: "pass", help: usage() }, null, 2)}\n`); return 0; } if (!args.apply && !args.status) { args.status = true; } validateArgs(args); if (args.status) { return await runDevCdStatus(args, ctx, stdout); } } catch (error) { const failure = formatDevCdApplyFailure(error); stdout.write(`${JSON.stringify(failure, null, 2)}\n`); return 2; } const startedAt = ctx.now().toISOString(); const transaction = { transactionId: randomUUID(), ownerTaskId: args.ownerTaskId ?? defaultOwnerTaskId(env), targetNamespace: args.targetNamespace, lockName: args.lockName, phases: [], kubectlContext: null }; const steps = []; const blockers = []; let lockState = null; let release = null; let target = null; let deployBefore = null; let deployAfter = null; let liveBefore = null; let liveVerify = null; let status = "blocked"; try { target = await resolveTargetRef(ctx, args.targetRef); deployBefore = await readDeployJson(ctx.repoRoot); const kubectlContext = await resolveKubectlContext(args, env, ctx.runCommand); transaction.kubectlContext = kubectlContext; liveBefore = { status: "pending", reason: "liveBefore is captured immediately after Lease acquisition so publish/apply cannot race before the transaction lock." }; lockState = await acquireDeployLock({ ctx, args, kubectlContext, target, deployBefore, transactionId: transaction.transactionId, ownerTaskId: transaction.ownerTaskId, liveBefore, now: ctx.now() }); liveBefore = await verifyDevLive(ctx, { expectedCommit: null }); await updateDeployLockLiveBefore({ ctx, args, kubectlContext, transactionId: transaction.transactionId, lockState, liveBefore }); transaction.phases.push({ phase: "publishing", status: "entered", at: ctx.now().toISOString() }); if (!target.headMatchesTarget) { blockers.push({ type: "contract_blocker", scope: "target-ref-head", status: "open", summary: `HEAD ${target.headShortCommitId} must match target ref ${target.ref} ${target.shortCommitId} before DEV CD publish.` }); } const plannedSteps = [ { id: "desired-state-check-before-publish", phase: "publishing", command: process.execPath, args: ["scripts/deploy-desired-state-plan.mjs", "--check", "--pretty"], timeoutMs: 120000 }, { id: "artifact-publish", phase: "publishing", command: process.execPath, args: ["scripts/dev-artifact-publish.mjs", "--publish"], timeoutMs: 60 * 60 * 1000, reportPath: artifactReportPath }, { id: "artifact-catalog-refresh", phase: "publishing", command: process.execPath, args: [ "scripts/refresh-artifact-catalog.mjs", "--target-ref", target.commitId, "--publish-report", artifactReportPath ], timeoutMs: 120000 }, { id: "artifact-catalog-validate", phase: "publishing", command: process.execPath, args: ["scripts/validate-artifact-catalog.mjs"], timeoutMs: 120000 }, { id: "desired-state-check-after-refresh", phase: "publishing", command: process.execPath, args: [ "scripts/deploy-desired-state-plan.mjs", "--promotion-commit", target.commitId, "--check", "--pretty" ], timeoutMs: 120000 }, { id: "runtime-db-provisioning", phase: "applying", kind: "runtime-k8s-job", jobNamePrefix: "hwlab-runtime-provision", commandArgs: [ "scripts/dev-runtime-provisioning.mjs", "--apply", "--confirm-dev", "--confirmed-non-production", "--write-report", "--fail-on-blocked" ], timeoutMs: 5 * 60 * 1000, reportPath: runtimeProvisioningReportPath }, { id: "runtime-db-migration", phase: "applying", kind: "runtime-k8s-job", jobNamePrefix: "hwlab-runtime-migrate", commandArgs: [ "scripts/dev-runtime-migration.mjs", "--apply", "--confirm-dev", "--confirmed-non-production", "--write-report", "--fail-on-blocked" ], timeoutMs: 5 * 60 * 1000, reportPath: runtimeMigrationReportPath }, { id: "dev-deploy-apply", phase: "applying", command: process.execPath, args: [ "scripts/dev-deploy-apply.mjs", "--apply", "--confirm-dev", "--confirmed-non-production", "--write-report", ...(args.kubeconfig ? ["--kubeconfig", args.kubeconfig] : []) ], timeoutMs: 20 * 60 * 1000, reportPath: "reports/dev-gate/dev-deploy-report.json" }, { id: "runtime-durable-postflight", phase: "verifying", command: process.execPath, args: [ "scripts/dev-runtime-postflight.mjs", "--live", "--confirm-dev", "--confirmed-non-production", "--target", "api", "--write-report", "--fail-on-blocked" ], timeoutMs: 2 * 60 * 1000, reportPath: runtimePostflightReportPath } ]; if (blockers.length === 0) { for (const step of plannedSteps) { if (step.phase !== lockState.lock?.phase) { await updateDeployLockPhase({ ctx, args, kubectlContext, transactionId: transaction.transactionId, lockState, phase: step.phase }); transaction.phases.push({ phase: step.phase, status: "entered", at: ctx.now().toISOString() }); } const result = step.kind === "runtime-k8s-job" ? await runRuntimeK8sJobStep(ctx, transaction, step) : await runStep(ctx, transaction, step); steps.push(result); if (result.status !== "pass") { blockers.push(stepBlocker(result)); break; } } } if (blockers.length === 0) { if (lockState.lock?.phase !== "verifying") { await updateDeployLockPhase({ ctx, args, kubectlContext, transactionId: transaction.transactionId, lockState, phase: "verifying" }); transaction.phases.push({ phase: "verifying", status: "entered", at: ctx.now().toISOString() }); } deployAfter = await readDeployJson(ctx.repoRoot); liveVerify = args.skipLiveVerify ? { status: "not_run", reason: "--skip-live-verify", endpoints: [], summary: { checked: 0 } } : await verifyDevLive(ctx, { expectedCommit: target.shortCommitId }); if (liveVerify.status !== "pass") { blockers.push({ type: "observability_blocker", scope: "live-verify", status: "open", summary: "16666/16667 live health did not match the DEV CD promotion commit." }); } } else { deployAfter = deployBefore; liveVerify = { status: "not_run", reason: "transaction blocked before live verify", endpoints: [], summary: { checked: 0 } }; } status = blockers.length === 0 ? "pass" : "blocked"; release = await releaseDeployLock({ ctx, args, kubectlContext, transactionId: transaction.transactionId, status }); transaction.phases.push({ phase: "released", status: release.status, at: ctx.now().toISOString() }); } catch (error) { if (error?.lockFailure) { stdout.write(`${JSON.stringify(error.lockFailure, null, 2)}\n`); return 2; } blockers.push({ type: "runtime_blocker", scope: error.code ?? "dev-cd-apply", status: "open", summary: oneLine(error.message) }); status = "blocked"; if (lockState?.acquired) { const kubectlContext = await resolveKubectlContext(args, env, ctx.runCommand); release = await releaseDeployLock({ ctx, args, kubectlContext, transactionId: transaction.transactionId, status }); transaction.phases.push({ phase: "released", status: release.status, at: ctx.now().toISOString() }); } } const finishedAt = ctx.now().toISOString(); const report = buildReport({ args, transaction, target: target ?? { ref: args.targetRef, commitId: "unknown", shortCommitId: "unknown", headCommitId: "unknown", headMatchesTarget: false }, deployBefore: deployBefore ?? { path: "deploy/deploy.json", hash: "unknown", commitId: "unknown" }, deployAfter: deployAfter ?? deployBefore ?? { path: "deploy/deploy.json", hash: "unknown", commitId: "unknown" }, lockState, steps, blockers, status, startedAt, finishedAt, liveBefore: liveBefore ?? { status: "not_run" }, liveVerify: liveVerify ?? { status: "not_run", endpoints: [], summary: { checked: 0 } }, release }); if (args.writeReport) { const absoluteReportPath = path.resolve(ctx.repoRoot, args.reportPath); await mkdir(path.dirname(absoluteReportPath), { recursive: true }); await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`); } const stdoutPayload = args.fullOutput ? report : summarizeDevCdApplyReport(report); stdout.write(`${JSON.stringify(stdoutPayload, null, 2)}\n`); return status === "pass" ? 0 : 2; }