diff --git a/.claude/plugin/plugin.json b/.claude/plugin/plugin.json new file mode 100644 index 000000000..906d0d240 --- /dev/null +++ b/.claude/plugin/plugin.json @@ -0,0 +1,49 @@ +{ + "name": "opentdf-test-harness", + "version": "0.1.0", + "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", + "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-ticket skill)", + "gh (GitHub CLI; needed for scenario-matrix to resolve PR refs)" + ], + "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 *)", + "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/bugs/*_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..a14484c31 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,34 @@ +{ + "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 *)", + "Skill(*)", + "Write(xtest/scenarios/**)", + "Write(xtest/features/**)", + "Write(xtest/bugs/*_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..bf3854aa6 --- /dev/null +++ b/.claude/skills/feature-design/SKILL.md @@ -0,0 +1,118 @@ +--- +name: feature-design +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 +--- + +# 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 — `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 --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. + +### 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/.claude/skills/instance-status/SKILL.md b/.claude/skills/instance-status/SKILL.md new file mode 100644 index 000000000..cef888d2a --- /dev/null +++ b/.claude/skills/instance-status/SKILL.md @@ -0,0 +1,36 @@ +--- +name: instance-status +description: Use when the user asks what's running, or before starting a scenario to check for 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-ticket/SKILL.md b/.claude/skills/scenario-from-ticket/SKILL.md new file mode 100644 index 000000000..21db97f88 --- /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/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 +--- + +# 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/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`. + +## Step 1 — Pull the Jira ticket into context + +**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 --key +``` + +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 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): + +```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/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. + +## 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..ee01aba54 --- /dev/null +++ b/.claude/skills/scenario-matrix/SKILL.md @@ -0,0 +1,91 @@ +--- +name: scenario-matrix +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 +--- + +# 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 new file mode 100644 index 000000000..c127ecee4 --- /dev/null +++ b/.claude/skills/scenario-run/SKILL.md @@ -0,0 +1,48 @@ +--- +name: scenario-run +description: Use after `scenario-up` to run the scenario's test suite and classify results against its expected/actual fields. +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 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 + +- 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** 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 (`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 +- `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..0838e9585 --- /dev/null +++ b/.claude/skills/scenario-tear-down/SKILL.md @@ -0,0 +1,42 @@ +--- +name: scenario-tear-down +description: Use when the user is done with a scenario or wants to stop, clean up, or free ports/disk. +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..dcf1ea357 --- /dev/null +++ b/.claude/skills/scenario-up/SKILL.md @@ -0,0 +1,51 @@ +--- +name: scenario-up +description: Use when the user has a scenario YAML and wants the environment started (before running tests). +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`). 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/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-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..a291a46e1 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 subprocess import sys import time -from typing import Annotated +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() @@ -558,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 @@ -594,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(): @@ -607,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 new file mode 100644 index 000000000..2699c86bb --- /dev/null +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -0,0 +1,204 @@ +"""`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..8046b012a --- /dev/null +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -0,0 +1,126 @@ +"""`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 json +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 + # 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"]: + args.extend(["--sdks-encrypt", " ".join(tokens["encrypt"])]) + if tokens["decrypt"]: + args.extend(["--sdks-decrypt", " ".join(tokens["decrypt"])]) + if suite.containers: + args.extend(["--containers", " ".join(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 + + # 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) + 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/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/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..7df82aef1 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,124 @@ 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 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.""" - return self.platform_dir / "docker-compose.yaml" + 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 keycloak_port: int = Ports.KEYCLOAK @@ -147,11 +242,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 +275,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..a5d346264 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,68 @@ 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.""" + 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: + 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(dist_label) + if not binary.exists(): + raise FileNotFoundError( + f"KAS {self._kas_name} binary not found at {binary}. " + f"Run `otdf-sdk-mgr install scenario` to provision it." + ) + 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 +116,45 @@ 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 +192,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 +208,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 +238,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..4ba1bd8f3 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,57 @@ 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. + """ + from otdf_sdk_mgr.semver import normalize_version + + instance = self.settings.load_instance() + if instance is None: + return None + + 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 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" + if not template_path.exists(): + template_path = platform_dir / "opentdf-dev.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 +127,16 @@ 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 +165,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 +182,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/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/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..04768207c --- /dev/null +++ b/otdf-local/tests/test_multi_instance.py @@ -0,0 +1,81 @@ +"""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 + +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" 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/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_install.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py index e3950d717..6bf0a4895 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,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[ @@ -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() @@ -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/` 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) 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..d111d08b9 --- /dev/null +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py @@ -0,0 +1,126 @@ +"""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.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, + install_platform_release, + install_platform_source, +) +from otdf_sdk_mgr.schema import ( + Instance, + KasPin, + PlatformPin, + Scenario, + load_yaml_mapping, +) +from otdf_sdk_mgr.semver import normalize_version + + +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) + + 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(): + 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] = [ + { + "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}") 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/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/src/otdf_sdk_mgr/platform_installer.py b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py new file mode 100644 index 000000000..d979c0bc5 --- /dev/null +++ b/otdf-sdk-mgr/src/otdf_sdk_mgr/platform_installer.py @@ -0,0 +1,230 @@ +"""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, 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 (exit {result.returncode}): {' '.join(cmd)}") + + +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) 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)}" + + +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: + raise PlatformInstallError( + f"git rev-parse {rev} failed in {worktree}: {result.stderr.strip()}" + ) + 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; 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: + # 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), "reset", "--hard", branch]) + src_scripts = worktree / "scripts" + 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 + + +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 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 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..4f007b18f --- /dev/null +++ b/otdf-sdk-mgr/tests/test_cli_scenario.py @@ -0,0 +1,125 @@ +"""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 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..7e950a9cb --- /dev/null +++ b/otdf-sdk-mgr/tests/test_schema_sync.py @@ -0,0 +1,36 @@ +"""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/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`) diff --git a/xtest/README.md b/xtest/README.md index 6bdfcc400..0c7400fac 100644 --- a/xtest/README.md +++ b/xtest/README.md @@ -122,3 +122,11 @@ 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`. +- **`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`. + +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/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. 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. 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." 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" +}