Skip to content

Commit 56edd4a

Browse files
committed
[fix.motif.sigma.D9+] make scheduler truth survive hostile replay
GPU methods previously escaped their declared adapters into an API thread pool, Kueue resubmission fought scheduler-owned fields, pending Pods masqueraded as running, cancellation blocked on controller GC, and row order/leakage changed model identity. Fail closed at the API boundary, make Attempt submission idempotent, derive state from Pods, close the policy race, bound cancellation and clean Workloads, align quota with the host, and canonicalize/refuse leaking training rows. Verified with 32 focused tests plus a real 11/11 Kueue adversarial loop on the RTX 5080.
1 parent f9dc9a6 commit 56edd4a

12 files changed

Lines changed: 477 additions & 38 deletions

‎backend/executors/kubernetes_kueue.py‎

Lines changed: 177 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

4548
class 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,

‎backend/invocation.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ def estimate(self, method_id: str, payload: dict) -> dict:
212212

213213
# ── job control surface ─────────────────────────────────────────────────
214214
def capabilities(self) -> dict:
215+
executor_adapter = self._executor_adapter()
215216
return {
216217
'job_store': {
217218
'kind': getattr(self.ledger, 'kind', 'none') if self.ledger else 'none',
@@ -223,7 +224,13 @@ def capabilities(self) -> dict:
223224
'durability': ('durable' if getattr(self, 'store_kind', '') == 'postgres'
224225
else 'process'),
225226
},
226-
'executor': {'kind': getattr(self.executor, 'kind', 'unknown')},
227+
'executor': {
228+
'kind': getattr(self.executor, 'kind', 'unknown'),
229+
'adapter': executor_adapter,
230+
'gpu_execution': executor_adapter in {
231+
'local_gpu', 'slurm', 'kubernetes'
232+
},
233+
},
227234
'command_traces': {
228235
'kind': getattr(self.command_traces, 'kind', 'none'),
229236
'durability': getattr(self.command_traces, 'durability', 'none'),
@@ -241,6 +248,43 @@ def capabilities(self) -> dict:
241248
'cancellation': 'queued-only',
242249
}
243250

251+
def _executor_adapter(self) -> str:
252+
"""Return the contract adapter represented by the injected executor.
253+
254+
A generic ``RemoteExecutor`` is only a callback boundary; it is not proof
255+
that Kubernetes or Slurm is actually connected. Deployments therefore
256+
have to set ``adapter_kind`` explicitly on a remote executor before a
257+
method that requires that adapter may run.
258+
"""
259+
explicit = getattr(self.executor, 'adapter_kind', None)
260+
if explicit:
261+
return str(explicit)
262+
return {
263+
'inline': 'inline',
264+
'thread': 'local_cpu',
265+
'process': 'local_cpu',
266+
}.get(getattr(self.executor, 'kind', 'unknown'), 'unconfigured')
267+
268+
def _require_executor_compatibility(self, spec: C.MethodSpec) -> None:
269+
"""Fail closed when a GPU method would escape its declared scheduler."""
270+
if spec.execution.get('resource_class') != 'gpu':
271+
return
272+
adapter = self._executor_adapter()
273+
supported = tuple(spec.execution.get('supported_adapters') or ())
274+
if adapter not in supported:
275+
raise failures.DiracUnsupported(
276+
f'{spec.method_id} requires a configured GPU execution adapter; '
277+
f'this kernel exposes {adapter!r}',
278+
details={
279+
'method_id': spec.method_id,
280+
'resource_class': 'gpu',
281+
'executor_kind': getattr(self.executor, 'kind', 'unknown'),
282+
'executor_adapter': adapter,
283+
'supported_adapters': list(supported),
284+
'recovery': ('configure a local_gpu, slurm, or kubernetes worker '
285+
'adapter; the API will not run this job in-process'),
286+
})
287+
244288
def list_jobs(self, *, state: str | None = None, limit: int = 100) -> list[dict]:
245289
if self.ledger is None or not hasattr(self.ledger, 'list'):
246290
return []
@@ -296,6 +340,7 @@ def submit(self, method_id: str, payload: dict, *,
296340
"""Create a reconnectable queued Job and execute it on the injected executor."""
297341
spec = self.catalog.get(method_id)
298342
self.catalog.validate(method_id, payload)
343+
self._require_executor_compatibility(spec)
299344
handler = spec.handler() # fail before minting a handle that can never run
300345
execution_identity = self._execution_identity(spec, payload, handler)
301346
if 'job' not in spec.execution.get('supported_modes', []):
@@ -393,6 +438,7 @@ def invoke(self, method_id: str, payload: dict, *,
393438
actor_ref = self._actor(actor)
394439
spec = self.catalog.get(method_id)
395440
self.catalog.validate(method_id, payload)
441+
self._require_executor_compatibility(spec)
396442
handler = spec.handler()
397443
execution_identity = self._execution_identity(spec, payload, handler)
398444

0 commit comments

Comments
 (0)