@@ -31,7 +31,8 @@ class KubernetesKueueConfig:
3131 namespace : str = "dirac-motif"
3232 queue_name : str = "motif"
3333 service_account : str = "dirac-motif-worker"
34- termination_grace_seconds : int = 300
34+ termination_grace_seconds : int = 5
35+ network_policy_settle_seconds : int = 3
3536
3637 def __post_init__ (self ) -> None :
3738 for field in ("namespace" , "queue_name" , "service_account" ):
@@ -40,6 +41,8 @@ def __post_init__(self) -> None:
4041 raise ValueError (f"{ field } must be a Kubernetes DNS label" )
4142 if self .termination_grace_seconds < 0 :
4243 raise ValueError ("termination_grace_seconds must be non-negative" )
44+ if not 0 <= self .network_policy_settle_seconds <= 30 :
45+ raise ValueError ("network_policy_settle_seconds must be between 0 and 30" )
4346
4447
4548class KubernetesKueueAdapter :
@@ -56,6 +59,7 @@ def __init__(
5659 * ,
5760 worker_command : Sequence [str ],
5861 allowed_images : Sequence [str ],
62+ policy_init_image : str | None = None ,
5963 config : KubernetesKueueConfig | None = None ,
6064 runner : Callable [..., subprocess .CompletedProcess [str ]] | None = None ,
6165 ) -> None :
@@ -69,6 +73,13 @@ def __init__(
6973 self .worker_command = tuple (worker_command )
7074 self .allowed_images = frozenset (allowed_images )
7175 self .config = config or KubernetesKueueConfig ()
76+ if self .config .network_policy_settle_seconds :
77+ if not policy_init_image :
78+ raise ValueError (
79+ "policy_init_image is required when the network policy barrier is enabled" )
80+ if not _is_digest_image (policy_init_image ):
81+ raise ValueError ("policy_init_image must be an immutable OCI digest" )
82+ self .policy_init_image = policy_init_image
7283 self ._runner = runner or subprocess .run
7384
7485 def admit (self , request : dict [str , Any ]) -> AdmissionDecision :
@@ -126,7 +137,16 @@ def submit(self, request: dict[str, Any]) -> AllocationStatus:
126137 name = self ._job_name (request ["attempt_id" ])
127138 config_map = f"{ name } -request"
128139 request_json = json .dumps (request , sort_keys = True , separators = ("," , ":" ))
129- self ._apply (self ._config_map_manifest (config_map , name , request_json ))
140+ existing = self ._get_optional ("job" , name )
141+ if existing is not None :
142+ self ._verify_existing_submission (
143+ existing , config_map = config_map , request_json = request_json , request = request )
144+ return self ._status (existing , self ._pods (name ))
145+ existing_request = self ._get_optional ("configmap" , config_map )
146+ if existing_request is not None :
147+ self ._verify_request_config_map (existing_request , request_json )
148+ else :
149+ self ._apply (self ._config_map_manifest (config_map , name , request_json ))
130150 try :
131151 self ._apply (self ._job_manifest (name , config_map , request ))
132152 job = self ._get ("job" , name )
@@ -148,26 +168,66 @@ def submit(self, request: dict[str, Any]) -> AllocationStatus:
148168 },
149169 )
150170 except Exception :
151- self ._delete ("configmap" , config_map , ignore_not_found = True )
171+ # A concurrent submitter may have won between the first GET and the
172+ # server-side apply. Treat the exact same immutable Attempt as an
173+ # idempotent replay; never force-conflict with Kueue's ownership of
174+ # spec.suspend.
175+ existing = self ._get_optional ("job" , name )
176+ if existing is not None :
177+ self ._verify_existing_submission (
178+ existing , config_map = config_map , request_json = request_json ,
179+ request = request )
180+ return self ._status (existing , self ._pods (name ))
181+ # Do not delete the immutable request here. A concurrent submitter
182+ # may have created the same ConfigMap between our GET and apply;
183+ # deleting it would make its newly-created Job fail to mount.
152184 raise
153- return self ._status (job )
185+ return self ._status (job , self . _pods ( name ) )
154186
155187 def inspect (self , allocation_id : str ) -> AllocationStatus :
156188 namespace , name = self ._parse_allocation_id (allocation_id )
157189 try :
158190 job = self ._get ("job" , name , namespace = namespace )
159191 except KubernetesObjectNotFound :
160192 return AllocationStatus (allocation_id , "unknown" , {})
161- return self ._status (job )
193+ return self ._status (job , self . _pods ( name , namespace = namespace ) )
162194
163195 def request_cancel (self , allocation_id : str , * , grace_seconds : int ) -> None :
164196 namespace , name = self ._parse_allocation_id (allocation_id )
197+ timeout = max (30 , max (0 , grace_seconds ) + 15 )
198+ job = self ._get_optional ("job" , name , namespace = namespace )
199+ job_uid = (job or {}).get ("metadata" , {}).get ("uid" )
200+ # Orphan first: background/foreground propagation stamps the child Pod
201+ # with the template's normal shutdown grace before our explicit deletion,
202+ # and Kubernetes will not shorten that already-running grace period. Once
203+ # the controller is deleting and cannot recreate work, terminate the
204+ # orphaned executable Pods with the caller's grace.
165205 self ._run (
166206 "delete" , "job" , name ,
167207 "--namespace" , namespace ,
168208 "--grace-period" , str (max (0 , grace_seconds )),
169- "--wait=false" , "--ignore-not-found=true" ,
209+ "--cascade=orphan" , "--wait=false" ,
210+ "--ignore-not-found=true" ,
211+ )
212+ self ._run (
213+ "delete" , "pods" , "--selector" , f"job-name={ name } " ,
214+ "--namespace" , namespace ,
215+ "--grace-period" , str (max (0 , grace_seconds )),
216+ "--wait=true" , f"--timeout={ timeout } s" ,
217+ "--ignore-not-found=true" ,
170218 )
219+ # Owner-reference collection is deliberately not the cancellation
220+ # completion signal: on a quiet single-node cluster it took ~60 s.
221+ # Delete the immutable request only after the Job and its Pods are gone.
222+ self ._run ("delete" , "configmap" , f"{ name } -request" , "--namespace" , namespace ,
223+ "--wait=true" , "--ignore-not-found=true" )
224+ if job_uid :
225+ self ._run (
226+ "delete" , "workloads.kueue.x-k8s.io" ,
227+ "--selector" , f"kueue.x-k8s.io/job-uid={ job_uid } " ,
228+ "--namespace" , namespace , "--wait=true" ,
229+ "--ignore-not-found=true" ,
230+ )
171231
172232 def suspend (self , allocation_id : str ) -> None :
173233 namespace , name = self ._parse_allocation_id (allocation_id )
@@ -259,7 +319,6 @@ def _job_manifest(self, name: str, config_map: str,
259319 "spec" : {
260320 "suspend" : True ,
261321 "backoffLimit" : 0 ,
262- "activeDeadlineSeconds" : resources ["walltime_seconds" ],
263322 "ttlSecondsAfterFinished" : 86400 ,
264323 "template" : {
265324 "metadata" : {"labels" : {
@@ -268,6 +327,9 @@ def _job_manifest(self, name: str, config_map: str,
268327 }},
269328 "spec" : {
270329 ** ({"runtimeClassName" : "nvidia" } if resources ["gpus" ] else {}),
330+ # Pod-level deadline leaves a terminal Pod behind, so
331+ # kubectl logs and termination evidence survive timeout.
332+ "activeDeadlineSeconds" : resources ["walltime_seconds" ],
271333 "restartPolicy" : "Never" ,
272334 "serviceAccountName" : self .config .service_account ,
273335 "automountServiceAccountToken" : False ,
@@ -299,6 +361,28 @@ def _job_manifest(self, name: str, config_map: str,
299361 {"name" : "tmp" , "mountPath" : "/tmp" },
300362 ],
301363 }],
364+ # K3s' NetworkPolicy controller is eventually consistent
365+ # with Pod creation. Without this barrier a millisecond-fast
366+ # worker can send packets before its per-Pod firewall chain
367+ # exists. The main worker cannot start until that window has
368+ # elapsed; the live security canary proves the deny policy.
369+ ** ({"initContainers" : [{
370+ "name" : "network-policy-settle" ,
371+ "image" : self .policy_init_image ,
372+ "imagePullPolicy" : "IfNotPresent" ,
373+ "command" : ["/bin/sh" , "-c" , "sleep " + str (
374+ self .config .network_policy_settle_seconds )],
375+ "resources" : {
376+ "requests" : {"cpu" : "10m" , "memory" : "16Mi" },
377+ "limits" : {"cpu" : "100m" , "memory" : "32Mi" },
378+ },
379+ "securityContext" : {
380+ "allowPrivilegeEscalation" : False ,
381+ "capabilities" : {"drop" : ["ALL" ]},
382+ "readOnlyRootFilesystem" : True ,
383+ "runAsNonRoot" : True ,
384+ },
385+ }]} if self .config .network_policy_settle_seconds else {}),
302386 "volumes" : [
303387 {"name" : "execution-request" , "configMap" : {"name" : config_map ,
304388 "items" : [{"key" : "execution-request.json" ,
@@ -327,7 +411,7 @@ def _config_map_manifest(self, name: str, job_name: str,
327411 "data" : {"execution-request.json" : request_json },
328412 }
329413
330- def _status (self , job : dict [str , Any ]) -> AllocationStatus :
414+ def _status (self , job : dict [str , Any ], pods : tuple [ dict [ str , Any ], ...] = () ) -> AllocationStatus :
331415 metadata = job .get ("metadata" , {})
332416 spec = job .get ("spec" , {})
333417 status = job .get ("status" , {})
@@ -336,16 +420,47 @@ def _status(self, job: dict[str, Any]) -> AllocationStatus:
336420 for condition in status .get ("conditions" , ())
337421 if condition .get ("status" ) == "True"
338422 }
423+ phases = [pod .get ("status" , {}).get ("phase" , "Unknown" ) for pod in pods ]
424+ pod_reasons = [
425+ {
426+ key : pod .get ("status" , {}).get (key )
427+ for key in ("phase" , "reason" , "message" )
428+ if pod .get ("status" , {}).get (key ) is not None
429+ }
430+ for pod in pods
431+ ]
432+ termination_reasons = [
433+ terminated .get ("reason" )
434+ for pod in pods
435+ for container in pod .get ("status" , {}).get ("containerStatuses" , ())
436+ for terminated in [container .get ("state" , {}).get ("terminated" , {})]
437+ if terminated .get ("reason" )
438+ ]
439+ unschedulable = [
440+ condition
441+ for pod in pods
442+ for condition in pod .get ("status" , {}).get ("conditions" , ())
443+ if condition .get ("type" ) == "PodScheduled" and condition .get ("status" ) == "False"
444+ ]
339445 if metadata .get ("deletionTimestamp" ):
340446 state = "cancelled"
341447 elif "Complete" in conditions or status .get ("succeeded" , 0 ) > 0 :
342448 state = "succeeded"
343449 elif "Failed" in conditions or status .get ("failed" , 0 ) > 0 :
344450 state = "failed"
345- elif status .get ("active" , 0 ) > 0 :
346- state = "running"
347451 elif spec .get ("suspend" ):
348452 state = "suspended"
453+ elif "Running" in phases :
454+ state = "running"
455+ elif "Failed" in phases :
456+ # Pod.activeDeadlineSeconds and some node failures become durable
457+ # Pod terminal truth before the Job controller publishes its own
458+ # Failed condition. Never report a terminal Pod as pending.
459+ state = "failed"
460+ elif phases or status .get ("active" , 0 ) > 0 :
461+ # A Job counts an unschedulable Pending Pod as active. It is not
462+ # running until at least one Pod reaches phase=Running.
463+ state = "pending"
349464 else :
350465 state = "pending"
351466 name = metadata ["name" ]
@@ -356,6 +471,13 @@ def _status(self, job: dict[str, Any]) -> AllocationStatus:
356471 "active" : status .get ("active" , 0 ),
357472 "succeeded" : status .get ("succeeded" , 0 ),
358473 "failed" : status .get ("failed" , 0 ),
474+ "pod_phases" : phases ,
475+ "pod_status" : pod_reasons ,
476+ "termination_reasons" : termination_reasons ,
477+ "unschedulable" : [
478+ {key : item .get (key ) for key in ("reason" , "message" ) if item .get (key )}
479+ for item in unschedulable
480+ ],
359481 "conditions" : [
360482 {key : condition .get (key ) for key in
361483 ("type" , "status" , "reason" , "message" , "lastTransitionTime" )
@@ -365,6 +487,37 @@ def _status(self, job: dict[str, Any]) -> AllocationStatus:
365487 }
366488 return AllocationStatus (self ._allocation_id (name ), state , summary )
367489
490+ def _verify_existing_submission (
491+ self , job : dict [str , Any ], * , config_map : str , request_json : str ,
492+ request : dict [str , Any ],
493+ ) -> None :
494+ stored = self ._get_optional ("configmap" , config_map )
495+ self ._verify_request_config_map (stored , request_json )
496+ annotations = job .get ("metadata" , {}).get ("annotations" , {})
497+ container = ((job .get ("spec" , {}).get ("template" , {}).get ("spec" , {})
498+ .get ("containers" ) or [{}])[0 ])
499+ matches = (
500+ annotations .get ("dirac.io/execution-digest" ) == request ["execution_digest" ]
501+ and annotations .get ("dirac.io/fencing-token" ) == str (request ["fencing_token" ])
502+ and container .get ("image" ) == request ["container_image" ]
503+ and tuple (container .get ("command" , ())) == self .worker_command
504+ )
505+ if not matches :
506+ raise RuntimeError (
507+ "ATTEMPT_IDENTITY_COLLISION: scheduler Job name already exists with "
508+ "different immutable request bytes"
509+ )
510+
511+ @staticmethod
512+ def _verify_request_config_map (stored : dict [str , Any ] | None ,
513+ request_json : str ) -> None :
514+ stored_request = (stored or {}).get ("data" , {}).get ("execution-request.json" )
515+ if stored_request != request_json :
516+ raise RuntimeError (
517+ "ATTEMPT_IDENTITY_COLLISION: immutable scheduler request already "
518+ "exists with different bytes"
519+ )
520+
368521 def _available (self ) -> dict [str , Any ]:
369522 return {"scheduler" : "kubernetes+kueue" , "namespace" : self .config .namespace ,
370523 "queue" : self .config .queue_name , "capacity" : "delegated_to_kueue" }
@@ -395,6 +548,20 @@ def _get(self, kind: str, name: str, *, namespace: str | None = None) -> dict[st
395548 raise KubernetesObjectNotFound (name ) from error
396549 raise
397550
551+ def _get_optional (self , kind : str , name : str , * ,
552+ namespace : str | None = None ) -> dict [str , Any ] | None :
553+ try :
554+ return self ._get (kind , name , namespace = namespace )
555+ except KubernetesObjectNotFound :
556+ return None
557+
558+ def _pods (self , job_name : str , * , namespace : str | None = None ) -> tuple [dict [str , Any ], ...]:
559+ payload = self ._json (
560+ "get" , "pods" , "--namespace" , namespace or self .config .namespace ,
561+ "--selector" , f"job-name={ job_name } " ,
562+ )
563+ return tuple (payload .get ("items" , ()))
564+
398565 def _patch (self , kind : str , name : str , patch : dict [str , Any ],
399566 * , namespace : str | None = None ) -> None :
400567 self ._run ("patch" , kind , name , "--namespace" , namespace or self .config .namespace ,
0 commit comments