pipewright-mlops 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. pipewright/__init__.py +10 -0
  2. pipewright/adapters/__init__.py +23 -0
  3. pipewright/adapters/kfp.py +149 -0
  4. pipewright/adapters/vertex.py +65 -0
  5. pipewright/alerts/__init__.py +0 -0
  6. pipewright/alerts/sync.py +246 -0
  7. pipewright/build/__init__.py +1 -0
  8. pipewright/build/compile.py +165 -0
  9. pipewright/build/images.py +226 -0
  10. pipewright/build/spec_patch.py +81 -0
  11. pipewright/build/templates/__init__.py +1 -0
  12. pipewright/build/templates/components.Dockerfile +32 -0
  13. pipewright/build/templates/pipewright_runtime.py +682 -0
  14. pipewright/build/templates/ray.Dockerfile +31 -0
  15. pipewright/build/templates/synthetic-engine-requirements.txt +9 -0
  16. pipewright/build/templates/synthetic-engine.Dockerfile +40 -0
  17. pipewright/build/templates/synthetic-evaluation-requirements.txt +10 -0
  18. pipewright/build/templates/synthetic-evaluation.Dockerfile +35 -0
  19. pipewright/checks/__init__.py +1 -0
  20. pipewright/checks/doctor.py +666 -0
  21. pipewright/checks/kfp_version.py +94 -0
  22. pipewright/checks/ray_version.py +61 -0
  23. pipewright/cli/__init__.py +5 -0
  24. pipewright/cli/main.py +786 -0
  25. pipewright/config/__init__.py +22 -0
  26. pipewright/config/document.py +97 -0
  27. pipewright/config/environment.py +156 -0
  28. pipewright/config/io.py +38 -0
  29. pipewright/config/merge.py +29 -0
  30. pipewright/config/ml.py +287 -0
  31. pipewright/config/models.py +140 -0
  32. pipewright/config/reference.py +274 -0
  33. pipewright/config/references.py +51 -0
  34. pipewright/config/resolve.py +378 -0
  35. pipewright/config/schema.py +110 -0
  36. pipewright/config/schemas/__init__.py +1 -0
  37. pipewright/config/schemas/environment.schema.json +52 -0
  38. pipewright/config/schemas/manifest.schema.json +728 -0
  39. pipewright/config/schemas/reference.yaml +326 -0
  40. pipewright/config/schemas/usecase.schema.json +816 -0
  41. pipewright/data/__init__.py +1 -0
  42. pipewright/data/erase.py +134 -0
  43. pipewright/data/erase_gcs.py +56 -0
  44. pipewright/errors.py +18 -0
  45. pipewright/examples/churn/components/champion.py +51 -0
  46. pipewright/examples/churn/components/churn_net.py +83 -0
  47. pipewright/examples/churn/components/evaluate.py +90 -0
  48. pipewright/examples/churn/components/ingest.py +61 -0
  49. pipewright/examples/churn/components/mlkit.py +369 -0
  50. pipewright/examples/churn/components/monitor.py +216 -0
  51. pipewright/examples/churn/components/prepare.py +50 -0
  52. pipewright/examples/churn/components/record.py +51 -0
  53. pipewright/examples/churn/components/register.py +114 -0
  54. pipewright/examples/churn/components/requirements.txt +19 -0
  55. pipewright/examples/churn/components/score.py +63 -0
  56. pipewright/examples/churn/components/torchserve_handler.py +63 -0
  57. pipewright/examples/churn/components/train.py +80 -0
  58. pipewright/examples/churn/components/tune.py +75 -0
  59. pipewright/examples/churn/components/validate.py +40 -0
  60. pipewright/examples/churn/config/data.yaml +32 -0
  61. pipewright/examples/churn/config/evaluation.yaml +31 -0
  62. pipewright/examples/churn/config/operations.yaml.tmpl +33 -0
  63. pipewright/examples/churn/config/runtime.yaml.tmpl +14 -0
  64. pipewright/examples/churn/config/synthetic.yaml +22 -0
  65. pipewright/examples/churn/config/tables.yaml.tmpl +28 -0
  66. pipewright/examples/churn/config/training.yaml +21 -0
  67. pipewright/examples/churn/config/triggers.yaml.tmpl +10 -0
  68. pipewright/examples/churn/data/generate_sample.py +85 -0
  69. pipewright/examples/churn/data/sample_customers.csv +5001 -0
  70. pipewright/examples/churn/data/scoring_customers.csv +1001 -0
  71. pipewright/examples/churn/data/scoring_drifted.csv +1001 -0
  72. pipewright/examples/churn/environments/dev.yaml +8 -0
  73. pipewright/examples/churn/inference_pipeline.py +61 -0
  74. pipewright/examples/churn/schemas/churn_outcomes.json +17 -0
  75. pipewright/examples/churn/schemas/model_metrics.json +127 -0
  76. pipewright/examples/churn/schemas/model_monitoring.json +82 -0
  77. pipewright/examples/churn/schemas/predictions.json +10 -0
  78. pipewright/examples/churn/schemas/raw_customers.json +72 -0
  79. pipewright/examples/churn/tests/requirements.txt +4 -0
  80. pipewright/examples/churn/tests/test_mlkit.py +149 -0
  81. pipewright/examples/churn/training_pipeline.py +93 -0
  82. pipewright/examples/churn/usecase.yaml.tmpl +41 -0
  83. pipewright/examples/features/config/data.yaml.tmpl +33 -0
  84. pipewright/examples/features/data/customer_profile.csv +15001 -0
  85. pipewright/examples/features/data/entities.csv +5001 -0
  86. pipewright/examples/features/data/load_tables.sh +21 -0
  87. pipewright/examples/features/data/support_activity.csv +10001 -0
  88. pipewright/examples/features/example.yaml +12 -0
  89. pipewright/examples/features/usecase.yaml.tmpl +49 -0
  90. pipewright/examples/minimal/config/data.yaml +21 -0
  91. pipewright/examples/minimal/example.yaml +9 -0
  92. pipewright/examples/minimal/usecase.yaml.tmpl +39 -0
  93. pipewright/examples/ray/components/ray_tune.py +56 -0
  94. pipewright/examples/ray/components/ray_tune_job.py +66 -0
  95. pipewright/examples/ray/components/requirements.txt +22 -0
  96. pipewright/examples/ray/config/data.yaml +21 -0
  97. pipewright/examples/ray/example.yaml +9 -0
  98. pipewright/examples/ray/ray-requirements.txt +5 -0
  99. pipewright/examples/ray/training_pipeline.py +84 -0
  100. pipewright/examples/ray/usecase.yaml.tmpl +57 -0
  101. pipewright/gcp/__init__.py +6 -0
  102. pipewright/gcp/bigquery.py +63 -0
  103. pipewright/gcp/session.py +154 -0
  104. pipewright/gcp/storage.py +56 -0
  105. pipewright/kube.py +89 -0
  106. pipewright/launcher/__init__.py +1 -0
  107. pipewright/launcher/app.py +323 -0
  108. pipewright/launcher/pull.py +138 -0
  109. pipewright/launcher/registry.py +91 -0
  110. pipewright/launcher/routing.py +169 -0
  111. pipewright/models/__init__.py +1 -0
  112. pipewright/models/deploy.py +172 -0
  113. pipewright/models/gates.py +113 -0
  114. pipewright/models/kserve.py +302 -0
  115. pipewright/models/registry.py +128 -0
  116. pipewright/monitoring/__init__.py +0 -0
  117. pipewright/monitoring/status.py +61 -0
  118. pipewright/observability/__init__.py +0 -0
  119. pipewright/observability/events.py +69 -0
  120. pipewright/pipelines_ui.py +97 -0
  121. pipewright/plugins.py +43 -0
  122. pipewright/ray_clusters/__init__.py +5 -0
  123. pipewright/ray_clusters/service.py +407 -0
  124. pipewright/ray_clusters/settings.py +186 -0
  125. pipewright/release/__init__.py +1 -0
  126. pipewright/release/contract.py +208 -0
  127. pipewright/release/promote.py +509 -0
  128. pipewright/release/publish.py +113 -0
  129. pipewright/release/service.py +227 -0
  130. pipewright/release/verify.py +103 -0
  131. pipewright/retraining/__init__.py +21 -0
  132. pipewright/retraining/cases.py +272 -0
  133. pipewright/retraining/client.py +145 -0
  134. pipewright/retraining/identity.py +73 -0
  135. pipewright/retraining/service.py +243 -0
  136. pipewright/retraining/store.py +99 -0
  137. pipewright/retraining/summary.py +137 -0
  138. pipewright/runs/__init__.py +0 -0
  139. pipewright/runs/launch.py +106 -0
  140. pipewright/runs/sources.py +36 -0
  141. pipewright/scaffold/__init__.py +1 -0
  142. pipewright/scaffold/ci.py +128 -0
  143. pipewright/scaffold/ci_templates/action.yml +62 -0
  144. pipewright/scaffold/ci_templates/pipewright-operate.yml +123 -0
  145. pipewright/scaffold/ci_templates/pipewright-pr.yml +95 -0
  146. pipewright/scaffold/ci_templates/pipewright-release.yml +66 -0
  147. pipewright/scaffold/from_bq.py +123 -0
  148. pipewright/scaffold/new_usecase.py +101 -0
  149. pipewright/schedules/__init__.py +1 -0
  150. pipewright/schedules/fire.py +47 -0
  151. pipewright/schedules/kubernetes.py +143 -0
  152. pipewright/schedules/sync.py +144 -0
  153. pipewright/synthetic/__init__.py +20 -0
  154. pipewright/synthetic/config.py +277 -0
  155. pipewright/synthetic/pipeline.py +59 -0
  156. pipewright/synthetic/runtime/__init__.py +5 -0
  157. pipewright/synthetic/runtime/engines.py +170 -0
  158. pipewright/synthetic/runtime/evaluation.py +234 -0
  159. pipewright/synthetic/runtime/gates.py +89 -0
  160. pipewright/synthetic/runtime/relational.py +151 -0
  161. pipewright/synthetic/runtime/rules.py +183 -0
  162. pipewright/synthetic/synthetic_steps.py +317 -0
  163. pipewright/tables/__init__.py +1 -0
  164. pipewright/tables/external.py +85 -0
  165. pipewright/tables/plan.py +157 -0
  166. pipewright/tables/sync.py +103 -0
  167. pipewright/testing/__init__.py +0 -0
  168. pipewright/testing/runner.py +29 -0
  169. pipewright_mlops-1.0.0.dist-info/METADATA +549 -0
  170. pipewright_mlops-1.0.0.dist-info/RECORD +174 -0
  171. pipewright_mlops-1.0.0.dist-info/WHEEL +4 -0
  172. pipewright_mlops-1.0.0.dist-info/entry_points.txt +2 -0
  173. pipewright_mlops-1.0.0.dist-info/licenses/LICENSE +202 -0
  174. pipewright_mlops-1.0.0.dist-info/licenses/NOTICE +7 -0
