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.
- pipewright/__init__.py +10 -0
- pipewright/adapters/__init__.py +23 -0
- pipewright/adapters/kfp.py +149 -0
- pipewright/adapters/vertex.py +65 -0
- pipewright/alerts/__init__.py +0 -0
- pipewright/alerts/sync.py +246 -0
- pipewright/build/__init__.py +1 -0
- pipewright/build/compile.py +165 -0
- pipewright/build/images.py +226 -0
- pipewright/build/spec_patch.py +81 -0
- pipewright/build/templates/__init__.py +1 -0
- pipewright/build/templates/components.Dockerfile +32 -0
- pipewright/build/templates/pipewright_runtime.py +682 -0
- pipewright/build/templates/ray.Dockerfile +31 -0
- pipewright/build/templates/synthetic-engine-requirements.txt +9 -0
- pipewright/build/templates/synthetic-engine.Dockerfile +40 -0
- pipewright/build/templates/synthetic-evaluation-requirements.txt +10 -0
- pipewright/build/templates/synthetic-evaluation.Dockerfile +35 -0
- pipewright/checks/__init__.py +1 -0
- pipewright/checks/doctor.py +666 -0
- pipewright/checks/kfp_version.py +94 -0
- pipewright/checks/ray_version.py +61 -0
- pipewright/cli/__init__.py +5 -0
- pipewright/cli/main.py +786 -0
- pipewright/config/__init__.py +22 -0
- pipewright/config/document.py +97 -0
- pipewright/config/environment.py +156 -0
- pipewright/config/io.py +38 -0
- pipewright/config/merge.py +29 -0
- pipewright/config/ml.py +287 -0
- pipewright/config/models.py +140 -0
- pipewright/config/reference.py +274 -0
- pipewright/config/references.py +51 -0
- pipewright/config/resolve.py +378 -0
- pipewright/config/schema.py +110 -0
- pipewright/config/schemas/__init__.py +1 -0
- pipewright/config/schemas/environment.schema.json +52 -0
- pipewright/config/schemas/manifest.schema.json +728 -0
- pipewright/config/schemas/reference.yaml +326 -0
- pipewright/config/schemas/usecase.schema.json +816 -0
- pipewright/data/__init__.py +1 -0
- pipewright/data/erase.py +134 -0
- pipewright/data/erase_gcs.py +56 -0
- pipewright/errors.py +18 -0
- pipewright/examples/churn/components/champion.py +51 -0
- pipewright/examples/churn/components/churn_net.py +83 -0
- pipewright/examples/churn/components/evaluate.py +90 -0
- pipewright/examples/churn/components/ingest.py +61 -0
- pipewright/examples/churn/components/mlkit.py +369 -0
- pipewright/examples/churn/components/monitor.py +216 -0
- pipewright/examples/churn/components/prepare.py +50 -0
- pipewright/examples/churn/components/record.py +51 -0
- pipewright/examples/churn/components/register.py +114 -0
- pipewright/examples/churn/components/requirements.txt +19 -0
- pipewright/examples/churn/components/score.py +63 -0
- pipewright/examples/churn/components/torchserve_handler.py +63 -0
- pipewright/examples/churn/components/train.py +80 -0
- pipewright/examples/churn/components/tune.py +75 -0
- pipewright/examples/churn/components/validate.py +40 -0
- pipewright/examples/churn/config/data.yaml +32 -0
- pipewright/examples/churn/config/evaluation.yaml +31 -0
- pipewright/examples/churn/config/operations.yaml.tmpl +33 -0
- pipewright/examples/churn/config/runtime.yaml.tmpl +14 -0
- pipewright/examples/churn/config/synthetic.yaml +22 -0
- pipewright/examples/churn/config/tables.yaml.tmpl +28 -0
- pipewright/examples/churn/config/training.yaml +21 -0
- pipewright/examples/churn/config/triggers.yaml.tmpl +10 -0
- pipewright/examples/churn/data/generate_sample.py +85 -0
- pipewright/examples/churn/data/sample_customers.csv +5001 -0
- pipewright/examples/churn/data/scoring_customers.csv +1001 -0
- pipewright/examples/churn/data/scoring_drifted.csv +1001 -0
- pipewright/examples/churn/environments/dev.yaml +8 -0
- pipewright/examples/churn/inference_pipeline.py +61 -0
- pipewright/examples/churn/schemas/churn_outcomes.json +17 -0
- pipewright/examples/churn/schemas/model_metrics.json +127 -0
- pipewright/examples/churn/schemas/model_monitoring.json +82 -0
- pipewright/examples/churn/schemas/predictions.json +10 -0
- pipewright/examples/churn/schemas/raw_customers.json +72 -0
- pipewright/examples/churn/tests/requirements.txt +4 -0
- pipewright/examples/churn/tests/test_mlkit.py +149 -0
- pipewright/examples/churn/training_pipeline.py +93 -0
- pipewright/examples/churn/usecase.yaml.tmpl +41 -0
- pipewright/examples/features/config/data.yaml.tmpl +33 -0
- pipewright/examples/features/data/customer_profile.csv +15001 -0
- pipewright/examples/features/data/entities.csv +5001 -0
- pipewright/examples/features/data/load_tables.sh +21 -0
- pipewright/examples/features/data/support_activity.csv +10001 -0
- pipewright/examples/features/example.yaml +12 -0
- pipewright/examples/features/usecase.yaml.tmpl +49 -0
- pipewright/examples/minimal/config/data.yaml +21 -0
- pipewright/examples/minimal/example.yaml +9 -0
- pipewright/examples/minimal/usecase.yaml.tmpl +39 -0
- pipewright/examples/ray/components/ray_tune.py +56 -0
- pipewright/examples/ray/components/ray_tune_job.py +66 -0
- pipewright/examples/ray/components/requirements.txt +22 -0
- pipewright/examples/ray/config/data.yaml +21 -0
- pipewright/examples/ray/example.yaml +9 -0
- pipewright/examples/ray/ray-requirements.txt +5 -0
- pipewright/examples/ray/training_pipeline.py +84 -0
- pipewright/examples/ray/usecase.yaml.tmpl +57 -0
- pipewright/gcp/__init__.py +6 -0
- pipewright/gcp/bigquery.py +63 -0
- pipewright/gcp/session.py +154 -0
- pipewright/gcp/storage.py +56 -0
- pipewright/kube.py +89 -0
- pipewright/launcher/__init__.py +1 -0
- pipewright/launcher/app.py +323 -0
- pipewright/launcher/pull.py +138 -0
- pipewright/launcher/registry.py +91 -0
- pipewright/launcher/routing.py +169 -0
- pipewright/models/__init__.py +1 -0
- pipewright/models/deploy.py +172 -0
- pipewright/models/gates.py +113 -0
- pipewright/models/kserve.py +302 -0
- pipewright/models/registry.py +128 -0
- pipewright/monitoring/__init__.py +0 -0
- pipewright/monitoring/status.py +61 -0
- pipewright/observability/__init__.py +0 -0
- pipewright/observability/events.py +69 -0
- pipewright/pipelines_ui.py +97 -0
- pipewright/plugins.py +43 -0
- pipewright/ray_clusters/__init__.py +5 -0
- pipewright/ray_clusters/service.py +407 -0
- pipewright/ray_clusters/settings.py +186 -0
- pipewright/release/__init__.py +1 -0
- pipewright/release/contract.py +208 -0
- pipewright/release/promote.py +509 -0
- pipewright/release/publish.py +113 -0
- pipewright/release/service.py +227 -0
- pipewright/release/verify.py +103 -0
- pipewright/retraining/__init__.py +21 -0
- pipewright/retraining/cases.py +272 -0
- pipewright/retraining/client.py +145 -0
- pipewright/retraining/identity.py +73 -0
- pipewright/retraining/service.py +243 -0
- pipewright/retraining/store.py +99 -0
- pipewright/retraining/summary.py +137 -0
- pipewright/runs/__init__.py +0 -0
- pipewright/runs/launch.py +106 -0
- pipewright/runs/sources.py +36 -0
- pipewright/scaffold/__init__.py +1 -0
- pipewright/scaffold/ci.py +128 -0
- pipewright/scaffold/ci_templates/action.yml +62 -0
- pipewright/scaffold/ci_templates/pipewright-operate.yml +123 -0
- pipewright/scaffold/ci_templates/pipewright-pr.yml +95 -0
- pipewright/scaffold/ci_templates/pipewright-release.yml +66 -0
- pipewright/scaffold/from_bq.py +123 -0
- pipewright/scaffold/new_usecase.py +101 -0
- pipewright/schedules/__init__.py +1 -0
- pipewright/schedules/fire.py +47 -0
- pipewright/schedules/kubernetes.py +143 -0
- pipewright/schedules/sync.py +144 -0
- pipewright/synthetic/__init__.py +20 -0
- pipewright/synthetic/config.py +277 -0
- pipewright/synthetic/pipeline.py +59 -0
- pipewright/synthetic/runtime/__init__.py +5 -0
- pipewright/synthetic/runtime/engines.py +170 -0
- pipewright/synthetic/runtime/evaluation.py +234 -0
- pipewright/synthetic/runtime/gates.py +89 -0
- pipewright/synthetic/runtime/relational.py +151 -0
- pipewright/synthetic/runtime/rules.py +183 -0
- pipewright/synthetic/synthetic_steps.py +317 -0
- pipewright/tables/__init__.py +1 -0
- pipewright/tables/external.py +85 -0
- pipewright/tables/plan.py +157 -0
- pipewright/tables/sync.py +103 -0
- pipewright/testing/__init__.py +0 -0
- pipewright/testing/runner.py +29 -0
- pipewright_mlops-1.0.0.dist-info/METADATA +549 -0
- pipewright_mlops-1.0.0.dist-info/RECORD +174 -0
- pipewright_mlops-1.0.0.dist-info/WHEEL +4 -0
- pipewright_mlops-1.0.0.dist-info/entry_points.txt +2 -0
- pipewright_mlops-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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."""
|