diff --git a/schemas/cluster-composition-lock.schema.json b/schemas/cluster-composition-lock.schema.json index 0da0510..a2a48f1 100644 --- a/schemas/cluster-composition-lock.schema.json +++ b/schemas/cluster-composition-lock.schema.json @@ -65,7 +65,7 @@ "namespace": { "type": "string" }, "layer": { "type": "string" }, "schemaVersion": { "type": "string" }, - "healthClass": { "type": "string", "enum": ["stateless", "stateful", "control-plane"] }, + "healthClass": { "type": "string", "enum": ["stateless", "stateful", "control-plane", "job"] }, "prune": { "type": "boolean" }, "imageDigests": { "type": "object", "additionalProperties": { "type": "string" } } } diff --git a/schemas/cluster-state.schema.json b/schemas/cluster-state.schema.json index ff8f620..12c278b 100644 --- a/schemas/cluster-state.schema.json +++ b/schemas/cluster-state.schema.json @@ -19,7 +19,7 @@ "namespace": { "type": "string" }, "layer": { "type": "string" }, "artifact_digest": { "type": "string", "pattern": "^sha256:" }, - "health_class": { "type": "string", "enum": ["stateless", "stateful", "control-plane"] }, + "health_class": { "type": "string", "enum": ["stateless", "stateful", "control-plane", "job"] }, "prune_policy": { "type": "string" }, "flux_ready": { "type": "string", "enum": ["True", "False", "Unknown"] }, "observed_image_digest": { "oneOf": [{ "type": "string", "pattern": "^sha256:" }, { "type": "null" }] }, diff --git a/src/adapters/kubernetes-workload-fragment.ts b/src/adapters/kubernetes-workload-fragment.ts index 2615186..1f46f01 100644 --- a/src/adapters/kubernetes-workload-fragment.ts +++ b/src/adapters/kubernetes-workload-fragment.ts @@ -54,6 +54,9 @@ export function renderKubernetesWorkloadFragment(input: FragmentInput): Kubernet if (manifest.kind === "Secret") { throw new Error("E_FORBIDDEN_KIND: raw Secret output forbidden in kubernetes-workload-fragment"); } + if (manifest.kind === "PersistentVolumeClaim") { + throw new Error("E_FORBIDDEN_KIND: PersistentVolumeClaim output forbidden in kubernetes-workload-fragment"); + } if (CLUSTER_SCOPED_KINDS.has(manifest.kind)) { throw new Error(`E_FORBIDDEN_KIND: cluster-scoped kind '${manifest.kind}' forbidden in fragment output`); } @@ -79,7 +82,21 @@ function imageFor(workload: WorkloadV2, images: Record): string return ref; } +function isJobWorkload(workload: WorkloadV2): boolean { + return workload.kind === "job"; +} + +function jobLabels(workload: WorkloadV2): Record { + return { + ...labels(workload), + "app.kubernetes.io/component": "migration", + }; +} + function buildWorkloadManifest(workload: WorkloadV2, ns: string, images: Record): K8sManifest { + if (isJobWorkload(workload)) { + return buildJobManifest(workload, ns, images); + } const podSpec: Record = { serviceAccountName: workload.name, containers: [{ name: workload.name, image: imageFor(workload, images) }], @@ -101,6 +118,28 @@ function buildWorkloadManifest(workload: WorkloadV2, ns: string, images: Record< }; } +function buildJobManifest(workload: WorkloadV2, ns: string, images: Record): K8sManifest { + const podSpec: Record = { + serviceAccountName: workload.name, + restartPolicy: "Never", + containers: [{ name: workload.name, image: imageFor(workload, images) }], + }; + const nodeSelector = workload.placement?.nodeSelector; + if (nodeSelector && Object.keys(nodeSelector).length > 0) { + podSpec["nodeSelector"] = Object.fromEntries( + Object.entries(nodeSelector).map(([key, values]) => [key, values[0]]), + ); + } + return { + apiVersion: "batch/v1", + kind: "Job", + metadata: { name: workload.name, namespace: ns, labels: jobLabels(workload) }, + spec: { + template: { metadata: { labels: jobLabels(workload) }, spec: podSpec }, + }, + }; +} + function buildServiceAccount(workload: WorkloadV2, ns: string): K8sManifest { return { apiVersion: "v1", diff --git a/src/artifact/kustomization-health.ts b/src/artifact/kustomization-health.ts index 0cdda40..2a33d9f 100644 --- a/src/artifact/kustomization-health.ts +++ b/src/artifact/kustomization-health.ts @@ -27,15 +27,18 @@ export type EmitKustomizationHealthOptions = { healthChecks: HealthCheck[]; imageDigests?: Record; pruneDecisions?: unknown[]; + /** Override the default wait:true. Set to false for job-class workloads. */ + waitOverride?: boolean; }; export function emitKustomizationHealth(options: EmitKustomizationHealthOptions): KustomizationHealth { const timeout = resolveHealthTimeout(options.workloads); + const wait = options.waitOverride !== undefined ? options.waitOverride : true; return { apiVersion: "deployment.jorisjonkers.dev/kustomization-health/v1", kind: "KustomizationHealth", spec: { - wait: true, + wait, retryInterval: "30s", timeout, healthChecks: options.healthChecks, diff --git a/src/deployment/commands.ts b/src/deployment/commands.ts index 2a46869..a1c426a 100644 --- a/src/deployment/commands.ts +++ b/src/deployment/commands.ts @@ -549,6 +549,12 @@ export function runParityCheck(args, streams, parseOptions) { } } +function resolveWorkloadKind(workload) { + if (workload.kind === "job") return "job"; + if (workload.stateful) return "statefulset"; + return "deployment"; +} + function runEmitKustomizationHealth(args, streams, parseOptions) { const { options, diagnostics } = parseOptions(args); const deploymentPath = Array.isArray(options.deployment) ? options.deployment[0] : options.deployment; @@ -559,15 +565,41 @@ function runEmitKustomizationHealth(args, streams, parseOptions) { try { const deployment = parseDeploymentV2(YAML.parse(readFileSync(deploymentPath, "utf8"))); const imageDigests = normalizeImageLock(JSON.parse(readFileSync(options.imageDigests, "utf8"))); + + // wait:false only when ALL workloads are job-kind. Mixed job+stateless deployments + // would incorrectly suppress wait for the stateless service if we set waitOverride globally. + const allJobWorkloads = deployment.spec.workloads.length > 0 && + deployment.spec.workloads.every((w) => resolveWorkloadKind(w) === "job"); + const healthChecks = deployment.spec.workloads .filter((workload) => workload.health?.mandatory !== false) - .map((workload) => ({ - apiVersion: "apps/v1", - kind: workload.stateful ? "StatefulSet" : "Deployment", - name: workload.name, - namespace: deployment.spec.namespace, - })); - const health = emitKustomizationHealth({ workloads: deployment.spec.workloads, healthChecks, imageDigests, pruneDecisions: [] }); + .map((workload) => { + const wkind = resolveWorkloadKind(workload); + if (wkind === "job") { + return { + apiVersion: "batch/v1", + kind: "Job", + name: workload.name, + namespace: deployment.spec.namespace, + }; + } + return { + apiVersion: "apps/v1", + kind: wkind === "statefulset" ? "StatefulSet" : "Deployment", + name: workload.name, + namespace: deployment.spec.namespace, + }; + }); + + // Job workloads: wait must be false (Flux uses healthChecks for Job completion, + // not Ready condition; setting wait:true would block on a condition that never fires). + const health = emitKustomizationHealth({ + workloads: deployment.spec.workloads, + healthChecks, + imageDigests, + pruneDecisions: [], + ...(allJobWorkloads ? { waitOverride: false } : {}), + }); mkdirSync(dirname(options.out), { recursive: true }); writeFileSync(options.out, YAML.stringify(health, { lineWidth: 0 })); streams.stdout.write(`${JSON.stringify({ out: options.out, env: options.env, timeout: health.spec.timeout }, null, 2)}\n`); diff --git a/src/deployment/v2-model.ts b/src/deployment/v2-model.ts index c6d252f..0ff28e2 100644 --- a/src/deployment/v2-model.ts +++ b/src/deployment/v2-model.ts @@ -11,6 +11,7 @@ export type RouteV2 = { export type WorkloadV2 = { name: string; image?: { alias?: string }; + kind?: "deployment" | "statefulset" | "job"; stateful?: boolean; migrationPolicy?: { required: boolean; strategy: "none" | "pre-deploy-job" | "external" }; rollbackTargetRetention?: { minimumDays: number; acknowledged: boolean }; @@ -81,6 +82,9 @@ export function validateDeploymentSemantics(deployment: DeploymentV2, context: C } for (const workload of deployment.spec.workloads) { + if (workload.kind === "job" && workload.stateful === true) { + throw new Error(`E_JOB_STATEFUL_CONFLICT: workload '${workload.name}' declares both kind:job and stateful:true; these are mutually exclusive`); + } if (workload.stateful === true) { if (!workload.migrationPolicy) { throw new Error(`E_STATEFUL_MIGRATION_POLICY_REQUIRED: workload '${workload.name}' is stateful and must declare migrationPolicy`); diff --git a/src/schemas/health-timeout-map.ts b/src/schemas/health-timeout-map.ts index 81ca9aa..8e10d56 100644 --- a/src/schemas/health-timeout-map.ts +++ b/src/schemas/health-timeout-map.ts @@ -2,6 +2,7 @@ export const HEALTH_TIMEOUT_CLASS_MAP: Record = { "stateless": "5m", "stateful": "10m", "control-plane": "15m", + "job": "10m", }; export function validateHealthTimeoutClass(cls: string): void { @@ -15,7 +16,7 @@ export type WorkloadWithHealth = { }; export function resolveHealthTimeout(workloads: WorkloadWithHealth[]): string { - const priority = ["control-plane", "stateful", "stateless"]; + const priority = ["control-plane", "stateful", "job", "stateless"]; for (const cls of priority) { if (workloads.some((w) => w.health?.timeoutClass === cls)) { return HEALTH_TIMEOUT_CLASS_MAP[cls]; diff --git a/test/fragment-engine.test.js b/test/fragment-engine.test.js index f19d48c..474faf1 100644 --- a/test/fragment-engine.test.js +++ b/test/fragment-engine.test.js @@ -7,6 +7,8 @@ import YAML from "yaml"; import { assertNoFloatingImages, assertNoUnselectedMutations, + emitKustomizationHealth, + validateDeploymentSemantics, buildOutputPaths, checkScopedParity, computeRenderHash, @@ -500,3 +502,173 @@ test("CLI: parity check --service routes to scoped parity", async () => { rmSync(root, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// Job workload tests (Phase 1 — healthClass: job) +// --------------------------------------------------------------------------- + +test("job workload: kubernetes fragment renders batch/v1 Job with migration label", () => { + const input = inputWith({ + workloads: [{ + name: "stalwart-provisioner", + kind: "job", + image: { alias: "app" }, + }], + }); + const rendered = renderKubernetesWorkloadFragment(input); + const job = rendered.manifests.find((m) => m.kind === "Job"); + assert.ok(job, "Job manifest not found in rendered output"); + assert.equal(job.apiVersion, "batch/v1"); + assert.equal(job.metadata.labels["app.kubernetes.io/component"], "migration", + "Job must carry app.kubernetes.io/component=migration for prune-guardrails"); + assert.equal(job.spec.template.spec.restartPolicy, "Never"); + // No PVCs in output + assert.ok(!rendered.manifests.some((m) => m.kind === "PersistentVolumeClaim"), + "Job workload must not emit PVCs"); + // No Deployment in output + assert.ok(!rendered.manifests.some((m) => m.kind === "Deployment"), + "Job workload must not emit Deployment"); +}); + +test("job workload: emit-kustomization-health produces kind:Job health check and wait:false", async () => { + const tmpDir = mkdtempSync(join("dist", "kh-job-")); + try { + // Write a minimal job deployment.yml + const deployment = { + apiVersion: "deployment.jorisjonkers.dev/v2", + kind: "Deployment", + metadata: { name: "stalwart-provisioner" }, + spec: { + namespace: "mail-system", + workloads: [{ + name: "stalwart-provisioner", + kind: "job", + image: { alias: "stalwart-provisioner" }, + health: { timeoutClass: "job" }, + }], + }, + }; + const deployPath = join(tmpDir, "deployment.yml"); + const imagesPath = join(tmpDir, "images.lock.json"); + const outPath = join(tmpDir, "kustomization-health.yml"); + writeFileSync(deployPath, YAML.stringify(deployment)); + writeFileSync(imagesPath, JSON.stringify({ "stalwart-provisioner": PINNED_IMAGE })); + + const stdout = stream(); + const stderr = stream(); + const code = await runCli([ + "artifact", "emit-kustomization-health", + "--deployment", deployPath, + "--env", "production", + "--image-digests", imagesPath, + "--out", outPath, + ], { stdout, stderr }); + assert.equal(code, 0, `emit-kustomization-health failed: ${stderr.text()}`); + + const kh = YAML.parse(readFileSync(outPath, "utf8")); + assert.equal(kh.kind, "KustomizationHealth"); + assert.equal(kh.spec.wait, false, "Job workload kustomization-health must have wait:false"); + assert.equal(kh.spec.healthChecks.length, 1); + assert.equal(kh.spec.healthChecks[0].kind, "Job"); + assert.equal(kh.spec.healthChecks[0].apiVersion, "batch/v1"); + assert.equal(kh.spec.healthChecks[0].name, "stalwart-provisioner"); + assert.equal(kh.spec.healthChecks[0].namespace, "mail-system"); + assert.equal(kh.spec.timeout, "10m", "Job workload timeout should be 10m"); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("job workload: emitKustomizationHealth with waitOverride:false sets wait:false", () => { + const result = emitKustomizationHealth({ + workloads: [{ health: { timeoutClass: "job" } }], + healthChecks: [{ apiVersion: "batch/v1", kind: "Job", name: "my-job", namespace: "default" }], + waitOverride: false, + }); + assert.equal(result.spec.wait, false); + assert.equal(result.spec.healthChecks[0].kind, "Job"); + assert.equal(result.spec.timeout, "10m"); +}); + +test("job workload: emitKustomizationHealth default wait:true for non-job workloads", () => { + const result = emitKustomizationHealth({ + workloads: [{ health: { timeoutClass: "stateless" } }], + healthChecks: [{ apiVersion: "apps/v1", kind: "Deployment", name: "svc", namespace: "default" }], + }); + assert.equal(result.spec.wait, true); +}); + +test("job workload: mixed job+stateless deployment uses wait:true (only all-job deployments get wait:false)", async () => { + const tmpDir = mkdtempSync(join("dist", "kh-mixed-")); + try { + const deployment = { + apiVersion: "deployment.jorisjonkers.dev/v2", + kind: "Deployment", + metadata: { name: "mixed-svc" }, + spec: { + namespace: "mixed-ns", + workloads: [ + { + name: "worker-job", + kind: "job", + image: { alias: "app" }, + health: { timeoutClass: "job" }, + }, + { + name: "api-server", + image: { alias: "app" }, + health: { timeoutClass: "stateless" }, + }, + ], + }, + }; + const deployPath = join(tmpDir, "deployment.yml"); + const imagesPath = join(tmpDir, "images.lock.json"); + const outPath = join(tmpDir, "kustomization-health.yml"); + writeFileSync(deployPath, YAML.stringify(deployment)); + writeFileSync(imagesPath, JSON.stringify({ app: PINNED_IMAGE })); + + const stdout = stream(); + const stderr = stream(); + const code = await runCli([ + "artifact", "emit-kustomization-health", + "--deployment", deployPath, + "--env", "production", + "--image-digests", imagesPath, + "--out", outPath, + ], { stdout, stderr }); + assert.equal(code, 0, `emit-kustomization-health failed: ${stderr.text()}`); + + const kh = YAML.parse(readFileSync(outPath, "utf8")); + // Mixed deployment: wait must NOT be forced to false (stateless workload needs wait:true) + assert.equal(kh.spec.wait, true, "Mixed job+stateless deployment must use wait:true"); + // Both workloads produce health checks + assert.equal(kh.spec.healthChecks.length, 2); + const jobCheck = kh.spec.healthChecks.find((hc) => hc.kind === "Job"); + const depCheck = kh.spec.healthChecks.find((hc) => hc.kind === "Deployment"); + assert.ok(jobCheck, "Job workload must produce kind:Job healthCheck"); + assert.ok(depCheck, "Stateless workload must produce kind:Deployment healthCheck"); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("job workload: kind:job + stateful:true conflict → E_JOB_STATEFUL_CONFLICT", () => { + const deployment = { + apiVersion: "deployment.jorisjonkers.dev/v2", + kind: "Deployment", + metadata: { name: "bad-svc" }, + spec: { + namespace: "test-ns", + workloads: [{ + name: "bad-workload", + kind: "job", + stateful: true, + image: { alias: "app" }, + migrationPolicy: { required: false, strategy: "none" }, + }], + }, + }; + const ctx = fixtureInput().context; + assert.throws(() => validateDeploymentSemantics(deployment, ctx), /E_JOB_STATEFUL_CONFLICT/); +});