Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
2 changes: 2 additions & 0 deletions otdf-local/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions otdf-sdk-mgr/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/<version>/` and `xtest/platform/dist/<version>/`.

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 <path>` | Reads `scenarios.yaml` / `instance.yaml`, installs every referenced artifact, writes `<name>.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 <worktree> reset
--hard <branch>` 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_<area>.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.
1 change: 1 addition & 0 deletions otdf-sdk-mgr/CLAUDE.md
97 changes: 91 additions & 6 deletions otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@
install_app = typer.Typer(help="Install SDK CLI artifacts from registries or source.")


def _register_scenario_cmd() -> None:
from otdf_sdk_mgr.cli_scenario import install_scenario_cmd

install_app.command("scenario")(install_scenario_cmd)


_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[
Expand All @@ -32,9 +65,19 @@ 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
from otdf_sdk_mgr.platform_installer import install_platform_release

cmd_lts(sdks or ALL_SDKS)
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)
_install_platform_or_exit(install_platform_release, version)
if sdk_targets:
cmd_lts(sdk_targets)


@install_app.command()
Expand All @@ -46,23 +89,65 @@ def tip(
) -> None:
"""Source checkout + build from main."""
from otdf_sdk_mgr.installers import cmd_tip
from otdf_sdk_mgr.platform_installer import install_platform_source

cmd_tip(sdks or ALL_SDKS)
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)


@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/<version>` tag in the
`opentdf/platform` monorepo.
"""
from otdf_sdk_mgr.installers import InstallError, cmd_release
from otdf_sdk_mgr.platform_installer import 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":
_install_platform_or_exit(install_platform_release, version)
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)

Expand Down
119 changes: 119 additions & 0 deletions otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""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 (
Instance,
KasPin,
PlatformPin,
Scenario,
load_yaml_mapping,
)


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)

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 kind == "Scenario":
scenario = Scenario.model_validate(raw)
instance = scenario.instance
elif kind == "Instance":
instance = Instance.model_validate(raw)
else:
typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True)
raise typer.Exit(1)
Comment on lines +57 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle non-YAML parse/validation failures as CLI exits.

Line 57-72 only handles YAML syntax errors. Read errors, top-level type errors, and schema validation failures currently escape as uncaught exceptions.

Proposed fix
 import typer
+from pydantic import ValidationError
@@
     try:
         raw = load_yaml_mapping(path)
-    except YAMLError as e:
+    except (OSError, YAMLError, ValueError) as e:
         typer.echo(f"Error: {path} is not valid YAML: {e}", err=True)
         raise typer.Exit(1)
@@
-    if kind == "Scenario":
-        scenario = Scenario.model_validate(raw)
-        instance = scenario.instance
-    elif kind == "Instance":
-        instance = Instance.model_validate(raw)
-    else:
-        typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True)
-        raise typer.Exit(1)
+    try:
+        if kind == "Scenario":
+            scenario = Scenario.model_validate(raw)
+            instance = scenario.instance
+        elif kind == "Instance":
+            instance = Instance.model_validate(raw)
+        else:
+            typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True)
+            raise typer.Exit(1)
+    except ValidationError as e:
+        typer.echo(f"Error: invalid manifest {path}: {e}", err=True)
+        raise typer.Exit(1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 kind == "Scenario":
scenario = Scenario.model_validate(raw)
instance = scenario.instance
elif kind == "Instance":
instance = Instance.model_validate(raw)
else:
typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True)
raise typer.Exit(1)
try:
raw = load_yaml_mapping(path)
except (OSError, YAMLError, ValueError) 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
try:
if kind == "Scenario":
scenario = Scenario.model_validate(raw)
instance = scenario.instance
elif kind == "Instance":
instance = Instance.model_validate(raw)
else:
typer.echo(f"Error: {path} has unknown kind {kind!r}", err=True)
raise typer.Exit(1)
except ValidationError as e:
typer.echo(f"Error: invalid manifest {path}: {e}", err=True)
raise typer.Exit(1)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py` around lines 57 - 72, The code
only catches YAMLError from load_yaml_mapping but lets other parse/validation
errors escape; update the block around load_yaml_mapping, the raw kind
extraction, and the calls to Scenario.model_validate and Instance.model_validate
to also handle non-YAML failures by validating that raw is a mapping
(isinstance(raw, dict)) and wrapping model_validate calls in a try/except that
catches validation/parse/type errors (e.g., ValueError/TypeError and the
validation error your pydantic layer raises) and in the except branch call
typer.echo with a clear message including the exception and then raise
typer.Exit(1); ensure instance and scenario variables are only used after
successful validation.


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)
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()

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:
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(_snapshot(), indent=2) + "\n")
typer.echo(f" Wrote {out}")
Loading
Loading