Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion schemas/cluster-composition-lock.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
}
Expand Down
2 changes: 1 addition & 1 deletion schemas/cluster-state.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] },
Expand Down
39 changes: 39 additions & 0 deletions src/adapters/kubernetes-workload-fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand All @@ -79,7 +82,21 @@ function imageFor(workload: WorkloadV2, images: Record<string, string>): string
return ref;
}

function isJobWorkload(workload: WorkloadV2): boolean {
return workload.kind === "job";
}

function jobLabels(workload: WorkloadV2): Record<string, string> {
return {
...labels(workload),
"app.kubernetes.io/component": "migration",
};
}

function buildWorkloadManifest(workload: WorkloadV2, ns: string, images: Record<string, string>): K8sManifest {
if (isJobWorkload(workload)) {
return buildJobManifest(workload, ns, images);
}
const podSpec: Record<string, unknown> = {
serviceAccountName: workload.name,
containers: [{ name: workload.name, image: imageFor(workload, images) }],
Expand All @@ -101,6 +118,28 @@ function buildWorkloadManifest(workload: WorkloadV2, ns: string, images: Record<
};
}

function buildJobManifest(workload: WorkloadV2, ns: string, images: Record<string, string>): K8sManifest {
const podSpec: Record<string, unknown> = {
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",
Expand Down
5 changes: 4 additions & 1 deletion src/artifact/kustomization-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ export type EmitKustomizationHealthOptions = {
healthChecks: HealthCheck[];
imageDigests?: Record<string, string>;
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,
Expand Down
46 changes: 39 additions & 7 deletions src/deployment/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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`);
Expand Down
4 changes: 4 additions & 0 deletions src/deployment/v2-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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`);
Expand Down
3 changes: 2 additions & 1 deletion src/schemas/health-timeout-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const HEALTH_TIMEOUT_CLASS_MAP: Record<string, string> = {
"stateless": "5m",
"stateful": "10m",
"control-plane": "15m",
"job": "10m",
};

export function validateHealthTimeoutClass(cls: string): void {
Expand All @@ -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];
Expand Down
172 changes: 172 additions & 0 deletions test/fragment-engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import YAML from "yaml";
import {
assertNoFloatingImages,
assertNoUnselectedMutations,
emitKustomizationHealth,
validateDeploymentSemantics,
buildOutputPaths,
checkScopedParity,
computeRenderHash,
Expand Down Expand Up @@ -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/);
});