Coverage for gco / services / manifest_processor.py: 100.00%

562 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Manifest Processor Service for GCO (Global Capacity Orchestrator on AWS). 

3 

4This service processes Kubernetes manifest submissions, validates them against 

5security and resource constraints, and applies them to the cluster. 

6 

7Key Features: 

8- Validates manifests for required fields and structure 

9- Enforces namespace restrictions (only allowed namespaces) 

10- Enforces resource limits (CPU, memory, GPU per manifest) 

11- Validates security context (no privileged containers) 

12- Validates image sources (trusted registries only) 

13- Supports dry-run mode for validation without applying 

14 

15Security Validations: 

16- Namespace must be in allowed list (default: gco-jobs) 

17- No privileged containers or privilege escalation 

18- Images must be from trusted registries 

19- Resource requests/limits within configured maximums 

20 

21Environment Variables: 

22 CLUSTER_NAME: Name of the EKS cluster 

23 REGION: AWS region of the cluster 

24 MAX_CPU_PER_MANIFEST: Maximum CPU per manifest (default: 384 cores — see 

25 gco.resource_governance.DEFAULT_MANIFEST_RESOURCE_CAPS) 

26 MAX_MEMORY_PER_MANIFEST: Maximum memory per manifest (default: 4096Gi) 

27 MAX_GPU_PER_MANIFEST: Maximum GPUs per manifest (default: 16) 

28 ALLOWED_NAMESPACES: Comma-separated list of allowed namespaces 

29 VALIDATION_ENABLED: Enable/disable validation (default: true) 

30 

31Usage: 

32 processor = create_manifest_processor_from_env() 

33 response = await processor.process_manifest_submission(request) 

34""" 

35 

36from __future__ import annotations 

37 

38import copy 

39import hashlib 

40import logging 

41import os 

42import re 

43from typing import Any, cast 

44 

45import yaml 

46from kubernetes import client, config, dynamic 

47from kubernetes.client.models import V1Job 

48from kubernetes.client.rest import ApiException 

49from kubernetes.dynamic.exceptions import ResourceNotFoundError 

50 

51# Pure admission helpers and the policy constants they read live in 

52# gco.job_admission, which imports no Kubernetes client. Re-exported here 

53# unchanged: these names are part of this module's import surface for the 

54# queue processor, the REST API, the CLI and the offline example validator, 

55# and relocating them is not meant to break any of them. The `X as X` form 

56# is deliberate -- under mypy's no_implicit_reexport a plain import would 

57# not be visible to those importers. 

58from gco.job_admission import ( 

59 ACCELERATOR_TAINTS as ACCELERATOR_TAINTS, 

60) 

61from gco.job_admission import ( 

62 ADDON_KIND_HINTS as ADDON_KIND_HINTS, 

63) 

64from gco.job_admission import ( 

65 DEFAULT_ALLOWED_KINDS as DEFAULT_ALLOWED_KINDS, 

66) 

67from gco.job_admission import ( 

68 DEFAULT_TRUSTED_DOCKERHUB_ORGS as DEFAULT_TRUSTED_DOCKERHUB_ORGS, 

69) 

70from gco.job_admission import ( 

71 DEFAULT_TRUSTED_REGISTRIES as DEFAULT_TRUSTED_REGISTRIES, 

72) 

73from gco.job_admission import ( 

74 RESOURCE_API_VERSIONS as RESOURCE_API_VERSIONS, 

75) 

76from gco.job_admission import ( 

77 TRAINJOB_API_VERSION as TRAINJOB_API_VERSION, 

78) 

79from gco.job_admission import ( 

80 JobValidationPolicy as JobValidationPolicy, 

81) 

82from gco.job_admission import ( 

83 TrainJobPodSpecs as TrainJobPodSpecs, 

84) 

85from gco.job_admission import ( 

86 _collect_embedded_pod_specs as _collect_embedded_pod_specs, 

87) 

88from gco.job_admission import ( 

89 _extract_validation_pod_spec as _extract_validation_pod_spec, 

90) 

91from gco.job_admission import ( 

92 _is_trusted_registry_domain as _is_trusted_registry_domain, 

93) 

94from gco.job_admission import ( 

95 _iter_all_containers as _iter_all_containers, 

96) 

97from gco.job_admission import ( 

98 _positive_quantity as _positive_quantity, 

99) 

100from gco.job_admission import ( 

101 _toleration_matches as _toleration_matches, 

102) 

103from gco.job_admission import ( 

104 _untrusted_container_in_pod_spec as _untrusted_container_in_pod_spec, 

105) 

106from gco.job_admission import ( 

107 check_resource_caps as check_resource_caps, 

108) 

109from gco.job_admission import ( 

110 check_security_context as check_security_context, 

111) 

112from gco.job_admission import ( 

113 check_tolerations as check_tolerations, 

114) 

115from gco.job_admission import ( 

116 extract_pod_spec as extract_pod_spec, 

117) 

118from gco.job_admission import ( 

119 extract_trainjob_pod_specs as extract_trainjob_pod_specs, 

120) 

121from gco.job_admission import ( 

122 parse_cpu_millicores as parse_cpu_millicores, 

123) 

124from gco.job_admission import ( 

125 parse_memory_bytes as parse_memory_bytes, 

126) 

127from gco.job_admission import ( 

128 requested_accelerators as requested_accelerators, 

129) 

130from gco.job_admission import ( 

131 trainjob_validation_pod_specs as trainjob_validation_pod_specs, 

132) 

133from gco.job_admission import ( 

134 validate_image_sources as validate_image_sources, 

135) 

136from gco.job_admission import ( 

137 validate_resource_kind as validate_resource_kind, 

138) 

139from gco.job_admission import ( 

140 weighted_pod_specs as weighted_pod_specs, 

141) 

142from gco.manifest_security_policy import ( 

143 MANIFEST_SECURITY_POLICY_DEFAULTS, 

144 parse_boolean_environment, 

145 validate_manifest_security_policy, 

146) 

147from gco.models import ( 

148 ManifestSubmissionRequest, 

149 ManifestSubmissionResponse, 

150 ResourceStatus, 

151) 

152from gco.resource_governance import DEFAULT_MANIFEST_RESOURCE_CAPS 

153from gco.services.structured_logging import configure_structured_logging, sanitize_log_value 

154 

155# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

156# Generated at (UTC): 2026-09-01T14:42:56Z 

157# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

158# Flowchart(s) generated from this file: 

159# * ``ManifestProcessor.apply_queued_job`` -> ``diagrams/code_diagrams/gco/services/manifest_processor.ManifestProcessor_apply_queued_job.html`` 

160# (PNG: ``diagrams/code_diagrams/gco/services/manifest_processor.ManifestProcessor_apply_queued_job.png``) 

161# * ``ManifestProcessor.validate_manifest`` -> ``diagrams/code_diagrams/gco/services/manifest_processor.ManifestProcessor_validate_manifest.html`` 

162# (PNG: ``diagrams/code_diagrams/gco/services/manifest_processor.ManifestProcessor_validate_manifest.png``) 

163# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

164# <pyflowchart-code-diagram> END 

165 

166 

167# NOTE: No logging.basicConfig() here. This module is imported by the CLI 

168# (cli/jobs.py, cli/commands/*_cmd.py) as a library for YAML loading helpers. 

169# Calling basicConfig() at import time would configure the root logger with 

170# INFO-level output, causing noisy botocore/urllib3 INFO messages on every 

171# CLI command. Container entry points (manifest_api.py) do their own 

172# basicConfig() call. 

173logger = logging.getLogger(__name__) 

174 

175 

176class RetryableQueuedJobApplyError(RuntimeError): 

177 """A deterministic queued Job apply can be retried or adopted safely.""" 

178 

179 

180class QueuedJobNotCreatedError(ValueError): 

181 """A queued Job was rejected before any Kubernetes operation began.""" 

182 

183 

184def _is_retryable_kubernetes_api_error(error: ApiException) -> bool: 

185 """Classify throttling, server, and transport-like Kubernetes API failures.""" 

186 try: 

187 status = int(error.status or 0) 

188 except TypeError, ValueError: 

189 status = 0 

190 return status == 0 or status in {408, 429} or status >= 500 

191 

192 

193# --------------------------------------------------------------------------- 

194# YAML Alias Rejection Loader 

195# --------------------------------------------------------------------------- 

196 

197 

198class NoAliasSafeLoader(yaml.SafeLoader): 

199 """A YAML SafeLoader that rejects anchors and aliases. 

