Skip to content

Commit 86a757d

Browse files
feat: add job workload support to healthClass schema and fragment pipeline
Adds healthClass:job to the schema enums (cluster-composition-lock, cluster-state). Extends kubernetes-workload-fragment to render batch/v1 Job manifests with the migration label. Extends emit-kustomization-health to emit kind:Job healthChecks with wait:false for all-job deployments. Validates kind:job + stateful:true conflict.
1 parent ae9f013 commit 86a757d

8 files changed

Lines changed: 262 additions & 11 deletions

File tree

schemas/cluster-composition-lock.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"namespace": { "type": "string" },
6666
"layer": { "type": "string" },
6767
"schemaVersion": { "type": "string" },
68-
"healthClass": { "type": "string", "enum": ["stateless", "stateful", "control-plane"] },
68+
"healthClass": { "type": "string", "enum": ["stateless", "stateful", "control-plane", "job"] },
6969
"prune": { "type": "boolean" },
7070
"imageDigests": { "type": "object", "additionalProperties": { "type": "string" } }
7171
}

schemas/cluster-state.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"namespace": { "type": "string" },
2020
"layer": { "type": "string" },
2121
"artifact_digest": { "type": "string", "pattern": "^sha256:" },
22-
"health_class": { "type": "string", "enum": ["stateless", "stateful", "control-plane"] },
22+
"health_class": { "type": "string", "enum": ["stateless", "stateful", "control-plane", "job"] },
2323
"prune_policy": { "type": "string" },
2424
"flux_ready": { "type": "string", "enum": ["True", "False", "Unknown"] },
2525
"observed_image_digest": { "oneOf": [{ "type": "string", "pattern": "^sha256:" }, { "type": "null" }] },

src/adapters/kubernetes-workload-fragment.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export function renderKubernetesWorkloadFragment(input: FragmentInput): Kubernet
5454
if (manifest.kind === "Secret") {
5555
throw new Error("E_FORBIDDEN_KIND: raw Secret output forbidden in kubernetes-workload-fragment");
5656
}
57+
if (manifest.kind === "PersistentVolumeClaim") {
58+
throw new Error("E_FORBIDDEN_KIND: PersistentVolumeClaim output forbidden in kubernetes-workload-fragment");
59+
}
5760
if (CLUSTER_SCOPED_KINDS.has(manifest.kind)) {
5861
throw new Error(`E_FORBIDDEN_KIND: cluster-scoped kind '${manifest.kind}' forbidden in fragment output`);
5962
}
@@ -79,7 +82,21 @@ function imageFor(workload: WorkloadV2, images: Record<string, string>): string
7982
return ref;
8083
}
8184

