@@ -7,6 +7,8 @@ import YAML from "yaml";
77import {
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 _ J O B _ S T A T E F U L _ C O N F L I C T / ) ;
674+ } ) ;
0 commit comments