200 

201 YAML anchors (``&anchor``) and aliases (``*anchor``) can be used to 

202 construct exponentially large data structures (billion-laughs attack). 

203 This loader raises an error when any alias is encountered, preventing 

204 such attacks at the parsing stage. 

205 """ 

206 

207 def compose_node(self, parent: Any, index: Any) -> Any: 

208 if self.check_event(yaml.AliasEvent): 

209 event = self.get_event() # type: ignore[no-untyped-call] 

210 raise yaml.composer.ComposerError( 

211 None, 

212 None, 

213 "YAML aliases are not allowed " 

214 "(security policy: yaml_allow_aliases=false), " 

215 f"found alias *{event.anchor}", 

216 event.start_mark, 

217 ) 

218 return super().compose_node(parent, index) 

219 

220 

221def safe_load_yaml(stream: str | Any, *, allow_aliases: bool = False) -> Any: 

222 """Load a single YAML document with optional alias rejection. 

223 

224 Args: 

225 stream: YAML string or file-like object. 

226 allow_aliases: If False (default), reject YAML anchors/aliases. 

227 

228 Returns: 

229 Parsed YAML document. 

230 

231 Raises: 

232 yaml.YAMLError: If the document is invalid or contains aliases 

233 when ``allow_aliases`` is False. 

234 """ 

235 loader_cls = yaml.SafeLoader if allow_aliases else NoAliasSafeLoader 

236 # Loader is always a SafeLoader subclass (SafeLoader or NoAliasSafeLoader), 

237 # so this is equivalent to yaml.safe_load. Bandit's B506 check does not 

238 # recognize the custom loader as safe. 

239 return yaml.load(stream, Loader=loader_cls) # nosec B506 

240 

241 

242def safe_load_all_yaml(stream: str | Any, *, allow_aliases: bool = False) -> list[Any]: 

243 """Load all YAML documents from a stream with optional alias rejection. 

244 

245 Args: 

246 stream: YAML string or file-like object. 

247 allow_aliases: If False (default), reject YAML anchors/aliases. 

248 

249 Returns: 

250 List of parsed YAML documents (``None`` documents are skipped). 

251 

252 Raises: 

253 yaml.YAMLError: If any document is invalid or contains aliases 

254 when ``allow_aliases`` is False. 

255 """ 

256 loader_cls = yaml.SafeLoader if allow_aliases else NoAliasSafeLoader 

257 # Loader is always a SafeLoader subclass, so this is equivalent to 

258 # yaml.safe_load_all. Bandit's B506 check does not recognize the custom 

259 # loader as safe. 

260 return [ 

261 doc 

262 for doc in yaml.load_all(stream, Loader=loader_cls) 

263 if doc is not None # nosec B506 

264 ] 

265 

266 

267class ManifestProcessor: 

268 """ 

269 Processes Kubernetes manifest submissions and applies them to the cluster 

270 """ 

271 

272 def __init__(self, cluster_id: str, region: str, config_dict: dict[str, Any]): 

273 self.cluster_id = cluster_id 

274 self.region = region 

275 self.config = config_dict 

276 

277 # Initialize Kubernetes clients 

278 try: 

279 # Try to load in-cluster config first (when running in pod) 

280 config.load_incluster_config() 

281 logger.info("Loaded in-cluster Kubernetes configuration") 

282 except config.ConfigException: 

283 try: 

284 # Fall back to local kubeconfig (for development) 

285 config.load_kube_config() 

286 logger.info("Loaded local Kubernetes configuration") 

287 except config.ConfigException as e: 

288 logger.error(f"Failed to load Kubernetes configuration: {e}") 

289 raise 

290 

291 # Initialize API clients 

292 self.api_client = client.ApiClient() 

293 self.api_client.configuration.request_timeout = int(os.environ.get("K8S_API_TIMEOUT", "30")) 

294 self.core_v1 = client.CoreV1Api() 

295 self.apps_v1 = client.AppsV1Api() 

296 self.batch_v1 = client.BatchV1Api() 

297 self.networking_v1 = client.NetworkingV1Api() 

298 self.custom_objects = client.CustomObjectsApi() 

299 

300 # Dynamic client for CRDs - lazy initialized to avoid cluster connection during init 

301 self._dynamic_client: dynamic.DynamicClient | None = None 

302 

303 # Timeout for Kubernetes API calls (seconds) 

304 self._k8s_timeout = int(os.environ.get("K8S_API_TIMEOUT", "30")) 

305 

306 # Resource quotas and limits. Defaults come from the shared source of 

307 # truth (gco.resource_governance.DEFAULT_MANIFEST_RESOURCE_CAPS): two 

308 # full accelerator-node slices, validated at synth against the 

309 # LimitRange / namespace-quota layering invariant. 

310 self.max_cpu_per_manifest = self._parse_cpu_string( 

311 config_dict.get( 

312 "max_cpu_per_manifest", 

313 DEFAULT_MANIFEST_RESOURCE_CAPS["max_cpu_per_manifest"], 

314 ) 

315 ) 

316 self.max_memory_per_manifest = self._parse_memory_string( 

317 config_dict.get( 

318 "max_memory_per_manifest", 

319 DEFAULT_MANIFEST_RESOURCE_CAPS["max_memory_per_manifest"], 

320 ) 

321 ) 

322 self.max_gpu_per_manifest = int( 

323 config_dict.get( 

324 "max_gpu_per_manifest", 

325 DEFAULT_MANIFEST_RESOURCE_CAPS["max_gpu_per_manifest"], 

326 ) 

327 ) 

328 # Hard-reject accelerator jobs that lack a matching node toleration. 

329 # Kept in sync with queue_processor.REQUIRE_ACCELERATOR_TOLERATION. 

330 require_accelerator_toleration = config_dict.get("require_accelerator_toleration", True) 

331 if type(require_accelerator_toleration) is not bool: 

332 raise ValueError("require_accelerator_toleration must be a boolean") 

333 self.require_accelerator_toleration = require_accelerator_toleration 

334 self.allowed_namespaces = set(config_dict.get("allowed_namespaces", ["gco-jobs"])) 

335 validation_enabled = config_dict.get("validation_enabled", True) 

336 if type(validation_enabled) is not bool: 

337 raise ValueError("validation_enabled must be a boolean") 

338 self.validation_enabled = validation_enabled 

339 

340 # Trusted registries for image validation (configurable via cdk.json) 

341 self.trusted_registries = config_dict.get( 

342 "trusted_registries", list(DEFAULT_TRUSTED_REGISTRIES) 

343 ) 

344 self.trusted_dockerhub_orgs = config_dict.get( 

345 "trusted_dockerhub_orgs", list(DEFAULT_TRUSTED_DOCKERHUB_ORGS) 

346 ) 

347 

348 # Warn about trusted_registries entries that look like Docker Hub orgs (no dot or colon) 

349 for registry in self.trusted_registries: 

350 if not self._is_registry_domain(registry): 

351 logger.warning( 

352 f"Trusted registry '{registry}' has no domain separator (dot or colon) — " 

353 f"consider moving it to trusted_dockerhub_orgs instead" 

354 ) 

355 

356 # YAML parsing limits (configurable via cdk.json) 

357 self.yaml_max_depth = int(config_dict.get("yaml_max_depth", 50)) 

358 

359 # Allowed resource kinds (configurable via cdk.json) 

360 self.allowed_kinds = set(config_dict.get("allowed_kinds", DEFAULT_ALLOWED_KINDS)) 

361 

362 # Security policy — toggleable checks (configurable via cdk.json) 

363 security_policy = validate_manifest_security_policy( 

364 config_dict.get("manifest_security_policy", {}) 

365 ) 

366 self.block_privileged = security_policy.get("block_privileged", True) 

367 self.block_privilege_escalation = security_policy.get("block_privilege_escalation", True) 

368 self.block_host_network = security_policy.get("block_host_network", True) 

369 self.block_host_pid = security_policy.get("block_host_pid", True) 

370 self.block_host_ipc = security_policy.get("block_host_ipc", True) 

371 self.block_host_path = security_policy.get("block_host_path", True) 

372 self.block_added_capabilities = security_policy.get("block_added_capabilities", True) 

373 self.block_run_as_root = security_policy.get("block_run_as_root", False) 

374 

375 # ------------------------------------------------------------------ 

376 # Effective-policy introspection (read-only) 

377 # ------------------------------------------------------------------ 

378 

379 def job_validation_policy(self) -> JobValidationPolicy: 

380 """Bundle the attributes the pure admission checks read. 

