Coverage for gco / services / queue_processor.py: 100.00%
469 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""
2Queue Processor Service for GCO (Global Capacity Orchestrator on AWS).
4Polls the regional SQS job queue, reads Kubernetes manifests from messages,
5validates them, and applies them to the cluster. Designed to run as a
6short-lived pod managed by a KEDA ScaledJob that scales based on queue depth.
8Each invocation processes a single SQS message (which may contain multiple
9manifests). On success the message is deleted; on failure it returns to the
10queue after the visibility timeout (5 min) and eventually lands in the DLQ
11after 3 failed attempts.
13Message format (produced by `gco jobs submit-sqs`):
14 {
15 "job_id": "abc123",
16 "manifests": [<k8s manifest dicts>],
17 "namespace": "gco-jobs",
18 "priority": 0,
19 "submitted_at": "2026-03-26T12:00:00+00:00"
20 }
22Configuration via environment variables:
23 JOB_QUEUE_URL: SQS queue URL to consume from (required)
24 AWS_REGION: AWS region (default: us-east-1)
25 ALLOWED_NAMESPACES: Comma-separated namespace allowlist
26 (default: gco-jobs)
27 ALLOWED_KINDS: Comma-separated resource-kind allowlist shared
28 with the REST manifest processor
29 MAX_GPU_PER_MANIFEST: Max GPUs summed across all containers
30 (regular + init + ephemeral) (default: 4)
31 MAX_CPU_PER_MANIFEST: Max CPU summed across all containers; accepts
32 K8s suffixes ("500m" or "10" for cores)
33 (default: 10000 millicores = 10 cores)
34 MAX_MEMORY_PER_MANIFEST: Max memory summed across all containers;
35 accepts K8s suffixes ("32Gi", "256Mi") or
36 a bare byte count (default: 32Gi)
37 TRUSTED_REGISTRIES: Comma-separated list of registry domains
38 (e.g. "nvcr.io,public.ecr.aws"). Empty/unset
39 uses the REST processor's secure defaults.
40 Keep in sync with
41 cdk.json::job_validation_policy.trusted_registries.
42 TRUSTED_DOCKERHUB_ORGS: Comma-separated list of Docker Hub org names
43 (e.g. "nvidia,pytorch"). Empty/unset uses the
44 REST processor's secure defaults. Keep in sync
45 with cdk.json::job_validation_policy.trusted_dockerhub_orgs.
47Security policy toggles (all default to true except ``BLOCK_RUN_AS_ROOT``
48which defaults to false, matching job_validation_policy.manifest_security_policy
49in cdk.json). Each one controls whether the corresponding pod/container
50setting is rejected; the REST manifest_processor enforces an identical set
51so both submission paths apply the same policy:
53 BLOCK_PRIVILEGED: Reject ``securityContext.privileged: true``
54 on pod or container (default: true)
55 BLOCK_PRIVILEGE_ESCALATION: Reject containers with
56 allowPrivilegeEscalation=true
57 (default: true)
58 BLOCK_HOST_NETWORK: Block pods with hostNetwork=true
59 (default: true)
60 BLOCK_HOST_PID: Block pods with hostPID=true
61 (default: true)
62 BLOCK_HOST_IPC: Block pods with hostIPC=true
63 (default: true)
64 BLOCK_HOST_PATH: Block volumes referencing hostPath
65 (default: true)
66 BLOCK_ADDED_CAPABILITIES: Block containers that add Linux
67 capabilities via securityContext.capabilities.add
68 (default: true)
69 BLOCK_RUN_AS_ROOT: Reject runAsUser: 0 at pod or container
70 level (default: false — many public
71 images still run as root)
72"""
74from __future__ import annotations
76import json
77import logging
78import os
79import sys
80import time
81from typing import Any
83import boto3
84from kubernetes import client, config, dynamic
85from kubernetes.client.rest import ApiException
86from kubernetes.dynamic.exceptions import NotFoundError, ResourceNotFoundError
88from gco.manifest_security_policy import parse_boolean_environment
89from gco.models import ResourceStatus
90from gco.resource_governance import DEFAULT_MANIFEST_RESOURCE_CAPS
91from gco.services.manifest_processor import (
92 ADDON_KIND_HINTS,
93 DEFAULT_ALLOWED_KINDS,
94 DEFAULT_TRUSTED_DOCKERHUB_ORGS,
95 DEFAULT_TRUSTED_REGISTRIES,
96 extract_trainjob_pod_specs,
97 validate_resource_kind,
98)
99from gco.services.structured_logging import sanitize_log_value
100from gco.services.template_store import JobStore
102# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
103# Generated at (UTC): 2026-09-05T22:58:10Z
104# Generated from Git commit: 745b3fa3a9af9380bfe2797a5d9716fe8ce3a557
105# Flowchart(s) generated from this file:
106# * ``validate_manifest`` -> ``diagrams/code_diagrams/gco/services/queue_processor.validate_manifest.html``
107# (PNG: ``diagrams/code_diagrams/gco/services/queue_processor.validate_manifest.png``)
108# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
109# <pyflowchart-code-diagram> END
112logging.basicConfig(
113 level=logging.INFO,
114 format="%(asctime)s %(levelname)s [queue-processor] %(message)s",
115)
116log = logging.getLogger("queue-processor")
119def _parse_cpu_string(cpu_str: str) -> int:
120 """Parse a Kubernetes-style CPU string to millicores.
122 Accepts:
123 - Millicore suffix: "500m" -> 500
124 - Whole cores: "4" -> 4000
125 """
126 if not cpu_str:
127 return 0
128 s = cpu_str.strip()
129 if s.endswith("m"):
130 return int(s[:-1])
131 return int(s) * 1000
134def _parse_memory_string(memory_str: str) -> int:
135 """Parse a Kubernetes-style memory string to bytes.
137 Accepts binary suffixes (Ki, Mi, Gi, Ti), decimal suffixes (k, M, G),
138 or a bare byte count.
139 """
140 if not memory_str:
141 return 0
142 s = memory_str.strip()
143 if s.endswith("Ki"):
144 return int(float(s[:-2]) * 1024)
145 if s.endswith("Mi"):
146 return int(float(s[:-2]) * 1024**2)
147 if s.endswith("Gi"):
148 return int(float(s[:-2]) * 1024**3)
149 if s.endswith("Ti"):
150 return int(float(s[:-2]) * 1024**4)
151 if s.endswith("k"):
152 return int(float(s[:-1]) * 1000)
153 if s.endswith("M"):
154 return int(float(s[:-1]) * 1000**2)
155 if s.endswith("G"):
156 return int(float(s[:-1]) * 1000**3)
157 return int(float(s))
160# --- Configuration from environment ---
161# These are set by the KEDA ScaledJob manifest (post-helm-sqs-consumer.yaml)
162# and populated from cdk.json queue_processor settings during CDK deploy.
163QUEUE_URL = os.environ.get("JOB_QUEUE_URL", "")
164REGION = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1"))
165# Jobs-table failure recording. Set by post-helm-sqs-consumer.yaml from the
166# stack's {{JOBS_TABLE_NAME}} replacement; the companion DYNAMODB_REGION env
167# var is consumed by JobStore itself. Empty (an older deployed ScaledJob
168# template) disables recording and preserves the pre-recording behavior.
169JOBS_TABLE_NAME = os.environ.get("JOBS_TABLE_NAME", "")
170# The job queue's redrive policy dead-letters a message after
171# maxReceiveCount failed receives (3 — see the JobQueue definition in
172# gco/stacks/regional_stack.py). The receive that reaches this count is the
173# message's final delivery, so it is the one attempt whose failure is
174# recorded as a terminal FAILED job record. Env override exists for tests
175# and for operators who retune the queue's redrive policy out-of-band.
176FINAL_RECEIVE_COUNT = int(os.environ.get("QP_JOB_QUEUE_MAX_RECEIVE_COUNT", "3"))
177_allowed_namespaces_env = os.environ.get("ALLOWED_NAMESPACES")
178ALLOWED_NAMESPACES = (
179 {"gco-jobs"}
180 if _allowed_namespaces_env is None
181 else {
182 namespace.strip() for namespace in _allowed_namespaces_env.split(",") if namespace.strip()
183 }
184)
185_allowed_kinds_env = os.environ.get("ALLOWED_KINDS")
186ALLOWED_KINDS = (
187 set(DEFAULT_ALLOWED_KINDS)
188 if _allowed_kinds_env is None
189 else {kind.strip() for kind in _allowed_kinds_env.split(",") if kind.strip()}
190)
191# Defaults come from the shared source of truth
192# (gco.resource_governance.DEFAULT_MANIFEST_RESOURCE_CAPS - two full
193# accelerator-node slices) so both submission front doors and the deployed
194# cdk.json values tell one story. The old inline fallback here was "10000",
195# which this parser reads as 10,000 whole cores - a thousandfold looser than
196# the REST processor's fallback of the same era.
197MAX_CPU = _parse_cpu_string(
198 os.environ.get(
199 "MAX_CPU_PER_MANIFEST",
200 str(DEFAULT_MANIFEST_RESOURCE_CAPS["max_cpu_per_manifest"]),
201 )
202) # millicores
203MAX_MEMORY = _parse_memory_string(
204 os.environ.get(
205 "MAX_MEMORY_PER_MANIFEST",
206 str(DEFAULT_MANIFEST_RESOURCE_CAPS["max_memory_per_manifest"]),
207 )
208) # bytes
209MAX_GPU = int(
210 os.environ.get(
211 "MAX_GPU_PER_MANIFEST",
212 str(DEFAULT_MANIFEST_RESOURCE_CAPS["max_gpu_per_manifest"]),
213 )
214)
216# Accelerator resource keys and their node taint keys (taint key == resource
217# key for all three). Kept in sync with the mirror in
218# gco/services/manifest_processor.py::ACCELERATOR_TAINTS.
219ACCELERATOR_TAINTS = ("nvidia.com/gpu", "aws.amazon.com/neuron", "vpc.amazonaws.com/efa")
221# Trusted image sources (populated from cdk.json::manifest_processor at deploy time).
222# Empty/unset values use the same secure defaults as ManifestProcessor so the
223# SQS path cannot bypass REST image-source validation after a wiring error.
224TRUSTED_REGISTRIES = [
225 r.strip() for r in os.environ.get("TRUSTED_REGISTRIES", "").split(",") if r.strip()
226] or list(DEFAULT_TRUSTED_REGISTRIES)
227TRUSTED_DOCKERHUB_ORGS = [
228 o.strip() for o in os.environ.get("TRUSTED_DOCKERHUB_ORGS", "").split(",") if o.strip()
229] or list(DEFAULT_TRUSTED_DOCKERHUB_ORGS)
232# Security-policy toggles. Every one of these mirrors an attribute the REST
233# manifest_processor exposes via cdk.json::job_validation_policy.manifest_security_policy.
234# Both submission paths MUST enforce the same policy — an attacker holding
235# sqs:SendMessage on the job queue must not be able to bypass checks the REST
236# path applies. Structural parity is pinned by
237# tests/test_queue_processor.py::TestSecurityPolicyParityWithManifestProcessor.
238BLOCK_PRIVILEGED = parse_boolean_environment("BLOCK_PRIVILEGED", True)
239BLOCK_PRIVILEGE_ESCALATION = parse_boolean_environment("BLOCK_PRIVILEGE_ESCALATION", True)
240BLOCK_HOST_NETWORK = parse_boolean_environment("BLOCK_HOST_NETWORK", True)
241BLOCK_HOST_PID = parse_boolean_environment("BLOCK_HOST_PID", True)
242BLOCK_HOST_IPC = parse_boolean_environment("BLOCK_HOST_IPC", True)
243BLOCK_HOST_PATH = parse_boolean_environment("BLOCK_HOST_PATH", True)
244BLOCK_ADDED_CAPABILITIES = parse_boolean_environment("BLOCK_ADDED_CAPABILITIES", True)
245BLOCK_RUN_AS_ROOT = parse_boolean_environment("BLOCK_RUN_AS_ROOT", False)
247# Hard-reject accelerator jobs that lack a matching node toleration. Mirrors
248# manifest_processor.require_accelerator_toleration so the SQS path is not a
249# bypass.
250REQUIRE_ACCELERATOR_TOLERATION = parse_boolean_environment("REQUIRE_ACCELERATOR_TOLERATION", True)
253def _is_registry_domain(entry: str) -> bool:
254 """True if the entry looks like a registry domain (has '.' or ':')."""
255 return "." in entry or ":" in entry
258def _positive_quantity(value: Any) -> bool:
259 """True if a K8s resource quantity is present and greater than zero."""
260 if value is None:
261 return False
262 try:
263 return float(value) > 0
264 except TypeError, ValueError:
265 return True
268def _toleration_matches(tolerations: list[dict[str, Any]], taint_key: str) -> bool:
269 """True if *tolerations* tolerates the ``<taint_key>=true:NoSchedule`` taint.
271 Matches manifest_processor._toleration_matches: the toleration's ``key``
272 must equal *taint_key*, its effect must be empty or ``NoSchedule``, and it
273 must use ``operator: Exists`` or ``operator: Equal`` with ``value: "true"``.
274 """
275 for tol in tolerations:
276 if not isinstance(tol, dict) or tol.get("key") != taint_key:
277 continue
278 effect = tol.get("effect", "")
279 if effect not in ("", "NoSchedule"):
280 continue
281 operator = tol.get("operator", "Equal")
282 if operator == "Exists":
283 return True
284 if operator == "Equal" and str(tol.get("value")) == "true":
285 return True
286 return False
289def _requested_accelerators(pod_spec: dict[str, Any]) -> set[str]:
290 """Return the set of accelerator taint keys any container requests."""
291 requested: set[str] = set()
292 for _kind, c in _iter_containers(pod_spec):
293 res = c.get("resources", {}) or {}
294 for section in ("requests", "limits"):
295 values = res.get(section, {}) or {}
296 for taint in ACCELERATOR_TAINTS:
297 if _positive_quantity(values.get(taint)):
298 requested.add(taint)
299 return requested
302def _iter_containers(pod_spec: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
303 """Yield (kind, container_dict) for every container, initContainer, and
304 ephemeralContainer in a pod spec."""
305 out: list[tuple[str, dict[str, Any]]] = []
306 for c in pod_spec.get("containers", []) or []:
307 out.append(("container", c))
308 for c in pod_spec.get("initContainers", []) or []:
309 out.append(("initContainer", c))
310 for c in pod_spec.get("ephemeralContainers", []) or []:
311 out.append(("ephemeralContainer", c))
312 return out
315def _is_image_trusted(image: str) -> bool:
316 """True if the image reference is from a trusted registry or Docker Hub org.
318 Matches the semantics of manifest_processor._validate_image_sources:
319 1. Official Docker Hub images (no '/') are always allowed
320 2. Images with a registry domain (first segment has '.' or ':') must
321 match an entry in TRUSTED_REGISTRIES exactly (or a multi-segment
322 prefix like "public.ecr.aws/lambda")
323 3. Docker Hub images with an org (first segment has no '.' or ':') must
324 match an entry in TRUSTED_DOCKERHUB_ORGS
326 Empty or missing environment allowlists use the REST processor's secure
327 defaults; they never disable image-source validation.
328 """
329 if not image:
330 return True
331 if "/" not in image:
332 # Case 1: Official Docker Hub image — always trusted
333 return True
334 first = image.split("/", 1)[0]
335 if _is_registry_domain(first):
336 for registry in TRUSTED_REGISTRIES:
337 if first == registry or image.startswith(registry + "/"):
338 return True
339 return False
340 return first in TRUSTED_DOCKERHUB_ORGS
343# The standard in-cluster credential paths the kubernetes client reads. The
344# worker's ServiceAccount sets ``automountServiceAccountToken: false``, so
345# these exist ONLY because post-helm-sqs-consumer.yaml projects the
346# ``kubernetes-api-token`` volume at this exact mount point.
347_SERVICEACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount"
348_SERVICEACCOUNT_TOKEN_PATH = f"{_SERVICEACCOUNT_DIR}/token"
350# Mirrors gco.services.central_queue_worker._MAX_ERROR_LENGTH — every error
351# string persisted to the jobs table is bounded the same way on both paths.
352_MAX_ERROR_LENGTH = 2_000
355def _bounded_error(value: object) -> str:
356 """Bound user/runtime error text before persisting it in DynamoDB."""
357 text = str(value)
358 return text if len(text) <= _MAX_ERROR_LENGTH else f"{text[:_MAX_ERROR_LENGTH]}...[truncated]"
361class KubernetesConfigurationError(RuntimeError):
362 """The process cannot construct a Kubernetes client at all.
364 Distinguishes a config/environment failure (the pod can never apply
365 anything; retrying the same message cannot succeed) from a per-message
366 failure (malformed body, validation, apply errors — which retain the
367 message for visibility-timeout retry and the DLQ).
368 """
371def _incluster_failure_detail(error: Exception) -> str:
372 """Name the actual broken precondition instead of the client's generic error."""
373 service_account = (
374 os.environ.get("SERVICE_ACCOUNT_NAME") or "<unknown - SERVICE_ACCOUNT_NAME unset>"
375 )
376 if not os.path.exists(_SERVICEACCOUNT_TOKEN_PATH):
377 return (
378 f"no Kubernetes API token at {_SERVICEACCOUNT_TOKEN_PATH}. This pod runs as "
379 f"ServiceAccount {service_account!r}, which sets automountServiceAccountToken: "
380 "false, so the token exists only when the workload projects one at "
381 f"{_SERVICEACCOUNT_DIR} (the kubernetes-api-token projected volume in "
382 "post-helm-sqs-consumer.yaml). Redeploy so the base-phase ServiceAccount "
383 "hardening and the post-Helm token projection are from the same release."
384 )
385 return (
386 f"a Kubernetes API token exists at {_SERVICEACCOUNT_TOKEN_PATH} but in-cluster "
387 f"configuration still failed for ServiceAccount {service_account!r}: {error}"
388 )
391def load_k8s() -> None:
392 """Load Kubernetes configuration (in-cluster, or local kubeconfig off-cluster).
394 Inside a pod (``KUBERNETES_SERVICE_HOST`` set) an in-cluster failure is
395 terminal and raises :class:`KubernetesConfigurationError` naming the
396 missing token path and ServiceAccount — it must NOT fall through to
397 ``load_kube_config()``, whose unguarded ``ConfigException`` used to kill
398 the pod before its first SQS receive and crash-loop invisibly.
399 """
400 try:
401 config.load_incluster_config()
402 log.info("Loaded in-cluster Kubernetes configuration")
403 return
404 except config.ConfigException as incluster_error:
405 if os.environ.get("KUBERNETES_SERVICE_HOST"):
406 raise KubernetesConfigurationError(
407 _incluster_failure_detail(incluster_error)
408 ) from incluster_error
409 try:
410 config.load_kube_config()
411 log.info("Loaded local kubeconfig")
412 except config.ConfigException as kubeconfig_error:
413 raise KubernetesConfigurationError(
414 "no Kubernetes credentials available: not running in a cluster "
415 f"(KUBERNETES_SERVICE_HOST unset) and no local kubeconfig: {kubeconfig_error}"
416 ) from kubeconfig_error
419def validate_manifest(m: dict[str, Any]) -> tuple[bool, str]:
420 """Validate a manifest before applying it to the cluster.
422 The queue processor mirrors the security checks performed by the REST
423 `manifest_processor` service (``gco/services/manifest_processor.py``)
424 so that the SQS path cannot bypass them. Checks performed:
426 1. **Namespace allowlist** — manifest namespace must be in
427 ``ALLOWED_NAMESPACES`` (from ``ALLOWED_NAMESPACES`` env var,
428 populated from ``cdk.json::job_validation_policy.allowed_namespaces``,
429 shared with the REST manifest_processor).
431 2. **Pod-level security policy** (configurable via cdk.json::
432 job_validation_policy.manifest_security_policy, shared between both
433 services). Rejects ``hostNetwork``, ``hostPID``, ``hostIPC``,
434 ``hostPath`` volumes, privileged pod security context, and
435 (if ``BLOCK_RUN_AS_ROOT``) pod-level ``runAsUser: 0``.
437 3. **Container-level security policy** — for every container kind
438 (regular, init, ephemeral) rejects ``privileged``,
439 ``allowPrivilegeEscalation``, ``capabilities.add``, and (if
440 ``BLOCK_RUN_AS_ROOT``) container-level ``runAsUser: 0``. Iterating
441 every container kind catches the classic "smuggle it via an init
442 container" bypass.
444 4. **Image registry allowlist** — every container's image must come
445 from ``TRUSTED_REGISTRIES`` (registry domains like ``nvcr.io``)
446 or ``TRUSTED_DOCKERHUB_ORGS`` (Docker Hub orgs like ``nvidia``).
447 Official Docker Hub images with no slash are always allowed. Empty or
448 missing allowlists use the shared secure defaults. Keep explicit lists
449 in sync with ``cdk.json::job_validation_policy.trusted_registries`` and
450 ``trusted_dockerhub_orgs`` — CDK wires the same config into both
451 services.
453 5. **Resource caps** — the TOTAL CPU, memory, and GPU across ALL
454 containers (regular + init + ephemeral) must not exceed
455 ``MAX_CPU``, ``MAX_MEMORY``, and ``MAX_GPU``. This matches
456 ``manifest_processor._validate_resource_limits`` — K8s accounts
457 init/ephemeral resources differently at scheduling time, but
458 from an enforcement perspective we sum them so an operator's
459 ``max_*_per_manifest`` budget is a hard cap regardless of where
460 the request is placed.
462 A TrainJob has no single pod spec: checks 2-5 run over its decomposition
463 (synthetic ``spec.trainer`` view plus every pod spec embedded in
464 ``runtimePatches`` — see ``manifest_processor.TrainJobPodSpecs``), with
465 the trainer view's resources counted once per ``numNodes`` for check 5
466 and accelerator tolerations unioned across views for the toleration
467 check.
469 Returns:
470 ``(True, "")`` if the manifest is accepted, otherwise
471 ``(False, reason)`` where ``reason`` is a human-readable string.
472 """
473 kind = m.get("kind")
474 if not kind:
475 return False, "missing 'kind'"
476 api = m.get("apiVersion")
477 if not api:
478 return False, "missing 'apiVersion'"
479 meta = m.get("metadata")
480 if not isinstance(meta, dict) or not meta.get("name"):
481 return False, "missing 'metadata.name'"
482 ns = meta.get("namespace", "gco-jobs")
483 if ns not in ALLOWED_NAMESPACES:
484 return False, f"namespace '{ns}' not in allowed list {ALLOWED_NAMESPACES}"
486 kind_valid, kind_error = validate_resource_kind(m, ALLOWED_KINDS)
487 if not kind_valid:
488 return False, kind_error or "resource kind is not allowed"
490 # Get pod spec(s) for security and resource checks, each with a replica
491 # multiplier for resource-cap accounting.
492 # Handle multiple resource shapes, matching manifest_processor._get_all_containers:
493 # - Deployments / StatefulSets / ReplicaSets / DaemonSets / Jobs: spec.template.spec
494 # - CronJob: spec.jobTemplate.spec.template.spec
495 # - Pod (bare): spec (has 'containers' directly)
496 # - TrainJob: synthetic spec.trainer view weighted by numNodes, plus every
497 # pod spec embedded under spec (runtimePatches) weighted 1 — see
498 # manifest_processor.TrainJobPodSpecs for why this decomposition exists.
499 weighted_pod_specs: list[tuple[dict[str, Any], int]] = []
500 if kind == "TrainJob":
501 trainjob_specs = extract_trainjob_pod_specs(m)
502 if trainjob_specs.trainer is not None:
503 weighted_pod_specs.append((trainjob_specs.trainer, trainjob_specs.num_nodes))
504 weighted_pod_specs.extend((item, 1) for item in trainjob_specs.embedded)
505 toleration_hint_example = (
506 "examples/kubeflow-trainjob.yaml (GPU variant, via runtimePatches)"
507 )
508 else:
509 pod_spec = _extract_pod_spec(m)
510 workload_kinds = {
511 "Job",
512 "CronJob",
513 "Deployment",
514 "StatefulSet",
515 "DaemonSet",
516 "ReplicaSet",
517 "Pod",
518 }
519 if kind in workload_kinds and pod_spec is None:
520 return False, f"{kind} manifest must contain a valid pod spec"
521 if pod_spec is not None:
522 weighted_pod_specs.append((pod_spec, 1))
523 toleration_hint_example = "examples/gpu-job.yaml"
525 if weighted_pod_specs:
526 # --- Accelerator toleration check ---
527 # Mirror manifest_processor._validate_tolerations: a job requesting a
528 # GPU/Neuron/EFA resource must carry a matching toleration or it would
529 # stay Pending forever on tainted accelerator nodes. For a TrainJob the
530 # request usually lives in spec.trainer.resourcesPerNode while the
531 # toleration can only be expressed through a runtimePatches pod spec,
532 # so requests and tolerations are each unioned across every view
533 # before matching.
534 if REQUIRE_ACCELERATOR_TOLERATION:
535 requested_taints: set[str] = set()
536 tolerations: list[dict[str, Any]] = []
537 for pod_spec, _multiplier in weighted_pod_specs:
538 requested_taints.update(_requested_accelerators(pod_spec))
539 tolerations.extend(pod_spec.get("tolerations", []) or [])
540 for taint in requested_taints:
541 if not _toleration_matches(tolerations, taint):
542 hint = (
543 f"add a matching toleration (e.g. key '{taint}', operator "
544 f"'Exists', effect 'NoSchedule'); see {toleration_hint_example}"
545 )
546 return (
547 False,
548 f"Job requests accelerator '{taint}' but no matching "
549 f"toleration for taint {taint}=true:NoSchedule was found. {hint}",
550 )
552 for pod_spec, _multiplier in weighted_pod_specs:
553 # --- Pod-level security policy checks ---
554 # Mirror manifest_processor._validate_security_context so the SQS
555 # path enforces the same policy as the REST path.
556 if BLOCK_HOST_NETWORK and pod_spec.get("hostNetwork", False):
557 return False, "hostNetwork is not permitted"
558 if BLOCK_HOST_PID and pod_spec.get("hostPID", False):
559 return False, "hostPID is not permitted"
560 if BLOCK_HOST_IPC and pod_spec.get("hostIPC", False):
561 return False, "hostIPC is not permitted"
562 if BLOCK_HOST_PATH:
563 for volume in pod_spec.get("volumes", []) or []:
564 if volume.get("hostPath") is not None:
565 return False, "hostPath volumes are not permitted"
567 pod_security_context = pod_spec.get("securityContext", {}) or {}
568 if BLOCK_PRIVILEGED and pod_security_context.get("privileged", False):
569 return False, "privileged pod security context is not permitted"
570 if BLOCK_RUN_AS_ROOT:
571 pod_run_as_user = pod_security_context.get("runAsUser")
572 if pod_run_as_user is not None and pod_run_as_user == 0:
573 return False, "running as root (runAsUser: 0) is not permitted"
575 # --- Container-level security policy checks ---
576 # Every toggle is applied to every container kind (regular, init,
577 # ephemeral). An init container running as root or with CAP_SYS_ADMIN
578 # has the same blast radius as a regular container running the same
579 # way; there is no reason to give any kind a free pass.
580 for container_type, c in _iter_containers(pod_spec):
581 cname = c.get("name", "unknown")
582 sc = c.get("securityContext", {}) or {}
583 if BLOCK_PRIVILEGED and sc.get("privileged", False):
584 return (
585 False,
586 f"{container_type} '{cname}': privileged containers are not permitted",
587 )
588 if BLOCK_PRIVILEGE_ESCALATION and sc.get("allowPrivilegeEscalation", False):
589 return (
590 False,
591 f"{container_type} '{cname}': allowPrivilegeEscalation is not permitted",
592 )
593 if BLOCK_ADDED_CAPABILITIES:
594 added_caps = (sc.get("capabilities", {}) or {}).get("add", []) or []
595 if added_caps:
596 return (
597 False,
598 f"{container_type} '{cname}': added capabilities are not permitted",
599 )
600 if BLOCK_RUN_AS_ROOT:
601 ras = sc.get("runAsUser")
602 if ras is not None and ras == 0:
603 return (
604 False,
605 f"{container_type} '{cname}': running as root (runAsUser: 0) is not permitted",
606 )
608 # Enforce image registry allowlist (matches manifest_processor semantics)
609 for container_type, c in _iter_containers(pod_spec):
610 image = c.get("image", "")
611 if not _is_image_trusted(image):
612 cname = c.get("name", "unknown")
613 return (
614 False,
615 f"{container_type} '{cname}': untrusted image source '{image}'",
616 )
618 # Enforce resource caps across ALL container kinds and pod-spec views.
619 # Sum the resource requests/limits of every container (regular,
620 # init, and ephemeral), scaled by each view's replica multiplier
621 # (a TrainJob runs its trainer spec once per node — counting a
622 # 16-node GPU job as one node would make the cap meaningless).
623 # This is stricter than the K8s scheduler's accounting but matches
624 # our security intent: an operator's configured "max CPU/memory/GPU
625 # per manifest" is a hard cap on the total resources a submitter
626 # can request regardless of which container kind carries the request.
627 total_gpu = 0
628 total_cpu = 0
629 total_memory = 0
630 for pod_spec, multiplier in weighted_pod_specs:
631 for _container_type, c in _iter_containers(pod_spec):
632 res = c.get("resources", {}) or {}
633 limits = res.get("limits", {}) or {}
634 requests = res.get("requests", {}) or {}
635 gpu = limits.get("nvidia.com/gpu") or requests.get("nvidia.com/gpu", "0") # nosec B113 - dict.get(), not HTTP requests
636 total_gpu += multiplier * int(gpu)
637 cpu_str = limits.get("cpu") or requests.get("cpu", "0") # nosec B113 - dict.get(), not HTTP requests
638 if isinstance(cpu_str, str) and cpu_str.endswith("m"):
639 total_cpu += multiplier * int(cpu_str[:-1])
640 else:
641 total_cpu += multiplier * int(float(cpu_str) * 1000)
642 mem_str = limits.get("memory") or requests.get("memory", "0") # nosec B113 - dict.get(), not HTTP requests
643 mem_bytes = _parse_memory_string(str(mem_str))
644 total_memory += multiplier * mem_bytes
646 errors = []
647 if total_gpu > MAX_GPU:
648 errors.append(f"GPU {total_gpu} exceeds max {MAX_GPU}")
649 if total_cpu > MAX_CPU:
650 errors.append(f"CPU {total_cpu}m exceeds max {MAX_CPU}m")
651 if total_memory > MAX_MEMORY:
652 errors.append(
653 f"Memory {total_memory / (1024**3):.0f}Gi "
654 f"exceeds max {MAX_MEMORY / (1024**3):.0f}Gi"
655 )
656 if errors:
657 hint = (
658 "To raise limits, update queue_processor in cdk.json "
659 "and redeploy (see examples/README.md#troubleshooting)"
660 )
661 return False, "; ".join(errors) + f". {hint}"
663 return True, ""
666def _extract_pod_spec(manifest: dict[str, Any]) -> dict[str, Any] | None:
667 """Return the pod spec for any supported workload kind, or None.
669 Mirrors manifest_processor._extract_pod_spec so the SQS path and the
670 REST path apply the same injection semantics.
671 """
672 spec = manifest.get("spec")
673 if not isinstance(spec, dict):
674 return None
676 kind = manifest.get("kind", "")
678 # CronJob: spec.jobTemplate.spec.template.spec
679 if kind == "CronJob":
680 job_template = spec.get("jobTemplate")
681 if isinstance(job_template, dict):
682 job_spec = job_template.get("spec")
683 if isinstance(job_spec, dict):
684 template = job_spec.get("template")
685 if isinstance(template, dict):
686 pod_spec = template.get("spec")
687 if isinstance(pod_spec, dict):
688 return pod_spec
689 return None
691 # Deployment / StatefulSet / DaemonSet / ReplicaSet / Job: spec.template.spec
692 if "template" in spec:
693 template = spec.get("template")
694 if isinstance(template, dict):
695 pod_spec = template.get("spec")
696 if isinstance(pod_spec, dict):
697 return pod_spec
698 return None
700 # Bare Pod: spec contains "containers" directly
701 if "containers" in spec:
702 return spec
704 return None
707def _inject_security_defaults(manifest: dict[str, Any]) -> dict[str, Any]:
708 """Inject security defaults into a user-submitted manifest in-place.
710 Currently sets ``automountServiceAccountToken: false`` on the pod spec
711 unless the user has explicitly set it either way (uses setdefault).
713 Mirrors manifest_processor._inject_security_defaults so jobs submitted
714 via SQS get the same SA-token-theft protection as those submitted via
715 the REST API.
717 For a TrainJob the default is injected into every pod spec embedded in
718 ``runtimePatches`` (live references into the manifest, so setdefault
719 mutates it in place); the base pod template comes from the shipped
720 ClusterTrainingRuntime, which already disables the token.
721 """
722 if manifest.get("kind") == "TrainJob":
723 for embedded in extract_trainjob_pod_specs(manifest).embedded:
724 embedded.setdefault("automountServiceAccountToken", False)
725 return manifest
726 pod_spec = _extract_pod_spec(manifest)
727 if pod_spec is not None:
728 pod_spec.setdefault("automountServiceAccountToken", False)
729 return manifest
732def _is_job_finished(job_resource: dict[str, Any]) -> bool:
733 """Return whether a Kubernetes Job has a true terminal condition."""
734 status = job_resource.get("status", {})
735 conditions = status.get("conditions") or [] if isinstance(status, dict) else []
736 return any(
737 isinstance(condition, dict)
738 and condition.get("type") in ("Complete", "Failed")
739 and condition.get("status") == "True"
740 for condition in conditions
741 )
744def apply_manifest(m: dict[str, Any]) -> ResourceStatus:
745 """Apply one prevalidated manifest and return an explicit operation status.
747 Unsupported API resources are failures, never successful skips. This keeps
748 the owning SQS message available for retry and eventual DLQ inspection.
749 """
750 # Inject security defaults BEFORE applying so user pods never
751 # auto-mount the default SA token (T-022 / M-113 parity with the
752 # REST manifest_processor path).
753 _inject_security_defaults(m)
755 api_version = m["apiVersion"]
756 kind = m["kind"]
757 name = m["metadata"]["name"]
758 namespace = m["metadata"].get("namespace", "gco-jobs")
760 def status(result: str, message: str) -> ResourceStatus:
761 return ResourceStatus(
762 api_version=api_version,
763 kind=kind,
764 name=name,
765 namespace=namespace,
766 status=result,
767 message=message,
768 )
770 dyn = dynamic.DynamicClient(client.ApiClient())
771 try:
772 resource = dyn.resources.get(api_version=api_version, kind=kind)
773 except ResourceNotFoundError:
774 # A policy-allowed kind whose addon is not installed gets the
775 # actionable remedy appended; the stable prefix is kept for DLQ
776 # triage tooling.
777 addon_hint = ADDON_KIND_HINTS.get(kind)
778 detail = f" ({addon_hint})" if addon_hint else ""
779 return status("failed", f"Unsupported Kubernetes resource {api_version}/{kind}{detail}")
781 # For Jobs, delete completed/failed ones first so re-submission works.
782 # Without this, re-submitting the same job name would fail with a 409 conflict
783 # because Kubernetes doesn't allow creating a Job with the same name as an
784 # existing one (even if it's finished).
785 if kind == "Job":
786 try:
787 existing = resource.get(name=name, namespace=namespace)
788 if _is_job_finished(existing):
789 log.info("Deleting finished Job %s/%s before re-creation", namespace, name)
790 resource.delete(
791 name=name,
792 namespace=namespace,
793 body=client.V1DeleteOptions(propagation_policy="Background"),
794 )
795 time.sleep(2)
796 except (NotFoundError, ApiException) as e:
797 log.debug("Pre-create lookup for Job %s/%s failed: %s", namespace, name, e)
799 # Create-or-update pattern: try create first, fall back to patch on 409 (conflict).
800 # This is idempotent — safe to retry without side effects.
801 try:
802 if resource.namespaced:
803 resource.create(body=m, namespace=namespace)
804 else:
805 resource.create(body=m)
806 return status("created", "Resource created successfully")
807 except ApiException as e:
808 if e.status == 409:
809 try:
810 if resource.namespaced:
811 resource.patch(body=m, name=name, namespace=namespace)
812 else:
813 resource.patch(body=m, name=name)
814 return status("updated", "Resource updated successfully")
815 except ApiException as patch_err:
816 return status("failed", f"Patch failed: {patch_err.reason}")
817 return status("failed", f"Create failed: {e.reason}")
818 except Exception as e:
819 return status("failed", f"Unexpected apply error: {e}")
822def process_one_message() -> bool:
823 """Receive one SQS message and delete it only after complete success.
825 ``True`` means either the poll was empty or the received message was fully
826 validated, applied, and deleted. ``False`` means the message was not
827 acknowledged (or queue configuration was invalid). Malformed, empty,
828 invalid, unsupported, and apply-failed messages deliberately remain in SQS
829 for visibility-timeout retries and eventual dead-letter-queue handling.
830 """
831 if not QUEUE_URL:
832 log.error("JOB_QUEUE_URL not set")
833 return False
835 sqs = boto3.client("sqs", region_name=REGION)
837 resp = sqs.receive_message(
838 QueueUrl=QUEUE_URL,
839 MaxNumberOfMessages=1,
840 WaitTimeSeconds=5,
841 MessageAttributeNames=["All"],
842 AttributeNames=["ApproximateReceiveCount"],
843 )
845 messages = resp.get("Messages", [])
846 if not messages:
847 log.info("No messages in queue")
848 return True
850 msg = messages[0]
851 receipt = msg.get("ReceiptHandle")
852 if not receipt:
853 log.error("Received SQS message without a receipt handle; cannot acknowledge it")
854 return False
856 try:
857 body = json.loads(msg.get("Body", ""))
858 except (json.JSONDecodeError, TypeError) as e:
859 log.error("Malformed SQS message body; retaining for retry/DLQ: %s", e)
860 return False
862 if not isinstance(body, dict):
863 log.error("SQS message body must be a JSON object; retaining for retry/DLQ")
864 return False
866 job_id = body.get("job_id", "unknown")
867 manifests = body.get("manifests")
868 if not isinstance(manifests, list) or not manifests:
869 log.error(
870 "Job %s must contain a non-empty manifests list; retaining for retry/DLQ",
871 job_id,
872 )
873 return False
874 if any(not isinstance(manifest, dict) for manifest in manifests):
875 log.error("Job %s contains a non-object manifest; retaining for retry/DLQ", job_id)
876 return False
878 log.info("Processing job_id=%s, manifests=%d", job_id, len(manifests))
880 # Validate the entire batch before applying anything. A disallowed resource
881 # later in the message must not leave an earlier resource partially applied.
882 validation_errors: list[tuple[int, str]] = []
883 for i, manifest in enumerate(manifests):
884 try:
885 ok, reason = validate_manifest(manifest)
886 except Exception as e:
887 ok, reason = False, f"validation error: {e}"
888 if not ok:
889 validation_errors.append((i, reason))
890 if validation_errors:
891 for i, reason in validation_errors:
892 log.error(" manifest[%d] validation failed: %s", i, reason)
893 log.error("Job %s failed prevalidation; message will return to queue", job_id)
894 _record_failure_on_final_receive(
895 msg,
896 body,
897 job_id,
898 message="SQS job failed prevalidation",
899 error="; ".join(f"manifest[{i}]: {reason}" for i, reason in validation_errors),
900 )
901 return False
903 failed = False
904 failure_details: list[str] = []
905 for i, manifest in enumerate(manifests):
906 try:
907 result = apply_manifest(manifest)
908 except Exception as e:
909 log.error(" manifest[%d] apply raised: %s", i, e)
910 failed = True
911 failure_details.append(f"manifest[{i}] apply raised: {e}")
912 continue
913 log.info(
914 " manifest[%d]: %s %s/%s: %s",
915 i,
916 result.status,
917 result.kind,
918 result.name,
919 result.message or "",
920 )
921 if not result.is_successful():
922 failed = True
923 failure_details.append(
924 f"manifest[{i}] {result.kind}/{result.name}: {result.message or result.status}"
925 )
927 if failed:
928 # Don't delete the SQS message — it will become visible again after the
929 # visibility timeout and retry. The queue redrive policy eventually
930 # moves it to the DLQ for operator inspection.
931 log.error("Job %s had failures; message will return to queue", job_id)
932 _record_failure_on_final_receive(
933 msg,
934 body,
935 job_id,
936 message="SQS job could not be applied to Kubernetes",
937 error="; ".join(failure_details),
938 )
939 return False
941 sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=receipt)
942 log.info("Job %s processed successfully", job_id)
943 return True
946def _receive_count(msg: dict[str, Any]) -> int:
947 """The message's ApproximateReceiveCount, defaulting to 1 when unreadable."""
948 raw = (msg.get("Attributes") or {}).get("ApproximateReceiveCount", "1")
949 try:
950 return max(int(raw), 1)
951 except TypeError, ValueError:
952 return 1
955def _record_job_failure(
956 job_id: str,
957 *,
958 namespace: str,
959 error: str,
960 message: str,
961 priority: int = 0,
962 submitted_at: str | None = None,
963) -> bool:
964 """Best-effort terminal FAILED record for an SQS job; never raises.
966 Recording failures must not change SQS retention semantics — the queue
967 and its DLQ remain the source of truth when DynamoDB is unreachable, so
968 every failure here is swallowed after logging.
969 """
970 if not JOBS_TABLE_NAME:
971 log.error(
972 "JOBS_TABLE_NAME not set; job %s failure will not be recorded",
973 sanitize_log_value(job_id),
974 )
975 return False
976 try:
977 store = JobStore(table_name=JOBS_TABLE_NAME)
978 created = store.record_job_failure(
979 job_id,
980 target_region=REGION,
981 namespace=namespace,
982 error=_bounded_error(error),
983 message=message,
984 priority=priority,
985 submitted_at=submitted_at,
986 )
987 except Exception:
988 log.exception("Unable to record FAILED for job %s", sanitize_log_value(job_id))
989 return False
990 if created:
991 log.info("Recorded FAILED job record for %s", sanitize_log_value(job_id))
992 else:
993 log.info(
994 "Job %s already has a centralized queue record; leaving it untouched",
995 sanitize_log_value(job_id),
996 )
997 return created
1000def _record_failure_on_final_receive(
1001 msg: dict[str, Any],
1002 body: dict[str, Any],
1003 job_id: str,
1004 *,
1005 message: str,
1006 error: str,
1007) -> None:
1008 """Persist a FAILED record when this receive is the message's last delivery.
1010 Earlier receives keep today's retain-for-retry behavior untouched — a
1011 transient failure that succeeds on retry must not leave a terminal
1012 record. On the final delivery (the receive that exhausts the queue's
1013 redrive maxReceiveCount) the message is about to dead-letter, so its
1014 job is recorded FAILED with the bounded failure detail.
1015 """
1016 if _receive_count(msg) < FINAL_RECEIVE_COUNT:
1017 return
1018 priority = body.get("priority", 0)
1019 if not isinstance(priority, int):
1020 priority = 0
1021 submitted_at = body.get("submitted_at")
1022 _record_job_failure(
1023 job_id,
1024 namespace=str(body.get("namespace", "gco-jobs")),
1025 error=error,
1026 message=message,
1027 priority=priority,
1028 submitted_at=submitted_at if isinstance(submitted_at, str) else None,
1029 )
1032def drain_one_message_after_config_failure(reason: str) -> bool:
1033 """Convert one queued message into a visible FAILED record, then delete it.
1035 A pod that cannot construct a Kubernetes client can never apply
1036 anything, and the failure happens before the first SQS receive — so
1037 the receive count never increments, the redrive policy never fires,
1038 and KEDA restarts the loop forever with nothing recorded. That is the
1039 exact shape of the SQS submission-path outage this module is being
1040 hardened against. Draining one message per failed pod records the
1041 terminal failure where operators and submitters look (the jobs table)
1042 while emptying the queue at the same bounded rate KEDA scales pods.
1044 The message is deleted ONLY after its ``job_id`` was recorded FAILED.
1045 A message whose ``job_id`` cannot be parsed — or whose record could not
1046 be written — is retained for the normal visibility-timeout/DLQ path.
1047 Returns whether one message was drained with a record.
1048 """
1049 if not QUEUE_URL:
1050 log.error("JOB_QUEUE_URL not set")
1051 return False
1052 if not JOBS_TABLE_NAME:
1053 log.error("JOBS_TABLE_NAME not set; cannot record failures, leaving the queue untouched")
1054 return False
1056 sqs = boto3.client("sqs", region_name=REGION)
1057 resp = sqs.receive_message(
1058 QueueUrl=QUEUE_URL,
1059 MaxNumberOfMessages=1,
1060 WaitTimeSeconds=5,
1061 MessageAttributeNames=["All"],
1062 AttributeNames=["ApproximateReceiveCount"],
1063 )
1064 messages = resp.get("Messages", [])
1065 if not messages:
1066 log.info("No messages in queue to drain")
1067 return False
1069 msg = messages[0]
1070 receipt = msg.get("ReceiptHandle")
1071 try:
1072 body = json.loads(msg.get("Body", ""))
1073 except json.JSONDecodeError, TypeError:
1074 body = None
1075 job_id = body.get("job_id") if isinstance(body, dict) else None
1076 if not receipt or not isinstance(job_id, str) or not job_id:
1077 log.error("Cannot identify the job in the queued message; retaining it for the DLQ")
1078 return False
1080 priority = body.get("priority", 0)
1081 submitted_at = body.get("submitted_at")
1082 recorded = _record_job_failure(
1083 job_id,
1084 namespace=str(body.get("namespace", "gco-jobs")),
1085 error=f"queue processor could not initialize Kubernetes credentials: {reason}",
1086 message="Queue processor configuration failure",
1087 priority=priority if isinstance(priority, int) else 0,
1088 submitted_at=submitted_at if isinstance(submitted_at, str) else None,
1089 )
1090 if not recorded:
1091 return False
1093 sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=receipt)
1094 log.error(
1095 "Job %s recorded FAILED and drained after a configuration failure",
1096 sanitize_log_value(job_id),
1097 )
1098 return True
1101def main() -> None:
1102 """Entry point for the queue processor.
1104 Configuration failures are terminal and loud: the error is emitted in a
1105 single structured line, one queued message is drained into a FAILED job
1106 record so the queue depth and job status both move, and the process
1107 exits nonzero. Per-message failures keep their retain-for-retry
1108 semantics via :func:`process_one_message`.
1109 """
1110 try:
1111 load_k8s()
1112 except KubernetesConfigurationError as error:
1113 drained = drain_one_message_after_config_failure(str(error))
1114 log.error(
1115 "terminal=config-failure drained_with_record=%s detail=%s",
1116 drained,
1117 error,
1118 )
1119 sys.exit(1)
1120 success = process_one_message()
1121 if not success:
1122 sys.exit(1)
1125if __name__ == "__main__":
1126 main()