Skip to content

Commit 8b176c9

Browse files
committed
Add SourceOS Operation conformance runner
1 parent 679b99f commit 8b176c9

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
#!/usr/bin/env python3
2+
"""SourceOS Workspace Operation conformance runner.
3+
4+
Validates Workspace Operation Plane fixture bundles from prophet-core-contracts or
5+
from a local adapter repo. The runner uses only stdlib for structural checks and
6+
optionally enables full JSON Schema validation when the `jsonschema` package is
7+
installed.
8+
9+
Default repo layout assumes:
10+
~/dev/sourceos-devtools
11+
~/dev/prophet-core-contracts
12+
13+
Usage:
14+
python3 tools/sourceos_operation_conformance.py \
15+
--contracts-dir ../prophet-core-contracts
16+
17+
python3 tools/sourceos_operation_conformance.py \
18+
--examples-dir ../prophet-core-contracts/examples/workspace-operation \
19+
--schemas-dir ../prophet-core-contracts/schemas
20+
"""
21+
from __future__ import annotations
22+
23+
import argparse
24+
import json
25+
import sys
26+
from dataclasses import dataclass
27+
from pathlib import Path
28+
from typing import Any, Iterable
29+
30+
try: # optional dependency, intentionally not required for basic checks
31+
import jsonschema # type: ignore
32+
except Exception: # noqa: BLE001
33+
jsonschema = None # type: ignore
34+
35+
36+
@dataclass(frozen=True)
37+
class Paths:
38+
examples_dir: Path
39+
schemas_dir: Path | None
40+
41+
42+
class ValidationErrorSet:
43+
def __init__(self) -> None:
44+
self.errors: list[str] = []
45+
self.warnings: list[str] = []
46+
47+
def error(self, message: str) -> None:
48+
self.errors.append(message)
49+
50+
def warning(self, message: str) -> None:
51+
self.warnings.append(message)
52+
53+
def ok(self) -> bool:
54+
return not self.errors
55+
56+
57+
def load_json(path: Path) -> dict[str, Any]:
58+
with path.open("r", encoding="utf-8") as handle:
59+
loaded = json.load(handle)
60+
if not isinstance(loaded, dict):
61+
raise ValueError("top-level JSON value must be an object")
62+
return loaded
63+
64+
65+
def as_list(value: Any) -> list[Any]:
66+
if value is None:
67+
return []
68+
if isinstance(value, list):
69+
return value
70+
return [value]
71+
72+
73+
def validate_structural(path: Path, data: dict[str, Any], results: ValidationErrorSet) -> None:
74+
operation = data.get("operation")
75+
if not isinstance(operation, dict):
76+
results.error(f"{path}: missing operation object")
77+
return
78+
79+
op_id = operation.get("operation_id")
80+
if not op_id:
81+
results.error(f"{path}: operation.operation_id is required")
82+
83+
if operation.get("schema_version") != "0.1.0":
84+
results.error(f"{path}: operation.schema_version must be 0.1.0")
85+
86+
if not operation.get("operation_type"):
87+
results.error(f"{path}: operation.operation_type is required")
88+
89+
if not operation.get("idempotency_key"):
90+
results.error(f"{path}: operation.idempotency_key is required")
91+
92+
task_ids = set(operation.get("task_ids", []))
93+
artifact_ids = set(operation.get("artifact_ids", []))
94+
decision_ids = set(operation.get("decision_ids", []))
95+
policy_gate_ids = set(operation.get("policy_gate_ids", []))
96+
97+
task_objects = as_list(data.get("task")) + list(data.get("tasks", []))
98+
artifact_objects = as_list(data.get("artifact")) + list(data.get("artifacts", []))
99+
decision_objects = as_list(data.get("decision")) + list(data.get("decisions", []))
100+
gate_objects = as_list(data.get("policy_gate")) + list(data.get("policy_gates", []))
101+
102+
seen_task_ids = {task.get("task_id") for task in task_objects if isinstance(task, dict)}
103+
missing_tasks = task_ids - seen_task_ids
104+
if missing_tasks:
105+
results.error(f"{path}: operation.task_ids missing objects: {sorted(missing_tasks)}")
106+
107+
seen_artifact_ids = {artifact.get("artifact_id") for artifact in artifact_objects if isinstance(artifact, dict)}
108+
missing_artifacts = artifact_ids - seen_artifact_ids
109+
if missing_artifacts:
110+
results.error(f"{path}: operation.artifact_ids missing objects: {sorted(missing_artifacts)}")
111+
112+
seen_decision_ids = {decision.get("decision_id") for decision in decision_objects if isinstance(decision, dict)}
113+
missing_decisions = decision_ids - seen_decision_ids
114+
if missing_decisions:
115+
results.error(f"{path}: operation.decision_ids missing objects: {sorted(missing_decisions)}")
116+
117+
seen_gate_ids = {gate.get("gate_id") for gate in gate_objects if isinstance(gate, dict)}
118+
missing_gates = policy_gate_ids - seen_gate_ids
119+
if missing_gates:
120+
results.error(f"{path}: operation.policy_gate_ids missing objects: {sorted(missing_gates)}")
121+
122+
for task in task_objects:
123+
if not isinstance(task, dict):
124+
continue
125+
task_id = task.get("task_id")
126+
if task.get("operation_id") != op_id:
127+
results.error(f"{path}: task {task_id} operation_id mismatch")
128+
if task_id and task_ids and task_id not in task_ids:
129+
results.error(f"{path}: task {task_id} not listed in operation.task_ids")
130+
if task.get("retryable") and not task.get("idempotency_key"):
131+
results.error(f"{path}: retryable task {task_id} lacks idempotency_key")
132+
if task.get("status") == "failed" and task.get("retryable") and not task.get("idempotency_key"):
133+
results.error(f"{path}: failed retryable task {task_id} lacks idempotency_key")
134+
135+
for artifact in artifact_objects:
136+
if not isinstance(artifact, dict):
137+
continue
138+
artifact_id = artifact.get("artifact_id")
139+
if artifact_id and artifact_ids and artifact_id not in artifact_ids:
140+
results.error(f"{path}: artifact {artifact_id} not listed in operation.artifact_ids")
141+
if artifact.get("activation_state") == "active" and artifact.get("admission_state") not in {"admitted", "activated"}:
142+
results.error(f"{path}: artifact {artifact_id} active before admission")
143+
if artifact.get("admission_state") == "quarantined" and artifact.get("activation_state") == "active":
144+
results.error(f"{path}: artifact {artifact_id} is quarantined but active")
145+
146+
for decision in decision_objects:
147+
if not isinstance(decision, dict):
148+
continue
149+
decision_id = decision.get("decision_id")
150+
if decision_id and decision_ids and decision_id not in decision_ids:
151+
results.error(f"{path}: decision {decision_id} not listed in operation.decision_ids")
152+
if decision.get("status") == "pending" and not decision.get("options"):
153+
results.error(f"{path}: pending decision {decision_id} has no options")
154+
155+
for gate in gate_objects:
156+
if not isinstance(gate, dict):
157+
continue
158+
gate_id = gate.get("gate_id")
159+
if gate_id and policy_gate_ids and gate_id not in policy_gate_ids:
160+
results.error(f"{path}: policy gate {gate_id} not listed in operation.policy_gate_ids")
161+
if gate.get("status") in {"blocking", "requires_decision", "requires_admin"}:
162+
if not gate.get("responsible_actor"):
163+
results.error(f"{path}: blocking gate {gate_id} lacks responsible_actor")
164+
if not gate.get("remediation_options"):
165+
results.error(f"{path}: blocking gate {gate_id} lacks remediation_options")
166+
167+
if operation.get("status") == "awaiting_decision" and not decision_ids:
168+
results.error(f"{path}: awaiting_decision operation lacks decision_ids")
169+
170+
if operation.get("status") == "blocked" and not policy_gate_ids:
171+
results.error(f"{path}: blocked operation lacks policy_gate_ids")
172+
173+
174+
def optional_schema_validation(paths: Paths, files: Iterable[Path], results: ValidationErrorSet) -> None:
175+
if paths.schemas_dir is None:
176+
results.warning("schema validation skipped: no schemas directory supplied")
177+
return
178+
if jsonschema is None:
179+
results.warning("schema validation skipped: install jsonschema for full checks")
180+
return
181+
182+
schema_paths = {
183+
"operation": paths.schemas_dir / "workspace-operation.schema.json",
184+
"task_event": paths.schemas_dir / "operation-task-event.schema.json",
185+
"artifact": paths.schemas_dir / "artifact-admission.schema.json",
186+
"decision_policy": paths.schemas_dir / "decision-policy-adapter.schema.json",
187+
}
188+
schemas: dict[str, dict[str, Any]] = {}
189+
for name, schema_path in schema_paths.items():
190+
if not schema_path.exists():
191+
results.error(f"missing schema file: {schema_path}")
192+
return
193+
schemas[name] = load_json(schema_path)
194+
195+
for fixture_path in files:
196+
data = load_json(fixture_path)
197+
objects: list[tuple[str, dict[str, Any]]] = []
198+
if isinstance(data.get("operation"), dict):
199+
objects.append(("operation", data["operation"]))
200+
for task in as_list(data.get("task")) + list(data.get("tasks", [])):
201+
if isinstance(task, dict):
202+
objects.append(("task_event", task))
203+
for event in as_list(data.get("event")) + list(data.get("events", [])):
204+
if isinstance(event, dict):
205+
objects.append(("task_event", event))
206+
for artifact in as_list(data.get("artifact")) + list(data.get("artifacts", [])):
207+
if isinstance(artifact, dict):
208+
objects.append(("artifact", artifact))
209+
for gate in as_list(data.get("policy_gate")) + list(data.get("policy_gates", [])):
210+
if isinstance(gate, dict):
211+
objects.append(("decision_policy", gate))
212+
for decision in as_list(data.get("decision")) + list(data.get("decisions", [])):
213+
if isinstance(decision, dict):
214+
objects.append(("decision_policy", decision))
215+
if isinstance(data.get("diagnostic_bundle"), dict):
216+
objects.append(("decision_policy", data["diagnostic_bundle"]))
217+
218+
for schema_name, obj in objects:
219+
try:
220+
jsonschema.validate(instance=obj, schema=schemas[schema_name]) # type: ignore[union-attr]
221+
except Exception as exc: # noqa: BLE001
222+
obj_id = obj.get("operation_id") or obj.get("task_id") or obj.get("artifact_id") or obj.get("decision_id") or obj.get("gate_id") or obj.get("diagnostic_bundle_id")
223+
results.error(f"{fixture_path}: schema validation failed for {schema_name}:{obj_id}: {exc}")
224+
225+
226+
def resolve_paths(args: argparse.Namespace) -> Paths:
227+
if args.contracts_dir:
228+
contracts = Path(args.contracts_dir).expanduser().resolve()
229+
return Paths(
230+
examples_dir=contracts / "examples" / "workspace-operation",
231+
schemas_dir=contracts / "schemas",
232+
)
233+
examples_dir = Path(args.examples_dir).expanduser().resolve()
234+
schemas_dir = Path(args.schemas_dir).expanduser().resolve() if args.schemas_dir else None
235+
return Paths(examples_dir=examples_dir, schemas_dir=schemas_dir)
236+
237+
238+
def parse_args(argv: list[str]) -> argparse.Namespace:
239+
parser = argparse.ArgumentParser(description="Validate Workspace Operation Plane fixtures")
240+
parser.add_argument("--contracts-dir", help="Path to prophet-core-contracts checkout")
241+
parser.add_argument("--examples-dir", default="../prophet-core-contracts/examples/workspace-operation")
242+
parser.add_argument("--schemas-dir", default="../prophet-core-contracts/schemas")
243+
parser.add_argument("--structural-only", action="store_true", help="Skip optional JSON Schema validation")
244+
return parser.parse_args(argv)
245+
246+
247+
def main(argv: list[str]) -> int:
248+
args = parse_args(argv)
249+
paths = resolve_paths(args)
250+
results = ValidationErrorSet()
251+
252+
if not paths.examples_dir.exists():
253+
print(f"missing examples directory: {paths.examples_dir}", file=sys.stderr)
254+
return 1
255+
256+
files = sorted(paths.examples_dir.glob("*.json"))
257+
if not files:
258+
print(f"no fixtures found in {paths.examples_dir}", file=sys.stderr)
259+
return 1
260+
261+
for path in files:
262+
try:
263+
data = load_json(path)
264+
except Exception as exc: # noqa: BLE001
265+
results.error(f"{path}: failed to parse JSON: {exc}")
266+
continue
267+
validate_structural(path, data, results)
268+
269+
if not args.structural_only:
270+
optional_schema_validation(paths, files, results)
271+
272+
for warning in results.warnings:
273+
print(f"warning: {warning}", file=sys.stderr)
274+
275+
if not results.ok():
276+
print("Operation conformance validation failed:", file=sys.stderr)
277+
for error in results.errors:
278+
print(f"- {error}", file=sys.stderr)
279+
return 1
280+
281+
print(f"Validated {len(files)} Workspace Operation fixture(s) from {paths.examples_dir}")
282+
return 0
283+
284+
285+
if __name__ == "__main__":
286+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)