381 

382 Built fresh per call rather than cached at ``__init__``: the caps and 

383 toggles are plain attributes and a test (or a future reload path) may 

384 set them after construction, and a stale snapshot here would enforce a 

385 policy the instance no longer reports. 

386 """ 

387 return JobValidationPolicy( 

388 max_cpu_millicores=self.max_cpu_per_manifest, 

389 max_memory_bytes=self.max_memory_per_manifest, 

390 max_gpu_count=self.max_gpu_per_manifest, 

391 allowed_namespaces=frozenset(self.allowed_namespaces), 

392 allowed_kinds=frozenset(self.allowed_kinds), 

393 # Sorted, like effective_job_validation_policy() reports them. 

394 # Matching is by equality and prefix so order cannot change an 

395 # admission outcome, but a canonical order is what lets two policies 

396 # be compared with == -- which cross-region drift detection relies 

397 # on, and which would otherwise report configuration order as drift. 

398 trusted_registries=tuple(sorted(self.trusted_registries)), 

399 trusted_dockerhub_orgs=tuple(sorted(self.trusted_dockerhub_orgs)), 

400 require_accelerator_toleration=self.require_accelerator_toleration, 

401 security={ 

402 "block_privileged": self.block_privileged, 

403 "block_privilege_escalation": self.block_privilege_escalation, 

404 "block_host_network": self.block_host_network, 

405 "block_host_pid": self.block_host_pid, 

406 "block_host_ipc": self.block_host_ipc, 

407 "block_host_path": self.block_host_path, 

408 "block_added_capabilities": self.block_added_capabilities, 

409 "block_run_as_root": self.block_run_as_root, 

410 }, 

411 validation_enabled=self.validation_enabled, 

412 yaml_max_depth=self.yaml_max_depth, 

413 ) 

414 

415 def effective_job_validation_policy(self) -> dict[str, Any]: 

416 """Return the validation policy this instance actually enforces. 

417 

418 Read straight off the instance attributes that ``validate_manifest`` 

419 and its helpers compare against, so the answer is the *deployed* 

420 policy rather than whatever ``cdk.json`` currently says on some 

421 operator's disk. The two can differ for two independent reasons: the 

422 cluster may have been deployed from a different checkout, and CDK 

423 augments ``trusted_registries`` with the project's own ECR registry 

424 hostnames at synth time, so the effective allowlist is strictly 

425 larger than the configured one. 

426 

427 Numeric caps are reported in the units the validator compares in 

428 (millicores, bytes, whole GPUs) alongside the raw configured strings, 

429 because ``384`` vCPU and ``384000`` millicores are the same cap and a 

430 consumer doing a local pre-check needs to know which it is holding. 

431 

432 Sets are returned as sorted lists so the payload is stable across 

433 calls and diffable between regions. 

434 """ 

435 return { 

436 "validation_enabled": self.validation_enabled, 

437 "manifest_caps": { 

438 "max_cpu_millicores": self.max_cpu_per_manifest, 

439 "max_memory_bytes": self.max_memory_per_manifest, 

440 "max_gpu_count": self.max_gpu_per_manifest, 

441 "configured": { 

442 "max_cpu_per_manifest": os.getenv("MAX_CPU_PER_MANIFEST"), 

443 "max_memory_per_manifest": os.getenv("MAX_MEMORY_PER_MANIFEST"), 

444 "max_gpu_per_manifest": os.getenv("MAX_GPU_PER_MANIFEST"), 

445 }, 

446 }, 

447 "allowed_namespaces": sorted(self.allowed_namespaces), 

448 "allowed_kinds": sorted(self.allowed_kinds), 

449 "allowed_api_versions": { 

450 kind: sorted(versions) 

451 for kind, versions in sorted(RESOURCE_API_VERSIONS.items()) 

452 if kind in self.allowed_kinds 

453 }, 

454 "trusted_registries": sorted(self.trusted_registries), 

455 "trusted_dockerhub_orgs": sorted(self.trusted_dockerhub_orgs), 

456 "require_accelerator_toleration": self.require_accelerator_toleration, 

457 "yaml_max_depth": self.yaml_max_depth, 

458 "manifest_security_policy": { 

459 "block_privileged": self.block_privileged, 

460 "block_privilege_escalation": self.block_privilege_escalation, 

461 "block_host_network": self.block_host_network, 

462 "block_host_pid": self.block_host_pid, 

463 "block_host_ipc": self.block_host_ipc, 

464 "block_host_path": self.block_host_path, 

465 "block_added_capabilities": self.block_added_capabilities, 

466 "block_run_as_root": self.block_run_as_root, 

467 }, 

468 } 

469 

470 def cluster_resource_governance(self) -> dict[str, Any]: 

471 """Return the live ResourceQuota / LimitRange ceilings per namespace. 

472 

473 The per-manifest caps in 

474 :meth:`effective_job_validation_policy` are only the **first** of three 

475 layers. A manifest that clears the front door can still be rejected by 

476 the namespace's LimitRange (per-container ceiling) or its ResourceQuota 

477 (aggregate ceiling). Reporting only the first layer would let a caller 

478 conclude a job is admissible when it is not, so this reads the other 

479 two straight from the Kubernetes API. 

480 

481 Fail-soft by design: a Kubernetes read failure yields 

482 ``status="unavailable"`` with the reason attached rather than raising, 

483 because a partial policy answer is more useful than a 500 — and the 

484 caller can see explicitly that the layer is missing instead of 

485 inferring absence from a silently truncated payload. 

486 """ 

487 namespaces: dict[str, Any] = {} 

488 for namespace in sorted(self.allowed_namespaces): 

489 entry: dict[str, Any] = {} 

490 try: 

491 quotas = self.core_v1.list_namespaced_resource_quota( 

492 namespace, _request_timeout=self._k8s_timeout 

493 ) 

494 entry["resource_quotas"] = { 

495 item.metadata.name: dict(item.status.hard or {}) 

496 if item.status and item.status.hard 

497 else dict(item.spec.hard or {}) 

498 for item in quotas.items 

499 } 

500 

501 limit_ranges = self.core_v1.list_namespaced_limit_range( 

502 namespace, _request_timeout=self._k8s_timeout 

503 ) 

504 entry["limit_ranges"] = { 

505 item.metadata.name: [ 

506 { 

507 "type": limit.type, 

508 "max": dict(limit.max or {}), 

509 "min": dict(limit.min or {}), 

510 "default": dict(limit.default or {}), 

511 "defaultRequest": dict(limit.default_request or {}), 

512 } 

513 for limit in (item.spec.limits or []) 

514 ] 

515 for item in limit_ranges.items 

516 } 

517 entry["status"] = "ok" 

518 except ApiException as e: 

519 logger.warning( 

520 "Failed to read resource governance for namespace %s: %s", 

521 sanitize_log_value(namespace), 

522 e.reason, 

523 ) 

524 entry = {"status": "unavailable", "reason": f"{e.status} {e.reason}"} 

525 except Exception as e: # noqa: BLE001 - any read failure is "unavailable" 

526 logger.warning( 

527 "Failed to read resource governance for namespace %s: %s", 

528 sanitize_log_value(namespace), 

529 e, 

530 ) 

531 entry = {"status": "unavailable", "reason": str(e)} 

532 namespaces[namespace] = entry 

533 return namespaces 

534 

535 def _resource_access_error(self, api_version: str, kind: str, namespace: str) -> str | None: 

536 """Return an authorization error for a CRUD resource identifier.""" 

537 if namespace not in self.allowed_namespaces: 

538 return f"Namespace '{namespace}' is not allowed" 

539 if kind not in self.allowed_kinds: 

540 return f"Resource kind '{kind}' is not allowed" 

541 allowed_versions = RESOURCE_API_VERSIONS.get(kind) 

542 if not allowed_versions or api_version not in allowed_versions: 

543 return ( 

544 f"API version '{api_version}' is not allowed for kind '{kind}'. " 

545 f"Allowed versions: {sorted(allowed_versions or ())}" 

546 ) 

547 return None 

548 

549 # ------------------------------------------------------------------ 

550 # Security defaults injection 

551 # ------------------------------------------------------------------ 

552 

553 @staticmethod 

554 def _extract_pod_spec(manifest: dict[str, Any]) -> dict[str, Any] | None: 

555 """Extract the pod spec from a manifest, handling all workload types. 

