Coverage for gco / job_envelope.py: 100.00%
33 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""The SQS job envelope, shared by the producer and the consumer.
3``gco jobs submit-sqs`` (:mod:`cli.jobs`) writes this envelope onto the
4regional queue; the in-cluster queue processor
5(:mod:`gco.services.queue_processor`) reads it back and applies the manifests.
6The two halves ship in different artifacts — the CLI runs on an operator's
7machine, the consumer inside a distroless service image that deliberately
8cannot import ``cli/`` — so the wire format lived twice: once as a dict
9literal in the CLI, once as a docstring and a set of ``body.get(...)`` calls
10in the service. This module is that format, written once.
12It is deliberately standard-library only, so anything that needs to *speak*
13the protocol can import it: the service image, the CLI, and CI, which builds a
14message with this exact code and hands it to the real consumer running in a
15kind cluster.
16"""
18from __future__ import annotations
20import json
21import uuid
22from dataclasses import dataclass
23from datetime import UTC, datetime
24from typing import Any
26#: Namespace recorded in the envelope when neither the caller nor any manifest
27#: names one. The consumer applies this same fallback when reading a message,
28#: and it is the namespace the shipped ``job_validation_policy`` allows.
29DEFAULT_JOB_NAMESPACE = "gco-jobs"
32def first_manifest_namespace(manifests: list[dict[str, Any]]) -> str | None:
33 """Return the first explicit ``metadata.namespace`` in a manifest list.
35 The envelope's ``namespace`` field is informational — the consumer
36 validates and applies each manifest under the namespace that manifest
37 declares — so the producer reports the first declared one to keep its
38 submission response truthful when the caller passes no namespace.
39 Returns ``None`` when no manifest declares one.
40 """
41 for manifest in manifests:
42 ns = manifest.get("metadata", {}).get("namespace") if isinstance(manifest, dict) else None
43 if ns:
44 return str(ns)
45 return None
48@dataclass(frozen=True)
49class JobMessage:
50 """One queue submission: the JSON body plus its SQS message attributes."""
52 body: dict[str, Any]
53 attributes: dict[str, dict[str, str]]
55 @property
56 def job_id(self) -> str:
57 """The short id correlating this submission across CLI, queue and logs."""
58 return str(self.body["job_id"])
60 @property
61 def namespace(self) -> str:
62 """The envelope's informational namespace."""
63 return str(self.body["namespace"])
65 @property
66 def priority(self) -> int:
67 """The submitted priority (higher is more important)."""
68 return int(self.body["priority"])
70 def json(self) -> str:
71 """Serialize the body exactly as it goes onto the queue."""
72 return json.dumps(self.body)
75def build_job_message(
76 manifests: list[dict[str, Any]],
77 *,
78 namespace: str | None = None,
79 priority: int = 0,
80 job_id: str | None = None,
81 submitted_at: datetime | None = None,
82) -> JobMessage:
83 """Build the queue message for ``manifests``.
85 ``job_id`` and ``submitted_at`` default to a fresh short uuid and the
86 current UTC instant; both are injectable so a caller that needs a
87 reproducible message (a test, a replay) can pin them. ``namespace``
88 falls back to the first namespace the manifests declare, then to
89 :data:`DEFAULT_JOB_NAMESPACE`.
90 """
91 resolved_id = job_id or str(uuid.uuid4())[:8]
92 resolved_namespace = namespace or first_manifest_namespace(manifests) or DEFAULT_JOB_NAMESPACE
93 stamp = submitted_at or datetime.now(UTC)
94 return JobMessage(
95 body={
96 "job_id": resolved_id,
97 "manifests": manifests,
98 "namespace": resolved_namespace,
99 "priority": priority,
100 "submitted_at": stamp.isoformat(),
101 },
102 attributes={
103 "Priority": {"DataType": "Number", "StringValue": str(priority)},
104 "JobId": {"DataType": "String", "StringValue": resolved_id},
105 },
106 )