Skip to content

Commit cd004bc

Browse files
chore(xtest): Shared Scenario/Instance Pydantic schema in otdf-sdk-mgr (#450)
## Summary First PR in a five-part stack that introduces a multi-instance test harness and a Claude plugin for OpenTDF bug reproduction. This PR adds *only* the shared Pydantic schema in `otdf-sdk-mgr` — no consumers yet. - Adds `otdf_sdk_mgr.schema` with v2 models: `Scenario`, `Instance`, `PlatformPin`, `KasPin`, `SdkPin`, `ScenarioSdks`, `Suite`, etc. - `ScenarioSdks.encrypt` / `.decrypt` mirror xtest's existing `--sdks-encrypt` / `--sdks-decrypt` convention so a→b-only scenarios are first-class. - `python -m otdf_sdk_mgr.schema validate <path>` validates either a Scenario or an Instance file based on its `kind:`. - Adds `pydantic` + `ruamel.yaml` to `otdf-sdk-mgr/pyproject.toml`. - 6 unit tests covering round-trips, pin invariants, and unknown-field rejection. ## Stack 1. [**This PR**](#450) — Shared schema 2. [Platform installer + `install scenario`](#451) in `otdf-sdk-mgr` (builds on this) 3. `otdf-local` [multi-instance refactor](#452) + new CLI subcommands 4. `xtest/conftest.py` [integration](#453) (`--scenario`, `--instance`) 5. [Claude plugin](#454) (`.claude/skills/`, settings, plugin manifest) 6. #455 ## Test plan - [x] `cd otdf-sdk-mgr && uv run pytest tests/test_schema.py` — all 6 pass - [x] `uv run python -m otdf_sdk_mgr.schema validate <path>` accepts a valid scenarios.yaml and rejects unknown fields Jira: https://virtru.atlassian.net/browse/DSPX-3302 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added schema validation for OpenTDF Scenario and Instance YAML configurations with a new CLI command. * Introduced strict validation with cross-field constraints for SDK and platform configurations. * **Documentation** * Updated supported container formats from `nano` to `ztdf-ecwrap`. * **Dependencies** * Updated core package dependencies to support enhanced validation capabilities. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/opentdf/tests/pull/450?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a8b1aaa commit cd004bc

5 files changed

Lines changed: 786 additions & 1 deletion

File tree

otdf-sdk-mgr/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ description = "SDK artifact management CLI for OpenTDF cross-client tests"
55
requires-python = ">=3.11"
66
dependencies = [
77
"gitpython>=3.1.50",
8+
"pydantic>=2.6.0",
89
"rich>=13.7.0",
10+
"ruamel.yaml>=0.18.0",
911
"typer>=0.12.0",
1012
]
1113

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
"""Shared Pydantic models for OpenTDF scenarios and instances.
2+
3+
Both `otdf-sdk-mgr` and `otdf-local` import from this module so the on-disk
4+
YAML formats (`scenarios.yaml`, `instance.yaml`) have exactly one canonical
5+
definition.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import sys
12+
from datetime import date
13+
from pathlib import Path
14+
from typing import Annotated, Literal
15+
16+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
17+
from ruamel.yaml import YAML, YAMLError
18+
19+
API_VERSION = "opentdf.io/v1alpha1"
20+
21+
KasMode = Literal["standard", "key_management"]
22+
SdkName = Literal["go", "java", "js"]
23+
ContainerKind = Literal["ztdf", "ztdf-ecwrap"]
24+
25+
26+
class _StrictModel(BaseModel):
27+
model_config = ConfigDict(extra="forbid", frozen=False)
28+
29+
30+
class SourceRef(_StrictModel):
31+
ref: str = Field(description="Git tag, branch, or SHA")
32+
path: Path | None = Field(default=None, description="Optional local checkout path")
33+
34+
35+
class PlatformPin(_StrictModel):
36+
"""Version pin for the platform service.
37+
38+
`dist` references a built binary at `xtest/platform/dist/<dist>/service`
39+
produced by `otdf-sdk-mgr install platform:<version>`.
40+
`source.ref` is a git ref to build from on demand.
41+
"""
42+
43+
dist: str | None = None
44+
source: SourceRef | None = None
45+
46+
@model_validator(mode="after")
47+
def _exactly_one(self) -> PlatformPin:
48+
set_fields = [k for k in ("dist", "source") if getattr(self, k) is not None]
49+
if len(set_fields) != 1:
50+
raise ValueError(
51+
f"PlatformPin must set exactly one of dist|source (got {set_fields or 'none'})"
52+
)
53+
return self
54+
55+
56+
class KasPin(_StrictModel):
57+
"""Per-KAS-instance version + mode pin."""
58+
59+
dist: str | None = None
60+
source: SourceRef | None = None
61+
mode: KasMode = "standard"
62+
features: dict[str, bool] = Field(default_factory=dict)
63+
64+
@model_validator(mode="after")
65+
def _exactly_one(self) -> KasPin:
66+
set_fields = [k for k in ("dist", "source") if getattr(self, k) is not None]
67+
if len(set_fields) != 1:
68+
raise ValueError(
69+
f"KasPin must set exactly one of dist|source (got {set_fields or 'none'})"
70+
)
71+
return self
72+
73+
74+
class ScenarioSdk(_StrictModel):
75+
"""One ordered SDK selection within a scenario role."""
76+
77+
sdk: SdkName
78+
version: str
79+
source: str | None = Field(
80+
default=None,
81+
description='For Go: "platform" to use the monorepo module path',
82+
)
83+
84+
def install_key(self) -> tuple[SdkName, str, str | None]:
85+
return (self.sdk, self.version, self.source)
86+
87+
88+
class PortsConfig(_StrictModel):
89+
base: int = Field(default=8080, ge=1024, le=60000)
90+
91+
92+
class Metadata(_StrictModel):
93+
name: str | None = None
94+
id: str | None = None
95+
title: str | None = None
96+
created: date | None = None
97+
98+
99+
class Fixtures(_StrictModel):
100+
attributes: Path | None = None
101+
policy: Path | None = None
102+
103+
104+
class Instance(_StrictModel):
105+
"""Standalone instance definition (one platform + N KAS).
106+
107+
Persisted to `tests/instances/<name>/instance.yaml`. Also embedded inside
108+
Scenario to keep the "describe a bug-repro environment" entry point a
109+
single file.
110+
"""
111+
112+
apiVersion: Literal["opentdf.io/v1alpha1"] = API_VERSION
113+
kind: Literal["Instance"] = "Instance"
114+
metadata: Metadata = Field(default_factory=Metadata)
115+
platform: PlatformPin
116+
ports: PortsConfig = Field(default_factory=PortsConfig)
117+
kas: dict[str, KasPin] = Field(default_factory=dict)
118+
features: dict[str, bool] = Field(default_factory=dict)
119+
fixtures: Fixtures = Field(default_factory=Fixtures)
120+
121+
122+
class ScenarioSdks(_StrictModel):
123+
"""Encrypt/decrypt split mirrors xtest's --sdks-encrypt/--sdks-decrypt.
124+
125+
Selections are ordered to preserve the eventual argv order, and are
126+
de-duplicated within each role by (sdk, version, source).
127+
"""
128+
129+
encrypt: list[ScenarioSdk] = Field(default_factory=list)
130+
decrypt: list[ScenarioSdk] = Field(default_factory=list)
131+
132+
@model_validator(mode="after")
133+
def _dedupe_per_role(self) -> ScenarioSdks:
134+
for role in ("encrypt", "decrypt"):
135+
seen: set[tuple[SdkName, str, str | None]] = set()
136+
duplicates = []
137+
for entry in getattr(self, role):
138+
key = entry.install_key()
139+
if key in seen:
140+
duplicates.append(key)
141+
seen.add(key)
142+
if duplicates:
143+
raise ValueError(
144+
f"ScenarioSdks.{role} contains duplicate sdk/version entries: {duplicates}"
145+
)
146+
return self
147+
148+
def union(self) -> list[ScenarioSdk]:
149+
"""Return the ordered union of encrypt+decrypt selections."""
150+
out: list[ScenarioSdk] = []
151+
seen: set[tuple[SdkName, str, str | None]] = set()
152+
for entry in [*self.encrypt, *self.decrypt]:
153+
key = entry.install_key()
154+
if key in seen:
155+
continue
156+
seen.add(key)
157+
out.append(entry)
158+
return out
159+
160+
161+
class Suite(_StrictModel):
162+
"""Pytest selection + flags."""
163+
164+
targets: list[str] = Field(
165+
default_factory=list,
166+
description="Positional pytest targets, e.g. test files or path::node ids",
167+
)
168+
kexpr: str | None = Field(default=None, description="Forwarded to pytest -k")
169+
containers: list[ContainerKind] = Field(
170+
default_factory=list,
171+
description="Forwarded to --containers as a whitespace-separated list",
172+
)
173+
markers: str | None = Field(default=None, description="Forwarded to -m")
174+
extra_args: list[str] = Field(default_factory=list)
175+
176+
177+
class Scenario(_StrictModel):
178+
"""Top-level scenarios.yaml model.
179+
180+
Composes an Instance with SDK pins and a pytest Suite selection.
181+
"""
182+
183+
apiVersion: Literal["opentdf.io/v1alpha1"] = API_VERSION
184+
kind: Literal["Scenario"] = "Scenario"
185+
metadata: Metadata = Field(default_factory=Metadata)
186+
instance: Annotated[Instance, Field(description="Inline instance definition")]
187+
sdks: ScenarioSdks = Field(default_factory=ScenarioSdks)
188+
suite: Suite
189+
expected: str | None = None
190+
actual: str | None = None
191+
192+
193+
def _yaml() -> YAML:
194+
return YAML(typ="safe")
195+
196+
197+
def _load_yaml_mapping(path: str | Path) -> dict[str, object]:
198+
p = Path(path)
199+
raw = _yaml().load(p.read_text(encoding="utf-8"))
200+
if not isinstance(raw, dict):
201+
raise ValueError(f"{p}: top-level YAML must be a mapping, got {type(raw).__name__}")
202+
return raw
203+
204+
205+
def load_scenario(path: str | Path) -> Scenario:
206+
"""Parse and validate a scenarios.yaml file."""
207+
return Scenario.model_validate(_load_yaml_mapping(path))
208+
209+
210+
def load_instance(path: str | Path) -> Instance:
211+
"""Parse and validate an instance.yaml file."""
212+
return Instance.model_validate(_load_yaml_mapping(path))
213+
214+
215+
def dump_instance(instance: Instance, path: str | Path) -> None:
216+
"""Serialize an Instance to YAML at `path`."""
217+
p = Path(path)
218+
p.parent.mkdir(parents=True, exist_ok=True)
219+
data = instance.model_dump(mode="json", exclude_none=True)
220+
y = _yaml()
221+
with p.open("w", encoding="utf-8") as f:
222+
y.dump(data, f)
223+
224+
225+
def installed_json_for(scenario_path: str | Path) -> Path:
226+
"""Path to the install-record file `otdf-sdk-mgr install scenario` writes.
227+
228+
Convention: alongside the scenario, with `.installed.json` swapped in for
229+
the file's suffix. e.g. `xtest/scenarios/x.yaml` →
230+
`xtest/scenarios/x.installed.json`.
231+
"""
232+
p = Path(scenario_path)
233+
return p.with_suffix(".installed.json")
234+
235+
236+
def scenario_to_pytest_sdks(
237+
scenario: Scenario,
238+
installed_json_path: str | Path,
239+
) -> dict[str, list[str]]:
240+
"""Turn a Scenario's encrypt/decrypt SDK pins into xtest `--sdks-*` tokens.
241+
242+
After PR #446, xtest's `--sdks`, `--sdks-encrypt`, and `--sdks-decrypt`
243+
accept whitespace-separated `sdk@version` specifiers where `version`
244+
must match a directory name under `xtest/sdk/<lang>/dist/`. Scenario
245+
version fields may be aliases (`lts`, `tip`) that only resolve after
246+
`otdf-sdk-mgr install scenario` writes a sibling `.installed.json`
247+
recording the dist paths actually laid down on disk.
248+
249+
Returns `{"encrypt": [...], "decrypt": [...]}` with each list containing
250+
`sdk@<dist-name>` tokens. Raises `FileNotFoundError` (with an actionable
251+
hint) when `installed.json` is missing, and `ValueError` when the
252+
scenario references an SDK the install record doesn't cover.
253+
"""
254+
p = Path(installed_json_path)
255+
if not p.is_file():
256+
raise FileNotFoundError(
257+
f"{p} not found. Run `otdf-sdk-mgr install scenario <scenario.yaml>` "
258+
"first so the dist names get resolved; scenario_to_pytest_sdks needs "
259+
"the installed record to translate aliases like `lts`/`tip` into the "
260+
"concrete `sdk@version` tokens xtest's pytest options expect."
261+
)
262+
try:
263+
data = json.loads(p.read_text(encoding="utf-8"))
264+
except json.JSONDecodeError as e:
265+
raise ValueError(f"{p}: malformed installed.json: {e}") from e
266+
sdk_map = data.get("sdks", {}) if isinstance(data, dict) else {}
267+
268+
def token(role: str, entry: ScenarioSdk) -> str:
269+
role_map = sdk_map.get(role)
270+
if not isinstance(role_map, list):
271+
raise ValueError(
272+
f"{p}: missing install records for role '{role}'. "
273+
"Re-run `otdf-sdk-mgr install scenario`."
274+
)
275+
install_entry = next(
276+
(
277+
candidate
278+
for candidate in role_map
279+
if isinstance(candidate, dict)
280+
and candidate.get("sdk") == entry.sdk
281+
and candidate.get("version") == entry.version
282+
and candidate.get("source") == entry.source
283+
),
284+
None,
285+
)
286+
if not isinstance(install_entry, dict) or "path" not in install_entry:
287+
raise ValueError(
288+
f"Scenario references {role} SDK '{entry.sdk}' version '{entry.version}'"
289+
f"{' source ' + entry.source if entry.source else ''}, but {p} has no matching "
290+
"install record for it. Re-run `otdf-sdk-mgr install scenario`."
291+
)
292+
dist_name = Path(str(install_entry["path"])).name
293+
return f"{entry.sdk}@{dist_name}"
294+
295+
return {
296+
"encrypt": [token("encrypt", entry) for entry in scenario.sdks.encrypt],
297+
"decrypt": [token("decrypt", entry) for entry in scenario.sdks.decrypt],
298+
}
299+
300+
301+
def _main(argv: list[str] | None = None) -> int:
302+
"""`python -m otdf_sdk_mgr.schema validate <path>` entry point."""
303+
args = list(sys.argv[1:] if argv is None else argv)
304+
if len(args) != 2 or args[0] != "validate":
305+
print("usage: python -m otdf_sdk_mgr.schema validate <path>", file=sys.stderr)
306+
return 2
307+
path = Path(args[1])
308+
try:
309+
raw = _load_yaml_mapping(path)
310+
except OSError as e:
311+
print(f"error: cannot read {path}: {e}", file=sys.stderr)
312+
return 1
313+
except YAMLError as e:
314+
print(f"error: invalid YAML in {path}: {e}", file=sys.stderr)
315+
return 1
316+
except ValueError as e:
317+
print(f"error: {e}", file=sys.stderr)
318+
return 1
319+
kind = raw.get("kind")
320+
model: type[BaseModel]
321+
if kind == "Scenario":
322+
model = Scenario
323+
elif kind == "Instance":
324+
model = Instance
325+
else:
326+
print(
327+
f"error: {path} has unknown kind {kind!r}; expected Scenario or Instance",
328+
file=sys.stderr,
329+
)
330+
return 1
331+
try:
332+
model.model_validate(raw)
333+
except ValidationError as e:
334+
print(f"invalid: {e}", file=sys.stderr)
335+
return 1
336+
print(f"ok: {path} ({kind})")
337+
return 0
338+
339+
340+
if __name__ == "__main__":
341+
raise SystemExit(_main())

0 commit comments

Comments
 (0)