556 

557 Supports: 

558 - Deployment / StatefulSet / DaemonSet / ReplicaSet → spec.template.spec 

559 - Job → spec.template.spec 

560 - CronJob → spec.jobTemplate.spec.template.spec 

561 - Bare Pod → spec (when ``containers`` key is present) 

562 

563 Returns: 

564 The pod spec dict (mutable reference), or ``None`` if the manifest 

565 does not contain a recognisable pod spec. 

566 """ 

567 spec = manifest.get("spec") 

568 if spec is None or not isinstance(spec, dict): 

569 return None 

570 

571 kind = manifest.get("kind", "") 

572 

573 # CronJob: spec.jobTemplate.spec.template.spec 

574 if kind == "CronJob": 

575 job_template = spec.get("jobTemplate") 

576 if isinstance(job_template, dict): 

577 job_spec = job_template.get("spec") 

578 if isinstance(job_spec, dict): 

579 template = job_spec.get("template") 

580 if isinstance(template, dict): 

581 pod_spec = template.get("spec") 

582 if isinstance(pod_spec, dict): 

583 return pod_spec 

584 return None 

585 

586 # Deployment / StatefulSet / DaemonSet / ReplicaSet / Job: 

587 # spec.template.spec 

588 if "template" in spec: 

589 template = spec.get("template") 

590 if isinstance(template, dict): 

591 pod_spec = template.get("spec") 

592 if isinstance(pod_spec, dict): 

593 return pod_spec 

594 return None 

595 

596 # Bare Pod: spec contains "containers" directly 

597 if "containers" in spec: 

598 return cast(dict[str, Any], spec) 

599 

600 return None 

601 

602 def _inject_security_defaults(self, manifest: dict[str, Any]) -> dict[str, Any]: 

603 """Inject security defaults into user-submitted manifests. 

604 

605 Currently injects: 

606 - ``automountServiceAccountToken: false`` in the pod spec (unless the 

607 user has explicitly set it). 

608 

609 The method mutates *manifest* in-place and returns it for convenience. 

610 

611 For a TrainJob the default is injected into every pod spec embedded 

612 in ``runtimePatches`` (live references into the manifest); the base 

613 pod template comes from the shipped ClusterTrainingRuntime, which 

614 already disables the token. 

615 """ 

616 if manifest.get("kind") == "TrainJob": 

617 for embedded in extract_trainjob_pod_specs(manifest).embedded: 

618 embedded.setdefault("automountServiceAccountToken", False) 

619 return manifest 

620 pod_spec = self._extract_pod_spec(manifest) 

621 if pod_spec is not None: 

622 # Use setdefault so we don't override an explicit user choice 

623 pod_spec.setdefault("automountServiceAccountToken", False) 

624 return manifest 

625 

626 @property 

627 def dynamic_client(self) -> dynamic.DynamicClient: 

628 """Lazy-initialized dynamic client for CRD support.""" 

629 if self._dynamic_client is None: 

630 self._dynamic_client = dynamic.DynamicClient(self.api_client) 

631 return self._dynamic_client 

632 

633 def _parse_cpu_string(self, cpu_str: str) -> int: 

634 """Parse CPU string to millicores""" 

635 return parse_cpu_millicores(cpu_str) 

636 

637 def _parse_memory_string(self, memory_str: str) -> int: 

638 """Parse memory string to bytes""" 

639 return parse_memory_bytes(memory_str) 

640 

641 def _check_yaml_depth(self, obj: Any, current_depth: int = 0) -> bool: 

642 """Check if a parsed YAML/JSON object exceeds max nesting depth. 

643 

644 Recursively walks dicts and lists. Returns False if depth exceeds 

645 ``self.yaml_max_depth``. 

646 

647 Args: 

648 obj: The parsed object to check (dict, list, or scalar). 

649 current_depth: Current recursion depth (callers should leave at 0). 

650 

651 Returns: 

652 True if the object is within the depth limit, False otherwise. 

653 """ 

654 if current_depth > self.yaml_max_depth: 

655 return False 

656 if isinstance(obj, dict): 

657 return all(self._check_yaml_depth(v, current_depth + 1) for v in obj.values()) 

658 if isinstance(obj, list): 

659 return all(self._check_yaml_depth(item, current_depth + 1) for item in obj) 

660 return True 

661 

662 def validate_manifest( 

663 self, 

664 manifest: dict[str, Any], 

665 default_namespace: str | None = None, 

666 ) -> tuple[bool, str | None]: 

667 """Validate a Kubernetes manifest for security and resource constraints. 

668 

669 ``default_namespace`` is the request-level destination for manifests 

670 that omit ``metadata.namespace``. Validation and apply must resolve the 

671 same effective namespace or the request default could bypass the 

672 namespace allowlist. 

673 

674 Returns: ``(is_valid, error_message)``. 

675 """ 

676 if not self.validation_enabled: 

677 return True, None 

678 

679 try: 

680 # YAML depth check — reject excessively nested documents 

681 if not self._check_yaml_depth(manifest): 

682 return ( 

683 False, 

684 f"Manifest exceeds maximum nesting depth of {self.yaml_max_depth} levels", 

685 ) 

686 

687 # Basic structure validation 

688 required_fields = ["apiVersion", "kind", "metadata"] 

689 for field in required_fields: 

690 if field not in manifest: 

691 return False, f"Missing required field: {field}" 

692 

693 # Validate metadata 

694 metadata = manifest.get("metadata", {}) 

695 if "name" not in metadata: 

696 return False, "Missing metadata.name field" 

697 

698 # Validate namespace 

699 namespace = metadata.get("namespace", default_namespace or "gco-jobs") 

700 if namespace not in self.allowed_namespaces: 

701 return ( 

702 False, 

703 f"Namespace '{namespace}' not allowed. Allowed namespaces: {list(self.allowed_namespaces)}", 

704 ) 

705 

706 # Validate resource kind using the policy shared with the SQS path. 

707 kind = manifest.get("kind", "") 

708 kind_valid, kind_error = validate_resource_kind(manifest, self.allowed_kinds) 

709 if not kind_valid: 

710 return False, kind_error 

711 

712 # Validate resource limits for workload resources 

713 if kind in [ 

714 "Deployment", 

715 "Job", 

716 "CronJob", 

717 "StatefulSet", 

718 "DaemonSet", 

719 "TrainJob", 

720 ]: 

721 resource_valid, resource_error = self._validate_resource_limits(manifest) 

722 if not resource_valid: 

723 return False, resource_error 

724 

725 # Require accelerator jobs to carry a matching toleration. 

726 if self.require_accelerator_toleration: 

727 tol_valid, tol_error = self._validate_tolerations(manifest) 

728 if not tol_valid: 

729 return False, tol_error 

730 

731 # Security validations 

732 sec_valid, sec_error = self._validate_security_context(manifest) 

733 if not sec_valid: 

734 return False, f"Security context validation failed: {sec_error}" 

735 

736 # Validate image sources (prevent pulling from untrusted registries) 

737 img_valid, img_error = self._validate_image_sources(manifest) 

738 if not img_valid: 

739 return False, img_error or "Untrusted image sources detected" 

740 

741 return True, None 

742 

743 except Exception as e: 

744 logger.error(f"Error validating manifest: {e}") 

745 return False, f"Validation error: {e!s}" 

746 

747 def _validate_resource_limits(self, manifest: dict[str, Any]) -> tuple[bool, str]: 

748 """Validate resource limits in manifest. 

749 

750 Delegates to :func:`gco.job_admission.check_resource_caps` so the 

751 offline and multi-region pre-checks judge a manifest with the same code 

752 that gates it here. 

753 

754 Returns: 

755 Tuple of (is_valid, error_message). error_message is empty if valid. 

756 """ 

757 return check_resource_caps(manifest, self.job_validation_policy()) 

758 

759 def _validate_tolerations(self, manifest: dict[str, Any]) -> tuple[bool, str | None]: 

760 """Require accelerator jobs to carry a matching node toleration. 

761 

762 Delegates to :func:`gco.job_admission.check_tolerations`; see there for 

763 why TrainJob needs every pod-spec view unioned before matching. 

764 

765 Returns: 

