From 7c04ca16c49edb879f26b46424c80daf876492e4 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 11:45:34 -0400 Subject: [PATCH 01/16] feat(otdf-sdk-mgr): manage platform service + install scenario (DSPX-3302) --- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py | 101 +++++++- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py | 100 ++++++++ .../src/otdf_sdk_mgr/platform_installer.py | 224 ++++++++++++++++++ 3 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py create mode 100644 otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py index e3950d717..dca6793b3 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py @@ -11,6 +11,16 @@ install_app = typer.Typer(help="Install SDK CLI artifacts from registries or source.") +def _register_scenario_cmd() -> None: + """Defer scenario import so pydantic is only imported when needed.""" + from otdf_sdk_mgr.cli_scenario import install_scenario_cmd + + install_app.command("scenario")(install_scenario_cmd) + + +_register_scenario_cmd() + + @install_app.command() def stable( sdks: Annotated[ @@ -32,9 +42,27 @@ def lts( ] = None, ) -> None: """Install LTS versions for each SDK.""" + from otdf_sdk_mgr.config import LTS_VERSIONS from otdf_sdk_mgr.installers import cmd_lts - - cmd_lts(sdks or ALL_SDKS) + from otdf_sdk_mgr.platform_installer import ( + PlatformInstallError, + install_platform_release, + ) + + requested = sdks or ALL_SDKS + sdk_targets = [s for s in requested if s != "platform"] + if "platform" in requested: + version = LTS_VERSIONS.get("platform") + if version is None: + typer.echo("Warning: no LTS version defined for platform; skipping", err=True) + else: + try: + install_platform_release(version) + except PlatformInstallError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + if sdk_targets: + cmd_lts(sdk_targets) @install_app.command() @@ -46,23 +74,80 @@ def tip( ) -> None: """Source checkout + build from main.""" from otdf_sdk_mgr.installers import cmd_tip - - cmd_tip(sdks or ALL_SDKS) + from otdf_sdk_mgr.platform_installer import ( + PlatformInstallError, + install_platform_source, + ) + + requested = sdks or ALL_SDKS + sdk_targets = [s for s in requested if s != "platform"] + if "platform" in requested: + try: + install_platform_source("main", dist_name="tip") + except PlatformInstallError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + if sdk_targets: + cmd_tip(sdk_targets) @install_app.command() def release( specs: Annotated[ list[str], - typer.Argument(help="Version specs as SDK:VERSION (e.g., go:v0.24.0)"), + typer.Argument(help="Version specs as SDK:VERSION (e.g., go:v0.24.0, platform:v0.9.0)"), ], ) -> None: - """Install specific released versions.""" + """Install specific released versions. + + `sdk` may be one of go/js/java or the literal `platform`. Platform is + built from source against the `service/` tag in the + `opentdf/platform` monorepo. + """ from otdf_sdk_mgr.installers import InstallError, cmd_release + from otdf_sdk_mgr.platform_installer import ( + PlatformInstallError, + install_platform_release, + ) + + sdk_specs: list[str] = [] + for spec in specs: + if ":" not in spec: + typer.echo(f"Error: invalid spec '{spec}'. Use SDK:VERSION.", err=True) + raise typer.Exit(1) + sdk, version = spec.split(":", 1) + if sdk == "platform": + try: + install_platform_release(version) + except PlatformInstallError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + else: + sdk_specs.append(spec) + if sdk_specs: + try: + cmd_release(sdk_specs) + except InstallError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + + +@install_app.command() +def scripts( + branch: Annotated[ + str, + typer.Option(help="Branch of opentdf/platform to pull scripts from"), + ] = "main", +) -> None: + """Refresh shared platform helper scripts under xtest/platform/scripts/.""" + from otdf_sdk_mgr.platform_installer import ( + PlatformInstallError, + install_helper_scripts, + ) try: - cmd_release(specs) - except InstallError as e: + install_helper_scripts(branch) + except PlatformInstallError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py new file mode 100644 index 000000000..34b428a44 --- /dev/null +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py @@ -0,0 +1,100 @@ +"""Scenario-driven install command. + +Reads a `scenarios.yaml` (or standalone `instance.yaml`) and installs every +artifact referenced — platform service binary, per-KAS binaries (each at +its own pinned version), and encrypt/decrypt SDK CLIs. Writes +`installed.json` next to the manifest so downstream tools (`otdf-local`, +plugin skills) can locate the dist paths without re-resolving. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated + +import typer + +from otdf_sdk_mgr.installers import InstallError, install_release +from otdf_sdk_mgr.platform_installer import ( + PlatformInstallError, + install_helper_scripts, + install_platform_release, + install_platform_source, +) +from otdf_sdk_mgr.schema import KasPin, PlatformPin, Scenario, load_instance, load_scenario + + +def _install_platform_pin(pin: PlatformPin | KasPin) -> dict[str, str]: + if pin.dist is not None: + dist_dir = install_platform_release(pin.dist) + return {"kind": "dist", "version": pin.dist, "path": str(dist_dir)} + assert pin.source is not None # by schema invariant + dist_dir = install_platform_source(pin.source.ref) + return {"kind": "source", "ref": pin.source.ref, "path": str(dist_dir)} + + +def install_scenario_cmd( + path: Annotated[Path, typer.Argument(help="Path to scenarios.yaml or instance.yaml")], + skip_scripts: Annotated[ + bool, + typer.Option("--skip-scripts", help="Skip refreshing helper scripts from main"), + ] = False, +) -> None: + """Install every artifact declared by a scenarios.yaml or instance.yaml.""" + if not path.exists(): + typer.echo(f"Error: {path} not found", err=True) + raise typer.Exit(1) + + raw_kind = _peek_kind(path) + scenario: Scenario | None = None + if raw_kind == "Scenario": + scenario = load_scenario(path) + instance = scenario.instance + elif raw_kind == "Instance": + instance = load_instance(path) + else: + typer.echo(f"Error: {path} has unknown kind {raw_kind!r}", err=True) + raise typer.Exit(1) + + installed: dict[str, object] = {"manifest": str(path), "platform": None, "kas": {}, "sdks": {}} + + try: + installed["platform"] = _install_platform_pin(instance.platform) + for kas_name, kas_pin in instance.kas.items(): + installed["kas"][kas_name] = _install_platform_pin(kas_pin) + if not skip_scripts: + install_helper_scripts() + except PlatformInstallError as e: + typer.echo(f"Error installing platform artifacts: {e}", err=True) + raise typer.Exit(1) + + if scenario is not None: + sdks = scenario.sdks.union() + for sdk_name, sdk_pin in sdks.items(): + try: + dist_dir = install_release(sdk_name, sdk_pin.version, source=sdk_pin.source) + installed["sdks"][sdk_name] = { + "version": sdk_pin.version, + "source": sdk_pin.source, + "path": str(dist_dir), + } + except InstallError as e: + typer.echo(f"Error installing SDK {sdk_name}: {e}", err=True) + raise typer.Exit(1) + + out = path.parent / f"{path.stem}.installed.json" + out.write_text(json.dumps(installed, indent=2) + "\n") + typer.echo(f" Wrote {out}") + + +def _peek_kind(path: Path) -> str | None: + """Cheap pre-validation read so we can dispatch to the right model loader.""" + from ruamel.yaml import YAML + + y = YAML(typ="safe") + raw = y.load(path.read_text()) + if isinstance(raw, dict): + kind = raw.get("kind") + return kind if isinstance(kind, str) else None + return None diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py new file mode 100644 index 000000000..67179672f --- /dev/null +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py @@ -0,0 +1,224 @@ +"""Installer for the OpenTDF platform service. + +Mirrors the SDK installer pattern but produces a built `service` binary at +`xtest/platform/dist//service`. v1 supports source builds only — +container images and release tarballs are not published by `opentdf/platform` +today. + +Tag namespacing: the platform monorepo tags releases as `service/vX.Y.Z`. +Users pass plain versions (e.g. `v0.9.0`); the installer prefixes `service/` +when resolving against git. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from otdf_sdk_mgr.config import SDK_GIT_URLS, SDK_TAG_INFIXES +from otdf_sdk_mgr.semver import normalize_version + +PLATFORM_BARE_REPO = "platform.git" +HELPER_SCRIPTS_BRANCH = "main" + + +class PlatformInstallError(Exception): + """Raised when platform installation fails.""" + + +def get_platform_dir() -> Path: + """Return `xtest/platform/`, creating an env-var override hook. + + Search precedence: + 1. `OTDF_PLATFORM_DIR` env var. + 2. Walk up from this package until an `xtest/` sibling is found. + """ + env = os.environ.get("OTDF_PLATFORM_DIR") + if env: + return Path(env) + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / "xtest").exists(): + return current / "xtest" / "platform" + current = current.parent + raise PlatformInstallError( + "Could not locate xtest/ root. Set OTDF_PLATFORM_DIR to override." + ) + + +def _platform_src_root() -> Path: + return get_platform_dir() / "src" + + +def _platform_dist_root() -> Path: + return get_platform_dir() / "dist" + + +def _platform_scripts_dir() -> Path: + return get_platform_dir() / "scripts" + + +def _platform_bare_repo() -> Path: + return _platform_src_root() / PLATFORM_BARE_REPO + + +def _run(cmd: list[str], cwd: Path | None = None) -> None: + """Run a command, raising PlatformInstallError on failure.""" + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + raise PlatformInstallError( + f"command failed: {' '.join(cmd)}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +def _ensure_bare_repo() -> Path: + """Clone the platform bare repo if missing; fetch updates otherwise.""" + bare = _platform_bare_repo() + bare.parent.mkdir(parents=True, exist_ok=True) + if not bare.exists(): + url = SDK_GIT_URLS["platform"].removesuffix(".git") + print(f"Cloning {url} as a bare repository into {bare}...") + _run(["git", "clone", "--bare", url, str(bare)]) + else: + print(f"Fetching updates for {bare}...") + _run(["git", f"--git-dir={bare}", "fetch", "--all", "--tags"]) + return bare + + +def _resolve_platform_ref(version_or_ref: str) -> str: + """Turn a user-supplied version into the actual git ref to checkout. + + `v0.9.0` → `service/v0.9.0` (matches SDK_TAG_INFIXES["platform"]). + A ref that already contains `/`, a hex SHA, or `main` is returned as-is. + """ + infix = SDK_TAG_INFIXES.get("platform", "service") + if "/" in version_or_ref or version_or_ref in ("main", "HEAD"): + return version_or_ref + if len(version_or_ref) >= 7 and all(c in "0123456789abcdef" for c in version_or_ref.lower()): + return version_or_ref + return f"{infix}/{normalize_version(version_or_ref)}" + + +def _worktree_path_for(ref: str) -> Path: + safe = ref.replace("/", "--") + return _platform_src_root() / safe + + +def _ensure_worktree(ref: str) -> Path: + """Create (or reuse) a git worktree at the given platform ref.""" + bare = _ensure_bare_repo() + worktree = _worktree_path_for(ref) + if worktree.exists(): + print(f"Worktree already exists at {worktree}; reusing.") + return worktree + print(f"Adding worktree at {worktree} for ref {ref}...") + _run(["git", f"--git-dir={bare}", "worktree", "add", "--detach", str(worktree), ref]) + return worktree + + +def _build_service(worktree: Path, dist_dir: Path) -> Path: + """Run `go build` to produce `dist_dir/service`.""" + dist_dir.mkdir(parents=True, exist_ok=True) + binary = dist_dir / "service" + if binary.exists(): + print(f" Binary already built at {binary}; reusing.") + return binary + print(f" Building platform service binary at {binary} from {worktree}...") + _run(["go", "build", "-o", str(binary), "./service"], cwd=worktree) + if not binary.exists(): + raise PlatformInstallError(f"go build completed but {binary} is missing") + return binary + + +def _record_version(dist_dir: Path, ref: str, worktree: Path) -> None: + """Write a `.version` metadata file alongside the binary.""" + sha = _git_rev_parse(worktree, "HEAD") + (dist_dir / ".version").write_text(f"ref={ref}\nsha={sha}\nworktree={worktree}\n") + + +def _git_rev_parse(worktree: Path, rev: str) -> str: + result = subprocess.run( + ["git", "-C", str(worktree), "rev-parse", rev], capture_output=True, text=True + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def install_platform_source(ref: str, dist_name: str | None = None) -> Path: + """Install a platform build by checking out and building `ref`. + + `ref` may be a plain version (`v0.9.0`), a namespaced tag + (`service/v0.9.0`), a branch (`main`), or a SHA. Returns the dist dir. + """ + full_ref = _resolve_platform_ref(ref) + dist_dir = _platform_dist_root() / (dist_name or normalize_version(ref)) + if (dist_dir / "service").exists(): + print(f" Dist already present at {dist_dir}; skipping build.") + return dist_dir + worktree = _ensure_worktree(full_ref) + _build_service(worktree, dist_dir) + _record_version(dist_dir, full_ref, worktree) + print(f" Platform {ref} → {dist_dir}") + return dist_dir + + +def install_platform_release(version: str, dist_name: str | None = None) -> Path: + """Install a released platform version (alias for `install_platform_source`). + + Kept as a separate function so the public CLI surface mirrors the SDK + `install release` semantics, even though there's no published-binary + fast path today. + """ + return install_platform_source(version, dist_name=dist_name) + + +def install_helper_scripts(branch: str = HELPER_SCRIPTS_BRANCH) -> Path: + """Check out provisioning helper scripts from the platform `main` branch. + + Scripts are shared across instances (see plan §1, helper-scripts decision) + and refreshed on demand. Returns the scripts directory. + """ + bare = _ensure_bare_repo() + scripts_dir = _platform_scripts_dir() + worktree = _worktree_path_for(f"scripts--{branch}") + if not worktree.exists(): + print(f"Adding scripts worktree at {worktree} ({branch})...") + _run(["git", f"--git-dir={bare}", "worktree", "add", str(worktree), branch]) + else: + print(f"Updating scripts worktree at {worktree}...") + _run(["git", "-C", str(worktree), "pull", "origin", branch]) + scripts_dir.mkdir(parents=True, exist_ok=True) + src_scripts = worktree / "scripts" + if src_scripts.exists(): + # Mirror the platform/scripts/ tree into xtest/platform/scripts/ + if scripts_dir.exists(): + shutil.rmtree(scripts_dir) + shutil.copytree(src_scripts, scripts_dir) + print(f" Helper scripts copied to {scripts_dir}") + else: + print(f" Warning: no scripts/ directory in platform@{branch}; nothing to copy.") + return scripts_dir + + +def list_platform_versions() -> list[str]: + """Return all `service/vX.Y.Z` tags from the platform repo, version-only.""" + from git import Git + + repo = Git() + raw = repo.ls_remote(SDK_GIT_URLS["platform"], tags=True) + infix = SDK_TAG_INFIXES.get("platform", "service") + out: list[str] = [] + for line in raw.strip().splitlines(): + if not line: + continue + _, ref = line.split("\t", 1) + if ref.endswith("^{}"): + continue + tag = ref.removeprefix("refs/tags/") + if tag.startswith(f"{infix}/"): + out.append(tag.removeprefix(f"{infix}/")) + out.sort() + return out From 739801e85a883fa0bba30db88cef1998aa059ff9 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 21 May 2026 12:00:49 -0400 Subject: [PATCH 02/16] fix(otdf-sdk-mgr): repair install scenario + harden silent failures `install scenario` could not run as written: it iterated `ScenarioSdks.union()` as a dict (it returns a list) and passed a `source=` kwarg `install_release` does not accept. The emitted `installed.json` shape also did not match what `scenario_to_pytest_sdks` reads (per-role lists, not sdk-name-keyed dict), so even the platform-only path produced a manifest no downstream tool could consume. Source fixes: - cli_scenario.py: iterate `union()` as the list it is, cache installs by (sdk, version, source), emit role-keyed lists matching the reader's expected shape; on failure write a partial manifest with `status=partial` so half-installed dist trees are diagnosable. Catch YAMLError in `_peek_kind` to surface a clean typer error. - platform_installer.py: `_git_rev_parse` raises on failure instead of silently writing an empty `sha=` into `.version`. Missing `scripts/` raises instead of warning-and-continuing. SHA passthrough heuristic tightened from `>=7` chars to exactly 40 (SHA-1) or 64 (SHA-256), so ambiguous short tags like `abc1234` no longer skip the `service/` prefix. Dropped a docstring fragment pointing to a planning doc that won't exist post-merge. - cli_install.py: dropped a docstring whose "deferred import" claim was false (the registration runs at module import). `lts platform` with no pinned version now exits 1 instead of warning-and-exit-0. Tests: - test_platform_installer.py: parametrized cases for `_resolve_platform_ref` covering version normalization, branch passthrough, the tightened hex heuristic, and SHA-1/SHA-256 passthrough. - test_cli_scenario.py: end-to-end smoke that mocks the installers and asserts the produced manifest is round-trip consumable by `scenario_to_pytest_sdks`. This is the gating test that would have caught the original bug. 79 passing (was 67). Co-Authored-By: Claude Opus 4.7 --- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py | 15 ++- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py | 53 ++++++--- .../src/otdf_sdk_mgr/platform_installer.py | 29 ++--- otdf-sdk-mgr/tests/test_cli_scenario.py | 110 ++++++++++++++++++ otdf-sdk-mgr/tests/test_platform_installer.py | 23 ++++ 5 files changed, 190 insertions(+), 40 deletions(-) create mode 100644 otdf-sdk-mgr/tests/test_cli_scenario.py create mode 100644 otdf-sdk-mgr/tests/test_platform_installer.py diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py index dca6793b3..1bf864478 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py @@ -12,7 +12,6 @@ def _register_scenario_cmd() -> None: - """Defer scenario import so pydantic is only imported when needed.""" from otdf_sdk_mgr.cli_scenario import install_scenario_cmd install_app.command("scenario")(install_scenario_cmd) @@ -54,13 +53,13 @@ def lts( if "platform" in requested: version = LTS_VERSIONS.get("platform") if version is None: - typer.echo("Warning: no LTS version defined for platform; skipping", err=True) - else: - try: - install_platform_release(version) - except PlatformInstallError as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + typer.echo("Error: no LTS version defined for platform", err=True) + raise typer.Exit(1) + try: + install_platform_release(version) + except PlatformInstallError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) if sdk_targets: cmd_lts(sdk_targets) diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py index 34b428a44..560b575ae 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py @@ -57,7 +57,13 @@ def install_scenario_cmd( typer.echo(f"Error: {path} has unknown kind {raw_kind!r}", err=True) raise typer.Exit(1) - installed: dict[str, object] = {"manifest": str(path), "platform": None, "kas": {}, "sdks": {}} + installed: dict[str, object] = { + "manifest": str(path), + "platform": None, + "kas": {}, + "sdks": {"encrypt": [], "decrypt": []}, + } + out = path.parent / f"{path.stem}.installed.json" try: installed["platform"] = _install_platform_pin(instance.platform) @@ -65,25 +71,29 @@ def install_scenario_cmd( installed["kas"][kas_name] = _install_platform_pin(kas_pin) if not skip_scripts: install_helper_scripts() - except PlatformInstallError as e: - typer.echo(f"Error installing platform artifacts: {e}", err=True) - raise typer.Exit(1) - if scenario is not None: - sdks = scenario.sdks.union() - for sdk_name, sdk_pin in sdks.items(): - try: - dist_dir = install_release(sdk_name, sdk_pin.version, source=sdk_pin.source) - installed["sdks"][sdk_name] = { - "version": sdk_pin.version, - "source": sdk_pin.source, - "path": str(dist_dir), - } - except InstallError as e: - typer.echo(f"Error installing SDK {sdk_name}: {e}", err=True) - raise typer.Exit(1) + if scenario is not None: + install_paths: dict[tuple[str, str, str | None], str] = {} + for entry in scenario.sdks.union(): + dist_dir = install_release(entry.sdk, entry.version) + install_paths[entry.install_key()] = str(dist_dir) + for role in ("encrypt", "decrypt"): + installed["sdks"][role] = [ + { + "sdk": entry.sdk, + "version": entry.version, + "source": entry.source, + "path": install_paths[entry.install_key()], + } + for entry in getattr(scenario.sdks, role) + ] + except (PlatformInstallError, InstallError) as e: + installed["status"] = "partial" + out.write_text(json.dumps(installed, indent=2) + "\n") + typer.echo(f"Error: {e}", err=True) + typer.echo(f" Wrote partial manifest to {out}", err=True) + raise typer.Exit(1) - out = path.parent / f"{path.stem}.installed.json" out.write_text(json.dumps(installed, indent=2) + "\n") typer.echo(f" Wrote {out}") @@ -91,9 +101,14 @@ def install_scenario_cmd( def _peek_kind(path: Path) -> str | None: """Cheap pre-validation read so we can dispatch to the right model loader.""" from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError y = YAML(typ="safe") - raw = y.load(path.read_text()) + try: + raw = y.load(path.read_text()) + except YAMLError as e: + typer.echo(f"Error: {path} is not valid YAML: {e}", err=True) + raise typer.Exit(1) if isinstance(raw, dict): kind = raw.get("kind") return kind if isinstance(kind, str) else None diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py index 67179672f..28f3c866f 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py @@ -96,7 +96,9 @@ def _resolve_platform_ref(version_or_ref: str) -> str: infix = SDK_TAG_INFIXES.get("platform", "service") if "/" in version_or_ref or version_or_ref in ("main", "HEAD"): return version_or_ref - if len(version_or_ref) >= 7 and all(c in "0123456789abcdef" for c in version_or_ref.lower()): + if len(version_or_ref) in (40, 64) and all( + c in "0123456789abcdef" for c in version_or_ref.lower() + ): return version_or_ref return f"{infix}/{normalize_version(version_or_ref)}" @@ -143,7 +145,9 @@ def _git_rev_parse(worktree: Path, rev: str) -> str: ["git", "-C", str(worktree), "rev-parse", rev], capture_output=True, text=True ) if result.returncode != 0: - return "" + raise PlatformInstallError( + f"git rev-parse {rev} failed in {worktree}: {result.stderr.strip()}" + ) return result.stdout.strip() @@ -178,8 +182,8 @@ def install_platform_release(version: str, dist_name: str | None = None) -> Path def install_helper_scripts(branch: str = HELPER_SCRIPTS_BRANCH) -> Path: """Check out provisioning helper scripts from the platform `main` branch. - Scripts are shared across instances (see plan §1, helper-scripts decision) - and refreshed on demand. Returns the scripts directory. + Scripts are shared across instances; refreshed on demand. Returns the + scripts directory. """ bare = _ensure_bare_repo() scripts_dir = _platform_scripts_dir() @@ -190,16 +194,15 @@ def install_helper_scripts(branch: str = HELPER_SCRIPTS_BRANCH) -> Path: else: print(f"Updating scripts worktree at {worktree}...") _run(["git", "-C", str(worktree), "pull", "origin", branch]) - scripts_dir.mkdir(parents=True, exist_ok=True) src_scripts = worktree / "scripts" - if src_scripts.exists(): - # Mirror the platform/scripts/ tree into xtest/platform/scripts/ - if scripts_dir.exists(): - shutil.rmtree(scripts_dir) - shutil.copytree(src_scripts, scripts_dir) - print(f" Helper scripts copied to {scripts_dir}") - else: - print(f" Warning: no scripts/ directory in platform@{branch}; nothing to copy.") + if not src_scripts.exists(): + raise PlatformInstallError( + f"no scripts/ directory in platform@{branch}; cannot install helper scripts" + ) + if scripts_dir.exists(): + shutil.rmtree(scripts_dir) + shutil.copytree(src_scripts, scripts_dir) + print(f" Helper scripts copied to {scripts_dir}") return scripts_dir diff --git a/otdf-sdk-mgr/tests/test_cli_scenario.py b/otdf-sdk-mgr/tests/test_cli_scenario.py new file mode 100644 index 000000000..c086e324b --- /dev/null +++ b/otdf-sdk-mgr/tests/test_cli_scenario.py @@ -0,0 +1,110 @@ +"""End-to-end smoke test for `otdf-sdk-mgr install scenario`.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +import typer + +from otdf_sdk_mgr.cli_scenario import install_scenario_cmd +from otdf_sdk_mgr.schema import load_scenario, scenario_to_pytest_sdks + + +SCENARIO_YAML = """ +apiVersion: opentdf.io/v1alpha1 +kind: Scenario +metadata: + id: smoke + title: install-scenario smoke +instance: + platform: { dist: v0.9.0 } + kas: + alpha: { dist: v0.9.0, mode: standard } +sdks: + encrypt: + - sdk: go + version: v0.24.0 + - sdk: js + version: v0.5.0 + decrypt: + - sdk: js + version: v0.5.0 + - sdk: java + version: v0.7.8 +suite: + targets: + - "xtest/test_tdfs.py::test_tdf_roundtrip" +""" + + +def test_install_scenario_writes_consumable_manifest(tmp_path: Path) -> None: + scenario_path = tmp_path / "s.yaml" + scenario_path.write_text(SCENARIO_YAML) + + platform_dist = tmp_path / "platform-dist" / "v0.9.0" + + def fake_install_release(sdk: str, version: str) -> Path: + return tmp_path / "sdk" / sdk / version + + with ( + patch("otdf_sdk_mgr.cli_scenario.install_platform_release", return_value=platform_dist), + patch("otdf_sdk_mgr.cli_scenario.install_helper_scripts"), + patch("otdf_sdk_mgr.cli_scenario.install_release", side_effect=fake_install_release), + ): + install_scenario_cmd(scenario_path, skip_scripts=False) + + out_path = tmp_path / "s.installed.json" + record = json.loads(out_path.read_text()) + + assert record["platform"] == { + "kind": "dist", + "version": "v0.9.0", + "path": str(platform_dist), + } + assert set(record["kas"].keys()) == {"alpha"} + assert record["sdks"]["encrypt"] == [ + {"sdk": "go", "version": "v0.24.0", "source": None, + "path": str(tmp_path / "sdk" / "go" / "v0.24.0")}, + {"sdk": "js", "version": "v0.5.0", "source": None, + "path": str(tmp_path / "sdk" / "js" / "v0.5.0")}, + ] + assert record["sdks"]["decrypt"] == [ + {"sdk": "js", "version": "v0.5.0", "source": None, + "path": str(tmp_path / "sdk" / "js" / "v0.5.0")}, + {"sdk": "java", "version": "v0.7.8", "source": None, + "path": str(tmp_path / "sdk" / "java" / "v0.7.8")}, + ] + assert "status" not in record + + # The manifest must be consumable by the downstream reader. + scenario = load_scenario(scenario_path) + tokens = scenario_to_pytest_sdks(scenario, out_path) + assert tokens == { + "encrypt": ["go@v0.24.0", "js@v0.5.0"], + "decrypt": ["js@v0.5.0", "java@v0.7.8"], + } + + +def test_install_scenario_writes_partial_manifest_on_failure(tmp_path: Path) -> None: + from otdf_sdk_mgr.installers import InstallError + + scenario_path = tmp_path / "s.yaml" + scenario_path.write_text(SCENARIO_YAML) + platform_dist = tmp_path / "platform-dist" / "v0.9.0" + + with ( + patch("otdf_sdk_mgr.cli_scenario.install_platform_release", return_value=platform_dist), + patch("otdf_sdk_mgr.cli_scenario.install_helper_scripts"), + patch("otdf_sdk_mgr.cli_scenario.install_release", + side_effect=InstallError("boom")), + pytest.raises(typer.Exit), + ): + install_scenario_cmd(scenario_path, skip_scripts=True) + + out_path = tmp_path / "s.installed.json" + record = json.loads(out_path.read_text()) + assert record["status"] == "partial" + assert record["platform"] is not None diff --git a/otdf-sdk-mgr/tests/test_platform_installer.py b/otdf-sdk-mgr/tests/test_platform_installer.py new file mode 100644 index 000000000..795054338 --- /dev/null +++ b/otdf-sdk-mgr/tests/test_platform_installer.py @@ -0,0 +1,23 @@ +"""Pure-function tests for platform_installer.""" + +import pytest + +from otdf_sdk_mgr.platform_installer import _resolve_platform_ref + + +@pytest.mark.parametrize( + "inp,expected", + [ + ("v0.9.0", "service/v0.9.0"), + ("0.9.0", "service/v0.9.0"), + ("main", "main"), + ("HEAD", "HEAD"), + ("service/v0.9.0", "service/v0.9.0"), + ("a" * 40, "a" * 40), + ("b" * 64, "b" * 64), + ("abc1234", "service/vabc1234"), + ("deadbeef", "service/vdeadbeef"), + ], +) +def test_resolve_platform_ref(inp, expected): + assert _resolve_platform_ref(inp) == expected From 2cf8a09f90995c106c06a203e46edf78b2b98734 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 21 May 2026 12:02:53 -0400 Subject: [PATCH 03/16] style(otdf-sdk-mgr): ruff format Co-Authored-By: Claude Opus 4.7 --- .../src/otdf_sdk_mgr/platform_installer.py | 4 +-- otdf-sdk-mgr/tests/test_cli_scenario.py | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py index 28f3c866f..b278e9cb1 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py @@ -43,9 +43,7 @@ def get_platform_dir() -> Path: if (current / "xtest").exists(): return current / "xtest" / "platform" current = current.parent - raise PlatformInstallError( - "Could not locate xtest/ root. Set OTDF_PLATFORM_DIR to override." - ) + raise PlatformInstallError("Could not locate xtest/ root. Set OTDF_PLATFORM_DIR to override.") def _platform_src_root() -> Path: diff --git a/otdf-sdk-mgr/tests/test_cli_scenario.py b/otdf-sdk-mgr/tests/test_cli_scenario.py index c086e324b..4f007b18f 100644 --- a/otdf-sdk-mgr/tests/test_cli_scenario.py +++ b/otdf-sdk-mgr/tests/test_cli_scenario.py @@ -66,16 +66,32 @@ def fake_install_release(sdk: str, version: str) -> Path: } assert set(record["kas"].keys()) == {"alpha"} assert record["sdks"]["encrypt"] == [ - {"sdk": "go", "version": "v0.24.0", "source": None, - "path": str(tmp_path / "sdk" / "go" / "v0.24.0")}, - {"sdk": "js", "version": "v0.5.0", "source": None, - "path": str(tmp_path / "sdk" / "js" / "v0.5.0")}, + { + "sdk": "go", + "version": "v0.24.0", + "source": None, + "path": str(tmp_path / "sdk" / "go" / "v0.24.0"), + }, + { + "sdk": "js", + "version": "v0.5.0", + "source": None, + "path": str(tmp_path / "sdk" / "js" / "v0.5.0"), + }, ] assert record["sdks"]["decrypt"] == [ - {"sdk": "js", "version": "v0.5.0", "source": None, - "path": str(tmp_path / "sdk" / "js" / "v0.5.0")}, - {"sdk": "java", "version": "v0.7.8", "source": None, - "path": str(tmp_path / "sdk" / "java" / "v0.7.8")}, + { + "sdk": "js", + "version": "v0.5.0", + "source": None, + "path": str(tmp_path / "sdk" / "js" / "v0.5.0"), + }, + { + "sdk": "java", + "version": "v0.7.8", + "source": None, + "path": str(tmp_path / "sdk" / "java" / "v0.7.8"), + }, ] assert "status" not in record @@ -98,8 +114,7 @@ def test_install_scenario_writes_partial_manifest_on_failure(tmp_path: Path) -> with ( patch("otdf_sdk_mgr.cli_scenario.install_platform_release", return_value=platform_dist), patch("otdf_sdk_mgr.cli_scenario.install_helper_scripts"), - patch("otdf_sdk_mgr.cli_scenario.install_release", - side_effect=InstallError("boom")), + patch("otdf_sdk_mgr.cli_scenario.install_release", side_effect=InstallError("boom")), pytest.raises(typer.Exit), ): install_scenario_cmd(scenario_path, skip_scripts=True) From 4f868957efc80f6b36ee2bdd2d3982daff83e7aa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 21 May 2026 12:18:02 -0400 Subject: [PATCH 04/16] refactor(otdf-sdk-mgr): address PR review feedback on platform installer - platform_installer: fix worktree update from bare repo (no `origin` remote exists), use `git reset --hard ` instead of `git pull` - platform_installer: stop swallowing subprocess output so long-running `go build`/`git clone` progress is visible to the user - cli_install: extract `_install_platform_or_exit` to dedupe platform handling across `lts`, `tip`, and `release` - cli_scenario: parse manifest YAML once and dispatch by `kind`, instead of peeking + re-parsing in each loader Co-Authored-By: Claude Opus 4.7 --- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py | 67 ++++++++++--------- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py | 45 ++++++------- .../src/otdf_sdk_mgr/platform_installer.py | 17 +++-- otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py | 9 +-- 4 files changed, 71 insertions(+), 67 deletions(-) diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py index 1bf864478..6bf0a4895 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py @@ -20,6 +20,30 @@ def _register_scenario_cmd() -> None: _register_scenario_cmd() +def _split_platform(sdks: list[str]) -> tuple[bool, list[str]]: + """Return (platform_requested, sdks_without_platform).""" + return ("platform" in sdks, [s for s in sdks if s != "platform"]) + + +def _install_platform_or_exit( + install_fn, + version: str, + *, + dist_name: str | None = None, +) -> None: + """Run a platform installer, mapping PlatformInstallError to typer.Exit(1).""" + from otdf_sdk_mgr.platform_installer import PlatformInstallError + + try: + if dist_name is None: + install_fn(version) + else: + install_fn(version, dist_name=dist_name) + except PlatformInstallError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + + @install_app.command() def stable( sdks: Annotated[ @@ -43,23 +67,15 @@ def lts( """Install LTS versions for each SDK.""" from otdf_sdk_mgr.config import LTS_VERSIONS from otdf_sdk_mgr.installers import cmd_lts - from otdf_sdk_mgr.platform_installer import ( - PlatformInstallError, - install_platform_release, - ) + from otdf_sdk_mgr.platform_installer import install_platform_release - requested = sdks or ALL_SDKS - sdk_targets = [s for s in requested if s != "platform"] - if "platform" in requested: + want_platform, sdk_targets = _split_platform(sdks or ALL_SDKS) + if want_platform: version = LTS_VERSIONS.get("platform") if version is None: typer.echo("Error: no LTS version defined for platform", err=True) raise typer.Exit(1) - try: - install_platform_release(version) - except PlatformInstallError as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + _install_platform_or_exit(install_platform_release, version) if sdk_targets: cmd_lts(sdk_targets) @@ -73,19 +89,11 @@ def tip( ) -> None: """Source checkout + build from main.""" from otdf_sdk_mgr.installers import cmd_tip - from otdf_sdk_mgr.platform_installer import ( - PlatformInstallError, - install_platform_source, - ) + from otdf_sdk_mgr.platform_installer import install_platform_source - requested = sdks or ALL_SDKS - sdk_targets = [s for s in requested if s != "platform"] - if "platform" in requested: - try: - install_platform_source("main", dist_name="tip") - except PlatformInstallError as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + want_platform, sdk_targets = _split_platform(sdks or ALL_SDKS) + if want_platform: + _install_platform_or_exit(install_platform_source, "main", dist_name="tip") if sdk_targets: cmd_tip(sdk_targets) @@ -104,10 +112,7 @@ def release( `opentdf/platform` monorepo. """ from otdf_sdk_mgr.installers import InstallError, cmd_release - from otdf_sdk_mgr.platform_installer import ( - PlatformInstallError, - install_platform_release, - ) + from otdf_sdk_mgr.platform_installer import install_platform_release sdk_specs: list[str] = [] for spec in specs: @@ -116,11 +121,7 @@ def release( raise typer.Exit(1) sdk, version = spec.split(":", 1) if sdk == "platform": - try: - install_platform_release(version) - except PlatformInstallError as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + _install_platform_or_exit(install_platform_release, version) else: sdk_specs.append(spec) if sdk_specs: diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py index 560b575ae..fa8c448d3 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py @@ -22,7 +22,13 @@ install_platform_release, install_platform_source, ) -from otdf_sdk_mgr.schema import KasPin, PlatformPin, Scenario, load_instance, load_scenario +from otdf_sdk_mgr.schema import ( + Instance, + KasPin, + PlatformPin, + Scenario, + load_yaml_mapping, +) def _install_platform_pin(pin: PlatformPin | KasPin) -> dict[str, str]: @@ -46,15 +52,23 @@ def install_scenario_cmd( typer.echo(f"Error: {path} not found", err=True) raise typer.Exit(1) - raw_kind = _peek_kind(path) + from ruamel.yaml.error import YAMLError + + try: + raw = load_yaml_mapping(path) + except YAMLError as e: + typer.echo(f"Error: {path} is not valid YAML: {e}", err=True) + raise typer.Exit(1) + + kind = raw.get("kind") if isinstance(raw.get("kind"), str) else None scenario: Scenario | None = None - if raw_kind == "Scenario": - scenario = load_scenario(path) + if kind == "Scenario": + scenario = Scenario.model_validate(raw) instance = scenario.instance - elif raw_kind == "Instance": - instance = load_instance(path) + elif kind == "Instance": + instance = Instance.model_validate(raw) else: - typer.echo(f"Error: {path} has unknown kind {raw_kind!r}", err=True) + typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True) raise typer.Exit(1) installed: dict[str, object] = { @@ -96,20 +110,3 @@ def install_scenario_cmd( out.write_text(json.dumps(installed, indent=2) + "\n") typer.echo(f" Wrote {out}") - - -def _peek_kind(path: Path) -> str | None: - """Cheap pre-validation read so we can dispatch to the right model loader.""" - from ruamel.yaml import YAML - from ruamel.yaml.error import YAMLError - - y = YAML(typ="safe") - try: - raw = y.load(path.read_text()) - except YAMLError as e: - typer.echo(f"Error: {path} is not valid YAML: {e}", err=True) - raise typer.Exit(1) - if isinstance(raw, dict): - kind = raw.get("kind") - return kind if isinstance(kind, str) else None - return None diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py index b278e9cb1..d979c0bc5 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py @@ -63,12 +63,15 @@ def _platform_bare_repo() -> Path: def _run(cmd: list[str], cwd: Path | None = None) -> None: - """Run a command, raising PlatformInstallError on failure.""" - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + """Run a command, streaming output to the terminal. + + Long-running commands (`go build`, `git clone`) need live output so the + user can see progress. We don't capture; on failure the user has already + seen the diagnostics in their terminal. + """ + result = subprocess.run(cmd, cwd=cwd) if result.returncode != 0: - raise PlatformInstallError( - f"command failed: {' '.join(cmd)}\nstdout: {result.stdout}\nstderr: {result.stderr}" - ) + raise PlatformInstallError(f"command failed (exit {result.returncode}): {' '.join(cmd)}") def _ensure_bare_repo() -> Path: @@ -190,8 +193,10 @@ def install_helper_scripts(branch: str = HELPER_SCRIPTS_BRANCH) -> Path: print(f"Adding scripts worktree at {worktree} ({branch})...") _run(["git", f"--git-dir={bare}", "worktree", "add", str(worktree), branch]) else: + # Worktrees from a bare clone have no `origin` remote, so `git pull` + # fails. Reset to the (just-fetched) branch ref in the bare repo. print(f"Updating scripts worktree at {worktree}...") - _run(["git", "-C", str(worktree), "pull", "origin", branch]) + _run(["git", "-C", str(worktree), "reset", "--hard", branch]) src_scripts = worktree / "scripts" if not src_scripts.exists(): raise PlatformInstallError( diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py index d91cf5042..9255badb6 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py @@ -194,7 +194,8 @@ def _yaml() -> YAML: return YAML(typ="safe") -def _load_yaml_mapping(path: str | Path) -> dict[str, object]: +def load_yaml_mapping(path: str | Path) -> dict[str, object]: + """Parse `path` as YAML and assert the top-level is a mapping.""" p = Path(path) raw = _yaml().load(p.read_text(encoding="utf-8")) if not isinstance(raw, dict): @@ -204,12 +205,12 @@ def _load_yaml_mapping(path: str | Path) -> dict[str, object]: def load_scenario(path: str | Path) -> Scenario: """Parse and validate a scenarios.yaml file.""" - return Scenario.model_validate(_load_yaml_mapping(path)) + return Scenario.model_validate(load_yaml_mapping(path)) def load_instance(path: str | Path) -> Instance: """Parse and validate an instance.yaml file.""" - return Instance.model_validate(_load_yaml_mapping(path)) + return Instance.model_validate(load_yaml_mapping(path)) def dump_instance(instance: Instance, path: str | Path) -> None: @@ -306,7 +307,7 @@ def _main(argv: list[str] | None = None) -> int: return 2 path = Path(args[1]) try: - raw = _load_yaml_mapping(path) + raw = load_yaml_mapping(path) except OSError as e: print(f"error: cannot read {path}: {e}", file=sys.stderr) return 1 From e8439e5fb7a196c618d932c4b78294d24c3c3a50 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 21 May 2026 12:23:30 -0400 Subject: [PATCH 05/16] docs+chore: require uv run ruff/pyright pre-commit; fix cli_scenario typing - AGENTS.md: add "Before Committing Python Changes" section requiring `uv run ruff check`, `uv run ruff format`, and `uv run pyright` on any touched Python package before commit. Explicitly call out that `uvx` must NOT be used for pyright (isolated env can't see project deps, so every project import becomes a spurious "could not be resolved" error). - cli_scenario: split the single `dict[str, object]` install record into per-section typed containers (`installed_platform`, `installed_kas`, `installed_sdks`) assembled at write time via a `_snapshot()` helper. Fixes pre-existing pyright `__setitem__ ... not defined on object` errors at the nested writes; on-disk JSON shape is unchanged. Co-Authored-By: Claude Opus 4.7 --- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py index fa8c448d3..9fa0cdf2e 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py @@ -71,18 +71,26 @@ def install_scenario_cmd( typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True) raise typer.Exit(1) - installed: dict[str, object] = { - "manifest": str(path), - "platform": None, - "kas": {}, - "sdks": {"encrypt": [], "decrypt": []}, - } + installed_platform: dict[str, str] | None = None + installed_kas: dict[str, dict[str, str]] = {} + installed_sdks: dict[str, list[dict[str, str | None]]] = {"encrypt": [], "decrypt": []} out = path.parent / f"{path.stem}.installed.json" + def _snapshot(status: str | None = None) -> dict[str, object]: + snap: dict[str, object] = { + "manifest": str(path), + "platform": installed_platform, + "kas": installed_kas, + "sdks": installed_sdks, + } + if status is not None: + snap["status"] = status + return snap + try: - installed["platform"] = _install_platform_pin(instance.platform) + installed_platform = _install_platform_pin(instance.platform) for kas_name, kas_pin in instance.kas.items(): - installed["kas"][kas_name] = _install_platform_pin(kas_pin) + installed_kas[kas_name] = _install_platform_pin(kas_pin) if not skip_scripts: install_helper_scripts() @@ -92,7 +100,7 @@ def install_scenario_cmd( dist_dir = install_release(entry.sdk, entry.version) install_paths[entry.install_key()] = str(dist_dir) for role in ("encrypt", "decrypt"): - installed["sdks"][role] = [ + installed_sdks[role] = [ { "sdk": entry.sdk, "version": entry.version, @@ -102,11 +110,10 @@ def install_scenario_cmd( for entry in getattr(scenario.sdks, role) ] except (PlatformInstallError, InstallError) as e: - installed["status"] = "partial" - out.write_text(json.dumps(installed, indent=2) + "\n") + out.write_text(json.dumps(_snapshot(status="partial"), indent=2) + "\n") typer.echo(f"Error: {e}", err=True) typer.echo(f" Wrote partial manifest to {out}", err=True) raise typer.Exit(1) - out.write_text(json.dumps(installed, indent=2) + "\n") + out.write_text(json.dumps(_snapshot(), indent=2) + "\n") typer.echo(f" Wrote {out}") From 13b5c968dd8d626bfe5d3d2eab4da077f9fefdd1 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 21 May 2026 13:41:47 -0400 Subject: [PATCH 06/16] docs(agents): expand and reorganize AGENTS.md across packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root AGENTS.md: add a Repository Layout table near the top, correct the `platform/` description (it's installed by `otdf-sdk-mgr install`, not committed source), and trim the duplicated "Summary → Preferred Workflow" block that restated the body. - otdf-local/AGENTS.md: lead with the dependency on `otdf-sdk-mgr` (otdf-local launches the binaries the installer produces). Mark the manual-keys YAML block as an emergency fallback that may drift. - otdf-sdk-mgr/AGENTS.md (new): operational guide for the installer — subcommand layout, bare-clone-worktree gotchas (no `origin` remote, namespaced `service/vX.Y.Z` tags, unbuffered subprocess output), pattern for adding a new subcommand. - xtest/AGENTS.md (new): test-suite layout, custom pytest options, audit-log fixture quick reference, authoring guidance. - otdf-sdk-mgr/CLAUDE.md, xtest/CLAUDE.md: symlinks to AGENTS.md to match the repo convention. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 10 +++---- otdf-local/AGENTS.md | 2 ++ otdf-sdk-mgr/AGENTS.md | 60 ++++++++++++++++++++++++++++++++++++++++++ otdf-sdk-mgr/CLAUDE.md | 1 + xtest/AGENTS.md | 3 ++- 5 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 otdf-sdk-mgr/AGENTS.md create mode 120000 otdf-sdk-mgr/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 49f16c8f1..cfca4cde6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,11 @@ This guide provides essential knowledge for AI agents performing updates, refact | Path | Purpose | Has its own AGENTS.md? | |------|---------|------------------------| | `xtest/` | pytest integration tests (the main test suite) | yes | -| `otdf-sdk-mgr/` | Python CLI that installs SDK CLIs from releases or source (see `otdf-sdk-mgr/README.md`) | no | +| `otdf-sdk-mgr/` | Python CLI that installs SDK CLIs and the platform service from releases or source | yes | | `otdf-local/` | Python CLI that runs/stops the platform + KAS instances locally | yes | | `vulnerability/` | Playwright UI test suite (run with `npx playwright test`) | no | -| `xtest/sdk/{go,java,js}/dist/` | Built SDK CLI wrappers, produced by `otdf-sdk-mgr install` (or by `cd xtest/sdk && make` for source builds) | n/a | +| `platform/` | Platform service source — **installed by `otdf-sdk-mgr install platform`**, not committed. Edits here may be wiped by a reinstall. | | +| `xtest/sdk/{go,java,js}/dist/` | Built SDK CLI wrappers, produced by `otdf-sdk-mgr install` (or by `cd xtest/sdk && make` for source builds) | | ## Test Framework Overview @@ -234,7 +235,4 @@ yq e '.services.kas.root_key' platform/opentdf-dev.yaml ## Closing Note -Test failures are usually configuration mismatches, not SDK bugs. Check -the local environment against what the tests expect before suspecting the -code. Per-subsystem details live in `xtest/AGENTS.md`, -`otdf-local/AGENTS.md`, and `otdf-sdk-mgr/README.md`. +The test failures are usually symptoms of configuration mismatches, not SDK bugs. Focus on ensuring the local environment matches what the tests expect. See the per-package guides in `xtest/`, `otdf-sdk-mgr/`, and `otdf-local/` for sub-system specifics. diff --git a/otdf-local/AGENTS.md b/otdf-local/AGENTS.md index 7e5a26347..0c116ef4b 100644 --- a/otdf-local/AGENTS.md +++ b/otdf-local/AGENTS.md @@ -2,6 +2,8 @@ This guide covers operational procedures for managing the test environment with `otdf-local`. For command reference, see [README.md](README.md). +**Depends on `otdf-sdk-mgr`.** `otdf-local` launches binaries that `otdf-sdk-mgr install platform` (or `otdf-sdk-mgr install scenario`) writes into `xtest/platform/dist/`. If `otdf-local up` complains that a binary is missing, run the installer first. + ## Environment Setup for pytest ```bash diff --git a/otdf-sdk-mgr/AGENTS.md b/otdf-sdk-mgr/AGENTS.md new file mode 100644 index 000000000..951b260b0 --- /dev/null +++ b/otdf-sdk-mgr/AGENTS.md @@ -0,0 +1,60 @@ +# otdf-sdk-mgr - Agent Guide + +Python CLI that installs SDK CLIs (`go`, `java`, `js`) and the OpenTDF +platform service from released artifacts or source. Outputs land in +`xtest/sdk/{go,java,js}/dist//` and `xtest/platform/dist//`. + +Full command reference: [README.md](README.md). + +## Subcommand Layout + +| File | Subcommand | Responsibility | +|------|------------|----------------| +| `cli_install.py` | `install {stable,lts,tip,release,scripts,artifact,scenario}` | All `install` subcommands; delegates per-SDK work to `installers.py` and platform work to `platform_installer.py`. | +| `cli_scenario.py` | `install scenario ` | Reads `scenarios.yaml` / `instance.yaml`, installs every referenced artifact, writes `.installed.json`. | +| `cli_versions.py` | `versions {list,latest}` | Lists released versions across registries. | +| `installers.py` | (lib) | Per-SDK install logic for go/java/js. | +| `platform_installer.py` | (lib) | Builds the platform `service` binary via git worktrees on a bare clone. | +| `schema.py` | (lib) | Pydantic models for `Scenario` / `Instance` + `load_yaml_mapping`. | + +## Platform Install via Git Worktrees + +`platform_installer.py` keeps a **bare clone** at `xtest/platform/src/platform.git` +and `git worktree add`s each requested ref into a sibling directory. A few +gotchas worth knowing before editing this module: + +- **Worktrees from a bare clone have no `origin` remote.** `git pull` inside + the worktree will fail. Update by fetching into the bare repo first + (`_ensure_bare_repo()` already does this), then `git -C reset + --hard ` to move the worktree HEAD to the refreshed ref. +- **Platform tags are namespaced** as `service/vX.Y.Z`. `_resolve_platform_ref` + prefixes the `service/` infix on plain versions; raw SHAs, refs with a + `/`, and `main`/`HEAD` pass through unchanged. +- Subprocess output is **not captured** — long-running `go build` / `git + clone` streams to the terminal so users can see progress. On failure the + error message just reports the command and exit code. + +## Before Committing + +Run from this directory: + +```bash +uv run ruff check . # lint — must pass +uv run ruff format . # auto-format — re-stage rewritten files +uv run pyright # type-check — must pass +uv run pytest -q # unit tests +``` + +Use `uv run`, **not `uvx`** — `uvx` strips the project venv, so pyright +reports every project import as unresolved. See the root `AGENTS.md` +("Before Committing Python Changes") for the rationale. + +## Adding a New Subcommand + +1. Create or extend a `cli_.py` module. +2. Register it in `cli.py` (the Typer app entry point), or — for `install` + subcommands — under `install_app` in `cli_install.py`. +3. Wrap any library exceptions (`InstallError`, `PlatformInstallError`) at + the CLI boundary and exit with `typer.Exit(1)`. The + `_install_platform_or_exit` helper in `cli_install.py` shows the + pattern for platform installers. diff --git a/otdf-sdk-mgr/CLAUDE.md b/otdf-sdk-mgr/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/otdf-sdk-mgr/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/xtest/AGENTS.md b/xtest/AGENTS.md index d588b4ce7..a9bb394ed 100644 --- a/xtest/AGENTS.md +++ b/xtest/AGENTS.md @@ -15,7 +15,8 @@ fixture system. | `conftest.py` | `pytest_addoption` + the encrypt/decrypt SDK parametrization. Defines `--sdks`, `--sdks-encrypt`, `--sdks-decrypt`, `--containers`, `--no-audit-logs`. | | `fixtures/` | Module-scoped pytest fixtures: `attributes.py`, `keys.py`, `audit.py`, `assertions.py`, `kas.py`, `encryption.py`, `obligations.py`. | | `tdfs.py` | SDK abstraction layer — wraps the `cli.sh` shims under `sdk//dist//`. | -| `sdk/{go,java,js}/dist//` | SDK CLI builds. Installed by `otdf-sdk-mgr install` (see `../otdf-sdk-mgr/README.md`). | +<<<<<<< HEAD +| `sdk/{go,java,js}/dist//` | SDK CLI builds. Installed by `otdf-sdk-mgr install` (see `../otdf-sdk-mgr/AGENTS.md`). | | `test.env` | Default endpoint and client-credential env vars. Source with `set -a && source test.env && set +a`. | ## Custom pytest Options (defined in `conftest.py`) From a1bcecce04ad0d7d793daff6cada1c24535f02f1 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 11:51:38 -0400 Subject: [PATCH 07/16] feat(otdf-local): multi-instance test environments (DSPX-3302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors otdf-local from a single-instance CLI (one platform checkout, fixed ports, hardcoded six KAS instances) into a multi-instance harness where each named instance under tests/instances// owns its own opentdf.yaml, keys, KAS configs, and port range. Why --- A single bug report often describes a *combination* — platform v0.9.0 with Java SDK 0.7.8 and a KAS at a pre-release. Today a developer has to hand-edit configs and re-checkout the platform to reproduce. After this change: otdf-local instance init java-078 --from-scenario .../scenario.yaml otdf-local --instance java-078 up brings up exactly the topology the scenario describes, using platform binaries that otdf-sdk-mgr already provisioned (each instance, and each KAS within an instance, can reference a different pinned version). Two instances on disjoint ports.base can coexist on a developer laptop. What changes ------------ otdf-local now depends on otdf-sdk-mgr via a uv path source so both tools share the canonical Scenario/Instance schema. Settings (otdf_local.config.settings): - New instance_name (env-overridable via OTDF_LOCAL_INSTANCE_NAME), instance_dir, instances_root, instance_yaml properties. - platform_dir becomes optional; legacy sibling-discovery only kicks in when no per-instance configuration is present. - platform_binary_for(dist) resolves to the otdf-sdk-mgr-managed xtest/platform/dist//service binary. - keys_dir, logs_dir, config_dir, platform_config, and get_kas_config_path switch to per-instance paths whenever instance.yaml exists; legacy behavior is preserved otherwise. - load_instance() reads the per-instance manifest via the shared Pydantic model. Ports (otdf_local.config.ports): - KAS_OFFSETS exposes the offset table (alpha=+101, beta=+202, ..., km2=+606) so multiple instances on different bases get disjoint port ranges. The legacy 8080-based constants are preserved as defaults. - get_kas_port(name, base=...) computes the port relative to base. Services (otdf_local.services.platform / .kas): - PlatformService.start() and KASService.start() use the pinned dist binary at xtest/platform/dist//service when an instance is loaded, with cwd set to the recorded worktree so the binary finds its embedded resources. Legacy `go run ./service` path runs unchanged when no instance is active. - KASService.is_key_management defers to the manifest's `mode` field instead of the legacy name-based heuristic; per-KAS features (e.g. ec_tdf_enabled) pass through to opentdf.yaml. - KASManager constructs only the KAS instances listed in instance.yaml's kas: map. start_standard / start_km filter on is_key_management so subset topologies still work. utils.keys.setup_golden_keys: - Writes key files into the target directory (per-instance keys_dir or legacy platform_dir) and uses absolute paths in the generated keys_config so the binary finds them regardless of cwd. CLI: - New top-level --instance option threads through every command via OTDF_LOCAL_INSTANCE_NAME. - New `instance` subcommand group: init [--from-scenario PATH], ls --json, rm. - New `scenario` subcommand: `run ` translates the scenario's suite block into `pytest --sdks-encrypt ... --sdks-decrypt ... --containers ...` under xtest/ with OTDF_LOCAL_INSTANCE_NAME set. Tests (otdf-local/tests/test_multi_instance.py): - Port arithmetic at default and alternate bases. - Settings round-trip with and without an instance.yaml. - platform_binary_for resolves under the otdf-sdk-mgr-managed xtest/platform/ tree. .gitignore additions: - tests/instances/ (per-instance config and logs) - xtest/scenarios/*.installed.json (provisioning records) - .claude/tmp/ Backward compatibility: - `otdf-local up` with no --instance flag keeps working against a sibling platform/ checkout. Refs: https://virtru.atlassian.net/browse/DSPX-3302 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 + otdf-local/pyproject.toml | 4 + otdf-local/src/otdf_local/cli.py | 28 ++- otdf-local/src/otdf_local/cli_instance.py | 183 ++++++++++++++++++ otdf-local/src/otdf_local/cli_scenario.py | 101 ++++++++++ otdf-local/src/otdf_local/config/ports.py | 30 ++- otdf-local/src/otdf_local/config/settings.py | 116 +++++++++-- otdf-local/src/otdf_local/services/kas.py | 116 +++++++---- .../src/otdf_local/services/platform.py | 69 +++++-- otdf-local/src/otdf_local/utils/keys.py | 10 +- otdf-local/tests/test_multi_instance.py | 78 ++++++++ otdf-local/uv.lock | 63 ++++++ 12 files changed, 736 insertions(+), 69 deletions(-) create mode 100644 otdf-local/src/otdf_local/cli_instance.py create mode 100644 otdf-local/src/otdf_local/cli_scenario.py create mode 100644 otdf-local/tests/test_multi_instance.py diff --git a/.gitignore b/.gitignore index ecb9a979f..5041673a3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ xtest/sdk/java/cmdline.jar /xtest/otdfctl/ /tmp/ + +# Multi-instance test harness state (DSPX-3302). Per-instance config, logs, and +# keys live under tests/instances/; otdf-sdk-mgr install scenario writes +# .installed.json next to each scenarios.yaml. +/instances/ +xtest/scenarios/*.installed.json +.claude/tmp/ diff --git a/otdf-local/pyproject.toml b/otdf-local/pyproject.toml index b95ac0609..180dc7977 100644 --- a/otdf-local/pyproject.toml +++ b/otdf-local/pyproject.toml @@ -6,12 +6,16 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "httpx>=0.27.0", + "otdf-sdk-mgr", "pydantic-settings>=2.2.0", "rich>=13.7.0", "ruamel.yaml>=0.18.0", "typer>=0.12.0", ] +[tool.uv.sources] +otdf-sdk-mgr = { path = "../otdf-sdk-mgr", editable = true } + [dependency-groups] dev = [ "pyright>=1.1.408", diff --git a/otdf-local/src/otdf_local/cli.py b/otdf-local/src/otdf_local/cli.py index d8e3597ff..422daa65a 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -1,10 +1,12 @@ """Typer CLI for otdf_local - OpenTDF test environment management.""" import json +import os import shutil import sys import time -from typing import Annotated +from pathlib import Path +from typing import Annotated, Optional import httpx import typer @@ -44,6 +46,18 @@ ) +def _register_subapps() -> None: + """Defer imports so the schema dependency only loads when needed.""" + from otdf_local.cli_instance import instance_app + from otdf_local.cli_scenario import scenario_app + + app.add_typer(instance_app, name="instance") + app.add_typer(scenario_app, name="scenario") + + +_register_subapps() + + def _show_provision_error(result: ProvisionResult, target: str) -> None: """Display provisioning error with stderr details.""" print_error(f"{target} provisioning failed (exit code {result.return_code})") @@ -75,9 +89,19 @@ def main( is_eager=True, ), ] = False, + instance: Annotated[ + Optional[str], + typer.Option( + "--instance", + help='Named instance under tests/instances/. Defaults to "default" (or $OTDF_LOCAL_INSTANCE_NAME).', + ), + ] = None, ) -> None: """OpenTDF test environment management CLI.""" - pass + if instance is not None: + os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance + # Invalidate the cached Settings so subsequent commands see the new value + get_settings.cache_clear() @app.command() diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py new file mode 100644 index 000000000..98407f56e --- /dev/null +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -0,0 +1,183 @@ +"""`otdf-local instance` subcommands: init / ls / rm.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Annotated, Optional + +import typer +from otdf_sdk_mgr.schema import Instance, Metadata, PlatformPin, PortsConfig, dump_instance + +from otdf_local.config.settings import get_settings + +instance_app = typer.Typer(help="Manage named test environment instances.") + + +@instance_app.command("init") +def init( + name: Annotated[str, typer.Argument(help="Instance name (used as directory name)")], + from_scenario: Annotated[ + Optional[Path], + typer.Option("--from-scenario", help="Initialize from a scenarios.yaml or instance.yaml"), + ] = None, + ports_base: Annotated[ + int, + typer.Option("--ports-base", help="Base port (KAS ports computed as base+N*101)"), + ] = 8080, + platform_dist: Annotated[ + Optional[str], + typer.Option("--platform", help="Platform dist version (e.g., v0.9.0)"), + ] = None, +) -> None: + """Scaffold a new instance directory at tests/instances//.""" + settings = get_settings() + instance_dir = settings.instances_root / name + + if from_scenario is not None: + _init_from_scenario(name, from_scenario, instance_dir) + else: + if platform_dist is None: + typer.echo("Error: --platform is required when not using --from-scenario", err=True) + raise typer.Exit(2) + _init_minimal(name, instance_dir, ports_base, platform_dist) + + _validate_port_uniqueness(settings.instances_root, name) + typer.echo(f" Initialized instance '{name}' at {instance_dir}") + + +def _init_from_scenario(name: str, scenario_path: Path, instance_dir: Path) -> None: + """Copy the embedded Instance from a Scenario or load a standalone Instance.""" + from otdf_sdk_mgr.schema import load_instance, load_scenario + from ruamel.yaml import YAML + + y = YAML(typ="safe") + raw = y.load(scenario_path.read_text()) + if not isinstance(raw, dict): + raise typer.BadParameter(f"{scenario_path} top-level YAML must be a mapping") + kind = raw.get("kind") + if kind == "Scenario": + scenario = load_scenario(scenario_path) + instance = scenario.instance + elif kind == "Instance": + instance = load_instance(scenario_path) + else: + raise typer.BadParameter(f"{scenario_path} has unknown kind {kind!r}") + # Ensure the metadata name matches the chosen directory name. + instance.metadata = Metadata(**{**instance.metadata.model_dump(exclude_none=True), "name": name}) + instance_dir.mkdir(parents=True, exist_ok=True) + (instance_dir / "kas").mkdir(parents=True, exist_ok=True) + (instance_dir / "keys").mkdir(mode=0o700, parents=True, exist_ok=True) + (instance_dir / "logs").mkdir(parents=True, exist_ok=True) + dump_instance(instance, instance_dir / "instance.yaml") + + +def _init_minimal(name: str, instance_dir: Path, ports_base: int, platform_dist: str) -> None: + """Create a barebones instance.yaml with default KAS layout.""" + instance = Instance( + metadata=Metadata(name=name), + platform=PlatformPin(dist=platform_dist), + ports=PortsConfig(base=ports_base), + kas={}, + ) + instance_dir.mkdir(parents=True, exist_ok=True) + (instance_dir / "kas").mkdir(parents=True, exist_ok=True) + (instance_dir / "keys").mkdir(mode=0o700, parents=True, exist_ok=True) + (instance_dir / "logs").mkdir(parents=True, exist_ok=True) + dump_instance(instance, instance_dir / "instance.yaml") + + +def _validate_port_uniqueness(instances_root: Path, new_name: str) -> None: + """Warn if another instance shares the same `ports.base`.""" + from otdf_sdk_mgr.schema import load_instance + + new_yaml = instances_root / new_name / "instance.yaml" + if not new_yaml.exists(): + return + new_inst = load_instance(new_yaml) + new_base = new_inst.ports.base + if not instances_root.exists(): + return + for child in instances_root.iterdir(): + if not child.is_dir() or child.name == new_name: + continue + other_yaml = child / "instance.yaml" + if not other_yaml.is_file(): + continue + try: + other = load_instance(other_yaml) + except Exception: + continue + if other.ports.base == new_base: + typer.echo( + f" Warning: instance '{child.name}' already uses ports.base={new_base}; " + f"running both simultaneously will collide. Change one with `otdf-local instance init`.", + err=True, + ) + + +@instance_app.command("ls") +def ls( + as_json: Annotated[bool, typer.Option("--json", "-j", help="Emit JSON")] = False, +) -> None: + """List known instances.""" + import json as _json + + from otdf_sdk_mgr.schema import load_instance + + settings = get_settings() + root = settings.instances_root + if not root.exists(): + if as_json: + typer.echo(_json.dumps([])) + else: + typer.echo(" (no instances yet)") + return + rows: list[dict[str, object]] = [] + for child in sorted(root.iterdir()): + if not child.is_dir(): + continue + ymp = child / "instance.yaml" + if not ymp.is_file(): + continue + try: + inst = load_instance(ymp) + except Exception as e: + rows.append({"name": child.name, "error": str(e)}) + continue + rows.append( + { + "name": child.name, + "platform": ( + inst.platform.dist + or (inst.platform.source.ref if inst.platform.source else inst.platform.image) + ), + "ports_base": inst.ports.base, + "kas": list(inst.kas.keys()), + } + ) + if as_json: + typer.echo(_json.dumps(rows, indent=2)) + else: + for row in rows: + typer.echo(f" {row}") + + +@instance_app.command("rm") +def rm( + name: Annotated[str, typer.Argument(help="Instance to remove")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False, +) -> None: + """Remove an instance directory.""" + settings = get_settings() + instance_dir = settings.instances_root / name + if not instance_dir.exists(): + typer.echo(f"Error: instance '{name}' not found at {instance_dir}", err=True) + raise typer.Exit(1) + if not yes: + confirm = typer.confirm(f"Delete {instance_dir}?", default=False) + if not confirm: + typer.echo("aborted") + raise typer.Exit(1) + shutil.rmtree(instance_dir) + typer.echo(f" Removed {instance_dir}") diff --git a/otdf-local/src/otdf_local/cli_scenario.py b/otdf-local/src/otdf_local/cli_scenario.py new file mode 100644 index 000000000..7d1dfde30 --- /dev/null +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -0,0 +1,101 @@ +"""`otdf-local scenario` subcommands. + +Today's surface area is intentionally narrow — `run` is the only command +that's part of the bug-repro MVP. Bisect and other higher-level loops are +deferred (see plan §9). +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Annotated + +import typer +from otdf_sdk_mgr.schema import ( + Scenario, + installed_json_for, + load_scenario, + scenario_to_pytest_sdks, +) + +from otdf_local.config.settings import get_settings + +scenario_app = typer.Typer(help="Run scenarios.yaml against a healthy instance.") + + +def _build_pytest_args(scenario: Scenario, scenario_path: Path) -> list[str]: + """Translate the scenario's `suite` block into pytest CLI args. + + SDK pins go through `scenario_to_pytest_sdks` so they're forwarded as + the `sdk@` tokens xtest's #446 specifier format expects. + Requires that `otdf-sdk-mgr install scenario` has been run first; the + helper raises FileNotFoundError with a clean hint otherwise. + """ + suite = scenario.suite + args: list[str] = [suite.select] + + tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path)) + if tokens["encrypt"]: + args.extend(["--sdks-encrypt", " ".join(tokens["encrypt"])]) + if tokens["decrypt"]: + args.extend(["--sdks-decrypt", " ".join(tokens["decrypt"])]) + if suite.containers: + args.extend(["--containers", suite.containers]) + if suite.markers: + args.extend(["-m", suite.markers]) + args.extend(suite.extra_args) + return args + + +@scenario_app.command("run") +def run( + path: Annotated[Path, typer.Argument(help="Path to scenarios.yaml")], + instance: Annotated[ + str | None, + typer.Option( + "--instance", + help="Override which instance to use (defaults to scenario.instance.metadata.name)", + ), + ] = None, + extra: Annotated[ + list[str] | None, + typer.Argument(help="Extra args passed through to pytest (after --)"), + ] = None, +) -> None: + """Run the pytest suite declared by the scenario against its instance.""" + if not path.exists(): + typer.echo(f"Error: {path} not found", err=True) + raise typer.Exit(1) + + scenario = load_scenario(path) + instance_name = instance or scenario.instance.metadata.name + if not instance_name: + typer.echo("Error: scenario.instance.metadata.name not set; pass --instance", err=True) + raise typer.Exit(2) + + settings = get_settings() + # Force the chosen instance via env so child pytest invocations agree. + os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance_name + + xtest_root = settings.xtest_root + if not xtest_root.exists(): + typer.echo(f"Error: xtest root not found at {xtest_root}", err=True) + raise typer.Exit(1) + + try: + pytest_args = _build_pytest_args(scenario, path) + except FileNotFoundError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + if extra: + pytest_args.extend(extra) + + cmd = ["uv", "run", "pytest", *pytest_args] + typer.echo(f" Running: {' '.join(cmd)} (cwd={xtest_root})") + completed = subprocess.run(cmd, cwd=xtest_root) + raise typer.Exit(completed.returncode) diff --git a/otdf-local/src/otdf_local/config/ports.py b/otdf-local/src/otdf_local/config/ports.py index 21d193358..913f970d0 100644 --- a/otdf-local/src/otdf_local/config/ports.py +++ b/otdf-local/src/otdf_local/config/ports.py @@ -33,14 +33,40 @@ class Ports: "km2": "KAS_KM2", } + # Offset of each KAS port from `base` (which is the platform port). + # The defaults at base=8080 reproduce the historical 8181/8282/... layout. + KAS_OFFSETS: ClassVar[dict[str, int]] = { + "alpha": 101, + "beta": 202, + "gamma": 303, + "delta": 404, + "km1": 505, + "km2": 606, + } + @classmethod - def get_kas_port(cls, name: str) -> int: - """Get port for a KAS instance by name.""" + def get_kas_port(cls, name: str, *, base: int | None = None) -> int: + """Get port for a KAS instance by name. + + When `base` is provided, the port is computed as `base + offset` so + multiple instances can coexist on disjoint port ranges. Otherwise the + legacy class constants are returned (base=8080 layout). + """ + if base is not None: + offset = cls.KAS_OFFSETS.get(name) + if offset is None: + raise ValueError(f"Unknown KAS instance: {name}") + return base + offset attr = cls._KAS_NAMES.get(name) if attr is None: raise ValueError(f"Unknown KAS instance: {name}") return getattr(cls, attr) + @classmethod + def platform_port_for(cls, base: int) -> int: + """Return the platform port for a given `base`. Trivially `base` today.""" + return base + @classmethod def all_kas_names(cls) -> list[str]: """Return all KAS instance names.""" diff --git a/otdf-local/src/otdf_local/config/settings.py b/otdf-local/src/otdf_local/config/settings.py index 96a4c20e8..dffc2cefc 100644 --- a/otdf-local/src/otdf_local/config/settings.py +++ b/otdf-local/src/otdf_local/config/settings.py @@ -8,6 +8,8 @@ from otdf_local.config.ports import Ports +DEFAULT_INSTANCE_NAME = "default" + def _pyproject_has_name(path: Path, project_name: str) -> bool: """Return True if path/pyproject.toml contains the given project name.""" @@ -80,6 +82,19 @@ def _find_platform_dir(xtest_root: Path) -> Path: ) +def _find_platform_dir_optional(xtest_root: Path) -> Path | None: + """Same as `_find_platform_dir` but returns None instead of raising. + + Multi-instance mode looks up platform binaries via `otdf-sdk-mgr` instead of + a sibling repo, so a missing sibling `platform/` is no longer fatal — only + the legacy single-instance path needs it. + """ + try: + return _find_platform_dir(xtest_root) + except FileNotFoundError: + return None + + class Settings(BaseSettings): """Application settings with environment variable support.""" @@ -91,44 +106,100 @@ class Settings(BaseSettings): # Directory paths - computed from xtest_root xtest_root: Path = Field(default_factory=_find_xtest_root) - platform_dir: Path = Field( - default_factory=lambda: _find_platform_dir(_find_xtest_root()) + platform_dir: Path | None = Field( + default_factory=lambda: _find_platform_dir_optional(_find_xtest_root()) ) + # Multi-instance: which named instance under `tests/instances//` to use. + instance_name: str = DEFAULT_INSTANCE_NAME + + @property + def tests_root(self) -> Path: + """Repo root that holds `xtest/`, `instances/`, `otdf-local/`, etc.""" + return self.xtest_root.parent + + @property + def instances_root(self) -> Path: + """Top-level `tests/instances/` directory (created on demand).""" + return self.tests_root / "instances" + + @property + def instance_dir(self) -> Path: + """Per-instance directory: `tests/instances//`.""" + return self.instances_root / self.instance_name + + @property + def instance_yaml(self) -> Path: + """Path to the per-instance manifest.""" + return self.instance_dir / "instance.yaml" + + def has_instance(self) -> bool: + """Return True if `instance.yaml` exists for the selected instance.""" + return self.instance_yaml.is_file() + + def platform_binary_for(self, dist: str) -> Path: + """Resolve a platform dist version to its built `service` binary path. + + Looks under `xtest/platform/dist//service` (managed by + `otdf-sdk-mgr install platform:`). The binary is not required + to exist at the time of the call — callers should check existence and + surface a clear error suggesting `otdf-sdk-mgr install` when missing. + """ + from otdf_sdk_mgr.platform_installer import get_platform_dir + + return get_platform_dir() / "dist" / dist / "service" + @property def logs_dir(self) -> Path: - """Logs directory.""" + """Logs directory. Per-instance when an instance is selected, falls back to legacy.""" + if self.has_instance(): + return self.instance_dir / "logs" return self.xtest_root / "tmp" / "logs" @property def keys_dir(self) -> Path: - """Keys directory.""" + """Keys directory. Per-instance when an instance is selected, falls back to legacy.""" + if self.has_instance(): + return self.instance_dir / "keys" return self.xtest_root / "tmp" / "keys" @property def config_dir(self) -> Path: - """Generated config files directory.""" + """Generated config files directory. Per-instance when present.""" + if self.has_instance(): + return self.instance_dir return self.xtest_root / "tmp" / "config" + def _require_platform_dir(self) -> Path: + if self.platform_dir is None: + raise FileNotFoundError( + "No sibling platform/ directory found. Either check out opentdf/platform as " + "a sibling of tests/, or run `otdf-sdk-mgr install platform:` and " + "select an instance with `otdf-local --instance `." + ) + return self.platform_dir + @property def platform_config(self) -> Path: - """Platform config file path.""" - return self.platform_dir / "opentdf-dev.yaml" + """Platform config file. Per-instance when present, else legacy template.""" + if self.has_instance(): + return self.instance_dir / "opentdf.yaml" + return self._require_platform_dir() / "opentdf-dev.yaml" @property def platform_template_config(self) -> Path: - """Platform config template path.""" - return self.platform_dir / "opentdf.yaml" + """Platform config template path (legacy mode).""" + return self._require_platform_dir() / "opentdf.yaml" @property def kas_template_config(self) -> Path: - """KAS config template path.""" - return self.platform_dir / "opentdf-kas-mode.yaml" + """KAS config template path (legacy mode).""" + return self._require_platform_dir() / "opentdf-kas-mode.yaml" @property def docker_compose_file(self) -> Path: """Docker compose file path.""" - return self.platform_dir / "docker-compose.yaml" + return self._require_platform_dir() / "docker-compose.yaml" # Service ports keycloak_port: int = Ports.KEYCLOAK @@ -147,11 +218,28 @@ def docker_compose_file(self) -> Path: log_level: str = "info" def get_kas_port(self, name: str) -> int: - """Get port for a KAS instance.""" + """Get port for a KAS instance. + + When an `instance.yaml` exists with a `ports.base`, computes ports + relative to it so multiple instances on different bases don't clash. + """ + instance = self.load_instance() + if instance is not None: + return Ports.get_kas_port(name, base=instance.ports.base) return Ports.get_kas_port(name) + def load_instance(self): + """Load the per-instance manifest, or return None when not present.""" + if not self.has_instance(): + return None + from otdf_sdk_mgr.schema import load_instance as _load + + return _load(self.instance_yaml) + def get_kas_config_path(self, name: str) -> Path: """Get config file path for a KAS instance.""" + if self.has_instance(): + return self.instance_dir / "kas" / name / "opentdf.yaml" return self.config_dir / f"kas-{name}.yaml" def get_kas_log_path(self, name: str) -> Path: @@ -163,6 +251,8 @@ def ensure_directories(self) -> None: self.logs_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True) self.keys_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + if self.has_instance(): + (self.instance_dir / "kas").mkdir(parents=True, exist_ok=True) @lru_cache diff --git a/otdf-local/src/otdf_local/services/kas.py b/otdf-local/src/otdf_local/services/kas.py index 0b7adfa64..d06350e77 100644 --- a/otdf-local/src/otdf_local/services/kas.py +++ b/otdf-local/src/otdf_local/services/kas.py @@ -35,7 +35,7 @@ def name(self) -> str: @property def port(self) -> int: - return Ports.get_kas_port(self._kas_name) + return self.settings.get_kas_port(self._kas_name) @property def service_type(self) -> ServiceType: @@ -47,25 +47,60 @@ def health_url(self) -> str: @property def is_key_management(self) -> bool: - """Check if this is a key management KAS instance.""" + """Check if this is a key management KAS instance. + + When an instance.yaml pins this KAS, prefer the manifest's `mode` + field. Otherwise fall back to the legacy name-based heuristic. + """ + instance = self.settings.load_instance() + if instance is not None and self._kas_name in instance.kas: + return instance.kas[self._kas_name].mode == "key_management" return Ports.is_km_kas(self._kas_name) + def _instance_paths(self) -> tuple[Path, Path] | None: + """Return (binary, worktree) for an instance-pinned KAS, or None.""" + instance = self.settings.load_instance() + if instance is None: + return None + pin = instance.kas.get(self._kas_name) + if pin is None or pin.dist is None: + return None + binary = self.settings.platform_binary_for(pin.dist) + if not binary.exists(): + raise FileNotFoundError( + f"KAS {self._kas_name} binary not found at {binary}. " + f"Run `otdf-sdk-mgr install release platform:{pin.dist}`." + ) + worktree = binary.parent + version_file = binary.parent / ".version" + if version_file.exists(): + for line in version_file.read_text().splitlines(): + if line.startswith("worktree="): + worktree = Path(line.split("=", 1)[1].strip()) + break + return binary, worktree + def _generate_config(self) -> Path: """Generate the KAS config file from template.""" + instance_paths = self._instance_paths() + if instance_paths is not None: + _, worktree = instance_paths + platform_dir = worktree + else: + platform_dir = self.settings._require_platform_dir() + config_path = self.settings.get_kas_config_path(self._kas_name) - template_path = self.settings.kas_template_config + config_path.parent.mkdir(parents=True, exist_ok=True) + template_path = platform_dir / "opentdf-kas-mode.yaml" # Load platform config to get root_key platform_config = load_yaml(self.settings.platform_config) root_key = get_nested(platform_config, "services.kas.root_key", "") # Detect platform features to determine supported config options - features = PlatformFeatures.detect(self.settings.platform_dir) - - # Use stderr if supported, otherwise stdout (v0.9.0 only supports stdout) + features = PlatformFeatures.detect(platform_dir) logger_output = "stderr" if features.supports("logger_stderr") else "stdout" - # Base updates for all KAS instances updates = { "logger.type": "json", "logger.output": logger_output, @@ -73,44 +108,43 @@ def _generate_config(self) -> Path: "services.kas.root_key": root_key, } - # Key management KAS instances need additional config + # Per-KAS features from instance.yaml override the legacy heuristic. + instance = self.settings.load_instance() + kas_pin = instance.kas.get(self._kas_name) if instance is not None else None + extra_features: dict[str, bool] = dict(kas_pin.features) if kas_pin is not None else {} + if self.is_key_management: updates["services.kas.preview.key_management"] = True updates["services.kas.preview.ec_tdf_enabled"] = True - # registered_kas_uri should NOT have /kas suffix updates["services.kas.registered_kas_uri"] = f"http://localhost:{self.port}" + for feature_key, feature_val in extra_features.items(): + updates[f"services.kas.preview.{feature_key}"] = feature_val + copy_yaml_with_updates(template_path, config_path, updates) return config_path def start(self) -> bool: """Start the KAS instance.""" - # Ensure directories exist self.settings.ensure_directories() - - # Kill any existing process on the port kill_process_on_port(self.port) - - # Generate config config_path = self._generate_config() - # Build the command - cmd = [ - "go", - "run", - "./service", - "start", - "--config-file", - str(config_path), - ] - - # Start the process + instance_paths = self._instance_paths() + if instance_paths is not None: + binary, worktree = instance_paths + cmd = [str(binary), "start", "--config-file", str(config_path)] + cwd = worktree + else: + cmd = ["go", "run", "./service", "start", "--config-file", str(config_path)] + cwd = self.settings._require_platform_dir() + log_file = self.settings.get_kas_log_path(self._kas_name) self._process = self._process_manager.start( name=self.name, cmd=cmd, - cwd=self.settings.platform_dir, + cwd=cwd, log_file=log_file, env={"OPENTDF_LOG_LEVEL": "info"}, ) @@ -148,7 +182,12 @@ def get_info(self) -> ServiceInfo: class KASManager: - """Manages all KAS instances.""" + """Manages KAS instances. + + When an `instance.yaml` is loaded, the managed set is restricted to the + KAS names listed in the manifest. Otherwise the legacy full set + (alpha/beta/gamma/delta/km1/km2) is managed. + """ def __init__( self, @@ -159,8 +198,13 @@ def __init__( self._process_manager = process_manager or ProcessManager() self._instances: dict[str, KASService] = {} - # Create instances for all configured KAS - for kas_name in Ports.all_kas_names(): + instance = settings.load_instance() + if instance is not None and instance.kas: + kas_names = list(instance.kas.keys()) + else: + kas_names = Ports.all_kas_names() + + for kas_name in kas_names: self._instances[kas_name] = KASService( settings, kas_name, self._process_manager ) @@ -184,17 +228,19 @@ def stop_all(self) -> dict[str, bool]: return results def start_standard(self) -> dict[str, bool]: - """Start only standard (non-km) KAS instances.""" + """Start only standard (non-key-management) KAS instances under management.""" results = {} - for name in Ports.standard_kas_names(): - results[name] = self._instances[name].start() + for name, inst in self._instances.items(): + if not inst.is_key_management: + results[name] = inst.start() return results def start_km(self) -> dict[str, bool]: - """Start only key management KAS instances.""" + """Start only key-management KAS instances under management.""" results = {} - for name in Ports.km_kas_names(): - results[name] = self._instances[name].start() + for name, inst in self._instances.items(): + if inst.is_key_management: + results[name] = inst.start() return results def get_all_info(self) -> list[ServiceInfo]: diff --git a/otdf-local/src/otdf_local/services/platform.py b/otdf-local/src/otdf_local/services/platform.py index 15f7f4e5e..aa65dcf1d 100644 --- a/otdf-local/src/otdf_local/services/platform.py +++ b/otdf-local/src/otdf_local/services/platform.py @@ -39,6 +39,9 @@ def name(self) -> str: @property def port(self) -> int: + instance = self.settings.load_instance() + if instance is not None: + return Ports.platform_port_for(instance.ports.base) return Ports.PLATFORM @property @@ -49,13 +52,46 @@ def service_type(self) -> ServiceType: def health_url(self) -> str: return f"http://localhost:{self.port}/healthz" + def _instance_dist_paths(self) -> tuple[Path, Path] | None: + """Return (binary, worktree) for an instance-pinned platform, or None. + + The platform binary is at `xtest/platform/dist//service` and its + `.version` file records the source worktree path that should be used + as `cwd` so the binary finds its embedded resources. + """ + instance = self.settings.load_instance() + if instance is None or instance.platform.dist is None: + return None + binary = self.settings.platform_binary_for(instance.platform.dist) + if not binary.exists(): + raise FileNotFoundError( + f"Platform binary not found at {binary}. " + f"Run `otdf-sdk-mgr install release platform:{instance.platform.dist}` " + f"or `otdf-sdk-mgr install scenario` to provision it." + ) + worktree = binary.parent # safe fallback + version_file = binary.parent / ".version" + if version_file.exists(): + for line in version_file.read_text().splitlines(): + if line.startswith("worktree="): + worktree = Path(line.split("=", 1)[1].strip()) + break + return binary, worktree + def _generate_config(self) -> Path: """Generate the platform config file from template.""" + instance_paths = self._instance_dist_paths() + if instance_paths is not None: + _, worktree = instance_paths + platform_dir = worktree + else: + platform_dir = self.settings._require_platform_dir() + config_path = self.settings.platform_config - template_path = self.settings.platform_template_config + template_path = platform_dir / "opentdf.yaml" # Detect platform features to determine supported config options - features = PlatformFeatures.detect(self.settings.platform_dir) + features = PlatformFeatures.detect(platform_dir) # Use stderr if supported, otherwise stdout (v0.9.0 only supports stdout) logger_output = "stderr" if features.supports("logger_stderr") else "stdout" @@ -80,10 +116,14 @@ def _setup_golden_keys(self, config_path: Path) -> None: Extracts keys from extra-keys.json and adds them to the platform config so legacy golden TDFs can be decrypted. """ - # Set up golden key files and get their config entries + # In multi-instance mode, golden keys live alongside the instance + # config; otherwise they go into the legacy platform_dir. + target_dir = self.settings.keys_dir if self.settings.has_instance() else ( + self.settings._require_platform_dir() + ) golden_keys = setup_golden_keys( self.settings.xtest_root, - self.settings.platform_dir, + target_dir, ) if not golden_keys: @@ -112,15 +152,16 @@ def start(self) -> bool: # Generate config config_path = self._generate_config() - # Build the command - cmd = [ - "go", - "run", - "./service", - "start", - "--config-file", - str(config_path), - ] + # Build the command — pinned binary when an instance is loaded, + # legacy `go run ./service` otherwise. + instance_paths = self._instance_dist_paths() + if instance_paths is not None: + binary, worktree = instance_paths + cmd = [str(binary), "start", "--config-file", str(config_path)] + cwd = worktree + else: + cmd = ["go", "run", "./service", "start", "--config-file", str(config_path)] + cwd = self.settings._require_platform_dir() # Start the process log_file = self.settings.logs_dir / "platform.log" @@ -128,7 +169,7 @@ def start(self) -> bool: self._process = self._process_manager.start( name=self.name, cmd=cmd, - cwd=self.settings.platform_dir, + cwd=cwd, log_file=log_file, env={"OPENTDF_LOG_LEVEL": "info"}, ) diff --git a/otdf-local/src/otdf_local/utils/keys.py b/otdf-local/src/otdf_local/utils/keys.py index dee84f2af..79b58bf08 100644 --- a/otdf-local/src/otdf_local/utils/keys.py +++ b/otdf-local/src/otdf_local/utils/keys.py @@ -197,7 +197,9 @@ def setup_golden_keys( f"Missing required fields in extra-keys.json for kid: {kid}" ) - # Write key files to platform directory + # Write key files into the target directory (platform_dir for legacy + # single-instance, or the per-instance keys dir for multi-instance). + platform_dir.mkdir(parents=True, exist_ok=True) private_path = platform_dir / f"{kid}-private.pem" cert_path = platform_dir / f"{kid}-cert.pem" @@ -205,12 +207,14 @@ def setup_golden_keys( private_path.chmod(0o600) cert_path.write_text(cert) + # Use absolute paths so the platform binary finds them regardless of + # its working directory (worktree in multi-instance mode). keys_config.append( { "kid": kid, "alg": alg, - "private": f"{kid}-private.pem", - "cert": f"{kid}-cert.pem", + "private": str(private_path.resolve()), + "cert": str(cert_path.resolve()), } ) diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py new file mode 100644 index 000000000..e290d7731 --- /dev/null +++ b/otdf-local/tests/test_multi_instance.py @@ -0,0 +1,78 @@ +"""Smoke tests for the multi-instance refactor. + +These tests exercise the path resolution and port arithmetic without +requiring a real platform build or running services. The goal is to catch +regressions in the wiring between `otdf-sdk-mgr.schema`, `Settings`, and the +service launchers. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from otdf_sdk_mgr.schema import ( + Instance, + KasPin, + Metadata, + PlatformPin, + PortsConfig, + dump_instance, +) + +from otdf_local.config.ports import Ports +from otdf_local.config.settings import Settings + + +def test_ports_offset_layout_at_default_base() -> None: + assert Ports.platform_port_for(8080) == 8080 + assert Ports.get_kas_port("alpha", base=8080) == 8181 + assert Ports.get_kas_port("km2", base=8080) == 8686 + + +def test_ports_offset_layout_at_alternate_base() -> None: + assert Ports.platform_port_for(9080) == 9080 + assert Ports.get_kas_port("alpha", base=9080) == 9181 + assert Ports.get_kas_port("km1", base=9080) == 9585 + + +def test_settings_default_has_no_instance(tmp_path: Path) -> None: + fake_xtest = tmp_path / "xtest" + fake_xtest.mkdir() + s = Settings(xtest_root=fake_xtest, platform_dir=None) + assert s.instance_name == "default" + assert not s.has_instance() + + +def test_settings_loads_instance_when_present(tmp_path: Path) -> None: + fake_xtest = tmp_path / "xtest" + fake_xtest.mkdir() + instances_root = tmp_path / "instances" + instance_dir = instances_root / "demo" + instance_dir.mkdir(parents=True) + dump_instance( + Instance( + metadata=Metadata(name="demo"), + platform=PlatformPin(dist="v0.9.0"), + ports=PortsConfig(base=9080), + kas={"alpha": KasPin(dist="v0.9.0", mode="standard")}, + ), + instance_dir / "instance.yaml", + ) + s = Settings(xtest_root=fake_xtest, platform_dir=None, instance_name="demo") + assert s.has_instance() + inst = s.load_instance() + assert inst is not None + assert inst.ports.base == 9080 + # Per-instance port arithmetic + assert s.get_kas_port("alpha") == 9181 + # Per-instance directory layout + assert s.logs_dir == instance_dir / "logs" + assert s.keys_dir == instance_dir / "keys" + + +def test_platform_binary_for_resolves_under_xtest_platform(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTDF_PLATFORM_DIR", "/tmp/fake-platform") + s = Settings() + assert s.platform_binary_for("v0.9.0") == Path("/tmp/fake-platform/dist/v0.9.0/service") diff --git a/otdf-local/uv.lock b/otdf-local/uv.lock index f1b9d2423..d781cc92c 100644 --- a/otdf-local/uv.lock +++ b/otdf-local/uv.lock @@ -54,6 +54,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -145,6 +169,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "otdf-sdk-mgr" }, { name = "pydantic-settings" }, { name = "rich" }, { name = "ruamel-yaml" }, @@ -161,6 +186,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, + { name = "otdf-sdk-mgr", editable = "../otdf-sdk-mgr" }, { name = "pydantic-settings", specifier = ">=2.2.0" }, { name = "rich", specifier = ">=13.7.0" }, { name = "ruamel-yaml", specifier = ">=0.18.0" }, @@ -174,6 +200,34 @@ dev = [ { name = "ruff", specifier = ">=0.9.0" }, ] +[[package]] +name = "otdf-sdk-mgr" +version = "0.1.0" +source = { editable = "../otdf-sdk-mgr" } +dependencies = [ + { name = "gitpython" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "ruamel-yaml" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "gitpython", specifier = ">=3.1.50" }, + { name = "pydantic", specifier = ">=2.6.0" }, + { name = "rich", specifier = ">=13.7.0" }, + { name = "ruamel-yaml", specifier = ">=0.18.0" }, + { name = "typer", specifier = ">=0.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.9.0" }, +] + [[package]] name = "packaging" version = "26.0" @@ -421,6 +475,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "typer" version = "0.21.1" From fe5c8ae57c84b904339ae3b5a47d24f589ec476c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 11:52:51 -0400 Subject: [PATCH 08/16] feat(xtest): --scenario and --instance flags for conftest (DSPX-3302) Adds two new pytest CLI options so xtest can be driven by a scenarios.yaml and run against a specific otdf-local instance. --scenario PATH When set, defaults --sdks-encrypt, --sdks-decrypt, and --containers from the scenario's `sdks` and `suite` blocks. Options explicitly passed on the CLI always override. --instance NAME Propagated to OTDF_LOCAL_INSTANCE_NAME so child `otdf-local` invocations within the test see the same instance the scenario expects. If otdf-sdk-mgr is not installed (minimal pytest environments), the --scenario flag silently no-ops via an ImportError guard. The flag shape is invariant either way so CI configs don't fork. This is the consumer side of the PR 3 / scenario-driven flow: the authoritative entry point remains `otdf-local scenario run `, which sets these flags for you; this PR lets pytest accept them directly when running scenario-aware sessions outside the wrapper. Refs: https://virtru.atlassian.net/browse/DSPX-3302 Co-Authored-By: Claude Opus 4.7 (1M context) --- xtest/conftest.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/xtest/conftest.py b/xtest/conftest.py index eaa88c342..eb2a4be95 100644 --- a/xtest/conftest.py +++ b/xtest/conftest.py @@ -78,6 +78,16 @@ def sdk_spec_type(v: str) -> str: def pytest_addoption(parser: pytest.Parser): """Add custom CLI options for pytest.""" + parser.addoption( + "--scenario", + help="path to scenarios.yaml; --sdks-encrypt/--sdks-decrypt/--containers default from it", + type=Path, + ) + parser.addoption( + "--instance", + help="otdf-local instance name; sets OTDF_LOCAL_INSTANCE_NAME for child tooling", + type=str, + ) parser.addoption( "--audit-log-dir", help="directory to write audit logs on test failure (default: tmp/audit-logs)", @@ -130,6 +140,57 @@ def pytest_addoption(parser: pytest.Parser): ) +def pytest_configure(config: pytest.Config) -> None: + """Apply --scenario defaults and --instance env-var threading. + + When `--scenario PATH` is given, missing `--sdks-encrypt`, `--sdks-decrypt`, + and `--containers` options are populated from the scenario file. Options + explicitly passed on the CLI always win. `--instance NAME` is propagated + via `OTDF_LOCAL_INSTANCE_NAME` so any child `otdf-local` invocation sees + the same instance. + """ + import os + + instance = config.getoption("--instance") + if instance: + os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance + + scenario_path = config.getoption("--scenario") + if not scenario_path: + return + try: + from otdf_sdk_mgr.schema import ( + installed_json_for, + load_scenario, + scenario_to_pytest_sdks, + ) + except ImportError: + # otdf-sdk-mgr may not be installed in a minimal pytest env. + return + scenario = load_scenario(scenario_path) + # `sdk@` tokens come from the install record so they match the + # dist directories #446's parser walks under `xtest/sdk//dist/`. + # If the user passed --sdks-encrypt / --sdks-decrypt explicitly, their + # tokens win and we skip the resolution step entirely. + need_resolve = ( + (not config.getoption("--sdks-encrypt") and scenario.sdks.encrypt) + or (not config.getoption("--sdks-decrypt") and scenario.sdks.decrypt) + ) + if need_resolve: + try: + tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path)) + except FileNotFoundError as e: + raise pytest.UsageError(str(e)) from e + if not config.getoption("--sdks-encrypt") and tokens["encrypt"]: + config.option.sdks_encrypt = " ".join(tokens["encrypt"]) + if not config.getoption("--sdks-decrypt") and tokens["decrypt"]: + config.option.sdks_decrypt = " ".join(tokens["decrypt"]) + if not config.getoption("--containers") and scenario.suite.containers: + config.option.containers = scenario.suite.containers + if not instance and scenario.instance.metadata.name: + os.environ["OTDF_LOCAL_INSTANCE_NAME"] = scenario.instance.metadata.name + + def pytest_generate_tests(metafunc: pytest.Metafunc): """Dynamically parametrize test functions based on CLI options. From 519e27b63b6fe2a2a8477d6a6b1223cc4b2434eb Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 11:54:44 -0400 Subject: [PATCH 09/16] feat(.claude): bug-repro plugin for OpenTDF (DSPX-3302) Adds five Claude Code skills under tests/.claude/skills/ that together turn a Jira bug ticket into a running reproduction, plus a downstream- installable plugin manifest under .claude/plugin/. Why --- The end-to-end goal of DSPX-3302 is to make bug reproduction approachable for QA, downstream-product engineers, and CI. PRs 1-4 build the plumbing (shared schema, platform installer, multi-instance otdf-local, xtest conftest hooks). This PR is the user-facing surface: a Claude can pull context from Jira, draft an xtest/scenarios/.yaml (and, when needed, an xtest/bug__test.py), bring the environment up at the right version pins, run the scenario's pytest selection, and tear down. Skills ------ scenario-from-bug-report Pulls the Jira issue and its comments via `acli jira workitem view --fields '*all' --json` and `acli jira workitem comment list`, extracts version pins / KAS topology / container type / feature flags, then writes xtest/scenarios/.yaml validated against otdf_sdk_mgr.schema.Scenario. Drafts a new xtest/bug__test.py only when no existing pytest covers the case; never silently lands assertions. scenario-up Runs `otdf-sdk-mgr install scenario`, then `otdf-local instance init --from-scenario`, then `otdf-local --instance up`, and polls status until healthy. Surfaces logs rather than retrying blindly when something stays unhealthy. scenario-run Invokes `otdf-local scenario run ` and classifies the result: "bug reproduced" / "not reproduced" / "unrelated failure". Cites the evidence line and points at per-service logs. scenario-tear-down Stops the instance and optionally removes the directory after explicit user confirmation. instance-status Lists known instances, their port bases, health, and flags port collisions. Jira-safety ----------- Permissions in both .claude/settings.json and the plugin manifest allow only read+comment via acli jira: workitem view, workitem search, workitem comment list, workitem comment create, plus a handful of read-only project/board/sprint queries. edit, delete, transition, assign, archive, link create, watcher add are all denied. The plugin.json carries a permission_notes block explaining the policy. Plugin manifest --------------- .claude/plugin/plugin.json declares the skill names, runtime requirements (uv, go, git, docker, acli), and the canonical permission allowlist, so downstream first/third-party integrators can install this plugin into their own Claude Code setups. Refs: https://virtru.atlassian.net/browse/DSPX-3302 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/plugin/plugin.json | 38 ++++++ .claude/settings.json | 32 +++++ .claude/skills/instance-status/SKILL.md | 36 ++++++ .../skills/scenario-from-bug-report/SKILL.md | 111 ++++++++++++++++++ .claude/skills/scenario-run/SKILL.md | 43 +++++++ .claude/skills/scenario-tear-down/SKILL.md | 42 +++++++ .claude/skills/scenario-up/SKILL.md | 51 ++++++++ 7 files changed, 353 insertions(+) create mode 100644 .claude/plugin/plugin.json create mode 100644 .claude/settings.json create mode 100644 .claude/skills/instance-status/SKILL.md create mode 100644 .claude/skills/scenario-from-bug-report/SKILL.md create mode 100644 .claude/skills/scenario-run/SKILL.md create mode 100644 .claude/skills/scenario-tear-down/SKILL.md create mode 100644 .claude/skills/scenario-up/SKILL.md diff --git a/.claude/plugin/plugin.json b/.claude/plugin/plugin.json new file mode 100644 index 000000000..fce6cee4c --- /dev/null +++ b/.claude/plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "opentdf-test-harness", + "version": "0.1.0", + "description": "Skills for reproducing OpenTDF bugs locally via otdf-local and otdf-sdk-mgr. Pulls bug context from Jira (acli), provisions pinned platform/KAS/SDK versions, runs the xtest pytest suite, and tears down. Useful for QA, platform/SDK developers, and downstream first/third-party integrators.", + "skills_dir": "../skills", + "skills": [ + "scenario-from-bug-report", + "scenario-up", + "scenario-run", + "scenario-tear-down", + "instance-status" + ], + "requirements": [ + "uv (python package manager) on PATH", + "go toolchain (platform binaries are built from source)", + "git (for worktrees of opentdf/platform)", + "docker (for keycloak/postgres dependencies)", + "acli (Atlassian CLI; needed for the scenario-from-bug-report skill)" + ], + "permissions": { + "allow": [ + "Bash(uv run otdf-local *)", + "Bash(uv run otdf-sdk-mgr *)", + "Bash(uv run pytest *)", + "Bash(acli jira workitem view *)", + "Bash(acli jira workitem search *)", + "Bash(acli jira workitem comment list *)", + "Bash(acli jira workitem comment create *)", + "Bash(acli jira workitem attachment list *)", + "Bash(acli jira workitem link list *)", + "Bash(acli jira project view *)", + "Write(xtest/scenarios/**)", + "Write(xtest/bug_*_test.py)", + "Write(tests/instances/**)" + ] + }, + "permission_notes": "acli jira write-paths intentionally excluded: edit/delete/transition/assign/archive/clone/create/create-bulk/link create/watcher add/comment update/comment delete. Add them explicitly via .claude/settings.local.json if your team needs them; the default plugin is read+comment only." +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..a1dba2d80 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "allow": [ + "Bash(uv run otdf-local *)", + "Bash(uv run otdf-sdk-mgr *)", + "Bash(uv run pytest *)", + "Bash(uv sync *)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git show *)", + "Bash(gh api *)", + "Bash(gh issue view *)", + "Bash(gh pr view *)", + "Bash(gh run *)", + "Bash(acli jira workitem view *)", + "Bash(acli jira workitem search *)", + "Bash(acli jira workitem comment list *)", + "Bash(acli jira workitem comment create *)", + "Bash(acli jira workitem attachment list *)", + "Bash(acli jira workitem link list *)", + "Bash(acli jira workitem watcher list *)", + "Bash(acli jira project view *)", + "Bash(acli jira board view *)", + "Bash(acli jira sprint view *)", + "Write(xtest/scenarios/**)", + "Write(xtest/bug_*_test.py)", + "Write(tests/instances/**)", + "Write(.claude/tmp/**)" + ] + } +} diff --git a/.claude/skills/instance-status/SKILL.md b/.claude/skills/instance-status/SKILL.md new file mode 100644 index 000000000..64bd545a0 --- /dev/null +++ b/.claude/skills/instance-status/SKILL.md @@ -0,0 +1,36 @@ +--- +name: instance-status +description: Report which test instances exist on disk, which are running, and the health of each service. Use when the user asks "what's running" or before bringing up another scenario to avoid port collisions. +allowed-tools: Bash, Read +--- + +# instance-status + +You give the user a snapshot of all test instances in this checkout: what's defined, what's running, and whether each service is healthy. + +## Process + +1. **List instances on disk**: + + ```bash + uv run otdf-local instance ls --json + ``` + + Each entry includes `name`, `platform` version, `ports_base`, and the `kas:` keys. Flag any two instances that share a `ports_base` — they cannot run concurrently. + +2. **For each instance**, check service status: + + ```bash + uv run otdf-local --instance status --json + ``` + + Each service reports `running`, `healthy`, and the bound port. Don't run all instances in parallel — iterate; a status query is cheap. + +3. **Summarize**: + - A short table per instance: service → port → state. + - Flag any unhealthy service with the path to its log (e.g. `tests/instances//logs/kas-alpha.log`). + - Mention port conflicts if two instances would collide on `ports.base`. + +## When ports collide + +`otdf-local instance init` warns about this at creation time but does not enforce it. If you see two instances with the same `ports_base`, recommend the user reassign one via `uv run otdf-local instance init --from-scenario --ports-base ` (or hand-edit the `instance.yaml`). diff --git a/.claude/skills/scenario-from-bug-report/SKILL.md b/.claude/skills/scenario-from-bug-report/SKILL.md new file mode 100644 index 000000000..3c28144ff --- /dev/null +++ b/.claude/skills/scenario-from-bug-report/SKILL.md @@ -0,0 +1,111 @@ +--- +name: scenario-from-bug-report +description: Pull a Jira bug into context (via `acli jira workitem view`) and turn it into an xtest/scenarios/.yaml manifest, optionally drafting xtest/bug__test.py when no existing pytest covers it. Use when the user mentions a Jira issue key like DSPX-1234 (or another [PROJECT]-[NUMBER] format) and asks for a reproducer. +allowed-tools: Bash, Read, Write, Grep, Glob +--- + +# scenario-from-bug-report + +Bugs are tracked in Jira. The user will reference an issue by its key in the form `[PROJECT]-[NUMBER]` — examples: `DSPX-3302`, `DSPX-1234`. `DSPX` is the current project's prefix but the prefix can change (e.g. `OPS-`, `SDK-`); accept any short uppercase prefix. + +You produce two artifacts the rest of the toolchain consumes: + +1. `xtest/scenarios/.yaml` — validated against `otdf_sdk_mgr.schema.Scenario`. +2. (Optional) `xtest/bug__test.py` — only if no existing xtest pytest already exercises the bug. + +The Jira key also becomes the working **branch name** (`-repro` if a fresh branch is needed) and the scenario file's `metadata.id`. + +## Step 1 — Pull the Jira issue into context + +Always start by fetching the full issue content. Don't proceed on the user's free-text summary alone — the issue body has the version pins and reproduction details you need. + +```bash +acli jira workitem view --fields '*all' --json +acli jira workitem comment list +``` + +The first command's JSON output includes `summary`, `description`, `status`, and labels. The second lists comments. Extract: + +- The **summary** (becomes scenario `metadata.title`). +- The **description** (read carefully — version numbers, KAS topology, container types, and feature flags typically live here). +- Recent **comments** — reproductions and "what changed" notes often appear in comments rather than the original description. + +If the issue references attached logs, screenshots, or linked PRs, list them via `acli jira workitem attachment list ` and `acli jira workitem link list ` and mention them in your reply. + +**Permitted Jira writes**: only `acli jira workitem comment create ...` (to post a reproduction-status update if the user asks). Everything else — `edit`, `transition`, `assign`, `archive`, `delete`, `link create`, `watcher add` — is explicitly disallowed by the plugin's permissions; if the user wants those actions, instruct them to run the command themselves. + +## Step 2 — Identify the scenario inputs + +From the issue text, extract: + +- **Encrypt-side SDKs** — which SDKs *create* the TDF? (`go`, `java`, `js`). Pin versions. +- **Decrypt-side SDKs** — which SDKs *consume* the TDF? Pin versions. +- **Platform version** — git tag like `v0.9.0` (resolves to the `service/v0.9.0` tag in `opentdf/platform`). +- **KAS topology** — which KAS instances must be running (`alpha`, `beta`, `gamma`, `delta`, `km1`, `km2`) and whether any need a different pinned version than the platform. +- **Container type** — `ztdf`, `ztdf-ecwrap`, `nano`, or `nano-with-policy`. +- **Feature flags** — e.g. `ec_tdf_enabled`. +- **Expected vs actual behavior** — copy concise prose from the issue. + +If anything is ambiguous in the Jira issue, ask the user — don't guess at versions. + +## Step 3 — Pick the id and (optionally) the branch + +- `metadata.id = ` — e.g. `DSPX-3302` → `dspx-3302`. +- Scenario file path: `xtest/scenarios/.yaml`. +- If you need a new git branch, propose `-repro` (e.g. `DSPX-3302-repro`) and let the user confirm before switching. + +## Step 4 — Search for an existing pytest + +```bash +grep -rn "" xtest/test_*.py +``` + +Likely candidates: `test_tdfs.py` (roundtrip), `test_abac.py` (ABAC), `test_legacy.py` (golden), `test_pqc.py`. If a test already asserts the relevant behavior, reuse it — only the scenario changes, not the code. + +## Step 5 — Write `xtest/scenarios/.yaml` + +Exact field shape (the schema rejects unknown fields): + +```yaml +apiVersion: opentdf.io/v1alpha1 +kind: Scenario +metadata: + id: + title: "" + created: +instance: + metadata: { name: } + platform: { dist: } + ports: { base: } + kas: + : { dist: , mode: standard } # or mode: key_management +sdks: + encrypt: + : { version: } + decrypt: + : { version: } +suite: + select: "" + containers: + # markers: "not slow" + # extra_args: ["--no-audit-logs"] +expected: "" +actual: "" +``` + +Validate before reporting success: + +```bash +uv run python -m otdf_sdk_mgr.schema validate xtest/scenarios/.yaml +``` + +## Step 6 — If no existing test fits + +Draft `xtest/bug__test.py` using the `encrypt_sdk` / `decrypt_sdk` fixtures (pattern: `xtest/test_tdfs.py`). Surface the new file in your reply for the user to review — never silently land assertions. + +## Notes + +- `sdks.encrypt` and `sdks.decrypt` map to xtest's `--sdks-encrypt` / `--sdks-decrypt`. After PR #446 those pytest options take `sdk@version` specifiers like `go@v0.24.0`, `go@main`, or `go@*`. **Do NOT write those tokens in the YAML** — write a normal `{ version: lts }` (or any version string `otdf-sdk-mgr resolve` accepts: `v0.24.0`, `main`, an SDK-specific SHA, etc.). The `scenario-up` skill runs `otdf-sdk-mgr install scenario`, which records the resolved dist directory names in `xtest/scenarios/.installed.json`; the bridge layers (`otdf-local scenario run` and pytest's `--scenario` default in `xtest/conftest.py`) read that file to emit the right `sdk@` tokens. If you forget the install step, those commands fail with `.installed.json not found — run otdf-sdk-mgr install scenario first`. +- List the same SDK in both `encrypt` and `decrypt` maps to reproduce xtest's legacy "all pairs" mode. Listing it on only one side keeps the scenario focused (a→b without b→a). +- `instance.platform.dist` and each `kas..dist` need `otdf-sdk-mgr install scenario ` (or `install release platform:`) to have built the binary first. `scenario-up` handles that downstream. +- One-line summary when done: report the scenario path, the new test file (if any), and the Jira link `https://virtru.atlassian.net/browse/` so the user can cross-reference. diff --git a/.claude/skills/scenario-run/SKILL.md b/.claude/skills/scenario-run/SKILL.md new file mode 100644 index 000000000..633846cf4 --- /dev/null +++ b/.claude/skills/scenario-run/SKILL.md @@ -0,0 +1,43 @@ +--- +name: scenario-run +description: Execute the pytest suite declared by a scenarios.yaml against the running instance, then classify the result as "bug reproduced", "not reproduced", or "unrelated failure". Use after `scenario-up` has confirmed the instance is healthy. +allowed-tools: Bash, Read +--- + +# scenario-run + +You run the pytest selection declared by the scenario's `suite` block against the running instance and interpret the result in terms of the bug being investigated. + +## Inputs + +- Path to the scenario YAML (`xtest/scenarios/.yaml`). +- (Optional) the user's expected outcome, if the scenario's `expected:` field is sparse. + +## Process + +1. **Invoke the runner**: + + ```bash + uv run otdf-local scenario run xtest/scenarios/.yaml + ``` + + This translates the scenario's `suite.select`, `suite.containers`, `suite.markers`, and `sdks.{encrypt,decrypt}` into the equivalent `pytest --sdks-encrypt ... --sdks-decrypt ... --containers ...` invocation under `xtest/` with `OTDF_LOCAL_INSTANCE_NAME` set. SDK tokens are emitted in xtest's `sdk@version` form (see PR #446) — the resolved version names come from the sibling `.installed.json` that `otdf-sdk-mgr install scenario` writes. + + If `scenario run` exits with `Error: .installed.json not found`, the user skipped the install step. Tell them to run `uv run otdf-sdk-mgr install scenario ` (or re-run `scenario-up`) before retrying. + +2. **Capture exit code and tail of output**. The pytest output is the source of truth; don't re-interpret. + +3. **Classify**: + - **Bug reproduced** — the test failed with an assertion or stderr that matches the scenario's `actual:` field. Cite the matching line. + - **Bug NOT reproduced** — the test passed. This is meaningful: either the bug is fixed at this version combination, or the scenario doesn't capture it precisely yet. Suggest the user widen the assertion or pick a different version pin. + - **Unrelated failure** — pytest errored out (collection error, environment issue, import error, timeout). Don't claim repro success or failure; report the error and recommend a next diagnostic step. + +4. **Record artifacts**. The pytest run leaves logs under `tests/instances//logs/`. List the relevant log files in your reply so the user can attach them to the Jira ticket. + +## Output format + +One-line headline (`bug reproduced` / `not reproduced` / `unrelated failure`), then a short bulleted summary: +- `select:` the pytest selector +- `exit_code:` the return value +- `evidence:` 1-2 lines from the output that justify the classification +- `logs:` paths to the relevant per-service logs diff --git a/.claude/skills/scenario-tear-down/SKILL.md b/.claude/skills/scenario-tear-down/SKILL.md new file mode 100644 index 000000000..39398c766 --- /dev/null +++ b/.claude/skills/scenario-tear-down/SKILL.md @@ -0,0 +1,42 @@ +--- +name: scenario-tear-down +description: Stop the services for a scenario's instance and optionally delete the instance directory. Use when the user is done with a reproduction or wants to free ports/disk for a different scenario. +allowed-tools: Bash, Read +--- + +# scenario-tear-down + +You stop a running scenario cleanly and optionally remove its on-disk state. + +## Inputs + +- The instance name (typically the lowercased Jira key, e.g. `dspx-3302`). If the user passes the scenario YAML path instead, read its `instance.metadata.name`. +- Whether the user wants the instance directory preserved (default: yes — keep it for re-runs). + +## Process + +1. **Stop services**: + + ```bash + uv run otdf-local --instance down + ``` + + The `down` command halts the platform process, all KAS instances under management, and the docker dependencies (keycloak, postgres) — unless another instance is still using them, in which case docker is left running. + +2. **Optionally clean state**. Only if the user explicitly asked to remove: + + ```bash + uv run otdf-local instance rm -y + ``` + + This deletes `tests/instances//` including its `logs/`, `keys/`, and per-KAS configs. The platform binary at `xtest/platform/dist//service` is shared and is NOT removed (`otdf-sdk-mgr clean --dist-only` is the right command if the user wants to free that too). + +3. **Confirm port range is free** (useful if the user is about to bring up another scenario on the same base): + + ```bash + uv run otdf-local instance ls --json + ``` + +## Caution + +Never remove an instance without explicit user confirmation. The directory may contain golden keys or generated configs that took time to assemble. If unsure, leave it. diff --git a/.claude/skills/scenario-up/SKILL.md b/.claude/skills/scenario-up/SKILL.md new file mode 100644 index 000000000..955fce8b9 --- /dev/null +++ b/.claude/skills/scenario-up/SKILL.md @@ -0,0 +1,51 @@ +--- +name: scenario-up +description: Provision artifacts, scaffold the instance directory, and start the test environment for a given xtest/scenarios/.yaml. Use after `scenario-from-bug-report` (or when the user already has a scenario YAML) and wants the environment running. +allowed-tools: Bash, Read +--- + +# scenario-up + +You bring the environment described by a `scenarios.yaml` up and confirm it's healthy. The three steps are non-negotiable; do them in order. + +## Inputs + +- Path to a validated `xtest/scenarios/.yaml`. If the user doesn't provide one, ask. + +## Process + +1. **Install artifacts** — platform binary, per-KAS binaries, helper scripts, and the encrypt+decrypt SDKs declared in the scenario: + + ```bash + uv run otdf-sdk-mgr install scenario xtest/scenarios/.yaml + ``` + + This writes `xtest/scenarios/.installed.json` next to the scenario with the resolved dist paths. The first `go build` per platform version takes ~30-60s; subsequent runs reuse the cached binary. + +2. **Scaffold the instance directory** (creates `tests/instances//`): + + ```bash + uv run otdf-local instance init --from-scenario xtest/scenarios/.yaml + ``` + + If the instance already exists, this is a no-op for the existing files; double-check with `uv run otdf-local instance ls` first to avoid surprising the user. + +3. **Bring it up**: + + ```bash + uv run otdf-local --instance up + ``` + + Then poll status until everything is healthy (don't proceed before this succeeds): + + ```bash + uv run otdf-local --instance status --json + ``` + + If any service stays unhealthy after ~60 seconds, surface the relevant log via `uv run otdf-local --instance logs -n 50` and report the failure mode rather than retrying blindly. + +## Output + +Once healthy, report: +- The instance name and which ports it occupies (look at `instance.yaml`'s `ports.base`). +- The next command the user is likely to run (`scenario-run`). From 16e64bc65d3362c94c345c8c65f134c934af1014 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 15:24:16 -0400 Subject: [PATCH 10/16] =?UTF-8?q?refactor(.claude):=20generalize=20scenari?= =?UTF-8?q?o-from-bug-report=20=E2=86=92=20scenario-from-ticket=20(DSPX-33?= =?UTF-8?q?02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless dogfooding (run-1 on DSPX-2719) showed the bug-only framing was too narrow — the common workflow is writing tests for new features first (TDD), not reproducing version-pinned bugs. - Rename and rewrite the skill to branch on Jira Issue Type. Bug follows the old expected/actual flow; Story/Task uses ref pins (`main`, feature branch, PR SHA via `gh pr view --json headRefOid`) for forward-looking regression gates; Spike bails out rather than fabricating. Mandates `acli workitem comment list` and steers away from cli.sh greps (both were run-1 gaps). - New `scenario-matrix` sibling skill: write N scenario files from a base × N refs (PRs/branches/releases). Schema/installer support was already there via `PlatformPin.source.ref` and `install_platform_source(ref)` — no other changes needed. - `scenario-run` output classification generalized from "bug reproduced / not reproduced" to "expected / unexpected outcome", with explicit branches for bug-repro vs TDD interpretations. - `scenario-up` description and `plugin.json` (description, skills array, requirements) updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/plugin/plugin.json | 8 +- .../skills/scenario-from-bug-report/SKILL.md | 111 ------------ .claude/skills/scenario-from-ticket/SKILL.md | 159 ++++++++++++++++++ .claude/skills/scenario-matrix/SKILL.md | 91 ++++++++++ .claude/skills/scenario-run/SKILL.md | 19 ++- .claude/skills/scenario-up/SKILL.md | 2 +- 6 files changed, 268 insertions(+), 122 deletions(-) delete mode 100644 .claude/skills/scenario-from-bug-report/SKILL.md create mode 100644 .claude/skills/scenario-from-ticket/SKILL.md create mode 100644 .claude/skills/scenario-matrix/SKILL.md diff --git a/.claude/plugin/plugin.json b/.claude/plugin/plugin.json index fce6cee4c..bb4c1311f 100644 --- a/.claude/plugin/plugin.json +++ b/.claude/plugin/plugin.json @@ -1,10 +1,11 @@ { "name": "opentdf-test-harness", "version": "0.1.0", - "description": "Skills for reproducing OpenTDF bugs locally via otdf-local and otdf-sdk-mgr. Pulls bug context from Jira (acli), provisions pinned platform/KAS/SDK versions, runs the xtest pytest suite, and tears down. Useful for QA, platform/SDK developers, and downstream first/third-party integrators.", + "description": "Jira-ticket-driven scenarios for the OpenTDF test harness. Pulls ticket context from Jira (acli) — any ticket type, including bugs, feature stories, and PR-driven work — provisions pinned platform/KAS/SDK versions or refs (released versions, main, feature branches, PR SHAs), runs the xtest pytest suite, and tears down. Useful for QA, platform/SDK developers writing tests for new features first, and downstream first/third-party integrators.", "skills_dir": "../skills", "skills": [ - "scenario-from-bug-report", + "scenario-from-ticket", + "scenario-matrix", "scenario-up", "scenario-run", "scenario-tear-down", @@ -15,7 +16,8 @@ "go toolchain (platform binaries are built from source)", "git (for worktrees of opentdf/platform)", "docker (for keycloak/postgres dependencies)", - "acli (Atlassian CLI; needed for the scenario-from-bug-report skill)" + "acli (Atlassian CLI; needed for the scenario-from-ticket skill)", + "gh (GitHub CLI; needed for scenario-matrix to resolve PR refs)" ], "permissions": { "allow": [ diff --git a/.claude/skills/scenario-from-bug-report/SKILL.md b/.claude/skills/scenario-from-bug-report/SKILL.md deleted file mode 100644 index 3c28144ff..000000000 --- a/.claude/skills/scenario-from-bug-report/SKILL.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: scenario-from-bug-report -description: Pull a Jira bug into context (via `acli jira workitem view`) and turn it into an xtest/scenarios/.yaml manifest, optionally drafting xtest/bug__test.py when no existing pytest covers it. Use when the user mentions a Jira issue key like DSPX-1234 (or another [PROJECT]-[NUMBER] format) and asks for a reproducer. -allowed-tools: Bash, Read, Write, Grep, Glob ---- - -# scenario-from-bug-report - -Bugs are tracked in Jira. The user will reference an issue by its key in the form `[PROJECT]-[NUMBER]` — examples: `DSPX-3302`, `DSPX-1234`. `DSPX` is the current project's prefix but the prefix can change (e.g. `OPS-`, `SDK-`); accept any short uppercase prefix. - -You produce two artifacts the rest of the toolchain consumes: - -1. `xtest/scenarios/.yaml` — validated against `otdf_sdk_mgr.schema.Scenario`. -2. (Optional) `xtest/bug__test.py` — only if no existing xtest pytest already exercises the bug. - -The Jira key also becomes the working **branch name** (`-repro` if a fresh branch is needed) and the scenario file's `metadata.id`. - -## Step 1 — Pull the Jira issue into context - -Always start by fetching the full issue content. Don't proceed on the user's free-text summary alone — the issue body has the version pins and reproduction details you need. - -```bash -acli jira workitem view --fields '*all' --json -acli jira workitem comment list -``` - -The first command's JSON output includes `summary`, `description`, `status`, and labels. The second lists comments. Extract: - -- The **summary** (becomes scenario `metadata.title`). -- The **description** (read carefully — version numbers, KAS topology, container types, and feature flags typically live here). -- Recent **comments** — reproductions and "what changed" notes often appear in comments rather than the original description. - -If the issue references attached logs, screenshots, or linked PRs, list them via `acli jira workitem attachment list ` and `acli jira workitem link list ` and mention them in your reply. - -**Permitted Jira writes**: only `acli jira workitem comment create ...` (to post a reproduction-status update if the user asks). Everything else — `edit`, `transition`, `assign`, `archive`, `delete`, `link create`, `watcher add` — is explicitly disallowed by the plugin's permissions; if the user wants those actions, instruct them to run the command themselves. - -## Step 2 — Identify the scenario inputs - -From the issue text, extract: - -- **Encrypt-side SDKs** — which SDKs *create* the TDF? (`go`, `java`, `js`). Pin versions. -- **Decrypt-side SDKs** — which SDKs *consume* the TDF? Pin versions. -- **Platform version** — git tag like `v0.9.0` (resolves to the `service/v0.9.0` tag in `opentdf/platform`). -- **KAS topology** — which KAS instances must be running (`alpha`, `beta`, `gamma`, `delta`, `km1`, `km2`) and whether any need a different pinned version than the platform. -- **Container type** — `ztdf`, `ztdf-ecwrap`, `nano`, or `nano-with-policy`. -- **Feature flags** — e.g. `ec_tdf_enabled`. -- **Expected vs actual behavior** — copy concise prose from the issue. - -If anything is ambiguous in the Jira issue, ask the user — don't guess at versions. - -## Step 3 — Pick the id and (optionally) the branch - -- `metadata.id = ` — e.g. `DSPX-3302` → `dspx-3302`. -- Scenario file path: `xtest/scenarios/.yaml`. -- If you need a new git branch, propose `-repro` (e.g. `DSPX-3302-repro`) and let the user confirm before switching. - -## Step 4 — Search for an existing pytest - -```bash -grep -rn "" xtest/test_*.py -``` - -Likely candidates: `test_tdfs.py` (roundtrip), `test_abac.py` (ABAC), `test_legacy.py` (golden), `test_pqc.py`. If a test already asserts the relevant behavior, reuse it — only the scenario changes, not the code. - -## Step 5 — Write `xtest/scenarios/.yaml` - -Exact field shape (the schema rejects unknown fields): - -```yaml -apiVersion: opentdf.io/v1alpha1 -kind: Scenario -metadata: - id: - title: "" - created: -instance: - metadata: { name: } - platform: { dist: } - ports: { base: } - kas: - : { dist: , mode: standard } # or mode: key_management -sdks: - encrypt: - : { version: } - decrypt: - : { version: } -suite: - select: "" - containers: - # markers: "not slow" - # extra_args: ["--no-audit-logs"] -expected: "" -actual: "" -``` - -Validate before reporting success: - -```bash -uv run python -m otdf_sdk_mgr.schema validate xtest/scenarios/.yaml -``` - -## Step 6 — If no existing test fits - -Draft `xtest/bug__test.py` using the `encrypt_sdk` / `decrypt_sdk` fixtures (pattern: `xtest/test_tdfs.py`). Surface the new file in your reply for the user to review — never silently land assertions. - -## Notes - -- `sdks.encrypt` and `sdks.decrypt` map to xtest's `--sdks-encrypt` / `--sdks-decrypt`. After PR #446 those pytest options take `sdk@version` specifiers like `go@v0.24.0`, `go@main`, or `go@*`. **Do NOT write those tokens in the YAML** — write a normal `{ version: lts }` (or any version string `otdf-sdk-mgr resolve` accepts: `v0.24.0`, `main`, an SDK-specific SHA, etc.). The `scenario-up` skill runs `otdf-sdk-mgr install scenario`, which records the resolved dist directory names in `xtest/scenarios/.installed.json`; the bridge layers (`otdf-local scenario run` and pytest's `--scenario` default in `xtest/conftest.py`) read that file to emit the right `sdk@` tokens. If you forget the install step, those commands fail with `.installed.json not found — run otdf-sdk-mgr install scenario first`. -- List the same SDK in both `encrypt` and `decrypt` maps to reproduce xtest's legacy "all pairs" mode. Listing it on only one side keeps the scenario focused (a→b without b→a). -- `instance.platform.dist` and each `kas..dist` need `otdf-sdk-mgr install scenario ` (or `install release platform:`) to have built the binary first. `scenario-up` handles that downstream. -- One-line summary when done: report the scenario path, the new test file (if any), and the Jira link `https://virtru.atlassian.net/browse/` so the user can cross-reference. diff --git a/.claude/skills/scenario-from-ticket/SKILL.md b/.claude/skills/scenario-from-ticket/SKILL.md new file mode 100644 index 000000000..1f573b592 --- /dev/null +++ b/.claude/skills/scenario-from-ticket/SKILL.md @@ -0,0 +1,159 @@ +--- +name: scenario-from-ticket +description: Pull a Jira ticket of any type (Bug, Story, Task, Spike) into context via `acli jira workitem view` + `acli jira workitem comment list`, then turn it into an xtest/scenarios/.yaml manifest. Pins platform/KAS/SDKs to a released version (`dist:`), a branch or SHA (`source.ref:`), or the head of a PR — whichever matches the ticket. Optionally drafts xtest/bug__test.py when no existing pytest covers the behavior. Use when the user mentions a Jira key like DSPX-1234 (or any [PROJECT]-[NUMBER]) and wants a runnable scenario — reproducing a bug, writing a TDD test for a new feature, or validating behavior at a specific ref. +allowed-tools: Bash, Read, Write, Grep, Glob +--- + +# scenario-from-ticket + +You produce a `xtest/scenarios/.yaml` manifest from a Jira ticket. The same skill handles bugs, features (TDD), and exploratory work — the *Issue Type* field on the ticket selects which way the rest of this skill behaves. + +Two artifacts: + +1. `xtest/scenarios/.yaml` — validated against `otdf_sdk_mgr.schema.Scenario`. +2. (Optional) `xtest/bug__test.py` — only if no existing xtest pytest already exercises the behavior. The `bug_` prefix is a slug, not a type marker: feature-driven tests use it too. + +The Jira key also becomes the working **branch name** (`-repro` for Bugs, `-tdd` for Stories/Tasks) and the scenario file's `metadata.id`. + +## Step 1 — Pull the Jira ticket into context + +**Always run BOTH commands**. Don't skip the comment list — comments often carry the most recent reproduction status, "what changed" notes, or "fixed by PR #N" pointers that aren't in the original description: + +```bash +acli jira workitem view --fields '*all' --json +acli jira workitem comment list +``` + +From the JSON output of the first command, extract: + +- **Issue Type** (Bug, Story, Task, Spike) — load-bearing; selects which Step 2 branch to follow. +- **Summary** — becomes scenario `metadata.title`. +- **Description** — version numbers, KAS topology, container types, feature flags, acceptance criteria typically live here. +- **Status** — Backlog / In Progress / Done affects whether the scenario is forward-looking (TDD on Backlog) or retroactive (regression gate on Done). + +From the comments, pull any "tested at version X" / "reproduces on platform Y" / "fixed by PR #N" annotations into your mental model. + +If the ticket references attached logs, screenshots, or linked PRs, list them via `acli jira workitem attachment list ` and `acli jira workitem link list ` and call them out in your reply. + +**Permitted Jira writes**: only `acli jira workitem comment create ...` (to post a reproduction-status update if the user asks). Everything else — `edit`, `transition`, `assign`, `archive`, `delete`, `link create`, `watcher add` — is explicitly disallowed by the plugin's permissions; if the user wants those actions, instruct them to run the command themselves. + +## Step 2 — Branch on Issue Type + +### Bug + +The ticket describes a behavior that should work but doesn't. + +- `expected:` — what should happen (copy from the description's "expected behavior" section or rephrase the summary). +- `actual:` — what actually happens, including the exact error message if the ticket quotes one. +- Pin platform / KAS / SDKs to the **versions where the bug reproduces**. Usually `dist:` against a released version. Mixed-version topologies (e.g. platform `v0.9.0` + km1 `v0.9.0-rc.2`) are common and the schema supports them. + +If the description doesn't name versions, ask the user. (A headless agent has no user — in that case default to `dist: lts` everywhere and call out the assumption in `actual:`.) + +### Story / Task (feature work, TDD-style) + +The ticket describes a behavior the user wants to *add*. The scenario you produce is a forward-looking regression gate, not a bug reproducer. + +- `expected:` — the new behavior the feature should provide, paraphrased from acceptance criteria. +- `actual:` — the current state, e.g. "feature not implemented; tests skip via `.supports('')` until the supports entry lands." The scenario's `actual:` is what `scenario-run`'s "expected outcome" classifier compares against: a real failure means progress was made; a uniform skip means the prereq SDK plumbing is still pending. +- Pin platform / KAS / SDKs to the **ref where the feature will land**: + - HEAD of mainline: `platform: { source: { ref: main } }`, `sdks..version: main`. + - Feature branch: `platform: { source: { ref: feature/ecdsa-binding } }`. + - Draft PR under review: resolve to its head SHA with `gh pr view --json headRefOid` and pin `platform: { source: { ref: <40-char-SHA> } }`. SHAs are reproducible; branch names move every push. +- Only pin the component(s) the feature actually touches. Leave the rest on `lts` / `stable`. + +### Spike / unclear + +The ticket asks an open question or lacks enough concrete behavior to encode. Don't fabricate a scenario. Emit: + +``` + is a Spike (or has no specific behavior / version pins yet). Add either: + (a) the version or ref where you want behavior exercised, or + (b) a concrete pass/fail criterion (what should the test assert?) +…and re-invoke this skill. +``` + +…and stop. + +## Step 3 — Pick the id and (optionally) the branch + +- `metadata.id = ` — e.g. `DSPX-3302` → `dspx-3302`. +- Scenario file path: `xtest/scenarios/.yaml`. +- If you need a new git branch, propose `-repro` for Bugs and `-tdd` for Stories/Tasks; let the user confirm before switching. + +## Step 4 — Search for an existing pytest + +```bash +grep -rn "" xtest/test_*.py xtest/tdfs.py +``` + +Likely candidates: `test_tdfs.py` (roundtrip), `test_abac.py` (ABAC), `test_legacy.py` (golden), `test_pqc.py`. If a test already asserts the relevant behavior, reuse it via `suite.select` — no draft test needed. + +**Don't grep `xtest/sdk//cli.sh`.** Those wrappers are reusable infrastructure (versioned alongside each SDK dist) and their contents have nothing to do with scenario YAML fields. The scenario YAML doesn't need to know HOW a feature is plumbed — only WHICH pytest suite exercises it. Reading the wrappers is a waste of turns. If a feature's `supports("")` gate isn't in `tdfs.py` yet, that's a signal that supporting infrastructure has to land separately from the scenario — note it in `actual:` and move on. + +## Step 5 — Write `xtest/scenarios/.yaml` + +The schema (`otdf_sdk_mgr.schema.Scenario`) rejects unknown fields. Each pin (`PlatformPin`, `KasPin`) requires **exactly one** of `dist:`, `source:`, or `image:`. `image:` is reserved for forward-compat and rejected today — pick `dist:` or `source:`. + +Released-version pin (typical Bug scenario): + +```yaml +apiVersion: opentdf.io/v1alpha1 +kind: Scenario +metadata: + id: + title: "" + created: +instance: + metadata: { name: } + platform: { dist: v0.9.0 } + ports: { base: } + kas: + alpha: { dist: v0.9.0, mode: standard } +sdks: + encrypt: + go: { version: lts } + decrypt: + java: { version: "0.7.8" } +suite: + select: "xtest/test_tdfs.py::test_tdf_roundtrip" + containers: ztdf +expected: "..." +actual: "..." +``` + +Ref pin (TDD / HEAD / branch / PR): + +```yaml +instance: + platform: + source: { ref: main } # branch, tag, or 40-char SHA + kas: + alpha: + source: { ref: feature/ecdsa-binding } + mode: standard +sdks: + encrypt: + go: { version: main } # SdkPin.version accepts the same range of strings +``` + +Mix-and-match is fine — `platform` on `main`, `kas.alpha` on a released `dist:`, SDKs on different refs. + +Validate before reporting success: + +```bash +uv run python -m otdf_sdk_mgr.schema validate xtest/scenarios/.yaml +``` + +## Step 6 — If no existing test fits + +Draft `xtest/bug__test.py` using the `encrypt_sdk` / `decrypt_sdk` fixtures (pattern: `xtest/test_tdfs.py`). The `bug_` prefix is a historical slug applied to every scenario-tied test — feature/TDD ones use it too; don't let the name confuse you. Surface the new file in your reply for the user to review — never silently land assertions. + +For TDD tests where the underlying feature isn't yet implemented, gate participation behind `.supports("")` and call `pytest.skip(...)` when the gate fails. The scenario then runs as "all skipped" until the SDK supports entry lands, at which point the test becomes a real assertion. + +## Notes + +- `sdks.encrypt` and `sdks.decrypt` map to xtest's `--sdks-encrypt` / `--sdks-decrypt`. After PR #446 those pytest options take `sdk@version` specifiers like `go@v0.24.0`, `go@main`, or `go@*`. **Do NOT write those tokens in the YAML** — write a normal `{ version: lts }` (or any version string `otdf-sdk-mgr resolve` accepts: `v0.24.0`, `main`, an SDK-specific SHA, etc.). The `scenario-up` skill runs `otdf-sdk-mgr install scenario`, which records the resolved dist directory names in `xtest/scenarios/.installed.json`; the bridge layers (`otdf-local scenario run` and pytest's `--scenario` default in `xtest/conftest.py`) read that file to emit the right `sdk@` tokens. If you forget the install step, those commands fail with `.installed.json not found — run otdf-sdk-mgr install scenario first`. +- List the same SDK in both `encrypt` and `decrypt` maps to reproduce xtest's legacy "all pairs" mode. Listing it on only one side keeps the scenario focused (a→b without b→a). +- `instance.platform.dist` / `source.ref` and each `kas..dist` / `source.ref` need `otdf-sdk-mgr install scenario ` to have built the binary first. `scenario-up` handles that downstream. +- For matrix runs (same suite × N refs), don't author N scenarios by hand — invoke the `scenario-matrix` skill against this scenario as the base. +- One-line summary when done: report the scenario path, the new test file (if any), and the Jira link `https://virtru.atlassian.net/browse/` so the user can cross-reference. diff --git a/.claude/skills/scenario-matrix/SKILL.md b/.claude/skills/scenario-matrix/SKILL.md new file mode 100644 index 000000000..d28637495 --- /dev/null +++ b/.claude/skills/scenario-matrix/SKILL.md @@ -0,0 +1,91 @@ +--- +name: scenario-matrix +description: Given a base scenario (or a Jira ticket) plus a list of refs (PRs, branches, released versions), write one scenario file per ref so the same pytest suite runs across all of them. Use to bisect a regression across releases, validate a fix across multiple PRs, or check feature compatibility between versions. Generates files only — does not install or run them. +allowed-tools: Bash, Read, Write, Grep, Glob +--- + +# scenario-matrix + +You produce N scenario files from one base scenario, where N = the number of refs the user wants exercised. Each output scenario differs only in `instance.platform` (and optionally any KAS pins the user says should track the same ref). SDK pins are preserved unless explicitly told to vary. + +## Inputs + +- A **base**, either: + - Path to an existing `xtest/scenarios/.yaml`, OR + - A Jira ticket key — in which case invoke `scenario-from-ticket` first to produce the base, then proceed. +- A **ref list** — any combination of: + - Released versions: `v0.9.0`, `v0.8.5` + - Branch names: `main`, `feature/ecdsa-binding` + - PR numbers: `1234`, `1235` (resolved to head SHAs for reproducibility) +- (Optional) which KAS instances should track the same ref as `platform`. Default: every KAS instance in the base also tracks the ref. + +## Process + +### Step 1 — Resolve the base scenario + +- If given a path: `Read` it. +- If given a ticket key: invoke `scenario-from-ticket` against the ticket first, then `Read` the produced file. + +The base scenario provides everything except `instance.platform` (and tracked KAS pins): metadata.title becomes the title prefix, `suite` is shared across all cells, `sdks` is preserved. + +### Step 2 — Resolve each ref to a concrete value + +- Released version → use verbatim under `dist:`. Example: `v0.9.0` → `platform: { dist: v0.9.0 }`. +- Branch name → use under `source.ref:`. Example: `main` → `platform: { source: { ref: main } }`. +- PR number `N` → fetch: + + ```bash + gh pr view --json number,headRefName,headRefOid + ``` + + …and pin under `source.ref:` to the **`headRefOid`** (40-char SHA), **not** `headRefName`. Reason: branch names move on every push, SHAs don't. Record `headRefName` in the scenario title for human readability. + +### Step 3 — Emit one scenario file per ref + +Naming: `xtest/scenarios/-.yaml`. Tokens: + +- Released version: strip `v` and dots — `v0.9.0` → `v090`. +- Branch: replace `/` with `-` — `feature/ecdsa-binding` → `feature-ecdsa-binding`. +- PR: `pr` — `1234` → `pr1234`. The SHA still lives inside the file. + +Each cell scenario gets: + +- A unique `metadata.id` (`-`) matching the file basename. +- A unique `instance.metadata.name` (same as `metadata.id`). +- A unique `instance.ports.base` — start from the base's value and add `+1000` per additional cell. `scenario-up` rejects overlapping port bases between concurrent instances. +- `metadata.title` gets a ` []` suffix for at-a-glance identification. +- `instance.platform` rewritten to the resolved ref. For KAS pins that should track the same ref (default: all of them), rewrite their pin too. KAS pins the user explicitly excluded keep the base's value. +- `suite`, `sdks`, `expected`, `actual` — unchanged from the base. + +### Step 4 — Validate every file + +```bash +for f in xtest/scenarios/-*.yaml; do + uv run python -m otdf_sdk_mgr.schema validate "$f" +done +``` + +Bail (delete the just-written files) if any cell fails validation — partial matrices are confusing. + +### Step 5 — Report + +- The list of files written. +- The exact `scenario-up` / `scenario-run` chain the user can run per cell (or in a loop): + + ```bash + for f in xtest/scenarios/-*.yaml; do + name="$(basename "$f" .yaml)" + uv run otdf-sdk-mgr install scenario "$f" + uv run otdf-local instance init "$name" --from-scenario "$f" + uv run otdf-local --instance "$name" up + uv run otdf-local scenario run "$f" + uv run otdf-local --instance "$name" down + done + ``` + +## Notes + +- This skill **writes scenario files only**. It does not install artifacts, scaffold instances, or run pytest. Hand the resulting files to `scenario-up` and `scenario-run` per cell. +- For two PRs that differ in *SDK* (not platform), vary `sdks...version` instead of `platform`. Same pattern, different field — `SdkPin.version` accepts the same range of refs (`v0.24.0`, `main`, SHA). +- For a full platform × SDK matrix, generate N×M scenarios. Be prepared for long install times — each new platform ref triggers a `go build` (~30-60s first time per version); subsequent runs reuse the cached binary. +- Don't update `expected:` / `actual:` per cell unless the user specifies that one of the refs is the "known good" or "known broken" baseline. diff --git a/.claude/skills/scenario-run/SKILL.md b/.claude/skills/scenario-run/SKILL.md index 633846cf4..c9a73eef5 100644 --- a/.claude/skills/scenario-run/SKILL.md +++ b/.claude/skills/scenario-run/SKILL.md @@ -1,12 +1,12 @@ --- name: scenario-run -description: Execute the pytest suite declared by a scenarios.yaml against the running instance, then classify the result as "bug reproduced", "not reproduced", or "unrelated failure". Use after `scenario-up` has confirmed the instance is healthy. +description: Execute the pytest suite declared by a scenarios.yaml against the running instance, then classify the result as "expected outcome", "unexpected outcome", or "unrelated failure" against the scenario's `expected:` / `actual:` fields. Works for bug-repro scenarios, TDD/feature scenarios, and matrix runs. Use after `scenario-up` has confirmed the instance is healthy. allowed-tools: Bash, Read --- # scenario-run -You run the pytest selection declared by the scenario's `suite` block against the running instance and interpret the result in terms of the bug being investigated. +You run the pytest selection declared by the scenario's `suite` block against the running instance and interpret the result in terms of the ticket the scenario was authored for. The same three-bucket classification works for bug-repros (where "expected" means *failure that matches `actual:`*) and for TDD scenarios (where "expected" means *skip-until-feature-lands*). ## Inputs @@ -27,16 +27,21 @@ You run the pytest selection declared by the scenario's `suite` block against th 2. **Capture exit code and tail of output**. The pytest output is the source of truth; don't re-interpret. -3. **Classify**: - - **Bug reproduced** — the test failed with an assertion or stderr that matches the scenario's `actual:` field. Cite the matching line. - - **Bug NOT reproduced** — the test passed. This is meaningful: either the bug is fixed at this version combination, or the scenario doesn't capture it precisely yet. Suggest the user widen the assertion or pick a different version pin. - - **Unrelated failure** — pytest errored out (collection error, environment issue, import error, timeout). Don't claim repro success or failure; report the error and recommend a next diagnostic step. +3. **Classify** against the scenario's `expected:` and `actual:` fields: + - **Expected outcome** — the test result matches what `expected:` (or, for a bug, `actual:`) predicts. + - Bug scenario: pytest FAILED with an assertion/stderr matching `actual:`. Bug reproduced. Cite the matching line. + - TDD/feature scenario on a ref where the feature isn't landed yet: tests SKIPPED via `supports("")`. Feature gate is still pending as predicted. + - TDD/feature scenario on a ref where the feature is landed: tests PASSED. Feature works; the scenario is now a regression gate. + - **Unexpected outcome** — the test result is *not* what the scenario predicted. + - Bug scenario: pytest PASSED. Either the bug is fixed at this pin, or the scenario doesn't capture it tightly enough. Suggest widening the assertion, pinning a different ref, or marking the bug closed. + - TDD/feature scenario: tests FAILED for a reason that doesn't match `actual:`. A real bug surfaced, OR the prereq implementation work landed and the test now needs a real assertion (not a skip). Surface the actual failure to the user. + - **Unrelated failure** — pytest errored out (collection error, environment issue, import error, timeout). Don't claim outcome match either way; report the error and recommend a next diagnostic step. 4. **Record artifacts**. The pytest run leaves logs under `tests/instances//logs/`. List the relevant log files in your reply so the user can attach them to the Jira ticket. ## Output format -One-line headline (`bug reproduced` / `not reproduced` / `unrelated failure`), then a short bulleted summary: +One-line headline (`expected outcome` / `unexpected outcome` / `unrelated failure`), then a short bulleted summary: - `select:` the pytest selector - `exit_code:` the return value - `evidence:` 1-2 lines from the output that justify the classification diff --git a/.claude/skills/scenario-up/SKILL.md b/.claude/skills/scenario-up/SKILL.md index 955fce8b9..8254dd610 100644 --- a/.claude/skills/scenario-up/SKILL.md +++ b/.claude/skills/scenario-up/SKILL.md @@ -1,6 +1,6 @@ --- name: scenario-up -description: Provision artifacts, scaffold the instance directory, and start the test environment for a given xtest/scenarios/.yaml. Use after `scenario-from-bug-report` (or when the user already has a scenario YAML) and wants the environment running. +description: Provision artifacts, scaffold the instance directory, and start the test environment for a given xtest/scenarios/.yaml. Use after `scenario-from-ticket` (or `scenario-matrix`, or when the user already has a scenario YAML) and wants the environment running. allowed-tools: Bash, Read --- From 432a216f253ae03c7c1724680d981eae7ade8820 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 21:24:46 -0400 Subject: [PATCH 11/16] feat(.claude): feature-design skill for cross-repo features (DSPX-3302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For features (or bugs) that touch more than one OpenTDF repo — platform plus the Go / Java / JS SDKs — feature-design captures the work as a single spec at xtest/features/.yaml plus the tests-side artifacts that land first (feature_type entry in tdfs.py, scenario, draft test). The model matches the team's existing pattern: tests-side artifacts merge first, dormant under a `supports("")` gate, and each per-repo PR activates the gate by adding `supports ` to its cli.sh. PRs land async, in any order; no cross-PR lockstep needed. - `feature-design` SKILL: propose-then-iterate authoring from a Jira ticket (or free-form description). Drafts a complete spec on the first pass, asks one composite redirect question, then writes the spec + patches tdfs.py + invokes scenario-from-ticket internally to produce the dormant scenario and draft test. Bails on Spike or unclear tickets rather than fabricating. - `xtest/features/{README,CLAUDE}.md`: progressive-disclosure docs — human-facing README and agent-facing CLAUDE.md. - `xtest/README.md` gains a brief "Test artifact directories" section pointing at scenarios/ and features/. - `settings.json` + `plugin.json`: Write(xtest/features/**) allowlist, feature-design added to plugin skills array. The complementary feature-orchestrate skill (fanning out per-repo subagents to draft impl PRs in each touched repo) is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/plugin/plugin.json | 2 + .claude/settings.json | 1 + .claude/skills/feature-design/SKILL.md | 118 +++++++++++++++++++++++++ xtest/README.md | 7 ++ xtest/features/CLAUDE.md | 13 +++ xtest/features/README.md | 14 +++ 6 files changed, 155 insertions(+) create mode 100644 .claude/skills/feature-design/SKILL.md create mode 100644 xtest/features/CLAUDE.md create mode 100644 xtest/features/README.md diff --git a/.claude/plugin/plugin.json b/.claude/plugin/plugin.json index bb4c1311f..952feaea2 100644 --- a/.claude/plugin/plugin.json +++ b/.claude/plugin/plugin.json @@ -4,6 +4,7 @@ "description": "Jira-ticket-driven scenarios for the OpenTDF test harness. Pulls ticket context from Jira (acli) — any ticket type, including bugs, feature stories, and PR-driven work — provisions pinned platform/KAS/SDK versions or refs (released versions, main, feature branches, PR SHAs), runs the xtest pytest suite, and tears down. Useful for QA, platform/SDK developers writing tests for new features first, and downstream first/third-party integrators.", "skills_dir": "../skills", "skills": [ + "feature-design", "scenario-from-ticket", "scenario-matrix", "scenario-up", @@ -32,6 +33,7 @@ "Bash(acli jira workitem link list *)", "Bash(acli jira project view *)", "Write(xtest/scenarios/**)", + "Write(xtest/features/**)", "Write(xtest/bug_*_test.py)", "Write(tests/instances/**)" ] diff --git a/.claude/settings.json b/.claude/settings.json index a1dba2d80..9fd70f3c4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -24,6 +24,7 @@ "Bash(acli jira board view *)", "Bash(acli jira sprint view *)", "Write(xtest/scenarios/**)", + "Write(xtest/features/**)", "Write(xtest/bug_*_test.py)", "Write(tests/instances/**)", "Write(.claude/tmp/**)" diff --git a/.claude/skills/feature-design/SKILL.md b/.claude/skills/feature-design/SKILL.md new file mode 100644 index 000000000..ebb2eed96 --- /dev/null +++ b/.claude/skills/feature-design/SKILL.md @@ -0,0 +1,118 @@ +--- +name: feature-design +description: Turn a multi-repo feature (or cross-repo bug fix) into a concrete spec at xtest/features/.yaml plus the tests-side artifacts that have to land first (scenario, draft pytest, feature_type entry in tdfs.py). Pulls Jira context, drafts a complete spec from the ticket, then iterates with the user. Use when a feature touches more than one repo (e.g. platform + Go SDK + Java SDK + JS SDK) and you want to set up the cross-repo work in one go without manually authoring each piece. +allowed-tools: Bash, Read, Write, Edit, Grep, Glob, Skill +--- + +# feature-design + +You turn a fuzzy "let's build X across the OpenTDF repos" into a concrete bundle of artifacts that pin down the tests-side work first and stage the cross-repo work for handoff to `feature-orchestrate`. + +Two ideas to internalize before reading the steps: + +1. **Tests-side artifacts land first, dormant.** The scenario + draft test + `feature_type` entry merge to `tests/main` as a regular PR. They stay "all skipped" until each SDK opens its own PR adding a `supports ` case to its `cli.sh` source — that PR's CI activates the test for that SDK. This means no cross-PR lockstep coordination; per-repo PRs land async, in any order. +2. **Propose, don't ask.** Draft a complete spec from the Jira ticket on the first pass and let the user redirect what's wrong in a single revision. Only ask one composite question. If you're missing information you can't fill in (no Jira ticket, ambiguous scope, unclear feature name), bail — don't fabricate. + +## Inputs + +- Jira key (Story/Task usually; Bug works the same way), OR a free-text description of the feature. +- (Optional) explicit list of repos to scope to, if the user wants something tighter than the default. + +## Steps + +### Step 1 — Pull the Jira context + +If a Jira key was given, run both — comments often carry scope refinements that aren't in the description: + +```bash +acli jira workitem view --fields '*all' --json +acli jira workitem comment list +``` + +Extract Issue Type, summary, description, status, and any comments about scope or implementation notes. If no Jira key, the user's description IS the spec input. + +### Step 2 — Propose a complete draft + +Draft the full spec body and the per-repo todo lists inline in your reply. Don't ask the user one field at a time — produce a complete first draft they can react to: + +- **Feature flag name** — snake_case identifier derived from the Jira summary. Becomes the `supports("")` gate string AND the `feature_type` entry in `xtest/tdfs.py`. Validate it's a valid Python identifier and doesn't collide with an existing `feature_type` member. +- **Touched repos** — default set is `tests, platform, sdk-go, sdk-java, sdk-web`. Trim or expand based on what the ticket says. Pure platform features skip the SDK repos; pure SDK-only features skip platform; `tests` is always present (the dormant scenario + tdfs.py entry has to live there). +- **Per-repo todo lists** — 2-4 bullets per repo, derived from the description plus each repo's known role: + - `tests` — register the feature in `feature_type`, author the scenario, draft the test gated on `supports("")`. + - `platform` — service-side implementation (KAS path, policy plumbing, etc.) and any env-var handling in the dev harness (e.g. honoring `XT_WITH_`). + - `sdk-go` / `sdk-java` / `sdk-web` — encrypt/decrypt path implementation, plus a `supports ` case in that SDK's `cli.sh` source. **Don't pin the version bound in the spec** — the implementing engineer sets the `awk` predicate at PR time, since the bound depends on which release will ship the impl. +- **Branch name** — `-`, the same string across every touched repo so `feature-orchestrate` (and the user) can find each repo's PR by branch alone. + +Present the draft, then ask exactly one composite question: "Anything to redirect — feature name, touched repos, todo items, branch?" Apply edits in a single revision rather than turn-by-turn. The user can always drop into plain chat if they want to think out loud — just answer them and re-invoke this skill once the design firms up. + +If no Jira key was given AND the user's description doesn't pin down a clear scope (feature flag name, touched repos, intended behavior), bail rather than fabricate: + +``` +I need either (a) a Jira Story/Task/Bug key, or (b) a description that names +the feature flag, the repos it touches, and the intended behavior. Add either +and re-invoke this skill. +``` + +### Step 3 — Write the spec + +Write `xtest/features/.yaml`. Shape (still informal — no Pydantic model yet): + +```yaml +apiVersion: opentdf.io/v1alpha1 +kind: Feature +metadata: + name: # supports() string + feature_type entry, snake_case + jira: # omit if no ticket + title: "" + created: +repos: + tests: + branch: - + todo: + - Register "" in xtest/tdfs.py feature_type + - Author scenario + draft test (via scenario-from-ticket) + platform: + branch: - + todo: [ ... ] + sdk-go: + branch: - + todo: + - Implement in the encrypt/decrypt path + - Add `supports ` case to cli.sh with version-bound awk predicate + sdk-java: { branch: ..., todo: [ ... ] } + sdk-web: { branch: ..., todo: [ ... ] } +scenarios: + - xtest/scenarios/.yaml +``` + +PR status (open/merged/CI passing) deliberately is NOT in the spec — it's auto-discovered from `gh pr list --search "head:"` per repo whenever something asks "where are we?" The spec is a declaration of intent. + +### Step 4 — Drive the tests-side artifacts + +In this order, so each step's output feeds the next: + +1. **Add the feature flag to `xtest/tdfs.py`**. Find the `feature_type` Literal alias near the top of the file. Insert the new entry alphabetically. Don't touch any `cli.sh` files — `supports ` cases land per-SDK in their own PRs. + +2. **Invoke `scenario-from-ticket`** via the Skill tool (`skill: scenario-from-ticket`, `args: `). It runs its Story/Task branch and produces the scenario + draft test gated on `supports("")` — pinning the feature-introducing components to `main` via `source.ref:`. If no Jira key was given, draft the scenario directly using the same shape (`xtest/scenarios/.yaml`). + +3. **Validate the scenario**: + + ```bash + uv run python -m otdf_sdk_mgr.schema validate xtest/scenarios/.yaml + ``` + +### Step 5 — Report + +One block summarizing: + +- The spec path (`xtest/features/.yaml`). +- The scenario + draft test paths. +- The line(s) added to `xtest/tdfs.py`. +- A one-liner suggesting the next step: `feature-orchestrate xtest/features/.yaml`. + +## Notes + +- This skill produces **tests-side artifacts only**. It does NOT create branches in other repos, does NOT open PRs, does NOT install platform/SDK builds. That's `feature-orchestrate`'s job. +- Bugs that span repos use the same shape — pass the Bug ticket key and `scenario-from-ticket`'s Bug branch fills `expected:` / `actual:` from the reproduction prose. The cross-repo gating still works: tests land dormant, each per-repo PR activates them by adding the supports case as part of the fix. +- For an existing spec being revised, read it first and propose a diff rather than a full rewrite. The tests-side artifacts (scenario, tdfs.py entry) usually shouldn't be regenerated — just edit them surgically. +- If the user starts the conversation by describing the feature in plain chat rather than invoking this skill, answer normally — re-invoke the skill once the scope firms up. Don't gatekeep. diff --git a/xtest/README.md b/xtest/README.md index 6bdfcc400..5de98942b 100644 --- a/xtest/README.md +++ b/xtest/README.md @@ -122,3 +122,10 @@ pytest rm -rf tmp pytest test_tdfs.py ``` + +## Test artifact directories + +- **`scenarios/`** — Per-ticket scenario YAMLs that pin a platform / KAS / SDK topology to a specific pytest selection. Consumed by `otdf-local scenario run`. +- **`features/`** — Multi-repo feature specs: features that touch more than one OpenTDF repo (platform + SDKs) authored as a single declaration of intent. See `features/README.md`. + +Both are produced by the Claude Code skills under `tests/.claude/skills/` (`scenario-from-ticket`, `feature-design`, etc.) and can also be hand-authored. diff --git a/xtest/features/CLAUDE.md b/xtest/features/CLAUDE.md new file mode 100644 index 000000000..9f5e9a7e3 --- /dev/null +++ b/xtest/features/CLAUDE.md @@ -0,0 +1,13 @@ +# Agent guidance for xtest/features + +This directory is owned by two skills: + +- **`feature-design`** drafts new spec files here from a Jira ticket (or free-form description) using propose-then-iterate authoring. It also writes the tests-side artifacts that have to land first: the `feature_type` entry in `xtest/tdfs.py`, the scenario under `xtest/scenarios/`, and (if needed) a draft pytest. +- **`feature-orchestrate`** reads spec files and fans out per-repo subagents that implement the feature in each touched repo and open draft PRs. + +When you see a `xtest/features/.yaml` referenced: + +- It is canonical for the feature's flag name, scope, and per-repo todos. +- It is NOT canonical for status — query `gh pr list --search "head:"` per repo. + +Don't hand-author spec files in this directory unless you've also done what `feature-design` would do (add the entry to `feature_type` in `xtest/tdfs.py`, generate the scenario + draft test). Those side effects keep the spec consistent with the tests it depends on. diff --git a/xtest/features/README.md b/xtest/features/README.md new file mode 100644 index 000000000..2a1f55510 --- /dev/null +++ b/xtest/features/README.md @@ -0,0 +1,14 @@ +# xtest/features + +Specs for features that touch more than one OpenTDF repo (e.g. platform + Go SDK + Java SDK + JS SDK). + +Each `.yaml` captures: + +- The feature flag name — the `supports("")` gate string in `xtest/tdfs.py`. +- The Jira ticket driving the work, if any. +- Per-repo todo lists and the shared branch name to use across them. +- The scenario(s) under `xtest/scenarios/` that exercise the feature once each repo's PR lands. + +Specs are declarative — they describe intent, not status. PR state (open / merged / CI passing) is auto-discovered from `gh pr list --search "head:"` per repo, not stored here. + +See `CLAUDE.md` in this directory for how Claude Code skills produce and consume these files. From 3a29b462e67a1641d0b50f4f28094ba00b553409 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 21:45:00 -0400 Subject: [PATCH 12/16] fix(.claude): allow Skill tool + correct acli comment list syntax (DSPX-3302) Headless dogfooding (runs 1 and 2 of scenario-from-ticket on DSPX-2719) surfaced two real gaps: - The `Skill` tool was denied on both runs because the allowlist didn't cover it, so the body of SKILL.md wasn't injected on invocation; the agent had to manually `Read` the skill file ~25 turns in, wasting time and biasing exploration toward grepping unrelated files first. Add `Skill(*)` to settings.json and per-skill `Skill()` entries to plugin.json (the latter enumerates exactly what downstream installs get, since they shouldn't inherit a wildcard). - `acli jira workitem comment list` requires `--key ` (the subcommand differs from `view`, which takes the key positionally). Both scenario-from-ticket and feature-design had the wrong form; corrected, with a one-line note about the asymmetry so the next agent doesn't paraphrase. Verified via run-3 on DSPX-2719: 41 turns / 5m16s / $1.07 (vs run-1's 48 turns / 6m44s / $1.27). Skill tool returned success on first call, both acli commands ran cleanly, the Story/Task branch produced `source.ref: main` pins correctly (no more incorrectly defaulting to `dist: lts`), and the agent's `actual:` field correctly enumerated all three test-infrastructure prerequisites including a `with_ecdsa_binding` parameter that run-1's scenario missed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/plugin/plugin.json | 7 +++++++ .claude/settings.json | 1 + .claude/skills/feature-design/SKILL.md | 4 ++-- .claude/skills/scenario-from-ticket/SKILL.md | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.claude/plugin/plugin.json b/.claude/plugin/plugin.json index 952feaea2..cbf554efa 100644 --- a/.claude/plugin/plugin.json +++ b/.claude/plugin/plugin.json @@ -32,6 +32,13 @@ "Bash(acli jira workitem attachment list *)", "Bash(acli jira workitem link list *)", "Bash(acli jira project view *)", + "Skill(feature-design)", + "Skill(scenario-from-ticket)", + "Skill(scenario-matrix)", + "Skill(scenario-up)", + "Skill(scenario-run)", + "Skill(scenario-tear-down)", + "Skill(instance-status)", "Write(xtest/scenarios/**)", "Write(xtest/features/**)", "Write(xtest/bug_*_test.py)", diff --git a/.claude/settings.json b/.claude/settings.json index 9fd70f3c4..0f4e65da3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,7 @@ "Bash(acli jira project view *)", "Bash(acli jira board view *)", "Bash(acli jira sprint view *)", + "Skill(*)", "Write(xtest/scenarios/**)", "Write(xtest/features/**)", "Write(xtest/bug_*_test.py)", diff --git a/.claude/skills/feature-design/SKILL.md b/.claude/skills/feature-design/SKILL.md index ebb2eed96..170e650ab 100644 --- a/.claude/skills/feature-design/SKILL.md +++ b/.claude/skills/feature-design/SKILL.md @@ -22,11 +22,11 @@ Two ideas to internalize before reading the steps: ### Step 1 — Pull the Jira context -If a Jira key was given, run both — comments often carry scope refinements that aren't in the description: +If a Jira key was given, run both — `view` takes the key positionally, `comment list` requires `--key`; comments often carry scope refinements that aren't in the description: ```bash acli jira workitem view --fields '*all' --json -acli jira workitem comment list +acli jira workitem comment list --key ``` Extract Issue Type, summary, description, status, and any comments about scope or implementation notes. If no Jira key, the user's description IS the spec input. diff --git a/.claude/skills/scenario-from-ticket/SKILL.md b/.claude/skills/scenario-from-ticket/SKILL.md index 1f573b592..7f154ded9 100644 --- a/.claude/skills/scenario-from-ticket/SKILL.md +++ b/.claude/skills/scenario-from-ticket/SKILL.md @@ -17,11 +17,11 @@ The Jira key also becomes the working **branch name** (`-repro` for Bu ## Step 1 — Pull the Jira ticket into context -**Always run BOTH commands**. Don't skip the comment list — comments often carry the most recent reproduction status, "what changed" notes, or "fixed by PR #N" pointers that aren't in the original description: +**Always run BOTH commands** — exactly as shown; the two subcommands take the key differently (`view` is positional, `comment list` requires `--key`). Don't skip the comment list — comments often carry the most recent reproduction status, "what changed" notes, or "fixed by PR #N" pointers that aren't in the original description: ```bash acli jira workitem view --fields '*all' --json -acli jira workitem comment list +acli jira workitem comment list --key ``` From the JSON output of the first command, extract: From d6eacb2c6e251349496f6a1e10694f2fec31bd39 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 21:53:41 -0400 Subject: [PATCH 13/16] feat(otdf-sdk-mgr): schema dump CLI + xtest/schema canonical JSON Schemas (DSPX-3302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless runs of scenario-from-ticket kept trying `python3 -c "from otdf_sdk_mgr.schema import Scenario; ..."` to introspect Pydantic model shape while authoring scenarios. That form isn't in the plugin's Bash allowlist (deliberately — it's arbitrary code execution), so the agent fell back to Reading schema.py source. Static, committed JSON Schemas give the same information declaratively without needing a python verb in the allowlist at all. - `otdf-sdk-mgr schema dump [--out-dir]`: writes `xtest/schema/{scenario,instance}.schema.json` from `Model.model_json_schema()`, sorted-keys + trailing newline so output is byte-stable. Add new models to `SCHEMAS` in cli_schema.py and they get picked up automatically. - `xtest/schema/` is committed with the generated files plus brief README/CLAUDE.md (progressive-disclosure, mirroring xtest/features/). - `test_schema_sync.py` parametrizes over `SCHEMAS` and fails if any committed file drifts from the live model — the safety net for "someone edited a Pydantic model without regenerating." - `scenario-from-ticket` SKILL.md Step 5 now points at `xtest/schema/scenario.schema.json` as the canonical field list. - `xtest/README.md` lists the new directory alongside `scenarios/` and `features/`. No allowlist changes needed — `Bash(uv run otdf-sdk-mgr *)` already covers the dump subcommand, and `Read` is unrestricted. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/scenario-from-ticket/SKILL.md | 2 +- otdf-sdk-mgr/src/otdf_sdk_mgr/cli.py | 2 + otdf-sdk-mgr/src/otdf_sdk_mgr/cli_schema.py | 57 +++ otdf-sdk-mgr/tests/test_schema_sync.py | 38 ++ xtest/README.md | 3 +- xtest/schema/CLAUDE.md | 8 + xtest/schema/README.md | 16 + xtest/schema/instance.schema.json | 261 +++++++++++ xtest/schema/scenario.schema.json | 443 +++++++++++++++++++ 9 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 otdf-sdk-mgr/src/otdf_sdk_mgr/cli_schema.py create mode 100644 otdf-sdk-mgr/tests/test_schema_sync.py create mode 100644 xtest/schema/CLAUDE.md create mode 100644 xtest/schema/README.md create mode 100644 xtest/schema/instance.schema.json create mode 100644 xtest/schema/scenario.schema.json diff --git a/.claude/skills/scenario-from-ticket/SKILL.md b/.claude/skills/scenario-from-ticket/SKILL.md index 7f154ded9..12e960674 100644 --- a/.claude/skills/scenario-from-ticket/SKILL.md +++ b/.claude/skills/scenario-from-ticket/SKILL.md @@ -92,7 +92,7 @@ Likely candidates: `test_tdfs.py` (roundtrip), `test_abac.py` (ABAC), `test_lega ## Step 5 — Write `xtest/scenarios/.yaml` -The schema (`otdf_sdk_mgr.schema.Scenario`) rejects unknown fields. Each pin (`PlatformPin`, `KasPin`) requires **exactly one** of `dist:`, `source:`, or `image:`. `image:` is reserved for forward-compat and rejected today — pick `dist:` or `source:`. +The canonical field list (titles, types, defaults, `anyOf` branches) lives in `xtest/schema/scenario.schema.json` — `Read` it whenever you need to know what's allowed. Each pin (`PlatformPin`, `KasPin`) requires **exactly one** of `dist:`, `source:`, or `image:`. `image:` is reserved for forward-compat and rejected today — pick `dist:` or `source:`. Released-version pin (typical Bug scenario): diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli.py index 24148bdd7..78b137c95 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli.py @@ -10,6 +10,7 @@ import typer from otdf_sdk_mgr.cli_install import install_app +from otdf_sdk_mgr.cli_schema import schema_app from otdf_sdk_mgr.cli_versions import versions_app from otdf_sdk_mgr.config import ALL_SDKS, get_sdk_dirs @@ -20,6 +21,7 @@ ) app.add_typer(install_app, name="install") +app.add_typer(schema_app, name="schema") app.add_typer(versions_app, name="versions") diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_schema.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_schema.py new file mode 100644 index 000000000..b3fb17b7d --- /dev/null +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_schema.py @@ -0,0 +1,57 @@ +"""`otdf-sdk-mgr schema` subcommands. + +Emit canonical JSON Schemas for the Pydantic models in `otdf_sdk_mgr.schema` +so agents (and humans) can introspect the on-disk YAML formats without +running `python -c` against the package. The generated files live under +`xtest/schema/` and are kept in sync via `tests/test_schema_sync.py`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated + +import typer +from otdf_sdk_mgr.schema import Instance, Scenario + +schema_app = typer.Typer(help="Emit JSON Schemas for the scenario/instance models.") + +# (model_class, output_filename). Add new models here and `schema dump` +# will pick them up automatically. +SCHEMAS: tuple[tuple[type, str], ...] = ( + (Scenario, "scenario.schema.json"), + (Instance, "instance.schema.json"), +) + + +def render(model: type) -> str: + """Render `model.model_json_schema()` as a deterministic JSON string. + + Sorted keys and a trailing newline so byte-equality comparisons in the + sync test are stable. + """ + return json.dumps(model.model_json_schema(), indent=2, sort_keys=True) + "\n" + + +@schema_app.command("dump") +def dump( + out_dir: Annotated[ + Path, + typer.Option( + "--out-dir", + help="Directory to write *.schema.json files into.", + ), + ] = Path("xtest/schema"), +) -> None: + """Write JSON Schemas for every canonical scenario/instance model. + + Overwrites existing files. Re-run whenever a Pydantic model changes; + the committed schemas in xtest/schema/ are otherwise the source of + truth that the scenario-authoring skills read. + """ + out_dir.mkdir(parents=True, exist_ok=True) + for model, filename in SCHEMAS: + path = out_dir / filename + path.write_text(render(model), encoding="utf-8") + typer.echo(f" wrote {path}") diff --git a/otdf-sdk-mgr/tests/test_schema_sync.py b/otdf-sdk-mgr/tests/test_schema_sync.py new file mode 100644 index 000000000..addeaf8ad --- /dev/null +++ b/otdf-sdk-mgr/tests/test_schema_sync.py @@ -0,0 +1,38 @@ +"""Guard that the committed JSON Schemas under xtest/schema/ stay in sync +with the live Pydantic models. + +The skills authoring scenarios read those JSON files directly to know what +fields are allowed; if a Pydantic model gains, loses, or renames a field +without a corresponding `uv run otdf-sdk-mgr schema dump`, the skills will +silently rely on a stale schema. This test makes that drift loud. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from otdf_sdk_mgr.cli_schema import SCHEMAS, render + + +def _xtest_schema_dir() -> Path: + """Locate xtest/schema/ relative to this test file. + + The repo layout puts otdf-sdk-mgr/tests/ next to xtest/, so two parents + up from this file is the tests/ root. + """ + return Path(__file__).resolve().parents[2] / "xtest" / "schema" + + +@pytest.mark.parametrize(("model", "filename"), SCHEMAS, ids=lambda v: getattr(v, "__name__", v)) +def test_committed_schema_matches_model(model: type, filename: str) -> None: + path = _xtest_schema_dir() / filename + assert path.is_file(), ( + f"Missing {path}. Run `uv run otdf-sdk-mgr schema dump` to regenerate." + ) + expected = render(model) + actual = path.read_text(encoding="utf-8") + assert actual == expected, ( + f"{path} is out of sync with {model.__name__}. " + f"Run `uv run otdf-sdk-mgr schema dump` to regenerate." + ) diff --git a/xtest/README.md b/xtest/README.md index 5de98942b..0c7400fac 100644 --- a/xtest/README.md +++ b/xtest/README.md @@ -127,5 +127,6 @@ pytest test_tdfs.py - **`scenarios/`** — Per-ticket scenario YAMLs that pin a platform / KAS / SDK topology to a specific pytest selection. Consumed by `otdf-local scenario run`. - **`features/`** — Multi-repo feature specs: features that touch more than one OpenTDF repo (platform + SDKs) authored as a single declaration of intent. See `features/README.md`. +- **`schema/`** — Generated JSON Schemas for the canonical scenario / instance models. Regenerate via `uv run otdf-sdk-mgr schema dump` after editing the Pydantic models in `otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py`. See `schema/README.md`. -Both are produced by the Claude Code skills under `tests/.claude/skills/` (`scenario-from-ticket`, `feature-design`, etc.) and can also be hand-authored. +The first two are produced by the Claude Code skills under `tests/.claude/skills/` (`scenario-from-ticket`, `feature-design`, etc.) and can also be hand-authored. diff --git a/xtest/schema/CLAUDE.md b/xtest/schema/CLAUDE.md new file mode 100644 index 000000000..7b2154591 --- /dev/null +++ b/xtest/schema/CLAUDE.md @@ -0,0 +1,8 @@ +# Agent guidance for xtest/schema + +These JSON Schemas are the canonical reference for the on-disk YAML formats. When you need to know what fields a scenario or instance accepts: + +- **Read these files**. Don't run `python -c "from otdf_sdk_mgr.schema import ..."` to introspect — those forms aren't in the plugin's allowlist, and the JSON Schemas have the same information in declarative form (titles, types, `anyOf` for ref-vs-version pins, `additionalProperties: false`, default values, etc.). +- The files are byte-stable and sorted; safe to grep, diff, or quote. + +If a Pydantic model changes and these files drift, the user (or CI) will regenerate them via `uv run otdf-sdk-mgr schema dump`. Don't try to regenerate them yourself unless you're explicitly fixing the drift in a schema-editing PR. diff --git a/xtest/schema/README.md b/xtest/schema/README.md new file mode 100644 index 000000000..c292457a6 --- /dev/null +++ b/xtest/schema/README.md @@ -0,0 +1,16 @@ +# xtest/schema + +JSON Schemas for the canonical scenario / instance YAML formats. One file per Pydantic model in `otdf-sdk-mgr/src/otdf_sdk_mgr/schema.py`: + +- `scenario.schema.json` — the shape that `xtest/scenarios/.yaml` validates against. +- `instance.schema.json` — the shape of `tests/instances//instance.yaml`. + +These files are generated artifacts. To refresh them after editing a Pydantic model: + +```bash +uv run --project otdf-sdk-mgr otdf-sdk-mgr schema dump +``` + +A pytest in `otdf-sdk-mgr/tests/test_schema_sync.py` fails CI if the committed files drift from what the live models would produce. + +See `CLAUDE.md` for how Claude Code skills consume these files. diff --git a/xtest/schema/instance.schema.json b/xtest/schema/instance.schema.json new file mode 100644 index 000000000..cdf172db0 --- /dev/null +++ b/xtest/schema/instance.schema.json @@ -0,0 +1,261 @@ +{ + "$defs": { + "Fixtures": { + "additionalProperties": false, + "properties": { + "attributes": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Attributes" + }, + "policy": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Policy" + } + }, + "title": "Fixtures", + "type": "object" + }, + "KasPin": { + "additionalProperties": false, + "description": "Per-KAS-instance version + mode pin.", + "properties": { + "dist": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dist" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Features", + "type": "object" + }, + "mode": { + "default": "standard", + "enum": [ + "standard", + "key_management" + ], + "title": "Mode", + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceRef" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "KasPin", + "type": "object" + }, + "Metadata": { + "additionalProperties": false, + "properties": { + "created": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + } + }, + "title": "Metadata", + "type": "object" + }, + "PlatformPin": { + "additionalProperties": false, + "description": "Version pin for the platform service.\n\n`dist` references a built binary at `xtest/platform/dist//service`\nproduced by `otdf-sdk-mgr install platform:`.\n`source.ref` is a git ref to build from on demand.", + "properties": { + "dist": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dist" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceRef" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "PlatformPin", + "type": "object" + }, + "PortsConfig": { + "additionalProperties": false, + "properties": { + "base": { + "default": 8080, + "maximum": 60000, + "minimum": 1024, + "title": "Base", + "type": "integer" + } + }, + "title": "PortsConfig", + "type": "object" + }, + "SourceRef": { + "additionalProperties": false, + "properties": { + "path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional local checkout path", + "title": "Path" + }, + "ref": { + "description": "Git tag, branch, or SHA", + "title": "Ref", + "type": "string" + } + }, + "required": [ + "ref" + ], + "title": "SourceRef", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Standalone instance definition (one platform + N KAS).\n\nPersisted to `tests/instances//instance.yaml`. Also embedded inside\nScenario to keep the \"describe a bug-repro environment\" entry point a\nsingle file.", + "properties": { + "apiVersion": { + "const": "opentdf.io/v1alpha1", + "default": "opentdf.io/v1alpha1", + "title": "Apiversion", + "type": "string" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Features", + "type": "object" + }, + "fixtures": { + "$ref": "#/$defs/Fixtures" + }, + "kas": { + "additionalProperties": { + "$ref": "#/$defs/KasPin" + }, + "title": "Kas", + "type": "object" + }, + "kind": { + "const": "Instance", + "default": "Instance", + "title": "Kind", + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "platform": { + "$ref": "#/$defs/PlatformPin" + }, + "ports": { + "$ref": "#/$defs/PortsConfig" + } + }, + "required": [ + "platform" + ], + "title": "Instance", + "type": "object" +} diff --git a/xtest/schema/scenario.schema.json b/xtest/schema/scenario.schema.json new file mode 100644 index 000000000..426e11c51 --- /dev/null +++ b/xtest/schema/scenario.schema.json @@ -0,0 +1,443 @@ +{ + "$defs": { + "Fixtures": { + "additionalProperties": false, + "properties": { + "attributes": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Attributes" + }, + "policy": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Policy" + } + }, + "title": "Fixtures", + "type": "object" + }, + "Instance": { + "additionalProperties": false, + "description": "Standalone instance definition (one platform + N KAS).\n\nPersisted to `tests/instances//instance.yaml`. Also embedded inside\nScenario to keep the \"describe a bug-repro environment\" entry point a\nsingle file.", + "properties": { + "apiVersion": { + "const": "opentdf.io/v1alpha1", + "default": "opentdf.io/v1alpha1", + "title": "Apiversion", + "type": "string" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Features", + "type": "object" + }, + "fixtures": { + "$ref": "#/$defs/Fixtures" + }, + "kas": { + "additionalProperties": { + "$ref": "#/$defs/KasPin" + }, + "title": "Kas", + "type": "object" + }, + "kind": { + "const": "Instance", + "default": "Instance", + "title": "Kind", + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "platform": { + "$ref": "#/$defs/PlatformPin" + }, + "ports": { + "$ref": "#/$defs/PortsConfig" + } + }, + "required": [ + "platform" + ], + "title": "Instance", + "type": "object" + }, + "KasPin": { + "additionalProperties": false, + "description": "Per-KAS-instance version + mode pin.", + "properties": { + "dist": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dist" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Features", + "type": "object" + }, + "mode": { + "default": "standard", + "enum": [ + "standard", + "key_management" + ], + "title": "Mode", + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceRef" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "KasPin", + "type": "object" + }, + "Metadata": { + "additionalProperties": false, + "properties": { + "created": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + } + }, + "title": "Metadata", + "type": "object" + }, + "PlatformPin": { + "additionalProperties": false, + "description": "Version pin for the platform service.\n\n`dist` references a built binary at `xtest/platform/dist//service`\nproduced by `otdf-sdk-mgr install platform:`.\n`source.ref` is a git ref to build from on demand.", + "properties": { + "dist": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dist" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceRef" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "PlatformPin", + "type": "object" + }, + "PortsConfig": { + "additionalProperties": false, + "properties": { + "base": { + "default": 8080, + "maximum": 60000, + "minimum": 1024, + "title": "Base", + "type": "integer" + } + }, + "title": "PortsConfig", + "type": "object" + }, + "ScenarioSdk": { + "additionalProperties": false, + "description": "One ordered SDK selection within a scenario role.", + "properties": { + "sdk": { + "enum": [ + "go", + "java", + "js" + ], + "title": "Sdk", + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "For Go: \"platform\" to use the monorepo module path", + "title": "Source" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "sdk", + "version" + ], + "title": "ScenarioSdk", + "type": "object" + }, + "ScenarioSdks": { + "additionalProperties": false, + "description": "Encrypt/decrypt split mirrors xtest's --sdks-encrypt/--sdks-decrypt.\n\nSelections are ordered to preserve the eventual argv order, and are\nde-duplicated within each role by (sdk, version, source).", + "properties": { + "decrypt": { + "items": { + "$ref": "#/$defs/ScenarioSdk" + }, + "title": "Decrypt", + "type": "array" + }, + "encrypt": { + "items": { + "$ref": "#/$defs/ScenarioSdk" + }, + "title": "Encrypt", + "type": "array" + } + }, + "title": "ScenarioSdks", + "type": "object" + }, + "SourceRef": { + "additionalProperties": false, + "properties": { + "path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional local checkout path", + "title": "Path" + }, + "ref": { + "description": "Git tag, branch, or SHA", + "title": "Ref", + "type": "string" + } + }, + "required": [ + "ref" + ], + "title": "SourceRef", + "type": "object" + }, + "Suite": { + "additionalProperties": false, + "description": "Pytest selection + flags.", + "properties": { + "containers": { + "description": "Forwarded to --containers as a whitespace-separated list", + "items": { + "enum": [ + "ztdf", + "ztdf-ecwrap" + ], + "type": "string" + }, + "title": "Containers", + "type": "array" + }, + "extra_args": { + "items": { + "type": "string" + }, + "title": "Extra Args", + "type": "array" + }, + "kexpr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Forwarded to pytest -k", + "title": "Kexpr" + }, + "markers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Forwarded to -m", + "title": "Markers" + }, + "targets": { + "description": "Positional pytest targets, e.g. test files or path::node ids", + "items": { + "type": "string" + }, + "title": "Targets", + "type": "array" + } + }, + "title": "Suite", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Top-level scenarios.yaml model.\n\nComposes an Instance with SDK pins and a pytest Suite selection.", + "properties": { + "actual": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actual" + }, + "apiVersion": { + "const": "opentdf.io/v1alpha1", + "default": "opentdf.io/v1alpha1", + "title": "Apiversion", + "type": "string" + }, + "expected": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected" + }, + "instance": { + "$ref": "#/$defs/Instance", + "description": "Inline instance definition" + }, + "kind": { + "const": "Scenario", + "default": "Scenario", + "title": "Kind", + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "sdks": { + "$ref": "#/$defs/ScenarioSdks" + }, + "suite": { + "$ref": "#/$defs/Suite" + } + }, + "required": [ + "instance", + "suite" + ], + "title": "Scenario", + "type": "object" +} From 57ca5f22bca7cdc1ae190179a8e583645db9cdae Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 22 May 2026 10:15:16 -0400 Subject: [PATCH 14/16] fixup play nicer with claude code permissions model --- .claude/plugin/plugin.json | 2 +- .claude/settings.json | 2 +- .claude/skills/scenario-from-ticket/SKILL.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/plugin/plugin.json b/.claude/plugin/plugin.json index cbf554efa..906d0d240 100644 --- a/.claude/plugin/plugin.json +++ b/.claude/plugin/plugin.json @@ -41,7 +41,7 @@ "Skill(instance-status)", "Write(xtest/scenarios/**)", "Write(xtest/features/**)", - "Write(xtest/bug_*_test.py)", + "Write(xtest/bugs/*_test.py)", "Write(tests/instances/**)" ] }, diff --git a/.claude/settings.json b/.claude/settings.json index 0f4e65da3..a14484c31 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -26,7 +26,7 @@ "Skill(*)", "Write(xtest/scenarios/**)", "Write(xtest/features/**)", - "Write(xtest/bug_*_test.py)", + "Write(xtest/bugs/*_test.py)", "Write(tests/instances/**)", "Write(.claude/tmp/**)" ] diff --git a/.claude/skills/scenario-from-ticket/SKILL.md b/.claude/skills/scenario-from-ticket/SKILL.md index 12e960674..21db97f88 100644 --- a/.claude/skills/scenario-from-ticket/SKILL.md +++ b/.claude/skills/scenario-from-ticket/SKILL.md @@ -1,6 +1,6 @@ --- name: scenario-from-ticket -description: Pull a Jira ticket of any type (Bug, Story, Task, Spike) into context via `acli jira workitem view` + `acli jira workitem comment list`, then turn it into an xtest/scenarios/.yaml manifest. Pins platform/KAS/SDKs to a released version (`dist:`), a branch or SHA (`source.ref:`), or the head of a PR — whichever matches the ticket. Optionally drafts xtest/bug__test.py when no existing pytest covers the behavior. Use when the user mentions a Jira key like DSPX-1234 (or any [PROJECT]-[NUMBER]) and wants a runnable scenario — reproducing a bug, writing a TDD test for a new feature, or validating behavior at a specific ref. +description: Pull a Jira ticket of any type (Bug, Story, Task, Spike) into context via `acli jira workitem view` + `acli jira workitem comment list`, then turn it into an xtest/scenarios/.yaml manifest. Pins platform/KAS/SDKs to a released version (`dist:`), a branch or SHA (`source.ref:`), or the head of a PR — whichever matches the ticket. Optionally drafts xtest/bugs/_test.py when no existing pytest covers the behavior. Use when the user mentions a Jira key like DSPX-1234 (or any [PROJECT]-[NUMBER]) and wants a runnable scenario — reproducing a bug, writing a TDD test for a new feature, or validating behavior at a specific ref. allowed-tools: Bash, Read, Write, Grep, Glob --- @@ -11,7 +11,7 @@ You produce a `xtest/scenarios/.yaml` manifest from a Jira Two artifacts: 1. `xtest/scenarios/.yaml` — validated against `otdf_sdk_mgr.schema.Scenario`. -2. (Optional) `xtest/bug__test.py` — only if no existing xtest pytest already exercises the behavior. The `bug_` prefix is a slug, not a type marker: feature-driven tests use it too. +2. (Optional) `xtest/bugs/_test.py` — only if no existing xtest pytest already exercises the behavior. The Jira key also becomes the working **branch name** (`-repro` for Bugs, `-tdd` for Stories/Tasks) and the scenario file's `metadata.id`. @@ -146,7 +146,7 @@ uv run python -m otdf_sdk_mgr.schema validate xtest/scenarios/.yaml ## Step 6 — If no existing test fits -Draft `xtest/bug__test.py` using the `encrypt_sdk` / `decrypt_sdk` fixtures (pattern: `xtest/test_tdfs.py`). The `bug_` prefix is a historical slug applied to every scenario-tied test — feature/TDD ones use it too; don't let the name confuse you. Surface the new file in your reply for the user to review — never silently land assertions. +Draft `xtest/bugs/_test.py` using the `encrypt_sdk` / `decrypt_sdk` fixtures (pattern: `xtest/test_tdfs.py`). Surface the new file in your reply for the user to review — never silently land assertions. For TDD tests where the underlying feature isn't yet implemented, gate participation behind `.supports("")` and call `pytest.skip(...)` when the gate fails. The scenario then runs as "all skipped" until the SDK supports entry lands, at which point the test becomes a real assertion. From 213031e9e47ec0672ab3878506c7a45ab1d33ef3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 22 May 2026 10:31:58 -0400 Subject: [PATCH 15/16] fixup better skill descriptions --- .claude/skills/feature-design/SKILL.md | 2 +- .claude/skills/instance-status/SKILL.md | 2 +- .claude/skills/scenario-matrix/SKILL.md | 2 +- .claude/skills/scenario-run/SKILL.md | 2 +- .claude/skills/scenario-tear-down/SKILL.md | 2 +- .claude/skills/scenario-up/SKILL.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/skills/feature-design/SKILL.md b/.claude/skills/feature-design/SKILL.md index 170e650ab..bf3854aa6 100644 --- a/.claude/skills/feature-design/SKILL.md +++ b/.claude/skills/feature-design/SKILL.md @@ -1,6 +1,6 @@ --- name: feature-design -description: Turn a multi-repo feature (or cross-repo bug fix) into a concrete spec at xtest/features/.yaml plus the tests-side artifacts that have to land first (scenario, draft pytest, feature_type entry in tdfs.py). Pulls Jira context, drafts a complete spec from the ticket, then iterates with the user. Use when a feature touches more than one repo (e.g. platform + Go SDK + Java SDK + JS SDK) and you want to set up the cross-repo work in one go without manually authoring each piece. +description: Use when a feature or bug fix spans multiple repos (platform + Go/Java/JS SDKs) and you want the cross-repo spec and test artifacts set up in one pass. allowed-tools: Bash, Read, Write, Edit, Grep, Glob, Skill --- diff --git a/.claude/skills/instance-status/SKILL.md b/.claude/skills/instance-status/SKILL.md index 64bd545a0..cef888d2a 100644 --- a/.claude/skills/instance-status/SKILL.md +++ b/.claude/skills/instance-status/SKILL.md @@ -1,6 +1,6 @@ --- name: instance-status -description: Report which test instances exist on disk, which are running, and the health of each service. Use when the user asks "what's running" or before bringing up another scenario to avoid port collisions. +description: Use when the user asks what's running, or before starting a scenario to check for port collisions. allowed-tools: Bash, Read --- diff --git a/.claude/skills/scenario-matrix/SKILL.md b/.claude/skills/scenario-matrix/SKILL.md index d28637495..ee01aba54 100644 --- a/.claude/skills/scenario-matrix/SKILL.md +++ b/.claude/skills/scenario-matrix/SKILL.md @@ -1,6 +1,6 @@ --- name: scenario-matrix -description: Given a base scenario (or a Jira ticket) plus a list of refs (PRs, branches, released versions), write one scenario file per ref so the same pytest suite runs across all of them. Use to bisect a regression across releases, validate a fix across multiple PRs, or check feature compatibility between versions. Generates files only — does not install or run them. +description: Use when running the same test suite across multiple refs, branches, PRs, or releases — bisecting regressions or validating a fix across versions. Generates scenario files only; does not run them. allowed-tools: Bash, Read, Write, Grep, Glob --- diff --git a/.claude/skills/scenario-run/SKILL.md b/.claude/skills/scenario-run/SKILL.md index c9a73eef5..c127ecee4 100644 --- a/.claude/skills/scenario-run/SKILL.md +++ b/.claude/skills/scenario-run/SKILL.md @@ -1,6 +1,6 @@ --- name: scenario-run -description: Execute the pytest suite declared by a scenarios.yaml against the running instance, then classify the result as "expected outcome", "unexpected outcome", or "unrelated failure" against the scenario's `expected:` / `actual:` fields. Works for bug-repro scenarios, TDD/feature scenarios, and matrix runs. Use after `scenario-up` has confirmed the instance is healthy. +description: Use after `scenario-up` to run the scenario's test suite and classify results against its expected/actual fields. allowed-tools: Bash, Read --- diff --git a/.claude/skills/scenario-tear-down/SKILL.md b/.claude/skills/scenario-tear-down/SKILL.md index 39398c766..0838e9585 100644 --- a/.claude/skills/scenario-tear-down/SKILL.md +++ b/.claude/skills/scenario-tear-down/SKILL.md @@ -1,6 +1,6 @@ --- name: scenario-tear-down -description: Stop the services for a scenario's instance and optionally delete the instance directory. Use when the user is done with a reproduction or wants to free ports/disk for a different scenario. +description: Use when the user is done with a scenario or wants to stop, clean up, or free ports/disk. allowed-tools: Bash, Read --- diff --git a/.claude/skills/scenario-up/SKILL.md b/.claude/skills/scenario-up/SKILL.md index 8254dd610..dcf1ea357 100644 --- a/.claude/skills/scenario-up/SKILL.md +++ b/.claude/skills/scenario-up/SKILL.md @@ -1,6 +1,6 @@ --- name: scenario-up -description: Provision artifacts, scaffold the instance directory, and start the test environment for a given xtest/scenarios/.yaml. Use after `scenario-from-ticket` (or `scenario-matrix`, or when the user already has a scenario YAML) and wants the environment running. +description: Use when the user has a scenario YAML and wants the environment started (before running tests). allowed-tools: Bash, Read --- From 4ea77cd81f9a9bbb9173322ebff43188aaec8a83 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 22 May 2026 13:58:46 -0400 Subject: [PATCH 16/16] feat(scenario-up): support source-pinned platform/KAS/Go-SDK in instance mode (DSPX-3356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `otdf-local --instance up` and `otdf-sdk-mgr install scenario` only handled `dist:` pins. Source pins (`source: { ref: main }`) failed with "No sibling platform/ directory found" and Go SDK at `version: main` failed with a module-proxy 404. Changes: - **otdf-sdk-mgr**: add `install_go_from_platform(ref, dist_dir)` — builds otdfctl from the platform monorepo worktree (needed when `version: main` can't be resolved via the Go module proxy). `cli_scenario.py` dispatches to it when `sdks.*.source == "platform"`. - **otdf-local/settings**: add `platform_source_dir` property that returns the platform src worktree in instance+source mode. `docker_compose_file` uses it to find docker-compose.yaml in the worktree. - **otdf-local/platform**: `_instance_dist_paths` handles `source` pins (not just `dist`). `_generate_config` falls back to `opentdf-dev.yaml` when the worktree has no `opentdf.yaml`. - **otdf-local/kas**: `_instance_paths` handles `source` pins. - **otdf-local/provisioner**: use `platform_source_dir` as cwd. - **otdf-local/cli** (env): emit `PLATFORM_VERSION` by reading it from the built binary directly (fast path), with `go run ./service version` fallback. Fix `service version` printing to stderr. Fix `PLATFORM_DIR` NoneType crash. - **otdf-local/cli_scenario** (run): fix `suite.select` → `suite.targets`; strip leading `xtest/` from target paths; set `OTDFCTL_HEADS` from the installed.json Go dist name so `load_otdfctl()` finds the right binary. - **xtest/scenarios**: add `dspx-3356.yaml` — reproduces X-Wing wrappedKey size bug (1190 bytes vs expected 1120) at platform@main. Bug confirmed: `test_xwing_roundtrip` FAILS with `AssertionError: X-Wing wrappedKey should be 1120 bytes, got 1190`. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- otdf-local/src/otdf_local/cli.py | 43 ++++++++++++++++--- otdf-local/src/otdf_local/cli_instance.py | 35 ++++++++++++--- otdf-local/src/otdf_local/cli_scenario.py | 31 +++++++++++-- otdf-local/src/otdf_local/config/features.py | 2 +- otdf-local/src/otdf_local/config/settings.py | 24 +++++++++++ otdf-local/src/otdf_local/services/kas.py | 18 ++++++-- .../src/otdf_local/services/platform.py | 25 ++++++++--- .../src/otdf_local/services/provisioner.py | 2 +- otdf-local/tests/test_multi_instance.py | 9 ++-- otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py | 11 ++++- otdf-sdk-mgr/src/otdf_sdk_mgr/installers.py | 40 +++++++++++++++++ otdf-sdk-mgr/tests/test_schema_sync.py | 4 +- xtest/scenarios/dspx-3356.yaml | 35 +++++++++++++++ 13 files changed, 243 insertions(+), 36 deletions(-) create mode 100644 xtest/scenarios/dspx-3356.yaml diff --git a/otdf-local/src/otdf_local/cli.py b/otdf-local/src/otdf_local/cli.py index 422daa65a..a291a46e1 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -3,9 +3,9 @@ import json import os import shutil +import subprocess import sys import time -from pathlib import Path from typing import Annotated, Optional import httpx @@ -582,11 +582,17 @@ def env( # Platform configuration env_vars["PLATFORMURL"] = settings.platform_url - env_vars["PLATFORM_DIR"] = str(settings.platform_dir.resolve()) + _platform_src = settings.platform_source_dir + if _platform_src is not None: + env_vars["PLATFORM_DIR"] = str(_platform_src.resolve()) # Schema file for manifest validation - schema_file = settings.platform_dir / "sdk" / "schema" / "manifest.schema.json" - if schema_file.exists(): + schema_file = ( + _platform_src / "sdk" / "schema" / "manifest.schema.json" + if _platform_src is not None + else None + ) + if schema_file is not None and schema_file.exists(): env_vars["SCHEMA_FILE"] = str(schema_file.resolve()) # Log file paths @@ -618,7 +624,7 @@ def env( except Exception as e: print_warning(f"Could not read root key from platform config: {e}") - # Try to get platform version from API + # Try to get platform version from API, then fall back to source detection try: platform = get_platform_service(settings) if platform.is_running(): @@ -631,7 +637,32 @@ def env( if "version" in config: env_vars["PLATFORM_VERSION"] = config["version"] except Exception as e: - print_warning(f"Could not get platform version: {e}") + print_warning(f"Could not get platform version from API: {e}") + + if "PLATFORM_VERSION" not in env_vars: + # Try the built binary first (fast), then fall back to go run (slow). + instance_paths = get_platform_service(settings)._instance_dist_paths() + if instance_paths is not None: + binary, _ = instance_paths + try: + result = subprocess.run( + [str(binary), "version"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + v = (result.stdout or result.stderr).strip() + if v: + env_vars["PLATFORM_VERSION"] = v + except Exception: + pass + if "PLATFORM_VERSION" not in env_vars and _platform_src is not None: + from otdf_local.config.features import _get_platform_version + + detected = _get_platform_version(_platform_src) + if detected and detected != "0.9.0": + env_vars["PLATFORM_VERSION"] = detected # Output in requested format if format == "json": diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py index 98407f56e..2699c86bb 100644 --- a/otdf-local/src/otdf_local/cli_instance.py +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -7,7 +7,13 @@ from typing import Annotated, Optional import typer -from otdf_sdk_mgr.schema import Instance, Metadata, PlatformPin, PortsConfig, dump_instance +from otdf_sdk_mgr.schema import ( + Instance, + Metadata, + PlatformPin, + PortsConfig, + dump_instance, +) from otdf_local.config.settings import get_settings @@ -19,11 +25,15 @@ def init( name: Annotated[str, typer.Argument(help="Instance name (used as directory name)")], from_scenario: Annotated[ Optional[Path], - typer.Option("--from-scenario", help="Initialize from a scenarios.yaml or instance.yaml"), + typer.Option( + "--from-scenario", help="Initialize from a scenarios.yaml or instance.yaml" + ), ] = None, ports_base: Annotated[ int, - typer.Option("--ports-base", help="Base port (KAS ports computed as base+N*101)"), + typer.Option( + "--ports-base", help="Base port (KAS ports computed as base+N*101)" + ), ] = 8080, platform_dist: Annotated[ Optional[str], @@ -38,7 +48,10 @@ def init( _init_from_scenario(name, from_scenario, instance_dir) else: if platform_dist is None: - typer.echo("Error: --platform is required when not using --from-scenario", err=True) + typer.echo( + "Error: --platform is required when not using --from-scenario", + err=True, + ) raise typer.Exit(2) _init_minimal(name, instance_dir, ports_base, platform_dist) @@ -64,7 +77,9 @@ def _init_from_scenario(name: str, scenario_path: Path, instance_dir: Path) -> N else: raise typer.BadParameter(f"{scenario_path} has unknown kind {kind!r}") # Ensure the metadata name matches the chosen directory name. - instance.metadata = Metadata(**{**instance.metadata.model_dump(exclude_none=True), "name": name}) + instance.metadata = Metadata( + **{**instance.metadata.model_dump(exclude_none=True), "name": name} + ) instance_dir.mkdir(parents=True, exist_ok=True) (instance_dir / "kas").mkdir(parents=True, exist_ok=True) (instance_dir / "keys").mkdir(mode=0o700, parents=True, exist_ok=True) @@ -72,7 +87,9 @@ def _init_from_scenario(name: str, scenario_path: Path, instance_dir: Path) -> N dump_instance(instance, instance_dir / "instance.yaml") -def _init_minimal(name: str, instance_dir: Path, ports_base: int, platform_dist: str) -> None: +def _init_minimal( + name: str, instance_dir: Path, ports_base: int, platform_dist: str +) -> None: """Create a barebones instance.yaml with default KAS layout.""" instance = Instance( metadata=Metadata(name=name), @@ -150,7 +167,11 @@ def ls( "name": child.name, "platform": ( inst.platform.dist - or (inst.platform.source.ref if inst.platform.source else inst.platform.image) + or ( + inst.platform.source.ref + if inst.platform.source + else inst.platform.image + ) ), "ports_base": inst.ports.base, "kas": list(inst.kas.keys()), diff --git a/otdf-local/src/otdf_local/cli_scenario.py b/otdf-local/src/otdf_local/cli_scenario.py index 7d1dfde30..8046b012a 100644 --- a/otdf-local/src/otdf_local/cli_scenario.py +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import os import subprocess from pathlib import Path @@ -34,7 +35,11 @@ def _build_pytest_args(scenario: Scenario, scenario_path: Path) -> list[str]: helper raises FileNotFoundError with a clean hint otherwise. """ suite = scenario.suite - args: list[str] = [suite.select] + # Strip leading "xtest/" from targets — pytest runs from within xtest_root, + # so paths prefixed with "xtest/" would be resolved as "xtest/xtest/...". + args: list[str] = [ + t.removeprefix("xtest/") if t.startswith("xtest/") else t for t in suite.targets + ] tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path)) if tokens["encrypt"]: @@ -42,7 +47,7 @@ def _build_pytest_args(scenario: Scenario, scenario_path: Path) -> list[str]: if tokens["decrypt"]: args.extend(["--sdks-decrypt", " ".join(tokens["decrypt"])]) if suite.containers: - args.extend(["--containers", suite.containers]) + args.extend(["--containers", " ".join(suite.containers)]) if suite.markers: args.extend(["-m", suite.markers]) args.extend(suite.extra_args) @@ -72,13 +77,33 @@ def run( scenario = load_scenario(path) instance_name = instance or scenario.instance.metadata.name if not instance_name: - typer.echo("Error: scenario.instance.metadata.name not set; pass --instance", err=True) + typer.echo( + "Error: scenario.instance.metadata.name not set; pass --instance", err=True + ) raise typer.Exit(2) settings = get_settings() # Force the chosen instance via env so child pytest invocations agree. os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance_name + # Tell xtest's load_otdfctl() which dist to use for the otdfctl admin CLI. + # Without this it falls back to sdk/go/dist/main/otdfctl.sh (hardcoded + # "main"), which doesn't exist when the resolved dist name is e.g. "vmain". + try: + installed_data = json.loads( + installed_json_for(path).read_text(encoding="utf-8") + ) + go_dists = [ + Path(e["path"]).name + for role in ("encrypt", "decrypt") + for e in installed_data.get("sdks", {}).get(role, []) + if isinstance(e, dict) and e.get("sdk") == "go" and e.get("path") + ] + if go_dists: + os.environ["OTDFCTL_HEADS"] = json.dumps(list(dict.fromkeys(go_dists))) + except (FileNotFoundError, json.JSONDecodeError, KeyError): + pass + xtest_root = settings.xtest_root if not xtest_root.exists(): typer.echo(f"Error: xtest root not found at {xtest_root}", err=True) diff --git a/otdf-local/src/otdf_local/config/features.py b/otdf-local/src/otdf_local/config/features.py index 567a81933..7945e1472 100644 --- a/otdf-local/src/otdf_local/config/features.py +++ b/otdf-local/src/otdf_local/config/features.py @@ -61,7 +61,7 @@ def _get_platform_version(platform_dir: Path) -> str: timeout=60, ) if result.returncode == 0: - return result.stdout.strip() + return (result.stdout or result.stderr).strip() except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): pass diff --git a/otdf-local/src/otdf_local/config/settings.py b/otdf-local/src/otdf_local/config/settings.py index dffc2cefc..7df82aef1 100644 --- a/otdf-local/src/otdf_local/config/settings.py +++ b/otdf-local/src/otdf_local/config/settings.py @@ -196,9 +196,33 @@ def kas_template_config(self) -> Path: """KAS config template path (legacy mode).""" return self._require_platform_dir() / "opentdf-kas-mode.yaml" + @property + def platform_source_dir(self) -> Path | None: + """Return the platform source directory for go run / provisioning. + + Legacy mode: the sibling platform/ checkout. + Instance + source build: the platform src worktree (xtest/platform/src//). + """ + if self.platform_dir is not None: + return self.platform_dir + instance = self.load_instance() + if instance is not None and instance.platform.source is not None: + from otdf_sdk_mgr.platform_installer import get_platform_dir + + safe_ref = instance.platform.source.ref.replace("/", "--") + src_worktree = get_platform_dir() / "src" / safe_ref + if src_worktree.exists(): + return src_worktree + return None + @property def docker_compose_file(self) -> Path: """Docker compose file path.""" + src = self.platform_source_dir + if src is not None: + compose = src / "docker-compose.yaml" + if compose.exists(): + return compose return self._require_platform_dir() / "docker-compose.yaml" # Service ports diff --git a/otdf-local/src/otdf_local/services/kas.py b/otdf-local/src/otdf_local/services/kas.py index d06350e77..a5d346264 100644 --- a/otdf-local/src/otdf_local/services/kas.py +++ b/otdf-local/src/otdf_local/services/kas.py @@ -59,17 +59,25 @@ def is_key_management(self) -> bool: def _instance_paths(self) -> tuple[Path, Path] | None: """Return (binary, worktree) for an instance-pinned KAS, or None.""" + from otdf_sdk_mgr.semver import normalize_version + instance = self.settings.load_instance() if instance is None: return None pin = instance.kas.get(self._kas_name) - if pin is None or pin.dist is None: + if pin is None: + return None + if pin.dist is not None: + dist_label = pin.dist + elif pin.source is not None: + dist_label = normalize_version(pin.source.ref) + else: return None - binary = self.settings.platform_binary_for(pin.dist) + binary = self.settings.platform_binary_for(dist_label) if not binary.exists(): raise FileNotFoundError( f"KAS {self._kas_name} binary not found at {binary}. " - f"Run `otdf-sdk-mgr install release platform:{pin.dist}`." + f"Run `otdf-sdk-mgr install scenario` to provision it." ) worktree = binary.parent version_file = binary.parent / ".version" @@ -111,7 +119,9 @@ def _generate_config(self) -> Path: # Per-KAS features from instance.yaml override the legacy heuristic. instance = self.settings.load_instance() kas_pin = instance.kas.get(self._kas_name) if instance is not None else None - extra_features: dict[str, bool] = dict(kas_pin.features) if kas_pin is not None else {} + extra_features: dict[str, bool] = ( + dict(kas_pin.features) if kas_pin is not None else {} + ) if self.is_key_management: updates["services.kas.preview.key_management"] = True diff --git a/otdf-local/src/otdf_local/services/platform.py b/otdf-local/src/otdf_local/services/platform.py index aa65dcf1d..4ba1bd8f3 100644 --- a/otdf-local/src/otdf_local/services/platform.py +++ b/otdf-local/src/otdf_local/services/platform.py @@ -59,15 +59,24 @@ def _instance_dist_paths(self) -> tuple[Path, Path] | None: `.version` file records the source worktree path that should be used as `cwd` so the binary finds its embedded resources. """ + from otdf_sdk_mgr.semver import normalize_version + instance = self.settings.load_instance() - if instance is None or instance.platform.dist is None: + if instance is None: return None - binary = self.settings.platform_binary_for(instance.platform.dist) + + if instance.platform.dist is not None: + dist_label = instance.platform.dist + elif instance.platform.source is not None: + dist_label = normalize_version(instance.platform.source.ref) + else: + return None + + binary = self.settings.platform_binary_for(dist_label) if not binary.exists(): raise FileNotFoundError( f"Platform binary not found at {binary}. " - f"Run `otdf-sdk-mgr install release platform:{instance.platform.dist}` " - f"or `otdf-sdk-mgr install scenario` to provision it." + f"Run `otdf-sdk-mgr install scenario` to provision it." ) worktree = binary.parent # safe fallback version_file = binary.parent / ".version" @@ -89,6 +98,8 @@ def _generate_config(self) -> Path: config_path = self.settings.platform_config template_path = platform_dir / "opentdf.yaml" + if not template_path.exists(): + template_path = platform_dir / "opentdf-dev.yaml" # Detect platform features to determine supported config options features = PlatformFeatures.detect(platform_dir) @@ -118,8 +129,10 @@ def _setup_golden_keys(self, config_path: Path) -> None: """ # In multi-instance mode, golden keys live alongside the instance # config; otherwise they go into the legacy platform_dir. - target_dir = self.settings.keys_dir if self.settings.has_instance() else ( - self.settings._require_platform_dir() + target_dir = ( + self.settings.keys_dir + if self.settings.has_instance() + else (self.settings._require_platform_dir()) ) golden_keys = setup_golden_keys( self.settings.xtest_root, diff --git a/otdf-local/src/otdf_local/services/provisioner.py b/otdf-local/src/otdf_local/services/provisioner.py index cb620ca07..578c8b675 100644 --- a/otdf-local/src/otdf_local/services/provisioner.py +++ b/otdf-local/src/otdf_local/services/provisioner.py @@ -84,7 +84,7 @@ def _provision_(self, mode: str) -> ProvisionResult: cmd, capture_output=True, text=True, - cwd=self.settings.platform_dir, + cwd=self.settings.platform_source_dir, ) # If provisioning failed, extract error message from stderr diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py index e290d7731..04768207c 100644 --- a/otdf-local/tests/test_multi_instance.py +++ b/otdf-local/tests/test_multi_instance.py @@ -8,7 +8,6 @@ from __future__ import annotations -import os from pathlib import Path import pytest @@ -72,7 +71,11 @@ def test_settings_loads_instance_when_present(tmp_path: Path) -> None: assert s.keys_dir == instance_dir / "keys" -def test_platform_binary_for_resolves_under_xtest_platform(monkeypatch: pytest.MonkeyPatch) -> None: +def test_platform_binary_for_resolves_under_xtest_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("OTDF_PLATFORM_DIR", "/tmp/fake-platform") s = Settings() - assert s.platform_binary_for("v0.9.0") == Path("/tmp/fake-platform/dist/v0.9.0/service") + assert s.platform_binary_for("v0.9.0") == Path( + "/tmp/fake-platform/dist/v0.9.0/service" + ) diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py index 9fa0cdf2e..d111d08b9 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py @@ -15,7 +15,8 @@ import typer -from otdf_sdk_mgr.installers import InstallError, install_release +from otdf_sdk_mgr.config import get_sdk_dirs +from otdf_sdk_mgr.installers import InstallError, install_go_from_platform, install_release from otdf_sdk_mgr.platform_installer import ( PlatformInstallError, install_helper_scripts, @@ -29,6 +30,7 @@ Scenario, load_yaml_mapping, ) +from otdf_sdk_mgr.semver import normalize_version def _install_platform_pin(pin: PlatformPin | KasPin) -> dict[str, str]: @@ -97,7 +99,12 @@ def _snapshot(status: str | None = None) -> dict[str, object]: if scenario is not None: install_paths: dict[tuple[str, str, str | None], str] = {} for entry in scenario.sdks.union(): - dist_dir = install_release(entry.sdk, entry.version) + if entry.sdk == "go" and entry.source == "platform": + sdk_dirs = get_sdk_dirs() + dist_dir = sdk_dirs["go"] / "dist" / normalize_version(entry.version) + install_go_from_platform(entry.version, dist_dir) + else: + dist_dir = install_release(entry.sdk, entry.version) install_paths[entry.install_key()] = str(dist_dir) for role in ("encrypt", "decrypt"): installed_sdks[role] = [ diff --git a/otdf-sdk-mgr/src/otdf_sdk_mgr/installers.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/installers.py index f3c11b7da..2bc0c3763 100644 --- a/otdf-sdk-mgr/src/otdf_sdk_mgr/installers.py +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/installers.py @@ -26,6 +26,46 @@ class InstallError(Exception): """Raised when SDK installation fails.""" +def install_go_from_platform(ref: str, dist_dir: Path) -> None: + """Build the Go otdfctl CLI from the platform monorepo at the given ref. + + Checks out the platform monorepo via checkout_go_from_platform, then + builds otdfctl using the workspace go.work so all platform modules resolve. + """ + import os + + from otdf_sdk_mgr.checkout import checkout_go_from_platform + + binary = dist_dir / "otdfctl" + if binary.exists(): + print(f" Go platform build already exists at {dist_dir}; skipping.") + return + + print(f" Building Go SDK from platform@{ref}...") + platform_dir = checkout_go_from_platform(ref) + otdfctl_src = platform_dir / "otdfctl" + + dist_dir.mkdir(parents=True, exist_ok=True) + go_dir = get_sdk_dir() / "go" + + env = os.environ.copy() + env["GOWORK"] = str(platform_dir / "go.work") + result = subprocess.run( + ["go", "build", "-o", str(binary), "."], + cwd=otdfctl_src, + env=env, + ) + if result.returncode != 0: + raise InstallError(f"go build failed for platform@{ref}") + if not binary.exists(): + raise InstallError(f"Build completed but {binary} is missing") + + shutil.copy(go_dir / "cli.sh", dist_dir / "cli.sh") + shutil.copy(go_dir / "otdfctl.sh", dist_dir / "otdfctl.sh") + shutil.copy(go_dir / "opentdfctl.yaml", dist_dir / "opentdfctl.yaml") + print(f" Go SDK from platform@{ref} installed to {dist_dir}") + + def install_go_release(version: str, dist_dir: Path) -> None: """Install a Go CLI release by writing a .version file. diff --git a/otdf-sdk-mgr/tests/test_schema_sync.py b/otdf-sdk-mgr/tests/test_schema_sync.py index addeaf8ad..7e950a9cb 100644 --- a/otdf-sdk-mgr/tests/test_schema_sync.py +++ b/otdf-sdk-mgr/tests/test_schema_sync.py @@ -27,9 +27,7 @@ def _xtest_schema_dir() -> Path: @pytest.mark.parametrize(("model", "filename"), SCHEMAS, ids=lambda v: getattr(v, "__name__", v)) def test_committed_schema_matches_model(model: type, filename: str) -> None: path = _xtest_schema_dir() / filename - assert path.is_file(), ( - f"Missing {path}. Run `uv run otdf-sdk-mgr schema dump` to regenerate." - ) + assert path.is_file(), f"Missing {path}. Run `uv run otdf-sdk-mgr schema dump` to regenerate." expected = render(model) actual = path.read_text(encoding="utf-8") assert actual == expected, ( diff --git a/xtest/scenarios/dspx-3356.yaml b/xtest/scenarios/dspx-3356.yaml new file mode 100644 index 000000000..4a83a673a --- /dev/null +++ b/xtest/scenarios/dspx-3356.yaml @@ -0,0 +1,35 @@ +apiVersion: opentdf.io/v1alpha1 +kind: Scenario +metadata: + id: dspx-3356 + title: "PQC Go test_xwing_roundtrip is failing at o/platform main" + created: "2026-05-22" +instance: + metadata: + name: dspx-3356 + platform: + source: + ref: main + ports: + base: 8080 + kas: + km1: + source: + ref: main + mode: key_management +sdks: + encrypt: + - sdk: go + version: main + source: platform + decrypt: + - sdk: go + version: main + source: platform +suite: + targets: + - test_pqc.py::test_xwing_roundtrip + containers: + - ztdf +expected: "X-Wing TDF roundtrip completes successfully; wrappedKey in the KAO is 1120 bytes and ephemeralPublicKey is 1216 bytes." +actual: "AssertionError: X-Wing wrappedKey should be 1120 bytes, got 1190 — KAS returns a ciphertext of the wrong size under platform main."