pipewright/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Pipewright: configurable, governed MLOps on Google Cloud. Distribution name: pipewright-mlops."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("pipewright-mlops")
7
+ except PackageNotFoundError: # running from a source checkout without install
8
+ __version__ = "0.0.0"
9
+
10
+ API_VERSION = "pipewright/v1"
@@ -0,0 +1,23 @@
1
+ """Orchestrator adapters: `vertex` (Agent Platform Pipelines) and `kfp` (Kubeflow Pipelines, private-gke)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pipewright.release.contract import PipelineTarget, ReleaseContract
6
+
7
+
8
+ def submit(contract: ReleaseContract, target: PipelineTarget, parameters: dict, job_id: str, labels: dict,
9
+ security: dict | None = None, manifest: dict | None = None, gcp=None) -> str:
10
+ """`security` is the manifest's security block (CMEK key, network): it is read at submit time, so the platform
11
+ can change it without app teams re-releasing. `manifest` and `gcp` are needed by kfp (the cluster and the
12
+ tenant's Kubernetes service account)."""
13
+ if contract.orchestrator == "vertex":
14
+ from pipewright.adapters.vertex import submit as vertex_submit
15
+
16
+ return vertex_submit(contract, target, parameters, job_id, labels, security)
17
+ if contract.orchestrator == "kfp":
18
+ from pipewright.adapters.kfp import submit as kfp_submit
19
+
20
+ if manifest is None or gcp is None:
21
+ raise ValueError("orchestrator kfp needs the environment manifest and a Google session")
22
+ return kfp_submit(contract, target, parameters, job_id, labels, manifest, gcp)
23
+ raise NotImplementedError(f"orchestrator {contract.orchestrator!r} is not supported yet")
@@ -0,0 +1,149 @@
1
+ """Kubeflow Pipelines adapter (orchestrator kfp, the private-gke blueprint).
2
+
3
+ A run is created through the Kubeflow Pipelines v2beta1 API, reached through the cluster's Kubernetes API (a service
4
+ proxy): the caller authenticates with its own Google identity and RBAC lets it call only that API, so the Kubeflow
5
+ Pipelines endpoint is never exposed. The run uses the same compiled template, parameters and pipeline root as on
6
+ Agent Platform, and runs as the tenant's Kubernetes service account, bound to its runner (Workload Identity).
7
+
8
+ Kubeflow Pipelines has no client-chosen run ID, so the deterministic job ID is the run's display name, and a run
9
+ with that name already present is a duplicate delivery (AlreadySubmitted).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+
17
+ import yaml
18
+
19
+ from pipewright.adapters.vertex import AlreadySubmitted
20
+ from pipewright.kube import KubeClient, message
21
+ from pipewright.release.contract import PipelineTarget, ReleaseContract
22
+
23
+ log = logging.getLogger("pipewright.launcher")
24
+
25
+
26
+ DESCRIPTION_MAX = 255 # Kubeflow Pipelines stores a run's description in a VARCHAR(255) column
27
+ # The labels a run keeps (Kubeflow Pipelines v2 runs have no labels of their own), most important first: what does not
28
+ # fit in the description is left out.
29
+ _DESCRIBED = ("pipewright-usecase", "run-id", "pipewright-pipeline", "pipewright-release", "pipewright-tenant",
30
+ "trigger")
31
+
32
+
33
+ def run_description(labels: dict) -> str:
34
+ """The run's labels as compact JSON within DESCRIPTION_MAX: {"pw": {"usecase": ..., "run-id": ...}}."""
35
+ kept: dict = {}
36
+ for key in _DESCRIBED:
37
+ if labels.get(key) is None:
38
+ continue
39
+ trial = {**kept, key.removeprefix("pipewright-"): labels[key]}
40
+ if len(json.dumps({"pw": trial}, separators=(",", ":"))) > DESCRIPTION_MAX:
41
+ break
42
+ kept = trial
43
+ return json.dumps({"pw": kept}, separators=(",", ":"))
44
+
45
+
46
+ def run_labels(description: str | None) -> dict:
47
+ """The labels run_description kept, under their label names (pipewright-usecase, run-id, ...); {} otherwise."""
48
+ try:
49
+ doc = json.loads(description or "{}") or {}
50
+ if isinstance(doc.get("labels"), dict): # runs submitted before the compact form
51
+ return doc["labels"]
52
+ kept = doc.get("pw") or {}
53
+ except (ValueError, AttributeError):
54
+ return {}
55
+ names = {k.removeprefix("pipewright-"): k for k in _DESCRIBED}
56
+ return {names.get(k, k): v for k, v in kept.items()} if isinstance(kept, dict) else {}
57
+
58
+
59
+ def api_path(namespace: str) -> str:
60
+ return f"/api/v1/namespaces/{namespace}/services/ml-pipeline:8888/proxy/apis/v2beta1"
61
+
62
+
63
+ def template(gcp, template_uri: str) -> dict:
64
+ """The compiled pipeline from the Artifact Registry KFP repository: the pipeline spec, and its platform spec
65
+ when the template has one (a second YAML document)."""
66
+ r = gcp.session.get(template_uri, timeout=60)
67
+ if r.status_code != 200:
68
+ raise RuntimeError(f"reading {template_uri} failed: {r.status_code} {r.text[:300]}")
69
+ docs = [d for d in yaml.safe_load_all(r.text) if d]
70
+ if not docs:
71
+ raise RuntimeError(f"{template_uri} is empty")
72
+ if len(docs) == 1:
73
+ return docs[0]
74
+ return {"pipeline_spec": docs[0], "platform_spec": docs[1]}
75
+
76
+
77
+ def uncache_branches(spec: dict) -> list[str]:
78
+ """Turns caching off for the tasks inside dsl.OneOf branches (components `comp-condition-branches-*` and
79
+ everything under them), in place; returns their names. Kubeflow Pipelines resolves a OneOf output only from a
80
+ branch task that ran: when the chosen branch's task was a cache hit, the next step fails ("No successful task
81
+ found for output key", found 2026-10-17 on 2.17.2). Agent Platform resolves cached branches, so templates keep
82
+ their caching and only runs on Kubeflow change."""
83
+ pipeline = spec.get("pipeline_spec", spec)
84
+ components = pipeline.get("components") or {}
85
+ changed: list[str] = []
86
+
87
+ def walk(component: str, seen: set) -> None:
88
+ if component in seen:
89
+ return
90
+ seen.add(component)
91
+ for name, task in ((components.get(component) or {}).get("dag") or {}).get("tasks", {}).items():
92
+ if (task.get("cachingOptions") or {}).get("enableCache"):
93
+ task["cachingOptions"] = {}
94
+ changed.append(name)
95
+ child = (task.get("componentRef") or {}).get("name")
96
+ if child:
97
+ walk(child, seen)
98
+
99
+ seen: set = set()
100
+ for component in components:
101
+ if component.startswith("comp-condition-branches-"):
102
+ walk(component, seen)
103
+ return changed
104
+
105
+
106
+ def find(kube: KubeClient, namespace: str, display_name: str) -> dict | None:
107
+ flt = json.dumps({"predicates": [{"key": "display_name", "operation": "EQUALS", "string_value": display_name}]})
108
+ r = kube.get(f"{api_path(namespace)}/runs", filter=flt, page_size=5)
109
+ if not r.ok:
110
+ raise RuntimeError(f"listing Kubeflow runs failed: {r.status} {message(r)}")
111
+ runs = (r.body or {}).get("runs") or []
112
+ return runs[0] if runs else None
113
+
114
+
115
+ def submit(contract: ReleaseContract, target: PipelineTarget, parameters: dict, job_id: str, labels: dict,
116
+ manifest: dict, gcp) -> str:
117
+ k8s = manifest.get("kubernetes") or {}
118
+ namespace = k8s.get("namespace") or "kubeflow"
119
+ tenant = (manifest.get("tenants") or {}).get(contract.tenant) or {}
120
+ service_account = tenant.get("kubernetes_service_account")
121
+ if not service_account:
122
+ raise RuntimeError(f"tenant {contract.tenant} has no kubernetes_service_account in the environment manifest")
123
+ kube = KubeClient.from_manifest(manifest, gcp)
124
+ if find(kube, namespace, job_id):
125
+ raise AlreadySubmitted(job_id)
126
+ spec = template(gcp, target.template_uri)
127
+ uncached = uncache_branches(spec)
128
+ if uncached:
129
+ log.info("kubeflow run %s: no caching for OneOf branch tasks %s", job_id, ", ".join(sorted(uncached)))
130
+ body = {
131
+ "display_name": job_id,
132
+ "description": run_description(labels),
133
+ "pipeline_spec": spec,
134
+ "runtime_config": {"parameters": parameters, "pipeline_root": contract.pipeline_root},
135
+ "service_account": service_account,
136
+ }
137
+ r = kube.post(f"{api_path(namespace)}/runs", body)
138
+ if not r.ok:
139
+ raise RuntimeError(f"creating Kubeflow run {job_id} failed: {r.status} {message(r)}")
140
+ run_id = (r.body or {}).get("run_id")
141
+ log.info("kubeflow run %s (%s) as %s", run_id, job_id, service_account)
142
+ return f"kubeflow:{namespace}/runs/{run_id}"
143
+
144
+
145
+ def run_state(manifest: dict, gcp, job_id: str) -> str | None:
146
+ """The run's state (PENDING, RUNNING, SUCCEEDED, FAILED, ...) by its job ID, or None when there is no such run."""
147
+ k8s = manifest.get("kubernetes") or {}
148
+ run = find(KubeClient.from_manifest(manifest, gcp), k8s.get("namespace") or "kubeflow", job_id)
149
+ return run.get("state") if run else None
@@ -0,0 +1,65 @@
1
+ """Agent Platform Pipelines adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from pipewright.release.contract import PipelineTarget, ReleaseContract
8
+
9
+ log = logging.getLogger("pipewright.launcher")
10
+
11
+
12
+ class AlreadySubmitted(Exception):
13
+ """The deterministic job ID already exists: a duplicate delivery of the same trigger."""
14
+
15
+
16
+ def _experiment(contract: ReleaseContract, target: PipelineTarget) -> str | None:
17
+ """Training runs are grouped in an Agent Platform experiment. A failure here never blocks the run."""
18
+ if not target.experiment:
19
+ return None
20
+ from google.cloud import aiplatform
21
+
22
+ try:
23
+ aiplatform.Experiment.get_or_create(target.experiment, project=contract.project, location=contract.region)
24
+ return target.experiment
25
+ except Exception as e: # noqa: BLE001
26
+ log.warning("experiment %s unavailable, submitting without it: %s", target.experiment, e)
27
+ return None
28
+
29
+
30
+ def submit(contract: ReleaseContract, target: PipelineTarget, parameters: dict, job_id: str, labels: dict,
31
+ security: dict | None = None) -> str:
32
+ from google.api_core import exceptions as gexc
33
+ from google.cloud import aiplatform
34
+
35
+ security = security or {}
36
+ job = aiplatform.PipelineJob(
37
+ display_name=job_id,
38
+ template_path=target.template_uri,
39
+ job_id=job_id,
40
+ pipeline_root=contract.pipeline_root,
41
+ parameter_values=parameters,
42
+ enable_caching=None, # per-step caching comes from the template (spec.steps.<step>.cache)
43
+ labels=labels,
44
+ encryption_spec_key_name=security.get("kms_key"),
45
+ project=contract.project,
46
+ location=contract.region,
47
+ )
48
+ try:
49
+ # Private profiles: the job's workers peer with the environment's VPC (Private Services Access).
50
+ job.submit(service_account=contract.runner_service_account, network=security.get("network"),
51
+ reserved_ip_ranges=security.get("reserved_ip_ranges") or None,
52
+ experiment=_experiment(contract, target))
53
+ except gexc.AlreadyExists as e:
54
+ raise AlreadySubmitted(job_id) from e
55
+ except RuntimeError as e:
56
+ # The SDK creates the job, then joins it to the experiment; joining can fail (for example in a new project
57
+ # whose experiment is not ready yet). The run is already going: report it, never fail it (retrying would only
58
+ # meet AlreadyExists).
59
+ try:
60
+ name = job.resource_name
61
+ except Exception: # noqa: BLE001 - the job was not created: the original error stands
62
+ raise e from None
63
+ log.warning("%s submitted, but not joined to experiment %s: %s", name, target.experiment, e)
64
+ return name
65
+ return job.resource_name
File without changes
@@ -0,0 +1,246 @@
1
+ """pipewright alerts sync: email alerts for a use case's Pipewright events (monitoring.alerts in usecase.yaml).
2
+
3
+ monitoring:
4
+ alerts:
5
+ notify: [team-ml@example.com, ml-oncall@example.com] # any addresses, including distribution lists
6
+ events: [monitoring.alert, training.rejected] # default: monitoring.alert
7
+ severity: WARNING
8
+
9
+ Retrain decisions (retraining.decisions.notify) get their own policies: each decision event emails its summary and
10
+ case ID (retrain.escalated also emails escalate_notify; retrain.digest only with decisions.digest). Email never
11
+ approves; the email says how to decide.
12
+
13
+ For each event: one Cloud Monitoring log-based alert policy that matches the event's structured log entry (the
14
+ labels Pipewright puts on every entry) and emails every address. One email notification channel
15
+ per address, shared by all use cases that notify it. Owned objects carry the label pipewright-usecase; a policy for
16
+ an event no longer configured is deleted. Idempotent, like `schedules sync`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+
24
+ from pipewright.config import Environment, ResolvedUseCase
25
+ from pipewright.gcp import Gcp, error_text
26
+
27
+ MON = "https://monitoring.googleapis.com/v3"
28
+
29
+ # event -> (log filter on the event entry, title, details pulled into the email)
30
+ EVENTS = {
31
+ "monitoring.alert": ('jsonPayload.pipewright_event.event_type="monitoring.alert"', "drift or performance alert",
32
+ ["model_version_id", "drifted_features", "max_feature_psi", "prediction_psi",
33
+ "performance_value", "retrain_status"]),
34
+ "retrain.requested": ('jsonPayload.pipewright_event.event_type="retrain.requested"', "retrain requested",
35
+ ["triggers", "job_id", "source_uri"]),
36
+ "training.rejected": ('jsonPayload.pipewright_event.event_type="training.evaluated" AND '
37
+ 'jsonPayload.pipewright_event.data.decision="reject"', "model rejected by evaluation",
38
+ ["primary_metric", "primary_value", "champion_version", "champion_value"]),
39
+ "training.evaluated": ('jsonPayload.pipewright_event.event_type="training.evaluated"', "training evaluated",
40
+ ["decision", "primary_metric", "primary_value", "champion_version"]),
41
+ "model.registered": ('jsonPayload.pipewright_event.event_type="model.registered"', "model registered",
42
+ ["model", "version", "default", "primary_value"]),
43
+ }
44
+
45
+ # Retrain decision events (pipewright.retraining): emailed to retraining.decisions.notify.
46
+ DECISION_EVENTS = {
47
+ "retrain.proposed": "retrain decision needed",
48
+ "retrain.reopened": "retrain case re-opened",
49
+ "retrain.escalated": "retrain decision overdue",
50
+ "retrain.approval": "retrain approval recorded",
51
+ "retrain.approved": "retrain approved",
52
+ "retrain.declined": "retrain declined",
53
+ "retrain.snoozed": "retrain snoozed",
54
+ "retrain.digest": "weekly retrain digest",
55
+ }
56
+ for _event, _title in DECISION_EVENTS.items():
57
+ EVENTS[_event] = (f'jsonPayload.pipewright_event.event_type="{_event}"', _title, ["case_id", "summary"])
58
+
59
+
60
+ def _label(v: str) -> str:
61
+ return re.sub(r"[^a-z0-9_-]", "-", v.lower())[:63]
62
+
63
+
64
+ def policy_name(uc: ResolvedUseCase, event: str) -> str:
65
+ return f"pipewright {uc.name} {uc.environment}: {EVENTS[event][1]}"
66
+
67
+
68
+ def policy_body(uc: ResolvedUseCase, event: str, channels: list[str], severity: str) -> dict:
69
+ match, title, details = EVENTS[event]
70
+ log_filter = (f'labels."pipewright-usecase"="{uc.name}" AND labels."pipewright-environment"="{uc.environment}" '
71
+ f"AND {match}")
72
+ extractors = {d: f"EXTRACT(jsonPayload.pipewright_event.data.{d})" for d in details}
73
+ extractors["run_id"] = 'EXTRACT(labels."run-id")'
74
+ extractors["release"] = 'EXTRACT(labels."pipewright-release")'
75
+ subject = f"[{uc.environment}] {uc.name}: {title}"
76
+ if event in DECISION_EVENTS:
77
+ subject += " (${log.extracted_label.case_id})" if event != "retrain.digest" else ""
78
+ # A code block keeps the summary's line breaks and stops markdown turning "--env" into a dash, so the
79
+ # commands in it can be copied.
80
+ lines = [f"**{uc.name}** ({uc.environment}): {title}.", "", "```", "${log.extracted_label.summary}", "```", "",
81
+ "This email never approves anything. Decide with your own identity: `pipewright retrain show | "
82
+ f"approve | decline | snooze --env {uc.environment} --usecase {uc.name} --case <case>`.",
83
+ f"Owner: {uc.owner}."]
84
+ else:
85
+ lines = [f"**{uc.name}** ({uc.environment}): {title}.", "",
86
+ *[f"- {d}: ${{log.extracted_label.{d}}}" for d in details],
87
+ "- run: ${log.extracted_label.run_id}, release ${log.extracted_label.release}", "",
88
+ f"Owner: {uc.owner}. Details: `pipewright monitor status --env {uc.environment} --usecase "
89
+ f"{uc.name}`, or the run in Agent Platform Pipelines."]
90
+ return {
91
+ "displayName": policy_name(uc, event),
92
+ "combiner": "OR",
93
+ "conditions": [{"displayName": f"{event} event", "conditionMatchedLog": {
94
+ "filter": log_filter, "labelExtractors": extractors}}],
95
+ "alertStrategy": {"notificationRateLimit": {"period": "300s"}, "autoClose": "1800s"},
96
+ "notificationChannels": channels,
97
+ "documentation": {"content": "\n".join(lines), "mimeType": "text/markdown",
98
+ "subject": subject},
99
+ "severity": severity,
100
+ "userLabels": {"pipewright-usecase": _label(uc.name), "pipewright-environment": _label(uc.environment),
101
+ "pipewright-event": _label(event.replace(".", "-")), "pipewright-managed": "true"},
102
+ "enabled": True,
103
+ }
104
+
105
+
106
+ @dataclass
107
+ class AlertsPlan:
108
+ channels_create: list[str] = field(default_factory=list) # emails without a channel yet
109
+ channels: dict = field(default_factory=dict) # email -> channel name (existing)
110
+ create: list[str] = field(default_factory=list) # events
111
+ update: list[str] = field(default_factory=list)
112
+ delete: list[str] = field(default_factory=list) # policy names (resource)
113
+ unchanged: list[str] = field(default_factory=list)
114
+ errors: list[str] = field(default_factory=list)
115
+
116
+
117
+ def _list(gcp: Gcp, url: str, key: str, **params) -> list[dict]:
118
+ out, token = [], None
119
+ while True:
120
+ r = gcp.get(url, **params, **({"pageToken": token} if token else {}))
121
+ if not r.ok:
122
+ raise RuntimeError(error_text(r))
123
+ out += r.body.get(key, [])
124
+ token = r.body.get("nextPageToken")
125
+ if not token:
126
+ return out
127
+
128
+
129
+ def _same(live: dict, want: dict) -> bool:
130
+ keys = ("displayName", "conditions", "alertStrategy", "documentation", "severity", "enabled")
131
+ norm = lambda d: {k: d.get(k) for k in keys} | {"channels": sorted(d.get("notificationChannels", []))} # noqa: E731
132
+ live_c = [{"displayName": c.get("displayName"), "conditionMatchedLog": c.get("conditionMatchedLog")}
133
+ for c in live.get("conditions", [])]
134
+ return norm({**live, "conditions": live_c}) == norm(want)
135
+
136
+
137
+ def sync(uc: ResolvedUseCase, env: Environment, gcp: Gcp | None = None, dry_run: bool = False) -> AlertsPlan:
138
+ gcp = gcp or Gcp()
139
+ alerts = uc.ml["monitoring"]["alerts"]
140
+ severity = alerts["severity"]
141
+ wanted_emails = wanted_policies(uc)
142
+ emails = sorted({e for addresses in wanted_emails.values() for e in addresses})
143
+ base = f"{MON}/projects/{env.project}"
144
+ plan = AlertsPlan()
145
+ try:
146
+ live_channels = _list(gcp, f"{base}/notificationChannels", "notificationChannels", filter='type="email"')
147
+ live_policies = _list(gcp, f"{base}/alertPolicies", "alertPolicies",
148
+ filter=f'user_labels."pipewright-usecase"="{_label(uc.name)}" AND '
149
+ f'user_labels."pipewright-environment"="{_label(uc.environment)}"')
150
+ except RuntimeError as e:
151
+ plan.errors.append(str(e))
152
+ return plan
153
+ # A project may have other email channels for the same address (a platform's own alerts): the one Pipewright
154
+ # made wins, so a new channel elsewhere never repoints the use case's policies.
155
+ mine = lambda c: (c.get("userLabels") or {}).get("pipewright-managed") == "true" # noqa: E731
156
+ by_email = {c["labels"].get("email_address"): c["name"]
157
+ for c in sorted(live_channels, key=lambda c: (mine(c), c["name"]))}
158
+ for email in emails:
159
+ if email in by_email:
160
+ plan.channels[email] = by_email[email]
161
+ else:
162
+ plan.channels_create.append(email)
163
+
164
+ if not dry_run:
165
+ for email in plan.channels_create:
166
+ r = gcp.post(f"{base}/notificationChannels", {
167
+ "type": "email", "displayName": f"pipewright: {email}", "labels": {"email_address": email},
168
+ "userLabels": {"pipewright-managed": "true"}})
169
+ if r.ok:
170
+ plan.channels[email] = r.body["name"]
171
+ else:
172
+ plan.errors.append(f"channel {email}: {error_text(r)}")
173
+
174
+ live_by_name = {p["displayName"]: p for p in live_policies}
175
+ wanted = {policy_name(uc, e): e for e in wanted_emails}
176
+ for name, live in live_by_name.items():
177
+ if name not in wanted:
178
+ plan.delete.append(live["name"])
179
+ for name, event in wanted.items():
180
+ event_channels = sorted(plan.channels[e] for e in wanted_emails[event] if e in plan.channels)
181
+ body = policy_body(uc, event, event_channels, severity)
182
+ live = live_by_name.get(name)
183
+ if live is None:
184
+ plan.create.append(event)
185
+ if not dry_run:
186
+ r = gcp.post(f"{base}/alertPolicies", body)
187
+ if not r.ok:
188
+ plan.errors.append(f"{event}: {error_text(r)}")
189
+ elif _same(live, body) and not plan.channels_create:
190
+ plan.unchanged.append(event)
191
+ else:
192
+ plan.update.append(event)
193
+ if not dry_run:
194
+ r = gcp.patch(f"{MON}/{live['name']}", body)
195
+ if not r.ok:
196
+ plan.errors.append(f"{event}: {error_text(r)}")
197
+ if not dry_run:
198
+ for name in plan.delete:
199
+ r = gcp.delete(f"{MON}/{name}")
200
+ if not r.ok and r.status != 404:
201
+ plan.errors.append(f"delete {name}: {error_text(r)}")
202
+ return plan
203
+
204
+
205
+ def wanted_policies(uc: ResolvedUseCase) -> dict[str, list[str]]:
206
+ """event -> email addresses: monitoring.alerts for its events, retraining.decisions for decision events."""
207
+ alerts = uc.ml["monitoring"]["alerts"]
208
+ out = {e: sorted(set(alerts["notify"])) for e in dict.fromkeys(alerts["events"])} if alerts["notify"] else {}
209
+ retraining = uc.ml.get("retraining") or {}
210
+ decisions = retraining.get("decisions") or {}
211
+ notify = sorted(set(decisions.get("notify") or []))
212
+ if retraining.get("mode", "off") != "off" and notify:
213
+ for event in DECISION_EVENTS:
214
+ if event == "retrain.digest" and not decisions.get("digest", True):
215
+ continue
216
+ extra = (decisions.get("escalate_notify") or []) if event == "retrain.escalated" else []
217
+ out[event] = sorted(set(notify) | set(extra))
218
+ return out
219
+
220
+
221
+ def render(uc: ResolvedUseCase, plan: AlertsPlan, dry_run: bool) -> str:
222
+ alerts = uc.ml["monitoring"]["alerts"]
223
+ head = f"Alerts for {uc.name} ({uc.environment})" + (" [dry run]" if dry_run else "")
224
+ if not alerts["notify"]:
225
+ lines = [head, " INFO monitoring.alerts.notify is empty: no alert emails"]
226
+ else:
227
+ lines = [head, f" notify: {', '.join(sorted(set(alerts['notify'])))}"]
228
+ decisions = (uc.ml.get("retraining") or {}).get("decisions") or {}
229
+ if (uc.ml.get("retraining") or {}).get("mode", "off") != "off":
230
+ lines.append(f" retrain decisions: {', '.join(sorted(set(decisions.get('notify') or []))) or 'nobody'}"
231
+ + ("" if decisions.get("notify") else " (set retraining.decisions.notify)"))
232
+ verb = (lambda w: f"would {w}") if dry_run else (lambda w: f"{w}d" if w.endswith("e") else f"{w}ed")
233
+ for e in plan.channels_create:
234
+ lines.append(f" {'PLAN' if dry_run else 'DONE'} email channel {e}: {verb('create')}")
235
+ for e in plan.create:
236
+ lines.append(f" {'PLAN' if dry_run else 'DONE'} {e}: {verb('create')}")
237
+ for e in plan.update:
238
+ lines.append(f" {'PLAN' if dry_run else 'DONE'} {e}: {verb('update')}")
239
+ for e in plan.unchanged:
240
+ lines.append(f" OK {e}: up to date")
241
+ for n in plan.delete:
242
+ status = "PLAN" if dry_run else "DONE"
243
+ lines.append(f" {status} {n.rsplit('/', 1)[-1]}: {verb('delete')} (no longer configured)")
244
+ for err in plan.errors:
245
+ lines.append(f" FAIL {err}")
246
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ """Turning a use case into artifacts: compiled templates and component images."""