766 Tuple of (is_valid, error_message). error_message is None if valid. 

767 """ 

768 return check_tolerations(manifest) 

769 

770 def _requested_accelerators(self, pod_spec: dict[str, Any]) -> set[str]: 

771 """Return the set of accelerator taint keys any container requests 

772 a nonzero quantity of.""" 

773 return requested_accelerators(pod_spec) 

774 

775 def _get_all_containers(self, pod_spec: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: 

776 """Get all containers from pod spec including init and ephemeral containers. 

777 

778 Returns: 

779 List of (container_type, container_dict) tuples where container_type 

780 is one of 'container', 'initContainer', or 'ephemeralContainer'. 

781 """ 

782 return _iter_all_containers(pod_spec) 

783 

784 def _validate_security_context(self, manifest: dict[str, Any]) -> tuple[bool, str | None]: 

785 """Validate security context settings. 

786 

787 Delegates to :func:`gco.job_admission.check_security_context`, which 

788 reads the same eight toggles off a policy object instead of ``self``. 

789 

790 Returns: 

791 Tuple of (is_valid, error_message). error_message is None if valid. 

792 """ 

793 return check_security_context(manifest, self.job_validation_policy()) 

794 

795 @staticmethod 

796 def _is_registry_domain(entry: str) -> bool: 

797 """Check if a registry entry is a proper domain (contains dot or colon). 

798 

799 A proper registry domain contains either a dot (e.g., 'docker.io', 'gcr.io') 

800 or a colon (e.g., 'localhost:5000'). Entries without these are Docker Hub 

801 organization names (e.g., 'nvidia', 'gco'). 

802 """ 

803 return "." in entry or ":" in entry 

804 

805 def _validate_image_sources(self, manifest: dict[str, Any]) -> tuple[bool, str | None]: 

806 """Validate container image sources against this deployment's allowlists. 

807 

808 Delegates to the module-level :func:`validate_image_sources` so the 

809 REST/SQS services and offline validators share one implementation. 

810 """ 

811 return validate_image_sources( 

812 manifest, 

813 trusted_registries=self.trusted_registries, 

814 trusted_dockerhub_orgs=self.trusted_dockerhub_orgs, 

815 ) 

816 

817 async def process_manifest_submission( 

818 self, request: ManifestSubmissionRequest 

819 ) -> ManifestSubmissionResponse: 

820 """ 

821 Process a manifest submission request 

822 """ 

823 logger.info(f"Processing manifest submission with {len(request.manifests)} manifests") 

824 

825 resources = [] 

826 errors = [] 

827 overall_success = True 

828 

829 try: 

830 # Process each manifest 

831 for i, manifest_data in enumerate(request.manifests): 

832 try: 

833 # Validate manifest 

834 is_valid, error_msg = self.validate_manifest(manifest_data, request.namespace) 

835 if not is_valid: 

836 error_msg = f"Manifest {i + 1} validation failed: {error_msg}" 

837 errors.append(error_msg) 

838 logger.error(error_msg) 

839 

840 # Create failed resource status 

841 resource_status = ResourceStatus( 

842 api_version=manifest_data.get("apiVersion", "unknown"), 

843 kind=manifest_data.get("kind", "unknown"), 

844 name=manifest_data.get("metadata", {}).get("name", f"manifest-{i + 1}"), 

845 namespace=manifest_data.get("metadata", {}).get( 

846 "namespace", request.namespace or "gco-jobs" 

847 ), 

848 status="failed", 

849 message=error_msg, 

850 ) 

851 resources.append(resource_status) 

852 overall_success = False 

853 continue 

854 

855 # Apply manifest if validation passed 

856 if not request.dry_run: 

857 resource_status = await self._apply_manifest( 

858 manifest_data, request.namespace 

859 ) 

860 resources.append(resource_status) 

861 

862 if not resource_status.is_successful(): 

863 overall_success = False 

864 else: 

865 # Dry run - just validate 

866 resource_status = ResourceStatus( 

867 api_version=manifest_data.get("apiVersion", "unknown"), 

868 kind=manifest_data.get("kind", "unknown"), 

869 name=manifest_data.get("metadata", {}).get("name", "unknown"), 

870 namespace=manifest_data.get("metadata", {}).get( 

871 "namespace", request.namespace or "gco-jobs" 

872 ), 

873 status="unchanged", 

874 message="Dry run - validation passed", 

875 ) 

876 resources.append(resource_status) 

877 

878 except Exception as e: 

879 error_msg = f"Error processing manifest {i + 1}: {e!s}" 

880 errors.append(error_msg) 

881 logger.error(error_msg) 

882 overall_success = False 

883 

884 # Create failed resource status 

885 resource_status = ResourceStatus( 

886 api_version=manifest_data.get("apiVersion", "unknown"), 

887 kind=manifest_data.get("kind", "unknown"), 

888 name=manifest_data.get("metadata", {}).get("name", f"manifest-{i + 1}"), 

889 namespace=manifest_data.get("metadata", {}).get( 

890 "namespace", request.namespace or "gco-jobs" 

891 ), 

892 status="failed", 

893 message=str(e), 

894 ) 

895 resources.append(resource_status) 

896 

897 except Exception as e: 

898 error_msg = f"Fatal error processing manifest submission: {e!s}" 

899 errors.append(error_msg) 

900 logger.error(error_msg) 

901 overall_success = False 

902 

903 response = ManifestSubmissionResponse( 

904 success=overall_success, 

905 cluster_id=self.cluster_id, 

906 region=self.region, 

907 resources=resources, 

908 errors=errors if errors else None, 

909 ) 

910 

911 logger.info( 

912 f"Manifest submission completed - Success: {overall_success}, " 

913 f"Resources: {len(resources)}, Errors: {len(errors)}" 

914 ) 

915 

916 return response 

917 

918 @staticmethod 

919 def queued_job_name(original_name: str, queue_job_id: str) -> str: 

920 """Return a DNS-label-safe Kubernetes name deterministically fenced by queue ID.""" 

921 suffix = hashlib.sha256(queue_job_id.encode("utf-8")).hexdigest()[:16] 

922 prefix = re.sub(r"[^a-z0-9-]+", "-", original_name.lower()).strip("-") 

923 prefix = prefix[: 63 - len(suffix) - 1].rstrip("-") or "gco-job" 

924 return f"{prefix}-{suffix}" 

925 

926 def apply_queued_job( 

927 self, 

928 manifest_data: dict[str, Any], 

929 namespace: str, 

930 queue_job_id: str, 

931 ) -> ResourceStatus: 

932 """Create or adopt exactly one deterministic ``batch/v1`` Job. 

933 

934 This path deliberately bypasses generic manifest upsert semantics: it 

935 never deletes, renames, or replaces an existing Job. An ambiguous API 

936 result is safe to retry because the same queue ID always resolves to the 

937 same Kubernetes name and adoption requires the full queue ID annotation. 