85+
function isJobWorkload(workload: WorkloadV2): boolean {
86+
return workload.kind === "job";
87+
}
88+
89+
function jobLabels(workload: WorkloadV2): Record<string, string> {
90+
return {
91+
...labels(workload),
92+
"app.kubernetes.io/component": "migration",
93+
};
94+
}
95+
8296
function buildWorkloadManifest(workload: WorkloadV2, ns: string, images: Record<string, string>): K8sManifest {
97+
if (isJobWorkload(workload)) {
98+
return buildJobManifest(workload, ns, images);
99+
}
83100
const podSpec: Record<string, unknown> = {
84101
serviceAccountName: workload.name,
85102
containers: [{ name: workload.name, image: imageFor(workload, images) }],
@@ -101,6 +118,28 @@ function buildWorkloadManifest(workload: WorkloadV2, ns: string, images: Record<
101118
};
102119
}
103120

121+
function buildJobManifest(workload: WorkloadV2, ns: string, images: Record<string, string>): K8sManifest {
122+
const podSpec: Record<string, unknown> = {
123+
serviceAccountName: workload.name,
124+
restartPolicy: "Never",
125+
containers: [{ name: workload.name, image: imageFor(workload, images) }],
126+
};
127+
const nodeSelector = workload.placement?.nodeSelector;
128+
if (nodeSelector && Object.keys(nodeSelector).length > 0) {
129+
podSpec["nodeSelector"] = Object.fromEntries(
130+
Object.entries(nodeSelector).map(([key, values]) => [key, values[0]]),
131+
);
132+
}
133+
return {
134+
apiVersion: "batch/v1",
135+
kind: "Job",
136+
metadata: { name: workload.name, namespace: ns, labels: jobLabels(workload) },
137+
spec: {
138+
template: { metadata: { labels: jobLabels(workload) }, spec: podSpec },
139+
},
140+
};
141+
}
142+
104143
function buildServiceAccount(workload: WorkloadV2, ns: string): K8sManifest {
105144
return {
106145
apiVersion: "v1",

src/artifact/kustomization-health.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,18 @@ export type EmitKustomizationHealthOptions = {
2727
healthChecks: HealthCheck[];
2828
imageDigests?: Record<string, string>;
2929
pruneDecisions?: unknown[];
30+
/** Override the default wait:true. Set to false for job-class workloads. */
31+
waitOverride?: boolean;
3032
};
3133

3234
export function emitKustomizationHealth(options: EmitKustomizationHealthOptions): KustomizationHealth {
3335
const timeout = resolveHealthTimeout(options.workloads);
36+
const wait = options.waitOverride !== undefined ? options.waitOverride : true;
3437
return {
3538
apiVersion: "deployment.jorisjonkers.dev/kustomization-health/v1",
3639
kind: "KustomizationHealth",
3740
spec: {
38-
wait: true,
41+
wait,
3942
retryInterval: "30s",
4043
timeout,
4144
healthChecks: options.healthChecks,

src/deployment/commands.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,12 @@ export function runParityCheck(args, streams, parseOptions) {
549549
}
550550
}
551551

552+
function resolveWorkloadKind(workload) {
553+
if (workload.kind === "job") return "job";
554+
if (workload.stateful) return "statefulset";
555+
return "deployment";
556+
}
557+
552558
function runEmitKustomizationHealth(args, streams, parseOptions) {
553559
const { options, diagnostics } = parseOptions(args);
554560
const deploymentPath = Array.isArray(options.deployment) ? options.deployment[0] : options.deployment;
@@ -559,15 +565,41 @@ function runEmitKustomizationHealth(args, streams, parseOptions) {
559565
try {
560566
const deployment = parseDeploymentV2(YAML.parse(readFileSync(deploymentPath, "utf8")));
561567
const imageDigests = normalizeImageLock(JSON.parse(readFileSync(options.imageDigests, "utf8")));
568+
569+
// wait:false only when ALL workloads are job-kind. Mixed job+stateless deployments
570+
// would incorrectly suppress wait for the stateless service if we set waitOverride globally.
571+
const allJobWorkloads = deployment.spec.workloads.length > 0 &&
572+
deployment.spec.workloads.every((w) => resolveWorkloadKind(w) === "job");
573+
562574
const healthChecks = deployment.spec.workloads
563575
.filter((workload) => workload.health?.mandatory !== false)
564-
.map((workload) => ({
565-
apiVersion: "apps/v1",
566-
kind: workload.stateful ? "StatefulSet" : "Deployment",
567-
name: workload.name,
568-
namespace: deployment.spec.namespace,
569-
}));
570-
const health = emitKustomizationHealth({ workloads: deployment.spec.workloads, healthChecks, imageDigests, pruneDecisions: [] });
576+
.map((workload) => {
577+
const wkind = resolveWorkloadKind(workload);
578+
if (wkind === "job") {
579+
return {
580+
apiVersion: "batch/v1",
581+
kind: "Job",
582+
name: workload.name,
583+
namespace: deployment.spec.namespace,
584+
};
585+
}
586+
return {
587+
apiVersion: "apps/v1",
588+
kind: wkind === "statefulset" ? "StatefulSet" : "Deployment",
589+
name: workload.name,
590+
namespace: deployment.spec.namespace,
591+
};
592+
});
593+
594+
// Job workloads: wait must be false (Flux uses healthChecks for Job completion,
595+
// not Ready condition; setting wait:true would block on a condition that never fires).
596+
const health = emitKustomizationHealth({
597+
workloads: deployment.spec.workloads,
598+
healthChecks,
599+
imageDigests,
600+
pruneDecisions: [],
601+
...(allJobWorkloads ? { waitOverride: false } : {}),
602+
});
571603
mkdirSync(dirname(options.out), { recursive: true });
572604
writeFileSync(options.out, YAML.stringify(health, { lineWidth: 0 }));
573605
streams.stdout.write(`${JSON.stringify({ out: options.out, env: options.env, timeout: health.spec.timeout }, null, 2)}\n`);

src/deployment/v2-model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type RouteV2 = {
1111
export type WorkloadV2 = {
1212
name: string;
1313
image?: { alias?: string };
14+
kind?: "deployment" | "statefulset" | "job";
1415
stateful?: boolean;
1516
migrationPolicy?: { required: boolean; strategy: "none" | "pre-deploy-job" | "external" };
1617
rollbackTargetRetention?: { minimumDays: number; acknowledged: boolean };
@@ -81,6 +82,9 @@ export function validateDeploymentSemantics(deployment: DeploymentV2, context: C
8182
}
8283

8384
for (const workload of deployment.spec.workloads) {
85+
if (workload.kind === "job" && workload.stateful === true) {
86+
throw new Error(`E_JOB_STATEFUL_CONFLICT: workload '${workload.name}' declares both kind:job and stateful:true; these are mutually exclusive`);
87+
}
8488
if (workload.stateful === true) {
8589
if (!workload.migrationPolicy) {
8690
throw new Error(`E_STATEFUL_MIGRATION_POLICY_REQUIRED: workload '${workload.name}' is stateful and must declare migrationPolicy`);

src/schemas/health-timeout-map.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const HEALTH_TIMEOUT_CLASS_MAP: Record<string, string> = {
22
"stateless": "5m",
33
"stateful": "10m",
44
"control-plane": "15m",
5+
"job": "10m",
56
};
67

78
export function validateHealthTimeoutClass(cls: string): void {
@@ -15,7 +16,7 @@ export type WorkloadWithHealth = {
1516
};
1617

1718
export function resolveHealthTimeout(workloads: WorkloadWithHealth[]): string {
18-
const priority = ["control-plane", "stateful", "stateless"];
19+
const priority = ["control-plane", "stateful", "job", "stateless"];
1920
for (const cls of priority) {
2021
if (workloads.some((w) => w.health?.timeoutClass === cls)) {
2122
return HEALTH_TIMEOUT_CLASS_MAP[cls];

test/fragment-engine.test.js

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import YAML from "yaml";
77
import {
88
assertNoFloatingImages,
99
assertNoUnselectedMutations,
10+
emitKustomizationHealth,
11+
validateDeploymentSemantics,
1012
buildOutputPaths,
1113
checkScopedParity,
1214
computeRenderHash,
@@ -500,3 +502,173 @@ test("CLI: parity check --service routes to scoped parity", async () => {
500502
rmSync(root, { recursive: true, force: true });
501503
}
502504
});
505+
506+
// ---------------------------------------------------------------------------
507+
// Job workload tests (Phase 1 — healthClass: job)
508+
// ---------------------------------------------------------------------------
509+
510+
test("job workload: kubernetes fragment renders batch/v1 Job with migration label", () => {
511+
const input = inputWith({
512+
workloads: [{
513+
name: "stalwart-provisioner",
514+
kind: "job",
515+
image: { alias: "app" },
516+
}],
517+
});
518+
const rendered = renderKubernetesWorkloadFragment(input);
519+
const job = rendered.manifests.find((m) => m.kind === "Job");
520+
assert.ok(job, "Job manifest not found in rendered output");
521+
assert.equal(job.apiVersion, "batch/v1");
522+
assert.equal(job.metadata.labels["app.kubernetes.io/component"], "migration",
523+
"Job must carry app.kubernetes.io/component=migration for prune-guardrails");
524+
assert.equal(job.spec.template.spec.restartPolicy, "Never");
525+
// No PVCs in output
526+
assert.ok(!rendered.manifests.some((m) => m.kind === "PersistentVolumeClaim"),
527+
"Job workload must not emit PVCs");
528+
// No Deployment in output
529+
assert.ok(!rendered.manifests.some((m) => m.kind === "Deployment"),
530+
"Job workload must not emit Deployment");
531+
});
532+
533+
test("job workload: emit-kustomization-health produces kind:Job health check and wait:false", async () => {
534+
const tmpDir = mkdtempSync(join("dist", "kh-job-"));
535+
try {
536+
// Write a minimal job deployment.yml
537+
const deployment = {
538+
apiVersion: "deployment.jorisjonkers.dev/v2",
539+
kind: "Deployment",
540+
metadata: { name: "stalwart-provisioner" },
541+
spec: {
542+
namespace: "mail-system",
543+
workloads: [{
544+
name: "stalwart-provisioner",
545+
kind: "job",
546+
image: { alias: "stalwart-provisioner" },
547+
health: { timeoutClass: "job" },
548+
}],
549+
},
550+
};
551+
const deployPath = join(tmpDir, "deployment.yml");
552+
const imagesPath = join(tmpDir, "images.lock.json");
553+
const outPath = join(tmpDir, "kustomization-health.yml");
554+
writeFileSync(deployPath, YAML.stringify(deployment));
555+
writeFileSync(imagesPath, JSON.stringify({ "stalwart-provisioner": PINNED_IMAGE }));
556+
557+
const stdout = stream();
558+
const stderr = stream();
559+
const code = await runCli([
560+
"artifact", "emit-kustomization-health",
561+
"--deployment", deployPath,
562+
"--env", "production",
563+
"--image-digests", imagesPath,
564+
"--out", outPath,
565+
], { stdout, stderr });
566+
assert.equal(code, 0, `emit-kustomization-health failed: ${stderr.text()}`);
567+
568+
const kh = YAML.parse(readFileSync(outPath, "utf8"));
569+
assert.equal(kh.kind, "KustomizationHealth");
570+
assert.equal(kh.spec.wait, false, "Job workload kustomization-health must have wait:false");
571+
assert.equal(kh.spec.healthChecks.length, 1);
572+
assert.equal(kh.spec.healthChecks[0].kind, "Job");
573+
assert.equal(kh.spec.healthChecks[0].apiVersion, "batch/v1");
574+
assert.equal(kh.spec.healthChecks[0].name, "stalwart-provisioner");
575+
assert.equal(kh.spec.healthChecks[0].namespace, "mail-system");
576+
assert.equal(kh.spec.timeout, "10m", "Job workload timeout should be 10m");
577+
} finally {
578+
rmSync(tmpDir, { recursive: true, force: true });
579+
}
580+
});
581+
582+
test("job workload: emitKustomizationHealth with waitOverride:false sets wait:false", () => {
583+
const result = emitKustomizationHealth({
584+
workloads: [{ health: { timeoutClass: "job" } }],
585+
healthChecks: [{ apiVersion: "batch/v1", kind: "Job", name: "my-job", namespace: "default" }],
586+
waitOverride: false,
587+
});
588+
assert.equal(result.spec.wait, false);
589+
assert.equal(result.spec.healthChecks[0].kind, "Job");
590+
assert.equal(result.spec.timeout, "10m");
591+
});
592+
593+
test("job workload: emitKustomizationHealth default wait:true for non-job workloads", () => {
594+
const result = emitKustomizationHealth({
595+
workloads: [{ health: { timeoutClass: "stateless" } }],
596+
healthChecks: [{ apiVersion: "apps/v1", kind: "Deployment", name: "svc", namespace: "default" }],
597+
});
598+
assert.equal(result.spec.wait, true);
599+
});
600+
601+
test("job workload: mixed job+stateless deployment uses wait:true (only all-job deployments get wait:false)", async () => {
602+
const tmpDir = mkdtempSync(join("dist", "kh-mixed-"));
603+
try {
604+
const deployment = {
605+
apiVersion: "deployment.jorisjonkers.dev/v2",
606+
kind: "Deployment",
607+
metadata: { name: "mixed-svc" },
608+
spec: {
609+
namespace: "mixed-ns",
610+
workloads: [
611+
{
612+
name: "worker-job",
613+
kind: "job",
614+
image: { alias: "app" },
615+
health: { timeoutClass: "job" },
616+
},
617+
{
618+
name: "api-server",
619+
image: { alias: "app" },
620+
health: { timeoutClass: "stateless" },
621+
},
622+
],
623+
},
624+
};
625+
const deployPath = join(tmpDir, "deployment.yml");
626+
const imagesPath = join(tmpDir, "images.lock.json");
627+
const outPath = join(tmpDir, "kustomization-health.yml");
628+
writeFileSync(deployPath, YAML.stringify(deployment));
629+
writeFileSync(imagesPath, JSON.stringify({ app: PINNED_IMAGE }));
630+
631+
const stdout = stream();
632+
const stderr = stream();
633+
const code = await runCli([
634+
"artifact", "emit-kustomization-health",
635+
"--deployment", deployPath,
636+
"--env", "production",
637+
"--image-digests", imagesPath,
638+
"--out", outPath,
639+
], { stdout, stderr });
640+
assert.equal(code, 0, `emit-kustomization-health failed: ${stderr.text()}`);
641+
642+
const kh = YAML.parse(readFileSync(outPath, "utf8"));
643+
// Mixed deployment: wait must NOT be forced to false (stateless workload needs wait:true)
644+
assert.equal(kh.spec.wait, true, "Mixed job+stateless deployment must use wait:true");
645+
// Both workloads produce health checks
646+
assert.equal(kh.spec.healthChecks.length, 2);
647+
const jobCheck = kh.spec.healthChecks.find((hc) => hc.kind === "Job");
648+
const depCheck = kh.spec.healthChecks.find((hc) => hc.kind === "Deployment");
649+
assert.ok(jobCheck, "Job workload must produce kind:Job healthCheck");
650+
assert.ok(depCheck, "Stateless workload must produce kind:Deployment healthCheck");
651+
} finally {
652+
rmSync(tmpDir, { recursive: true, force: true });
653+
}
654+
});
655+
656+
test("job workload: kind:job + stateful:true conflict → E_JOB_STATEFUL_CONFLICT", () => {
657+
const deployment = {
658+
apiVersion: "deployment.jorisjonkers.dev/v2",
659+
kind: "Deployment",
660+
metadata: { name: "bad-svc" },
661+
spec: {
662+
namespace: "test-ns",
663+
workloads: [{
664+
name: "bad-workload",
665+
kind: "job",
666+
stateful: true,
667+
image: { alias: "app" },
668+
migrationPolicy: { required: false, strategy: "none" },
669+
}],
670+
},
671+
};
672+
const ctx = fixtureInput().context;
673+
assert.throws(() => validateDeploymentSemantics(deployment, ctx), /E_JOB_STATEFUL_CONFLICT/);
674+
});

0 commit comments

Comments
 (0)