938 """ 

939 manifest = copy.deepcopy(manifest_data) 

940 if manifest.get("apiVersion") != "batch/v1" or manifest.get("kind") != "Job": 

941 raise QueuedJobNotCreatedError( 

942 "Central queue accepts only apiVersion 'batch/v1', kind 'Job'" 

943 ) 

944 

945 metadata = manifest.get("metadata") 

946 if not isinstance(metadata, dict): 

947 raise QueuedJobNotCreatedError("Queued Job metadata must be an object") 

948 declared_namespace = metadata.get("namespace") 

949 if declared_namespace is not None and declared_namespace != namespace: 

950 raise QueuedJobNotCreatedError("Queued Job namespace does not match the queue envelope") 

951 original_name = metadata.get("name") 

952 if not isinstance(original_name, str) or not original_name: 

953 raise QueuedJobNotCreatedError("Queued Job metadata.name is required") 

954 

955 deterministic_name = self.queued_job_name(original_name, queue_job_id) 

956 metadata["name"] = deterministic_name 

957 metadata["namespace"] = namespace 

958 labels = metadata.setdefault("labels", {}) 

959 annotations = metadata.setdefault("annotations", {}) 

960 if not isinstance(labels, dict) or not isinstance(annotations, dict): 

961 raise QueuedJobNotCreatedError( 

962 "Queued Job metadata labels and annotations must be objects" 

963 ) 

964 labels["gco.io/managed-by"] = "central-queue" 

965 labels["gco.io/queue-job-key"] = hashlib.sha256(queue_job_id.encode("utf-8")).hexdigest()[ 

966 :32 

967 ] 

968 annotations["gco.io/queue-job-id"] = queue_job_id 

969 annotations["gco.io/original-job-name"] = original_name 

970 

971 is_valid, validation_error = self.validate_manifest(manifest, namespace) 

972 if not is_valid: 

973 raise QueuedJobNotCreatedError(f"Queued Job validation failed: {validation_error}") 

974 self._inject_security_defaults(manifest) 

975 

976 try: 

977 job = self.batch_v1.read_namespaced_job( 

978 name=deterministic_name, 

979 namespace=namespace, 

980 _request_timeout=self._k8s_timeout, 

981 ) 

982 operation = "unchanged" 

983 message = "Existing deterministic Kubernetes Job adopted" 

984 except ApiException as error: 

985 if error.status != 404: 

986 if _is_retryable_kubernetes_api_error(error): 

987 raise RetryableQueuedJobApplyError( 

988 "Kubernetes Job lookup was inconclusive; retry deterministic adoption" 

989 ) from error 

990 raise 

991 try: 

992 job = self.batch_v1.create_namespaced_job( 

993 namespace=namespace, 

994 body=manifest, 

995 _request_timeout=self._k8s_timeout, 

996 ) 

997 operation = "created" 

998 message = "Deterministic Kubernetes Job created" 

999 except ApiException as create_error: 

1000 if create_error.status == 409: 

1001 try: 

1002 job = self.batch_v1.read_namespaced_job( 

1003 name=deterministic_name, 

1004 namespace=namespace, 

1005 _request_timeout=self._k8s_timeout, 

1006 ) 

1007 except ApiException as adoption_error: 

1008 if adoption_error.status == 404 or _is_retryable_kubernetes_api_error( 

1009 adoption_error 

1010 ): 

1011 raise RetryableQueuedJobApplyError( 

1012 "Concurrent Kubernetes Job adoption was inconclusive" 

1013 ) from adoption_error 

1014 raise 

1015 except Exception as adoption_error: 

1016 raise RetryableQueuedJobApplyError( 

1017 "Concurrent Kubernetes Job adoption was inconclusive" 

1018 ) from adoption_error 

1019 operation = "unchanged" 

1020 message = "Concurrent deterministic Kubernetes Job adopted" 

1021 elif _is_retryable_kubernetes_api_error(create_error): 

1022 raise RetryableQueuedJobApplyError( 

1023 "Kubernetes Job create result was inconclusive; retry deterministic adoption" 

1024 ) from create_error 

1025 else: 

1026 raise 

1027 except Exception as create_error: 

1028 # A timeout or connection loss can occur after the API server 

1029 # persisted the Job. Never mark that ambiguous result terminal. 

1030 raise RetryableQueuedJobApplyError( 

1031 "Kubernetes Job create result was inconclusive; retry deterministic adoption" 

1032 ) from create_error 

1033 except Exception as read_error: 

1034 raise RetryableQueuedJobApplyError( 

1035 "Kubernetes Job lookup was inconclusive; retry deterministic adoption" 

1036 ) from read_error 

1037 

1038 actual_annotations = getattr(job.metadata, "annotations", None) or {} 

1039 if actual_annotations.get("gco.io/queue-job-id") != queue_job_id: 

1040 raise RuntimeError( 

1041 f"Kubernetes Job name collision for {namespace}/{deterministic_name}" 

1042 ) 

1043 uid = str(getattr(job.metadata, "uid", "") or "") 

1044 actual_name = str(getattr(job.metadata, "name", "") or deterministic_name) 

1045 actual_namespace = str(getattr(job.metadata, "namespace", "") or namespace) 

1046 if not uid: 

1047 raise RuntimeError("Kubernetes API returned a queued Job without a UID") 

1048 return ResourceStatus( 

1049 api_version="batch/v1", 

1050 kind="Job", 

1051 name=actual_name, 

1052 namespace=actual_namespace, 

1053 status=operation, 

1054 message=message, 

1055 uid=uid, 

1056 ) 

1057 

1058 def read_queued_job(self, name: str, namespace: str) -> V1Job: 

1059 """Read one reconciled Job through the processor's bounded client contract.""" 

1060 return self.batch_v1.read_namespaced_job( 

1061 name=name, 

1062 namespace=namespace, 

1063 _request_timeout=self._k8s_timeout, 

1064 ) 

1065 

1066 async def _apply_manifest( 

1067 self, manifest_data: dict[str, Any], default_namespace: str | None = None 

1068 ) -> ResourceStatus: 

1069 """ 

1070 Apply a single manifest to the cluster. 

1071 

1072 For Jobs and CronJobs, if the resource already exists and is completed/failed, 

1073 it will be automatically deleted and recreated (since these resources are immutable). 

1074 """ 

1075 try: 

1076 api_version: str = manifest_data.get("apiVersion", "unknown") 

1077 kind: str = manifest_data.get("kind", "unknown") 

1078 metadata = manifest_data.get("metadata", {}) 

1079 name: str = metadata.get("name", "unknown") 

1080 namespace: str = metadata.get("namespace", default_namespace or "gco-jobs") 

1081 

1082 # Ensure namespace is set in manifest 

1083 if "namespace" not in metadata and namespace: 

1084 manifest_data["metadata"]["namespace"] = namespace 

1085 

1086 # Inject security defaults (e.g., automountServiceAccountToken: false) 

1087 self._inject_security_defaults(manifest_data) 

1088 

1089 # Check if resource already exists 

1090 existing_resource = await self._get_existing_resource( 

1091 api_version, kind, name, namespace 

1092 ) 

1093 

1094 if existing_resource: 

1095 # Jobs are immutable — if one already exists and is finished, 

1096 # delete it first so we can recreate cleanly. 

1097 # If the job is still active, auto-rename to avoid collision. 

1098 if kind == "Job": 

1099 if self._is_job_finished(existing_resource): 

1100 logger.info( 

1101 f"Job {name} already exists and is finished, deleting before recreating" 

1102 ) 

1103 await self.delete_resource(api_version, kind, name, namespace) 

1104 import asyncio 

1105 

1106 await asyncio.sleep(1) 

1107 await self._create_resource(manifest_data) 

1108 status = "created" 

1109 message = "Previous completed job replaced with new submission" 

1110 else: 

1111 # Active job — rename to avoid destroying it 

1112 import uuid 

1113 

1114 suffix = uuid.uuid4().hex[:5] 

1115 new_name = f"{name}-{suffix}" 

1116 manifest_data["metadata"]["name"] = new_name 

1117 logger.warning( 

1118 f"Job {name} is still active, renamed new submission to {new_name}" 

1119 ) 

1120 await self._create_resource(manifest_data) 

1121 status = "created" 

1122 message = ( 

1123 f"Job '{name}' is still running. " 

1124 f"New submission renamed to '{new_name}'." 

1125 ) 

1126 name = new_name 

1127 else: 

1128 # Update existing resource (works for mutable resources) 

1129 updated_resource = await self._update_resource(manifest_data) 

1130 status = "updated" if updated_resource else "unchanged" 

1131 message = ( 

1132 "Resource updated successfully" if updated_resource else "No changes needed" 

1133 ) 

1134 else: 

1135 # Create new resource 

1136 await self._create_resource(manifest_data) 

1137 status = "created" 

1138 message = "Resource created successfully" 

1139 

1140 return ResourceStatus( 

1141 api_version=api_version, 

1142 kind=kind, 

1143 name=name, 

1144 namespace=namespace, 

1145 status=status, 

1146 message=message, 

1147 ) 

1148 

1149 except ApiException as e: 

1150 logger.error(f"Kubernetes API error applying manifest: {e}") 

1151 return ResourceStatus( 

1152 api_version=manifest_data.get("apiVersion", "unknown"), 

1153 kind=manifest_data.get("kind", "unknown"), 

1154 name=manifest_data.get("metadata", {}).get("name", "unknown"), 

1155 namespace=manifest_data.get("metadata", {}).get("namespace", "gco-jobs"), 

1156 status="failed", 

1157 message=f"API error: {e.reason}", 

1158 ) 

1159 except Exception as e: 

1160 logger.error(f"Error applying manifest: {e}") 

1161 return ResourceStatus( 

1162 api_version=manifest_data.get("apiVersion", "unknown"), 

1163 kind=manifest_data.get("kind", "unknown"), 

1164 name=manifest_data.get("metadata", {}).get("name", "unknown"), 

1165 namespace=manifest_data.get("metadata", {}).get("namespace", "gco-jobs"), 

1166 status="failed", 

1167 message=str(e), 

1168 ) 

1169 

1170 def _is_job_finished(self, job_resource: dict[str, Any]) -> bool: 

1171 """Check if a Kubernetes Job resource is in a terminal state (Complete or Failed).""" 

1172 status = job_resource.get("status", {}) 

1173 conditions = status.get("conditions") or [] 

1174 for condition in conditions: 

1175 cond_type = condition.get("type", "") 

1176 cond_status = condition.get("status", "") 

1177 if cond_type in ("Complete", "Failed") and cond_status == "True": 

1178 return True 

1179 return False 

1180 

1181 async def _get_existing_resource( 

1182 self, 

1183 api_version: str, 

1184 kind: str, 

1185 name: str, 

1186 namespace: str, 

1187 *, 

1188 api_resource: Any | None = None, 

1189 ) -> dict[str, Any] | None: 

1190 """Check if a resource already exists using dynamic client.""" 

1191 try: 

1192 # Reuse an already-authorized discovery result when supplied so a 

1193 # status request cannot observe a different resource definition 

1194 # between the scope check and the actual read. 

1195 if api_resource is None: 

1196 api_resource = self._get_api_resource(api_version, kind) 

1197 

1198 # Try to get the resource 

1199 if namespace and api_resource.namespaced: 

1200 resource = api_resource.get(name=name, namespace=namespace) 

1201 else: 

1202 resource = api_resource.get(name=name) 

1203 

1204 if resource is not None: 

1205 return dict(resource.to_dict()) 

1206 

1207 except ApiException as e: 

1208 if e.status == 404: 

1209 return None # Resource doesn't exist 

1210 raise 

1211 except ValueError: 

1212 # Unknown resource type 

1213 return None 

1214 

1215 return None 

1216 

1217 def _get_api_resource(self, api_version: str, kind: str) -> Any: 

1218 """Get the API resource for a given apiVersion and kind using dynamic client.""" 

1219 try: 

1220 return self.dynamic_client.resources.get(api_version=api_version, kind=kind) 

1221 except ResourceNotFoundError as e: 

1222 logger.error( 

1223 "Resource type not found: %s/%s", 

1224 sanitize_log_value(api_version), 

1225 sanitize_log_value(kind), 

1226 ) 

1227 # A policy-allowed kind whose addon is not installed gets the 

1228 # actionable remedy instead of an inscrutable discovery error. 

1229 addon_hint = ADDON_KIND_HINTS.get(kind) 

1230 if addon_hint: 

1231 raise ValueError(addon_hint) from e 

1232 raise ValueError(f"Unknown resource type: {api_version}/{kind}") from e 

1233 

1234 async def _create_resource(self, manifest_data: dict[str, Any]) -> Any: 

1235 """Create a resource and return the API object, including server identity.""" 

1236 try: 

1237 api_version = manifest_data.get("apiVersion", "") 

1238 kind = manifest_data.get("kind", "") 

1239 namespace = manifest_data.get("metadata", {}).get("namespace") 

1240 

1241 api_resource = self._get_api_resource(api_version, kind) 

1242 if namespace and api_resource.namespaced: 

1243 return api_resource.create(body=manifest_data, namespace=namespace) 

1244 return api_resource.create(body=manifest_data) 

1245 except Exception as e: 

1246 logger.error(f"Error creating resource: {e}") 

1247 raise 

1248 

1249 async def _update_resource(self, manifest_data: dict[str, Any]) -> bool: 

1250 """Update an existing resource using dynamic client""" 

1251 try: 

1252 api_version = manifest_data.get("apiVersion", "") 

1253 kind = manifest_data.get("kind", "") 

1254 name = manifest_data.get("metadata", {}).get("name", "") 

1255 namespace = manifest_data.get("metadata", {}).get("namespace") 

1256 

1257 # Get the API resource 

1258 api_resource = self._get_api_resource(api_version, kind) 

1259 

1260 # Update the resource using patch (server-side apply) 

1261 if namespace and api_resource.namespaced: 

1262 api_resource.patch( 

1263 body=manifest_data, 

1264 name=name, 

1265 namespace=namespace, 

1266 content_type="application/merge-patch+json", 

1267 ) 

1268 else: 

1269 api_resource.patch( 

1270 body=manifest_data, 

1271 name=name, 

1272 content_type="application/merge-patch+json", 

1273 ) 

1274 

1275 return True 

1276 except Exception as e: 

1277 logger.error(f"Error updating resource: {e}") 

1278 raise 

1279 

1280 async def delete_resource( 

1281 self, api_version: str, kind: str, name: str, namespace: str 

1282 ) -> ResourceStatus: 

1283 """ 

1284 Delete a resource from the cluster using dynamic client 

1285 """ 

1286 access_error = self._resource_access_error(api_version, kind, namespace) 

1287 if access_error: 

1288 return ResourceStatus( 

1289 api_version=api_version, 

1290 kind=kind, 

1291 name=name, 

1292 namespace=namespace, 

1293 status="forbidden", 

1294 message=access_error, 

1295 ) 

1296 

1297 try: 

1298 # Get the API resource 

1299 api_resource = self._get_api_resource(api_version, kind) 

1300 if not api_resource.namespaced: 

1301 return ResourceStatus( 

1302 api_version=api_version, 

1303 kind=kind, 

1304 name=name, 

1305 namespace=namespace, 

1306 status="forbidden", 

1307 message="Cluster-scoped resource operations are not allowed", 

1308 ) 

1309 

1310 # Every authorized GVK is namespaced; never drop the namespace. 

1311 api_resource.delete(name=name, namespace=namespace) 

1312 

1313 return ResourceStatus( 

1314 api_version=api_version, 

1315 kind=kind, 

1316 name=name, 

1317 namespace=namespace, 

1318 status="deleted", 

1319 message="Resource deleted successfully", 

1320 ) 

1321 

1322 except ValueError as e: 

1323 # Unknown resource type 

1324 return ResourceStatus( 

1325 api_version=api_version, 

1326 kind=kind, 

1327 name=name, 

1328 namespace=namespace, 

1329 status="failed", 

1330 message=str(e), 

1331 ) 

1332 except ApiException as e: 

1333 if e.status == 404: 

1334 return ResourceStatus( 

1335 api_version=api_version, 

1336 kind=kind, 

1337 name=name, 

1338 namespace=namespace, 

1339 status="unchanged", 

1340 message="Resource not found (already deleted)", 

1341 ) 

1342 return ResourceStatus( 

1343 api_version=api_version, 

1344 kind=kind, 

1345 name=name, 

1346 namespace=namespace, 

1347 status="failed", 

1348 message=f"Delete failed: {e.reason}", 

1349 ) 

1350 except Exception as e: 

1351 return ResourceStatus( 

1352 api_version=api_version, 

1353 kind=kind, 

1354 name=name, 

1355 namespace=namespace, 

1356 status="failed", 

1357 message=str(e), 

1358 ) 

1359 

1360 async def list_jobs( 

1361 self, namespace: str | None = None, status_filter: str | None = None 

1362 ) -> list[dict[str, Any]]: 

1363 """ 

1364 List Kubernetes Jobs from allowed namespaces. 

1365 

1366 Args: 

1367 namespace: Filter by specific namespace (must be in allowed_namespaces) 

1368 status_filter: Filter by status: "running", "completed", "failed" 

1369 

1370 Returns: 

1371 List of job dictionaries with metadata and status 

1372 """ 

1373 jobs = [] 

1374 

1375 # Determine which namespaces to query 

1376 if namespace: 

1377 if namespace not in self.allowed_namespaces: 

1378 raise ValueError( 

1379 f"Namespace '{namespace}' not allowed. " 

1380 f"Allowed namespaces: {list(self.allowed_namespaces)}" 

1381 ) 

1382 namespaces_to_query = [namespace] 

1383 else: 

1384 namespaces_to_query = list(self.allowed_namespaces) 

1385 

1386 for ns in namespaces_to_query: 

1387 try: 

1388 job_list = self.batch_v1.list_namespaced_job( 

1389 namespace=ns, _request_timeout=self._k8s_timeout 

1390 ) 

1391 for job in job_list.items: 

1392 job_dict = self._job_to_dict(job) 

1393 

1394 # Apply status filter 

1395 if status_filter: 

1396 job_status = self._get_job_status(job) 

1397 if job_status != status_filter: 

1398 continue 

1399 

1400 jobs.append(job_dict) 

1401 except ApiException as e: 

1402 logger.warning( 

1403 "Failed to list jobs in namespace %s: %s", sanitize_log_value(ns), e.reason 

1404 ) 

1405 continue 

1406 

1407 return jobs 

1408 

1409 def _job_to_dict(self, job: V1Job) -> dict[str, Any]: 

1410 """Convert a Kubernetes Job object to a dictionary.""" 

1411 metadata = job.metadata 

1412 status = job.status 

1413 spec = job.spec 

1414 

1415 return { 

1416 "metadata": { 

1417 "name": metadata.name, 

1418 "namespace": metadata.namespace, 

1419 "creationTimestamp": ( 

1420 metadata.creation_timestamp.isoformat() if metadata.creation_timestamp else None 

1421 ), 

1422 "labels": metadata.labels or {}, 

1423 "uid": metadata.uid, 

1424 }, 

1425 "spec": { 

1426 "parallelism": spec.parallelism, 

1427 "completions": spec.completions, 

1428 "backoffLimit": spec.backoff_limit, 

1429 }, 

1430 "status": { 

1431 "active": status.active or 0, 

1432 "succeeded": status.succeeded or 0, 

1433 "failed": status.failed or 0, 

1434 "startTime": status.start_time.isoformat() if status.start_time else None, 

1435 "completionTime": ( 

1436 status.completion_time.isoformat() if status.completion_time else None 

1437 ), 

1438 "conditions": [ 

1439 { 

1440 "type": c.type, 

1441 "status": c.status, 

1442 "reason": c.reason, 

1443 "message": c.message, 

1444 } 

1445 for c in (status.conditions or []) 

1446 ], 

1447 }, 

1448 } 

1449 

1450 def _get_job_status(self, job: V1Job) -> str: 

1451 """Determine the status of a job: running, completed, or failed.""" 

1452 status = job.status 

1453 conditions = status.conditions or [] 

1454 

1455 for condition in conditions: 

1456 if condition.type == "Complete" and condition.status == "True": 

1457 return "completed" 

1458 if condition.type == "Failed" and condition.status == "True": 

1459 return "failed" 

1460 

1461 if (status.active or 0) > 0: 

1462 return "running" 

1463 

1464 return "pending" 

1465 

1466 async def get_resource_status( 

1467 self, api_version: str, kind: str, name: str, namespace: str 

1468 ) -> dict[str, Any] | None: 

1469 """ 

1470 Get the status of a specific resource 

1471 """ 

1472 access_error = self._resource_access_error(api_version, kind, namespace) 

1473 if access_error: 

1474 return { 

1475 "api_version": api_version, 

1476 "kind": kind, 

1477 "name": name, 

1478 "namespace": namespace, 

1479 "exists": False, 

1480 "forbidden": True, 

1481 "error": access_error, 

1482 } 

1483 

1484 try: 

1485 api_resource = self._get_api_resource(api_version, kind) 

1486 if not api_resource.namespaced: 

1487 return { 

1488 "api_version": api_version, 

1489 "kind": kind, 

1490 "name": name, 

1491 "namespace": namespace, 

1492 "exists": False, 

1493 "forbidden": True, 

1494 "error": "Cluster-scoped resource operations are not allowed", 

1495 } 

1496 resource = await self._get_existing_resource( 

1497 api_version, 

1498 kind, 

1499 name, 

1500 namespace, 

1501 api_resource=api_resource, 

1502 ) 

1503 if resource: 

1504 return { 

1505 "api_version": api_version, 

1506 "kind": kind, 

1507 "name": name, 

1508 "namespace": namespace, 

1509 "exists": True, 

1510 "status": resource.get("status", {}), 

1511 "metadata": resource.get("metadata", {}), 

1512 "spec": resource.get("spec", {}), 

1513 } 

1514 return { 

1515 "api_version": api_version, 

1516 "kind": kind, 

1517 "name": name, 

1518 "namespace": namespace, 

1519 "exists": False, 

1520 } 

1521 except Exception as e: 

1522 logger.error(f"Error getting resource status: {e}") 

1523 return None 

1524 

1525 

1526def _manifest_security_policy_from_env() -> dict[str, bool]: 

1527 return { 

1528 key: parse_boolean_environment(key.upper(), default) 

1529 for key, default in MANIFEST_SECURITY_POLICY_DEFAULTS.items() 

1530 } 

1531 

1532 

1533def create_manifest_processor_from_env() -> ManifestProcessor: 

1534 """ 

1535 Create ManifestProcessor instance from environment variables 

1536 """ 

1537 cluster_id = os.getenv("CLUSTER_NAME", "unknown-cluster") 

1538 region = os.getenv("REGION", "unknown-region") 

1539 

1540 # Enable structured JSON logging for CloudWatch Insights 

1541 configure_structured_logging( 

1542 service_name="manifest-processor", 

1543 cluster_id=cluster_id, 

1544 region=region, 

1545 ) 

1546 

1547 # Load configuration from environment 

1548 config_dict = { 

1549 "max_cpu_per_manifest": os.getenv( 

1550 "MAX_CPU_PER_MANIFEST", 

1551 str(DEFAULT_MANIFEST_RESOURCE_CAPS["max_cpu_per_manifest"]), 

1552 ), 

1553 "max_memory_per_manifest": os.getenv( 

1554 "MAX_MEMORY_PER_MANIFEST", 

1555 str(DEFAULT_MANIFEST_RESOURCE_CAPS["max_memory_per_manifest"]), 

1556 ), 

1557 "max_gpu_per_manifest": int( 

1558 os.getenv( 

1559 "MAX_GPU_PER_MANIFEST", 

1560 str(DEFAULT_MANIFEST_RESOURCE_CAPS["max_gpu_per_manifest"]), 

1561 ) 

1562 ), 

1563 "require_accelerator_toleration": parse_boolean_environment( 

1564 "REQUIRE_ACCELERATOR_TOLERATION", True 

1565 ), 

1566 "allowed_namespaces": ( 

1567 ["gco-jobs"] 

1568 if os.getenv("ALLOWED_NAMESPACES") is None 

1569 else [ 

1570 namespace.strip() 

1571 for namespace in os.environ["ALLOWED_NAMESPACES"].split(",") 

1572 if namespace.strip() 

1573 ] 

1574 ), 

1575 "validation_enabled": parse_boolean_environment("VALIDATION_ENABLED", True), 

1576 "yaml_max_depth": int(os.getenv("YAML_MAX_DEPTH", "50")), 

1577 "manifest_security_policy": _manifest_security_policy_from_env(), 

1578 } 

1579 

1580 allowed_kinds_env = os.getenv("ALLOWED_KINDS") 

1581 if allowed_kinds_env is not None: 

1582 # An absent variable uses the authoritative defaults; an explicitly 

1583 # empty value is a deliberate deny-all policy and must stay empty. 

1584 config_dict["allowed_kinds"] = [ 

1585 kind.strip() for kind in allowed_kinds_env.split(",") if kind.strip() 

1586 ] 

1587 

1588 # Image registry allowlist — sourced from the same CDK env vars the 

1589 # queue_processor reads, so an attacker who holds sqs:SendMessage on 

1590 # the regional queue can't reach an image source the REST path 

1591 # rejects. When unset (or empty) the ManifestProcessor falls back 

1592 # to its hardcoded default. Empty/missing values are dropped to 

1593 # match the queue_processor's parsing rules. 

1594 trusted_registries_env = os.getenv("TRUSTED_REGISTRIES", "") 

1595 trusted_registries = [r.strip() for r in trusted_registries_env.split(",") if r.strip()] 

1596 if trusted_registries: 

1597 config_dict["trusted_registries"] = trusted_registries 

1598 

1599 trusted_dockerhub_orgs_env = os.getenv("TRUSTED_DOCKERHUB_ORGS", "") 

1600 trusted_dockerhub_orgs = [o.strip() for o in trusted_dockerhub_orgs_env.split(",") if o.strip()] 

1601 if trusted_dockerhub_orgs: 

1602 config_dict["trusted_dockerhub_orgs"] = trusted_dockerhub_orgs 

1603 

1604 return ManifestProcessor(cluster_id, region, config_dict)