From 7908beb72e2d34e0319cd9d070f92f686e3ebf8c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 15 May 2026 11:51:38 -0400 Subject: [PATCH 01/17] feat(otdf-local): multi-instance test environments (DSPX-3302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors otdf-local from a single-instance CLI (one platform checkout, fixed ports, hardcoded six KAS instances) into a multi-instance harness where each named instance under tests/instances// owns its own opentdf.yaml, keys, KAS configs, and port range. Why --- A single bug report often describes a *combination* — platform v0.9.0 with Java SDK 0.7.8 and a KAS at a pre-release. Today a developer has to hand-edit configs and re-checkout the platform to reproduce. After this change: otdf-local instance init java-078 --from-scenario .../scenario.yaml otdf-local --instance java-078 up brings up exactly the topology the scenario describes, using platform binaries that otdf-sdk-mgr already provisioned (each instance, and each KAS within an instance, can reference a different pinned version). Two instances on disjoint ports.base can coexist on a developer laptop. What changes ------------ otdf-local now depends on otdf-sdk-mgr via a uv path source so both tools share the canonical Scenario/Instance schema. Settings (otdf_local.config.settings): - New instance_name (env-overridable via OTDF_LOCAL_INSTANCE_NAME), instance_dir, instances_root, instance_yaml properties. - platform_dir becomes optional; legacy sibling-discovery only kicks in when no per-instance configuration is present. - platform_binary_for(dist) resolves to the otdf-sdk-mgr-managed xtest/platform/dist//service binary. - keys_dir, logs_dir, config_dir, platform_config, and get_kas_config_path switch to per-instance paths whenever instance.yaml exists; legacy behavior is preserved otherwise. - load_instance() reads the per-instance manifest via the shared Pydantic model. Ports (otdf_local.config.ports): - KAS_OFFSETS exposes the offset table (alpha=+101, beta=+202, ..., km2=+606) so multiple instances on different bases get disjoint port ranges. The legacy 8080-based constants are preserved as defaults. - get_kas_port(name, base=...) computes the port relative to base. Services (otdf_local.services.platform / .kas): - PlatformService.start() and KASService.start() use the pinned dist binary at xtest/platform/dist//service when an instance is loaded, with cwd set to the recorded worktree so the binary finds its embedded resources. Legacy `go run ./service` path runs unchanged when no instance is active. - KASService.is_key_management defers to the manifest's `mode` field instead of the legacy name-based heuristic; per-KAS features (e.g. ec_tdf_enabled) pass through to opentdf.yaml. - KASManager constructs only the KAS instances listed in instance.yaml's kas: map. start_standard / start_km filter on is_key_management so subset topologies still work. utils.keys.setup_golden_keys: - Writes key files into the target directory (per-instance keys_dir or legacy platform_dir) and uses absolute paths in the generated keys_config so the binary finds them regardless of cwd. CLI: - New top-level --instance option threads through every command via OTDF_LOCAL_INSTANCE_NAME. - New `instance` subcommand group: init [--from-scenario PATH], ls --json, rm. - New `scenario` subcommand: `run ` translates the scenario's suite block into `pytest --sdks-encrypt ... --sdks-decrypt ... --containers ...` under xtest/ with OTDF_LOCAL_INSTANCE_NAME set. Tests (otdf-local/tests/test_multi_instance.py): - Port arithmetic at default and alternate bases. - Settings round-trip with and without an instance.yaml. - platform_binary_for resolves under the otdf-sdk-mgr-managed xtest/platform/ tree. .gitignore additions: - tests/instances/ (per-instance config and logs) - xtest/scenarios/*.installed.json (provisioning records) - .claude/tmp/ Backward compatibility: - `otdf-local up` with no --instance flag keeps working against a sibling platform/ checkout. Refs: https://virtru.atlassian.net/browse/DSPX-3302 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 + otdf-local/pyproject.toml | 4 + otdf-local/src/otdf_local/cli.py | 28 ++- otdf-local/src/otdf_local/cli_instance.py | 183 ++++++++++++++++++ otdf-local/src/otdf_local/cli_scenario.py | 101 ++++++++++ otdf-local/src/otdf_local/config/ports.py | 30 ++- otdf-local/src/otdf_local/config/settings.py | 116 +++++++++-- otdf-local/src/otdf_local/services/kas.py | 115 +++++++---- .../src/otdf_local/services/platform.py | 69 +++++-- otdf-local/src/otdf_local/utils/keys.py | 10 +- otdf-local/tests/test_multi_instance.py | 78 ++++++++ otdf-local/uv.lock | 69 ++++++- 12 files changed, 739 insertions(+), 71 deletions(-) create mode 100644 otdf-local/src/otdf_local/cli_instance.py create mode 100644 otdf-local/src/otdf_local/cli_scenario.py create mode 100644 otdf-local/tests/test_multi_instance.py diff --git a/.gitignore b/.gitignore index a1bb32382..6fa376ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ xtest/sdk/java/cmdline.jar /xtest/otdfctl/ /tmp/ + +# Multi-instance test harness state (DSPX-3302). Per-instance config, logs, and +# keys live under tests/instances/; otdf-sdk-mgr install scenario writes +# .installed.json next to each scenarios.yaml. +/instances/ +xtest/scenarios/*.installed.json +.claude/tmp/ diff --git a/otdf-local/pyproject.toml b/otdf-local/pyproject.toml index fc3d08bc8..7800389a0 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.14.1", "rich>=15.0.0", "ruamel.yaml>=0.18.0", "typer>=0.26.5", ] +[tool.uv.sources] +otdf-sdk-mgr = { path = "../otdf-sdk-mgr", editable = true } + [dependency-groups] dev = [ "pyright>=1.1.410", diff --git a/otdf-local/src/otdf_local/cli.py b/otdf-local/src/otdf_local/cli.py index d8e3597ff..422daa65a 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -1,10 +1,12 @@ """Typer CLI for otdf_local - OpenTDF test environment management.""" import json +import os import shutil import sys import time -from typing import Annotated +from pathlib import Path +from typing import Annotated, Optional import httpx import typer @@ -44,6 +46,18 @@ ) +def _register_subapps() -> None: + """Defer imports so the schema dependency only loads when needed.""" + from otdf_local.cli_instance import instance_app + from otdf_local.cli_scenario import scenario_app + + app.add_typer(instance_app, name="instance") + app.add_typer(scenario_app, name="scenario") + + +_register_subapps() + + def _show_provision_error(result: ProvisionResult, target: str) -> None: """Display provisioning error with stderr details.""" print_error(f"{target} provisioning failed (exit code {result.return_code})") @@ -75,9 +89,19 @@ def main( is_eager=True, ), ] = False, + instance: Annotated[ + Optional[str], + typer.Option( + "--instance", + help='Named instance under tests/instances/. Defaults to "default" (or $OTDF_LOCAL_INSTANCE_NAME).', + ), + ] = None, ) -> None: """OpenTDF test environment management CLI.""" - pass + if instance is not None: + os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance + # Invalidate the cached Settings so subsequent commands see the new value + get_settings.cache_clear() @app.command() diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py new file mode 100644 index 000000000..98407f56e --- /dev/null +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -0,0 +1,183 @@ +"""`otdf-local instance` subcommands: init / ls / rm.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Annotated, Optional + +import typer +from otdf_sdk_mgr.schema import Instance, Metadata, PlatformPin, PortsConfig, dump_instance + +from otdf_local.config.settings import get_settings + +instance_app = typer.Typer(help="Manage named test environment instances.") + + +@instance_app.command("init") +def init( + name: Annotated[str, typer.Argument(help="Instance name (used as directory name)")], + from_scenario: Annotated[ + Optional[Path], + typer.Option("--from-scenario", help="Initialize from a scenarios.yaml or instance.yaml"), + ] = None, + ports_base: Annotated[ + int, + typer.Option("--ports-base", help="Base port (KAS ports computed as base+N*101)"), + ] = 8080, + platform_dist: Annotated[ + Optional[str], + typer.Option("--platform", help="Platform dist version (e.g., v0.9.0)"), + ] = None, +) -> None: + """Scaffold a new instance directory at tests/instances//.""" + settings = get_settings() + instance_dir = settings.instances_root / name + + if from_scenario is not None: + _init_from_scenario(name, from_scenario, instance_dir) + else: + if platform_dist is None: + typer.echo("Error: --platform is required when not using --from-scenario", err=True) + raise typer.Exit(2) + _init_minimal(name, instance_dir, ports_base, platform_dist) + + _validate_port_uniqueness(settings.instances_root, name) + typer.echo(f" Initialized instance '{name}' at {instance_dir}") + + +def _init_from_scenario(name: str, scenario_path: Path, instance_dir: Path) -> None: + """Copy the embedded Instance from a Scenario or load a standalone Instance.""" + from otdf_sdk_mgr.schema import load_instance, load_scenario + from ruamel.yaml import YAML + + y = YAML(typ="safe") + raw = y.load(scenario_path.read_text()) + if not isinstance(raw, dict): + raise typer.BadParameter(f"{scenario_path} top-level YAML must be a mapping") + kind = raw.get("kind") + if kind == "Scenario": + scenario = load_scenario(scenario_path) + instance = scenario.instance + elif kind == "Instance": + instance = load_instance(scenario_path) + else: + raise typer.BadParameter(f"{scenario_path} has unknown kind {kind!r}") + # Ensure the metadata name matches the chosen directory name. + instance.metadata = Metadata(**{**instance.metadata.model_dump(exclude_none=True), "name": name}) + instance_dir.mkdir(parents=True, exist_ok=True) + (instance_dir / "kas").mkdir(parents=True, exist_ok=True) + (instance_dir / "keys").mkdir(mode=0o700, parents=True, exist_ok=True) + (instance_dir / "logs").mkdir(parents=True, exist_ok=True) + dump_instance(instance, instance_dir / "instance.yaml") + + +def _init_minimal(name: str, instance_dir: Path, ports_base: int, platform_dist: str) -> None: + """Create a barebones instance.yaml with default KAS layout.""" + instance = Instance( + metadata=Metadata(name=name), + platform=PlatformPin(dist=platform_dist), + ports=PortsConfig(base=ports_base), + kas={}, + ) + instance_dir.mkdir(parents=True, exist_ok=True) + (instance_dir / "kas").mkdir(parents=True, exist_ok=True) + (instance_dir / "keys").mkdir(mode=0o700, parents=True, exist_ok=True) + (instance_dir / "logs").mkdir(parents=True, exist_ok=True) + dump_instance(instance, instance_dir / "instance.yaml") + + +def _validate_port_uniqueness(instances_root: Path, new_name: str) -> None: + """Warn if another instance shares the same `ports.base`.""" + from otdf_sdk_mgr.schema import load_instance + + new_yaml = instances_root / new_name / "instance.yaml" + if not new_yaml.exists(): + return + new_inst = load_instance(new_yaml) + new_base = new_inst.ports.base + if not instances_root.exists(): + return + for child in instances_root.iterdir(): + if not child.is_dir() or child.name == new_name: + continue + other_yaml = child / "instance.yaml" + if not other_yaml.is_file(): + continue + try: + other = load_instance(other_yaml) + except Exception: + continue + if other.ports.base == new_base: + typer.echo( + f" Warning: instance '{child.name}' already uses ports.base={new_base}; " + f"running both simultaneously will collide. Change one with `otdf-local instance init`.", + err=True, + ) + + +@instance_app.command("ls") +def ls( + as_json: Annotated[bool, typer.Option("--json", "-j", help="Emit JSON")] = False, +) -> None: + """List known instances.""" + import json as _json + + from otdf_sdk_mgr.schema import load_instance + + settings = get_settings() + root = settings.instances_root + if not root.exists(): + if as_json: + typer.echo(_json.dumps([])) + else: + typer.echo(" (no instances yet)") + return + rows: list[dict[str, object]] = [] + for child in sorted(root.iterdir()): + if not child.is_dir(): + continue + ymp = child / "instance.yaml" + if not ymp.is_file(): + continue + try: + inst = load_instance(ymp) + except Exception as e: + rows.append({"name": child.name, "error": str(e)}) + continue + rows.append( + { + "name": child.name, + "platform": ( + inst.platform.dist + or (inst.platform.source.ref if inst.platform.source else inst.platform.image) + ), + "ports_base": inst.ports.base, + "kas": list(inst.kas.keys()), + } + ) + if as_json: + typer.echo(_json.dumps(rows, indent=2)) + else: + for row in rows: + typer.echo(f" {row}") + + +@instance_app.command("rm") +def rm( + name: Annotated[str, typer.Argument(help="Instance to remove")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False, +) -> None: + """Remove an instance directory.""" + settings = get_settings() + instance_dir = settings.instances_root / name + if not instance_dir.exists(): + typer.echo(f"Error: instance '{name}' not found at {instance_dir}", err=True) + raise typer.Exit(1) + if not yes: + confirm = typer.confirm(f"Delete {instance_dir}?", default=False) + if not confirm: + typer.echo("aborted") + raise typer.Exit(1) + shutil.rmtree(instance_dir) + typer.echo(f" Removed {instance_dir}") diff --git a/otdf-local/src/otdf_local/cli_scenario.py b/otdf-local/src/otdf_local/cli_scenario.py new file mode 100644 index 000000000..7d1dfde30 --- /dev/null +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -0,0 +1,101 @@ +"""`otdf-local scenario` subcommands. + +Today's surface area is intentionally narrow — `run` is the only command +that's part of the bug-repro MVP. Bisect and other higher-level loops are +deferred (see plan §9). +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Annotated + +import typer +from otdf_sdk_mgr.schema import ( + Scenario, + installed_json_for, + load_scenario, + scenario_to_pytest_sdks, +) + +from otdf_local.config.settings import get_settings + +scenario_app = typer.Typer(help="Run scenarios.yaml against a healthy instance.") + + +def _build_pytest_args(scenario: Scenario, scenario_path: Path) -> list[str]: + """Translate the scenario's `suite` block into pytest CLI args. + + SDK pins go through `scenario_to_pytest_sdks` so they're forwarded as + the `sdk@` tokens xtest's #446 specifier format expects. + Requires that `otdf-sdk-mgr install scenario` has been run first; the + helper raises FileNotFoundError with a clean hint otherwise. + """ + suite = scenario.suite + args: list[str] = [suite.select] + + tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path)) + if tokens["encrypt"]: + args.extend(["--sdks-encrypt", " ".join(tokens["encrypt"])]) + if tokens["decrypt"]: + args.extend(["--sdks-decrypt", " ".join(tokens["decrypt"])]) + if suite.containers: + args.extend(["--containers", suite.containers]) + if suite.markers: + args.extend(["-m", suite.markers]) + args.extend(suite.extra_args) + return args + + +@scenario_app.command("run") +def run( + path: Annotated[Path, typer.Argument(help="Path to scenarios.yaml")], + instance: Annotated[ + str | None, + typer.Option( + "--instance", + help="Override which instance to use (defaults to scenario.instance.metadata.name)", + ), + ] = None, + extra: Annotated[ + list[str] | None, + typer.Argument(help="Extra args passed through to pytest (after --)"), + ] = None, +) -> None: + """Run the pytest suite declared by the scenario against its instance.""" + if not path.exists(): + typer.echo(f"Error: {path} not found", err=True) + raise typer.Exit(1) + + scenario = load_scenario(path) + instance_name = instance or scenario.instance.metadata.name + if not instance_name: + typer.echo("Error: scenario.instance.metadata.name not set; pass --instance", err=True) + raise typer.Exit(2) + + settings = get_settings() + # Force the chosen instance via env so child pytest invocations agree. + os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance_name + + xtest_root = settings.xtest_root + if not xtest_root.exists(): + typer.echo(f"Error: xtest root not found at {xtest_root}", err=True) + raise typer.Exit(1) + + try: + pytest_args = _build_pytest_args(scenario, path) + except FileNotFoundError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + if extra: + pytest_args.extend(extra) + + cmd = ["uv", "run", "pytest", *pytest_args] + typer.echo(f" Running: {' '.join(cmd)} (cwd={xtest_root})") + completed = subprocess.run(cmd, cwd=xtest_root) + raise typer.Exit(completed.returncode) diff --git a/otdf-local/src/otdf_local/config/ports.py b/otdf-local/src/otdf_local/config/ports.py index 21d193358..913f970d0 100644 --- a/otdf-local/src/otdf_local/config/ports.py +++ b/otdf-local/src/otdf_local/config/ports.py @@ -33,14 +33,40 @@ class Ports: "km2": "KAS_KM2", } + # Offset of each KAS port from `base` (which is the platform port). + # The defaults at base=8080 reproduce the historical 8181/8282/... layout. + KAS_OFFSETS: ClassVar[dict[str, int]] = { + "alpha": 101, + "beta": 202, + "gamma": 303, + "delta": 404, + "km1": 505, + "km2": 606, + } + @classmethod - def get_kas_port(cls, name: str) -> int: - """Get port for a KAS instance by name.""" + def get_kas_port(cls, name: str, *, base: int | None = None) -> int: + """Get port for a KAS instance by name. + + When `base` is provided, the port is computed as `base + offset` so + multiple instances can coexist on disjoint port ranges. Otherwise the + legacy class constants are returned (base=8080 layout). + """ + if base is not None: + offset = cls.KAS_OFFSETS.get(name) + if offset is None: + raise ValueError(f"Unknown KAS instance: {name}") + return base + offset attr = cls._KAS_NAMES.get(name) if attr is None: raise ValueError(f"Unknown KAS instance: {name}") return getattr(cls, attr) + @classmethod + def platform_port_for(cls, base: int) -> int: + """Return the platform port for a given `base`. Trivially `base` today.""" + return base + @classmethod def all_kas_names(cls) -> list[str]: """Return all KAS instance names.""" diff --git a/otdf-local/src/otdf_local/config/settings.py b/otdf-local/src/otdf_local/config/settings.py index 96a4c20e8..dffc2cefc 100644 --- a/otdf-local/src/otdf_local/config/settings.py +++ b/otdf-local/src/otdf_local/config/settings.py @@ -8,6 +8,8 @@ from otdf_local.config.ports import Ports +DEFAULT_INSTANCE_NAME = "default" + def _pyproject_has_name(path: Path, project_name: str) -> bool: """Return True if path/pyproject.toml contains the given project name.""" @@ -80,6 +82,19 @@ def _find_platform_dir(xtest_root: Path) -> Path: ) +def _find_platform_dir_optional(xtest_root: Path) -> Path | None: + """Same as `_find_platform_dir` but returns None instead of raising. + + Multi-instance mode looks up platform binaries via `otdf-sdk-mgr` instead of + a sibling repo, so a missing sibling `platform/` is no longer fatal — only + the legacy single-instance path needs it. + """ + try: + return _find_platform_dir(xtest_root) + except FileNotFoundError: + return None + + class Settings(BaseSettings): """Application settings with environment variable support.""" @@ -91,44 +106,100 @@ class Settings(BaseSettings): # Directory paths - computed from xtest_root xtest_root: Path = Field(default_factory=_find_xtest_root) - platform_dir: Path = Field( - default_factory=lambda: _find_platform_dir(_find_xtest_root()) + platform_dir: Path | None = Field( + default_factory=lambda: _find_platform_dir_optional(_find_xtest_root()) ) + # Multi-instance: which named instance under `tests/instances//` to use. + instance_name: str = DEFAULT_INSTANCE_NAME + + @property + def tests_root(self) -> Path: + """Repo root that holds `xtest/`, `instances/`, `otdf-local/`, etc.""" + return self.xtest_root.parent + + @property + def instances_root(self) -> Path: + """Top-level `tests/instances/` directory (created on demand).""" + return self.tests_root / "instances" + + @property + def instance_dir(self) -> Path: + """Per-instance directory: `tests/instances//`.""" + return self.instances_root / self.instance_name + + @property + def instance_yaml(self) -> Path: + """Path to the per-instance manifest.""" + return self.instance_dir / "instance.yaml" + + def has_instance(self) -> bool: + """Return True if `instance.yaml` exists for the selected instance.""" + return self.instance_yaml.is_file() + + def platform_binary_for(self, dist: str) -> Path: + """Resolve a platform dist version to its built `service` binary path. + + Looks under `xtest/platform/dist//service` (managed by + `otdf-sdk-mgr install platform:`). The binary is not required + to exist at the time of the call — callers should check existence and + surface a clear error suggesting `otdf-sdk-mgr install` when missing. + """ + from otdf_sdk_mgr.platform_installer import get_platform_dir + + return get_platform_dir() / "dist" / dist / "service" + @property def logs_dir(self) -> Path: - """Logs directory.""" + """Logs directory. Per-instance when an instance is selected, falls back to legacy.""" + if self.has_instance(): + return self.instance_dir / "logs" return self.xtest_root / "tmp" / "logs" @property def keys_dir(self) -> Path: - """Keys directory.""" + """Keys directory. Per-instance when an instance is selected, falls back to legacy.""" + if self.has_instance(): + return self.instance_dir / "keys" return self.xtest_root / "tmp" / "keys" @property def config_dir(self) -> Path: - """Generated config files directory.""" + """Generated config files directory. Per-instance when present.""" + if self.has_instance(): + return self.instance_dir return self.xtest_root / "tmp" / "config" + def _require_platform_dir(self) -> Path: + if self.platform_dir is None: + raise FileNotFoundError( + "No sibling platform/ directory found. Either check out opentdf/platform as " + "a sibling of tests/, or run `otdf-sdk-mgr install platform:` and " + "select an instance with `otdf-local --instance `." + ) + return self.platform_dir + @property def platform_config(self) -> Path: - """Platform config file path.""" - return self.platform_dir / "opentdf-dev.yaml" + """Platform config file. Per-instance when present, else legacy template.""" + if self.has_instance(): + return self.instance_dir / "opentdf.yaml" + return self._require_platform_dir() / "opentdf-dev.yaml" @property def platform_template_config(self) -> Path: - """Platform config template path.""" - return self.platform_dir / "opentdf.yaml" + """Platform config template path (legacy mode).""" + return self._require_platform_dir() / "opentdf.yaml" @property def kas_template_config(self) -> Path: - """KAS config template path.""" - return self.platform_dir / "opentdf-kas-mode.yaml" + """KAS config template path (legacy mode).""" + return self._require_platform_dir() / "opentdf-kas-mode.yaml" @property def docker_compose_file(self) -> Path: """Docker compose file path.""" - return self.platform_dir / "docker-compose.yaml" + return self._require_platform_dir() / "docker-compose.yaml" # Service ports keycloak_port: int = Ports.KEYCLOAK @@ -147,11 +218,28 @@ def docker_compose_file(self) -> Path: log_level: str = "info" def get_kas_port(self, name: str) -> int: - """Get port for a KAS instance.""" + """Get port for a KAS instance. + + When an `instance.yaml` exists with a `ports.base`, computes ports + relative to it so multiple instances on different bases don't clash. + """ + instance = self.load_instance() + if instance is not None: + return Ports.get_kas_port(name, base=instance.ports.base) return Ports.get_kas_port(name) + def load_instance(self): + """Load the per-instance manifest, or return None when not present.""" + if not self.has_instance(): + return None + from otdf_sdk_mgr.schema import load_instance as _load + + return _load(self.instance_yaml) + def get_kas_config_path(self, name: str) -> Path: """Get config file path for a KAS instance.""" + if self.has_instance(): + return self.instance_dir / "kas" / name / "opentdf.yaml" return self.config_dir / f"kas-{name}.yaml" def get_kas_log_path(self, name: str) -> Path: @@ -163,6 +251,8 @@ def ensure_directories(self) -> None: self.logs_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True) self.keys_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + if self.has_instance(): + (self.instance_dir / "kas").mkdir(parents=True, exist_ok=True) @lru_cache diff --git a/otdf-local/src/otdf_local/services/kas.py b/otdf-local/src/otdf_local/services/kas.py index 00de6a2cd..8c2ed5b09 100644 --- a/otdf-local/src/otdf_local/services/kas.py +++ b/otdf-local/src/otdf_local/services/kas.py @@ -35,7 +35,7 @@ def name(self) -> str: @property def port(self) -> int: - return Ports.get_kas_port(self._kas_name) + return self.settings.get_kas_port(self._kas_name) @property def service_type(self) -> ServiceType: @@ -47,25 +47,60 @@ def health_url(self) -> str: @property def is_key_management(self) -> bool: - """Check if this is a key management KAS instance.""" + """Check if this is a key management KAS instance. + + When an instance.yaml pins this KAS, prefer the manifest's `mode` + field. Otherwise fall back to the legacy name-based heuristic. + """ + instance = self.settings.load_instance() + if instance is not None and self._kas_name in instance.kas: + return instance.kas[self._kas_name].mode == "key_management" return Ports.is_km_kas(self._kas_name) + def _instance_paths(self) -> tuple[Path, Path] | None: + """Return (binary, worktree) for an instance-pinned KAS, or None.""" + instance = self.settings.load_instance() + if instance is None: + return None + pin = instance.kas.get(self._kas_name) + if pin is None or pin.dist is None: + return None + binary = self.settings.platform_binary_for(pin.dist) + if not binary.exists(): + raise FileNotFoundError( + f"KAS {self._kas_name} binary not found at {binary}. " + f"Run `otdf-sdk-mgr install release platform:{pin.dist}`." + ) + worktree = binary.parent + version_file = binary.parent / ".version" + if version_file.exists(): + for line in version_file.read_text().splitlines(): + if line.startswith("worktree="): + worktree = Path(line.split("=", 1)[1].strip()) + break + return binary, worktree + def _generate_config(self) -> Path: """Generate the KAS config file from template.""" + instance_paths = self._instance_paths() + if instance_paths is not None: + _, worktree = instance_paths + platform_dir = worktree + else: + platform_dir = self.settings._require_platform_dir() + config_path = self.settings.get_kas_config_path(self._kas_name) - template_path = self.settings.kas_template_config + config_path.parent.mkdir(parents=True, exist_ok=True) + template_path = platform_dir / "opentdf-kas-mode.yaml" # Load platform config to get root_key platform_config = load_yaml(self.settings.platform_config) root_key = get_nested(platform_config, "services.kas.root_key", "") # Detect platform features to determine supported config options - features = PlatformFeatures.detect(self.settings.platform_dir) - - # Use stderr if supported, otherwise stdout (v0.9.0 only supports stdout) + features = PlatformFeatures.detect(platform_dir) logger_output = "stderr" if features.supports("logger_stderr") else "stdout" - # Base updates for all KAS instances updates = { "logger.type": "json", "logger.output": logger_output, @@ -73,7 +108,11 @@ 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 @@ -81,37 +120,33 @@ def _generate_config(self) -> Path: # 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"}, ) @@ -149,7 +184,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, @@ -160,8 +200,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 ) @@ -185,17 +230,19 @@ def stop_all(self) -> dict[str, bool]: return results def start_standard(self) -> dict[str, bool]: - """Start only standard (non-km) KAS instances.""" + """Start only standard (non-key-management) KAS instances under management.""" results = {} - for name in Ports.standard_kas_names(): - results[name] = self._instances[name].start() + for name, inst in self._instances.items(): + if not inst.is_key_management: + results[name] = inst.start() return results def start_km(self) -> dict[str, bool]: - """Start only key management KAS instances.""" + """Start only key-management KAS instances under management.""" results = {} - for name in Ports.km_kas_names(): - results[name] = self._instances[name].start() + for name, inst in self._instances.items(): + if inst.is_key_management: + results[name] = inst.start() return results def get_all_info(self) -> list[ServiceInfo]: diff --git a/otdf-local/src/otdf_local/services/platform.py b/otdf-local/src/otdf_local/services/platform.py index 15f7f4e5e..aa65dcf1d 100644 --- a/otdf-local/src/otdf_local/services/platform.py +++ b/otdf-local/src/otdf_local/services/platform.py @@ -39,6 +39,9 @@ def name(self) -> str: @property def port(self) -> int: + instance = self.settings.load_instance() + if instance is not None: + return Ports.platform_port_for(instance.ports.base) return Ports.PLATFORM @property @@ -49,13 +52,46 @@ def service_type(self) -> ServiceType: def health_url(self) -> str: return f"http://localhost:{self.port}/healthz" + def _instance_dist_paths(self) -> tuple[Path, Path] | None: + """Return (binary, worktree) for an instance-pinned platform, or None. + + The platform binary is at `xtest/platform/dist//service` and its + `.version` file records the source worktree path that should be used + as `cwd` so the binary finds its embedded resources. + """ + instance = self.settings.load_instance() + if instance is None or instance.platform.dist is None: + return None + binary = self.settings.platform_binary_for(instance.platform.dist) + if not binary.exists(): + raise FileNotFoundError( + f"Platform binary not found at {binary}. " + f"Run `otdf-sdk-mgr install release platform:{instance.platform.dist}` " + f"or `otdf-sdk-mgr install scenario` to provision it." + ) + worktree = binary.parent # safe fallback + version_file = binary.parent / ".version" + if version_file.exists(): + for line in version_file.read_text().splitlines(): + if line.startswith("worktree="): + worktree = Path(line.split("=", 1)[1].strip()) + break + return binary, worktree + def _generate_config(self) -> Path: """Generate the platform config file from template.""" + instance_paths = self._instance_dist_paths() + if instance_paths is not None: + _, worktree = instance_paths + platform_dir = worktree + else: + platform_dir = self.settings._require_platform_dir() + config_path = self.settings.platform_config - template_path = self.settings.platform_template_config + template_path = platform_dir / "opentdf.yaml" # Detect platform features to determine supported config options - features = PlatformFeatures.detect(self.settings.platform_dir) + features = PlatformFeatures.detect(platform_dir) # Use stderr if supported, otherwise stdout (v0.9.0 only supports stdout) logger_output = "stderr" if features.supports("logger_stderr") else "stdout" @@ -80,10 +116,14 @@ def _setup_golden_keys(self, config_path: Path) -> None: Extracts keys from extra-keys.json and adds them to the platform config so legacy golden TDFs can be decrypted. """ - # Set up golden key files and get their config entries + # In multi-instance mode, golden keys live alongside the instance + # config; otherwise they go into the legacy platform_dir. + target_dir = self.settings.keys_dir if self.settings.has_instance() else ( + self.settings._require_platform_dir() + ) golden_keys = setup_golden_keys( self.settings.xtest_root, - self.settings.platform_dir, + target_dir, ) if not golden_keys: @@ -112,15 +152,16 @@ def start(self) -> bool: # Generate config config_path = self._generate_config() - # Build the command - cmd = [ - "go", - "run", - "./service", - "start", - "--config-file", - str(config_path), - ] + # Build the command — pinned binary when an instance is loaded, + # legacy `go run ./service` otherwise. + instance_paths = self._instance_dist_paths() + if instance_paths is not None: + binary, worktree = instance_paths + cmd = [str(binary), "start", "--config-file", str(config_path)] + cwd = worktree + else: + cmd = ["go", "run", "./service", "start", "--config-file", str(config_path)] + cwd = self.settings._require_platform_dir() # Start the process log_file = self.settings.logs_dir / "platform.log" @@ -128,7 +169,7 @@ def start(self) -> bool: self._process = self._process_manager.start( name=self.name, cmd=cmd, - cwd=self.settings.platform_dir, + cwd=cwd, log_file=log_file, env={"OPENTDF_LOG_LEVEL": "info"}, ) diff --git a/otdf-local/src/otdf_local/utils/keys.py b/otdf-local/src/otdf_local/utils/keys.py index dee84f2af..79b58bf08 100644 --- a/otdf-local/src/otdf_local/utils/keys.py +++ b/otdf-local/src/otdf_local/utils/keys.py @@ -197,7 +197,9 @@ def setup_golden_keys( f"Missing required fields in extra-keys.json for kid: {kid}" ) - # Write key files to platform directory + # Write key files into the target directory (platform_dir for legacy + # single-instance, or the per-instance keys dir for multi-instance). + platform_dir.mkdir(parents=True, exist_ok=True) private_path = platform_dir / f"{kid}-private.pem" cert_path = platform_dir / f"{kid}-cert.pem" @@ -205,12 +207,14 @@ def setup_golden_keys( private_path.chmod(0o600) cert_path.write_text(cert) + # Use absolute paths so the platform binary finds them regardless of + # its working directory (worktree in multi-instance mode). keys_config.append( { "kid": kid, "alg": alg, - "private": f"{kid}-private.pem", - "cert": f"{kid}-cert.pem", + "private": str(private_path.resolve()), + "cert": str(cert_path.resolve()), } ) diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py new file mode 100644 index 000000000..e290d7731 --- /dev/null +++ b/otdf-local/tests/test_multi_instance.py @@ -0,0 +1,78 @@ +"""Smoke tests for the multi-instance refactor. + +These tests exercise the path resolution and port arithmetic without +requiring a real platform build or running services. The goal is to catch +regressions in the wiring between `otdf-sdk-mgr.schema`, `Settings`, and the +service launchers. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from otdf_sdk_mgr.schema import ( + Instance, + KasPin, + Metadata, + PlatformPin, + PortsConfig, + dump_instance, +) + +from otdf_local.config.ports import Ports +from otdf_local.config.settings import Settings + + +def test_ports_offset_layout_at_default_base() -> None: + assert Ports.platform_port_for(8080) == 8080 + assert Ports.get_kas_port("alpha", base=8080) == 8181 + assert Ports.get_kas_port("km2", base=8080) == 8686 + + +def test_ports_offset_layout_at_alternate_base() -> None: + assert Ports.platform_port_for(9080) == 9080 + assert Ports.get_kas_port("alpha", base=9080) == 9181 + assert Ports.get_kas_port("km1", base=9080) == 9585 + + +def test_settings_default_has_no_instance(tmp_path: Path) -> None: + fake_xtest = tmp_path / "xtest" + fake_xtest.mkdir() + s = Settings(xtest_root=fake_xtest, platform_dir=None) + assert s.instance_name == "default" + assert not s.has_instance() + + +def test_settings_loads_instance_when_present(tmp_path: Path) -> None: + fake_xtest = tmp_path / "xtest" + fake_xtest.mkdir() + instances_root = tmp_path / "instances" + instance_dir = instances_root / "demo" + instance_dir.mkdir(parents=True) + dump_instance( + Instance( + metadata=Metadata(name="demo"), + platform=PlatformPin(dist="v0.9.0"), + ports=PortsConfig(base=9080), + kas={"alpha": KasPin(dist="v0.9.0", mode="standard")}, + ), + instance_dir / "instance.yaml", + ) + s = Settings(xtest_root=fake_xtest, platform_dir=None, instance_name="demo") + assert s.has_instance() + inst = s.load_instance() + assert inst is not None + assert inst.ports.base == 9080 + # Per-instance port arithmetic + assert s.get_kas_port("alpha") == 9181 + # Per-instance directory layout + assert s.logs_dir == instance_dir / "logs" + assert s.keys_dir == instance_dir / "keys" + + +def test_platform_binary_for_resolves_under_xtest_platform(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTDF_PLATFORM_DIR", "/tmp/fake-platform") + s = Settings() + assert s.platform_binary_for("v0.9.0") == Path("/tmp/fake-platform/dist/v0.9.0/service") diff --git a/otdf-local/uv.lock b/otdf-local/uv.lock index 4da54a0f6..f594e80f2 100644 --- a/otdf-local/uv.lock +++ b/otdf-local/uv.lock @@ -51,6 +51,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" @@ -142,6 +166,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "otdf-sdk-mgr" }, { name = "pydantic-settings" }, { name = "rich" }, { name = "ruamel-yaml" }, @@ -158,6 +183,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.14.1" }, { name = "rich", specifier = ">=15.0.0" }, { name = "ruamel-yaml", specifier = ">=0.18.0" }, @@ -171,6 +197,34 @@ dev = [ { name = "ruff", specifier = ">=0.15.15" }, ] +[[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 = ">=15.0.0" }, + { name = "ruamel-yaml", specifier = ">=0.18.0" }, + { name = "typer", specifier = ">=0.26.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.410" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.15.15" }, +] + [[package]] name = "packaging" version = "26.0" @@ -418,9 +472,18 @@ 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.26.5" +version = "0.26.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -428,9 +491,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/1a/2cf40b65b1d9c254fe5814bb0519f9b8f2ac38059df0810f9b866300c04a/typer-0.26.5.tar.gz", hash = "sha256:9b9b39e35c3afc9e1e51a06f21155246e457c0911279b09b35d8210ca74b935c", size = 201494, upload-time = "2026-06-01T14:42:49.744Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/d6/baac76fc04a6532883de3d8722c7f921dae94d10965e7ffba9e38e42a251/typer-0.26.5-py3-none-any.whl", hash = "sha256:4bfd901d564e41608920134aa5d4481200f4ba76d98e982d9f9d32dcb7b84da0", size = 122451, upload-time = "2026-06-01T14:42:51.021Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, ] [[package]] From 2ba28bbf2cf1a326a43b8f77157201729c4dea47 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 2 Jun 2026 08:25:33 -0400 Subject: [PATCH 02/17] feat(otdf-local): self-provision keys + opentdf.yaml at instance init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, `otdf-local instance init` only wrote `instance.yaml` and empty subdirs. Anyone running a fresh instance had to manually copy keys from another worktree, run `init-temp-keys.sh` by hand, and copy `opentdf-dev.yaml` into the instance dir before `up` would succeed — otherwise Keycloak crash-looped on a missing `truststore.jks`, and pytest failed with `OT_ROOT_KEY environment variable is not set`. Changes: - utils/keys.py: add `generate_localhost_cert()` and `generate_ca_jks()` to produce the Keycloak TLS pair + JKS truststore (matches the platform's `init-temp-keys.sh`). `generate_ca_jks()` runs `keytool` inside the `keycloak/keycloak:25.0` image so a local JDK isn't required. `ensure_keys_exist()` now generates the full bootstrap bundle, idempotently. - cli_instance.py: `_init_from_scenario` and `_init_minimal` call a new `_provision_instance_dir()` helper that runs `ensure_keys_exist()` and copies the platform's `opentdf-dev.yaml` (or `opentdf-example.yaml`) into the instance dir, overriding `services.kas.root_key` with a freshly generated value so every instance owns its own root key. - services/platform.py: `_generate_config()` preserves an existing per-instance `opentdf.yaml`, only patching logger + golden-key fields in place, so the init-time `root_key` survives restarts. - services/docker.py: docker-compose subprocesses are now run with `KEYS_DIR=/keys` so the compose file's `${KEYS_DIR:-./keys}` mounts resolve to the per-instance bundle. Users can now run: otdf-local instance init --from-scenario path/to/scenario.yaml otdf-local --instance up eval $(otdf-local --instance env) cd xtest && uv run pytest ... with no manual key-copying, no editing of `opentdf.yaml`, and no shell-script fallback. Verified end-to-end against `pure-mlkem.yaml` (PR opentdf/platform#3537): all 9 services come up healthy on the first try and `env` exports `OT_ROOT_KEY`. Co-Authored-By: Claude Opus 4.7 --- otdf-local/src/otdf_local/cli_instance.py | 116 +++++++++- otdf-local/src/otdf_local/services/docker.py | 11 + .../src/otdf_local/services/platform.py | 26 ++- otdf-local/src/otdf_local/utils/keys.py | 200 +++++++++++++++++- 4 files changed, 334 insertions(+), 19 deletions(-) diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py index 98407f56e..712dc2967 100644 --- a/otdf-local/src/otdf_local/cli_instance.py +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -7,9 +7,17 @@ from typing import Annotated, Optional import typer -from otdf_sdk_mgr.schema import Instance, Metadata, PlatformPin, PortsConfig, dump_instance +from otdf_sdk_mgr.schema import ( + Instance, + Metadata, + PlatformPin, + PortsConfig, + dump_instance, +) -from otdf_local.config.settings import get_settings +from otdf_local.config.settings import Settings, get_settings +from otdf_local.utils.keys import ensure_keys_exist, generate_root_key +from otdf_local.utils.yaml import copy_yaml_with_updates instance_app = typer.Typer(help="Manage named test environment instances.") @@ -19,11 +27,15 @@ def init( name: Annotated[str, typer.Argument(help="Instance name (used as directory name)")], from_scenario: Annotated[ Optional[Path], - typer.Option("--from-scenario", help="Initialize from a scenarios.yaml or instance.yaml"), + typer.Option( + "--from-scenario", help="Initialize from a scenarios.yaml or instance.yaml" + ), ] = None, ports_base: Annotated[ int, - typer.Option("--ports-base", help="Base port (KAS ports computed as base+N*101)"), + typer.Option( + "--ports-base", help="Base port (KAS ports computed as base+N*101)" + ), ] = 8080, platform_dist: Annotated[ Optional[str], @@ -38,7 +50,10 @@ def init( _init_from_scenario(name, from_scenario, instance_dir) else: if platform_dist is None: - typer.echo("Error: --platform is required when not using --from-scenario", err=True) + typer.echo( + "Error: --platform is required when not using --from-scenario", + err=True, + ) raise typer.Exit(2) _init_minimal(name, instance_dir, ports_base, platform_dist) @@ -64,15 +79,20 @@ def _init_from_scenario(name: str, scenario_path: Path, instance_dir: Path) -> N else: raise typer.BadParameter(f"{scenario_path} has unknown kind {kind!r}") # Ensure the metadata name matches the chosen directory name. - instance.metadata = Metadata(**{**instance.metadata.model_dump(exclude_none=True), "name": name}) + instance.metadata = Metadata( + **{**instance.metadata.model_dump(exclude_none=True), "name": name} + ) instance_dir.mkdir(parents=True, exist_ok=True) (instance_dir / "kas").mkdir(parents=True, exist_ok=True) (instance_dir / "keys").mkdir(mode=0o700, parents=True, exist_ok=True) (instance_dir / "logs").mkdir(parents=True, exist_ok=True) dump_instance(instance, instance_dir / "instance.yaml") + _provision_instance_dir(instance_dir, instance) -def _init_minimal(name: str, instance_dir: Path, ports_base: int, platform_dist: str) -> None: +def _init_minimal( + name: str, instance_dir: Path, ports_base: int, platform_dist: str +) -> None: """Create a barebones instance.yaml with default KAS layout.""" instance = Instance( metadata=Metadata(name=name), @@ -85,6 +105,82 @@ def _init_minimal(name: str, instance_dir: Path, ports_base: int, platform_dist: (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") + _provision_instance_dir(instance_dir, instance) + + +def _resolve_platform_worktree(instance: Instance) -> Path: + """Find the platform source worktree for this instance's pin. + + For both `dist` and `source` pins, the platform installer writes a + `.version` file next to the binary with `worktree=`. We follow + that pointer because the binary's parent directory only holds the + built artifact — the YAML templates live in the source tree. + """ + from otdf_sdk_mgr.platform_installer import get_platform_dir + from otdf_sdk_mgr.refs import expand_pr_shorthand, ref_slug + + settings = Settings() + pin = instance.platform + if pin.dist is not None: + dist_name = pin.dist + elif pin.source is not None: + dist_name = ref_slug(expand_pr_shorthand(pin.source.ref)) + else: + raise typer.BadParameter("instance.platform must set dist or source") + + binary = get_platform_dir() / "dist" / dist_name / "service" + if not binary.exists(): + raise FileNotFoundError( + f"Platform binary not found at {binary}. " + f"Run `otdf-sdk-mgr install scenario` (or `install release platform:`) " + f"to provision it before `instance init`." + ) + 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()) + if worktree.is_dir(): + return worktree + # Fallback to sibling platform dir (legacy single-instance layout). + if settings.platform_dir is not None: + return settings.platform_dir + raise FileNotFoundError( + f"Could not resolve platform source worktree from {version_file}; " + f"no sibling platform/ directory available either." + ) + + +def _provision_instance_dir(instance_dir: Path, instance: Instance) -> None: + """Generate the bootstrap bundle: keys + opentdf.yaml with a fresh root_key. + + Idempotent — `ensure_keys_exist` skips files that already exist, and + `opentdf.yaml` is only generated when missing so reruns of `instance init` + don't churn the per-instance root_key. + """ + keys_dir = instance_dir / "keys" + keys_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + ensure_keys_exist(keys_dir) + + config_path = instance_dir / "opentdf.yaml" + if config_path.exists(): + return + + worktree = _resolve_platform_worktree(instance) + template = worktree / "opentdf-dev.yaml" + if not template.is_file(): + template = worktree / "opentdf-example.yaml" + if not template.is_file(): + raise FileNotFoundError( + f"No platform config template found in {worktree} " + f"(looked for opentdf-dev.yaml and opentdf-example.yaml)." + ) + + copy_yaml_with_updates( + template, + config_path, + {"services.kas.root_key": generate_root_key()}, + ) def _validate_port_uniqueness(instances_root: Path, new_name: str) -> None: @@ -150,7 +246,11 @@ def ls( "name": child.name, "platform": ( inst.platform.dist - or (inst.platform.source.ref if inst.platform.source else inst.platform.image) + or ( + inst.platform.source.ref + if inst.platform.source + else inst.platform.image + ) ), "ports_base": inst.ports.base, "kas": list(inst.kas.keys()), diff --git a/otdf-local/src/otdf_local/services/docker.py b/otdf-local/src/otdf_local/services/docker.py index 911b42e3c..5cf746f2d 100644 --- a/otdf-local/src/otdf_local/services/docker.py +++ b/otdf-local/src/otdf_local/services/docker.py @@ -1,6 +1,7 @@ """Docker compose service management.""" import json +import os import subprocess from otdf_local.config.ports import Ports @@ -16,6 +17,13 @@ def __init__(self, settings: Settings) -> None: super().__init__(settings) self._compose_file = settings.docker_compose_file + def _compose_env(self) -> dict[str, str]: + """Env passed to docker-compose so `${KEYS_DIR}` resolves per-instance.""" + env = os.environ.copy() + if self.settings.has_instance(): + env["KEYS_DIR"] = str(self.settings.keys_dir.resolve()) + return env + @property def name(self) -> str: return "docker" @@ -42,6 +50,7 @@ def start(self) -> bool: capture_output=True, text=True, cwd=self._compose_file.parent, + env=self._compose_env(), ) return result.returncode == 0 @@ -55,6 +64,7 @@ def stop(self) -> bool: capture_output=True, text=True, cwd=self._compose_file.parent, + env=self._compose_env(), ) return result.returncode == 0 @@ -89,6 +99,7 @@ def get_container_status(self) -> dict[str, dict]: capture_output=True, text=True, cwd=self._compose_file.parent, + env=self._compose_env(), ) if result.returncode != 0: diff --git a/otdf-local/src/otdf_local/services/platform.py b/otdf-local/src/otdf_local/services/platform.py index aa65dcf1d..3f2ad9cb0 100644 --- a/otdf-local/src/otdf_local/services/platform.py +++ b/otdf-local/src/otdf_local/services/platform.py @@ -18,6 +18,7 @@ copy_yaml_with_updates, load_yaml, save_yaml, + set_nested, ) @@ -79,7 +80,13 @@ def _instance_dist_paths(self) -> tuple[Path, Path] | None: return binary, worktree def _generate_config(self) -> Path: - """Generate the platform config file from template.""" + """Generate the platform config file from template. + + When an instance config already exists (written at `instance init` + time), we keep its body intact — only patch logger keys + golden + keys in place. This preserves the per-instance root_key across + restarts. + """ instance_paths = self._instance_dist_paths() if instance_paths is not None: _, worktree = instance_paths @@ -88,7 +95,6 @@ def _generate_config(self) -> Path: platform_dir = self.settings._require_platform_dir() config_path = self.settings.platform_config - template_path = platform_dir / "opentdf.yaml" # Detect platform features to determine supported config options features = PlatformFeatures.detect(platform_dir) @@ -96,14 +102,20 @@ def _generate_config(self) -> Path: # Use stderr if supported, otherwise stdout (v0.9.0 only supports stdout) logger_output = "stderr" if features.supports("logger_stderr") else "stdout" - # Updates for platform config updates = { "logger.level": "debug", "logger.type": "json", "logger.output": logger_output, } - copy_yaml_with_updates(template_path, config_path, updates) + if config_path.exists(): + data = load_yaml(config_path) + for dot_path, value in updates.items(): + set_nested(data, dot_path, value) + save_yaml(config_path, data) + else: + template_path = platform_dir / "opentdf.yaml" + copy_yaml_with_updates(template_path, config_path, updates) # Set up golden keys for legacy TDF tests self._setup_golden_keys(config_path) @@ -118,8 +130,10 @@ def _setup_golden_keys(self, config_path: Path) -> None: """ # In multi-instance mode, golden keys live alongside the instance # config; otherwise they go into the legacy platform_dir. - target_dir = self.settings.keys_dir if self.settings.has_instance() else ( - self.settings._require_platform_dir() + target_dir = ( + self.settings.keys_dir + if self.settings.has_instance() + else (self.settings._require_platform_dir()) ) golden_keys = setup_golden_keys( self.settings.xtest_root, diff --git a/otdf-local/src/otdf_local/utils/keys.py b/otdf-local/src/otdf_local/utils/keys.py index 79b58bf08..5dd5fe5fe 100644 --- a/otdf-local/src/otdf_local/utils/keys.py +++ b/otdf-local/src/otdf_local/utils/keys.py @@ -1,6 +1,7 @@ """Cryptographic key generation utilities.""" import json +import os import secrets import subprocess from pathlib import Path @@ -136,24 +137,213 @@ def generate_ec_keypair(key_dir: Path, name: str = "kas-ec") -> tuple[Path, Path return private_key, public_key +def generate_localhost_cert(key_dir: Path) -> tuple[Path, Path]: + """Generate the TLS cert pair Keycloak mounts at /etc/x509/tls/. + + Mirrors the localhost cert flow in the platform's init-temp-keys.sh: + self-signed CA → CSR with SAN → signed leaf cert. Keycloak rejects a + plain self-signed leaf because it pins the SAN to localhost+127.0.0.1. + """ + key_dir.mkdir(parents=True, exist_ok=True) + ca_key = key_dir / "keycloak-ca-private.pem" + ca_cert = key_dir / "keycloak-ca.pem" + leaf_key = key_dir / "localhost.key" + leaf_csr = key_dir / "localhost.req" + leaf_cert = key_dir / "localhost.crt" + san_conf = key_dir / "sanX509.conf" + req_conf = key_dir / "req.conf" + + san_conf.write_text("subjectAltName=DNS:localhost,IP:127.0.0.1") + req_conf.write_text( + "[req]\n" + "distinguished_name=req_distinguished_name\n" + "[req_distinguished_name]\n" + "[alt_names]\n" + "DNS.1=localhost\n" + "IP.1=127.0.0.1" + ) + + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-nodes", + "-newkey", + "RSA:2048", + "-subj", + "/CN=ca", + "-keyout", + str(ca_key), + "-out", + str(ca_cert), + "-days", + "365", + ], + check=True, + capture_output=True, + ) + ca_key.chmod(0o600) + subprocess.run( + [ + "openssl", + "req", + "-new", + "-nodes", + "-newkey", + "rsa:2048", + "-keyout", + str(leaf_key), + "-out", + str(leaf_csr), + "-batch", + "-subj", + "/CN=localhost", + "-config", + str(req_conf), + ], + check=True, + capture_output=True, + ) + leaf_key.chmod(0o600) + subprocess.run( + [ + "openssl", + "x509", + "-req", + "-in", + str(leaf_csr), + "-CA", + str(ca_cert), + "-CAkey", + str(ca_key), + "-CAcreateserial", + "-out", + str(leaf_cert), + "-days", + "3650", + "-sha256", + "-extfile", + str(san_conf), + ], + check=True, + capture_output=True, + ) + + return leaf_key, leaf_cert + + +def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: + """Convert the keycloak CA into the JKS truststore Keycloak mounts. + + Uses keytool inside the keycloak/keycloak:25.0 image so we don't need a + local JDK — docker is already a hard dependency for the test env. + Requires generate_localhost_cert() to have run first. + """ + ca_key = key_dir / "keycloak-ca-private.pem" + ca_cert = key_dir / "keycloak-ca.pem" + if not ca_key.exists() or not ca_cert.exists(): + raise FileNotFoundError( + f"CA files missing in {key_dir}; call generate_localhost_cert() first" + ) + p12 = key_dir / "ca.p12" + jks = key_dir / "ca.jks" + + subprocess.run( + [ + "openssl", + "pkcs12", + "-export", + "-in", + str(ca_cert), + "-inkey", + str(ca_key), + "-out", + str(p12), + "-nodes", + "-passout", + f"pass:{password}", + ], + check=True, + capture_output=True, + ) + + # keytool -importkeystore via the keycloak image (matches init-temp-keys.sh) + result = subprocess.run( + [ + "docker", + "run", + "--rm", + "-v", + f"{key_dir.resolve()}:/keys", + "--entrypoint", + "keytool", + "--user", + f"{os.getuid()}:{os.getgid()}", + "keycloak/keycloak:25.0", + "-importkeystore", + "-srckeystore", + "/keys/ca.p12", + "-srcstoretype", + "PKCS12", + "-destkeystore", + "/keys/ca.jks", + "-deststoretype", + "JKS", + "-srcstorepass", + password, + "-deststorepass", + password, + "-noprompt", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"keytool failed converting PKCS12 → JKS:\n{result.stderr}\n" + "Ensure Docker is running and `keycloak/keycloak:25.0` is pullable." + ) + return jks + + def ensure_keys_exist(key_dir: Path, force: bool = False) -> bool: """Ensure all required keys exist, generating if needed. + Generates the full bootstrap bundle the platform + Keycloak need: + KAS RSA/EC keypairs, the localhost TLS cert pair, and the ca.jks + truststore. PQC keys (ML-KEM, X-Wing) are not generated here — those + are provisioned at test time via the key-management API. + Args: key_dir: Directory for key storage force: If True, regenerate keys even if they exist Returns: - True if keys were generated, False if they already existed + True if any keys were generated, False if everything already existed """ rsa_private = key_dir / "kas-private.pem" ec_private = key_dir / "kas-ec-private.pem" - - if not force and rsa_private.exists() and ec_private.exists(): + localhost_key = key_dir / "localhost.key" + ca_jks = key_dir / "ca.jks" + + if ( + not force + and rsa_private.exists() + and ec_private.exists() + and localhost_key.exists() + and ca_jks.exists() + ): return False - generate_rsa_keypair(key_dir, "kas") - generate_ec_keypair(key_dir, "kas-ec") + if force or not rsa_private.exists(): + generate_rsa_keypair(key_dir, "kas") + if force or not ec_private.exists(): + generate_ec_keypair(key_dir, "kas-ec") + if force or not localhost_key.exists(): + generate_localhost_cert(key_dir) + if force or not ca_jks.exists(): + generate_ca_jks(key_dir) return True From 1f5cf6e84dfda71b57b2559df513b72329203e37 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 2 Jun 2026 09:25:52 -0400 Subject: [PATCH 03/17] fix(otdf-local): translate scenario suite to pytest argv per actual schema `_build_pytest_args` read `suite.select` and treated `suite.containers` as a string, but the Pydantic Suite model exposes `targets: list[str]` and `containers: list[ContainerKind]`. Any user invoking `otdf-local scenario run` hit AttributeError. Also wires `suite.kexpr` through as `-k`; it was silently dropped. Adds unit tests covering empty/multi targets, container join, kexpr, markers + extra args, and SDK token forwarding. Co-Authored-By: Claude Sonnet 4.5 --- otdf-local/src/otdf_local/cli_scenario.py | 10 +- otdf-local/tests/test_cli_scenario.py | 115 ++++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 otdf-local/tests/test_cli_scenario.py diff --git a/otdf-local/src/otdf_local/cli_scenario.py b/otdf-local/src/otdf_local/cli_scenario.py index 7d1dfde30..08e8def65 100644 --- a/otdf-local/src/otdf_local/cli_scenario.py +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -34,7 +34,7 @@ def _build_pytest_args(scenario: Scenario, scenario_path: Path) -> list[str]: helper raises FileNotFoundError with a clean hint otherwise. """ suite = scenario.suite - args: list[str] = [suite.select] + args: list[str] = list(suite.targets) tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path)) if tokens["encrypt"]: @@ -42,7 +42,9 @@ def _build_pytest_args(scenario: Scenario, scenario_path: Path) -> list[str]: if tokens["decrypt"]: args.extend(["--sdks-decrypt", " ".join(tokens["decrypt"])]) if suite.containers: - args.extend(["--containers", suite.containers]) + args.extend(["--containers", " ".join(suite.containers)]) + if suite.kexpr: + args.extend(["-k", suite.kexpr]) if suite.markers: args.extend(["-m", suite.markers]) args.extend(suite.extra_args) @@ -72,7 +74,9 @@ def run( scenario = load_scenario(path) instance_name = instance or scenario.instance.metadata.name if not instance_name: - typer.echo("Error: scenario.instance.metadata.name not set; pass --instance", err=True) + typer.echo( + "Error: scenario.instance.metadata.name not set; pass --instance", err=True + ) raise typer.Exit(2) settings = get_settings() diff --git a/otdf-local/tests/test_cli_scenario.py b/otdf-local/tests/test_cli_scenario.py new file mode 100644 index 000000000..628f7f0a1 --- /dev/null +++ b/otdf-local/tests/test_cli_scenario.py @@ -0,0 +1,115 @@ +"""Tests for `_build_pytest_args` — the scenario-suite → pytest argv translator.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from otdf_sdk_mgr.schema import ( + Instance, + Metadata, + PlatformPin, + Scenario, + ScenarioSdk, + ScenarioSdks, + Suite, +) + +from otdf_local import cli_scenario + + +def _scenario(suite: Suite, sdks: ScenarioSdks | None = None) -> Scenario: + return Scenario( + metadata=Metadata(name="t"), + instance=Instance( + metadata=Metadata(name="t"), + platform=PlatformPin(dist="v0.9.0"), + ), + sdks=sdks or ScenarioSdks(), + suite=suite, + ) + + +@pytest.fixture +def stub_sdks(monkeypatch: pytest.MonkeyPatch) -> None: + """Bypass the installed.json round-trip; tests focus on the suite block.""" + monkeypatch.setattr( + cli_scenario, + "scenario_to_pytest_sdks", + lambda _s, _p: {"encrypt": [], "decrypt": []}, + ) + + +def test_empty_targets(stub_sdks: None) -> None: + args = cli_scenario._build_pytest_args(_scenario(Suite(targets=[])), Path("s.yaml")) + assert args == [] + + +def test_multi_target(stub_sdks: None) -> None: + args = cli_scenario._build_pytest_args( + _scenario(Suite(targets=["test_a.py", "test_b.py::test_x"])), Path("s.yaml") + ) + assert args == ["test_a.py", "test_b.py::test_x"] + + +def test_containers_joined(stub_sdks: None) -> None: + args = cli_scenario._build_pytest_args( + _scenario(Suite(targets=["test_pqc.py"], containers=["ztdf", "ztdf-ecwrap"])), + Path("s.yaml"), + ) + assert args == ["test_pqc.py", "--containers", "ztdf ztdf-ecwrap"] + + +def test_no_containers_omits_flag(stub_sdks: None) -> None: + args = cli_scenario._build_pytest_args( + _scenario(Suite(targets=["t.py"], containers=[])), Path("s.yaml") + ) + assert "--containers" not in args + + +def test_kexpr_forwarded(stub_sdks: None) -> None: + args = cli_scenario._build_pytest_args( + _scenario(Suite(targets=["t.py"], kexpr="not slow")), Path("s.yaml") + ) + assert args == ["t.py", "-k", "not slow"] + + +def test_markers_and_extra_args(stub_sdks: None) -> None: + args = cli_scenario._build_pytest_args( + _scenario(Suite(targets=["t.py"], markers="smoke", extra_args=["-vv", "-x"])), + Path("s.yaml"), + ) + assert args == ["t.py", "-m", "smoke", "-vv", "-x"] + + +def test_sdks_tokens_forwarded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + cli_scenario, + "scenario_to_pytest_sdks", + lambda _s, _p: { + "encrypt": ["go@v0.24.0"], + "decrypt": ["go@v0.24.0", "java@v0.10.0"], + }, + ) + args = cli_scenario._build_pytest_args( + _scenario( + Suite(targets=["t.py"]), + sdks=ScenarioSdks( + encrypt=[ScenarioSdk(sdk="go", version="v0.24.0")], + decrypt=[ + ScenarioSdk(sdk="go", version="v0.24.0"), + ScenarioSdk(sdk="java", version="v0.10.0"), + ], + ), + ), + Path("s.yaml"), + ) + assert args == [ + "t.py", + "--sdks-encrypt", + "go@v0.24.0", + "--sdks-decrypt", + "go@v0.24.0 java@v0.10.0", + ] From 9801860f6ddf8c9141450ea8d4087f005cdd1e7d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 2 Jun 2026 09:26:32 -0400 Subject: [PATCH 04/17] style(otdf-local): apply ruff format Co-Authored-By: Claude Sonnet 4.5 --- otdf-local/src/otdf_local/services/kas.py | 4 +++- otdf-local/tests/test_multi_instance.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/otdf-local/src/otdf_local/services/kas.py b/otdf-local/src/otdf_local/services/kas.py index 8c2ed5b09..7c7e5cd6f 100644 --- a/otdf-local/src/otdf_local/services/kas.py +++ b/otdf-local/src/otdf_local/services/kas.py @@ -111,7 +111,9 @@ def _generate_config(self) -> Path: # Per-KAS features from instance.yaml override the legacy heuristic. instance = self.settings.load_instance() kas_pin = instance.kas.get(self._kas_name) if instance is not None else None - extra_features: dict[str, bool] = dict(kas_pin.features) if kas_pin is not None else {} + extra_features: dict[str, bool] = ( + dict(kas_pin.features) if kas_pin is not None else {} + ) if self.is_key_management: updates["services.kas.preview.key_management"] = True diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py index e290d7731..8c3b44908 100644 --- a/otdf-local/tests/test_multi_instance.py +++ b/otdf-local/tests/test_multi_instance.py @@ -72,7 +72,11 @@ def test_settings_loads_instance_when_present(tmp_path: Path) -> None: assert s.keys_dir == instance_dir / "keys" -def test_platform_binary_for_resolves_under_xtest_platform(monkeypatch: pytest.MonkeyPatch) -> None: +def test_platform_binary_for_resolves_under_xtest_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("OTDF_PLATFORM_DIR", "/tmp/fake-platform") s = Settings() - assert s.platform_binary_for("v0.9.0") == Path("/tmp/fake-platform/dist/v0.9.0/service") + assert s.platform_binary_for("v0.9.0") == Path( + "/tmp/fake-platform/dist/v0.9.0/service" + ) From 7d14248c4932fdb24b0bf5476c4241209403921f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 10:54:54 -0400 Subject: [PATCH 05/17] =?UTF-8?q?fix(otdf-local):=20address=20review=20fee?= =?UTF-8?q?dback=20=E2=80=94=20instance-aware=20up=20ports,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `up` command now uses `settings.get_platform_port()` and iterates `kas_manager._instances` with `settings.get_kas_port()` for health checks so non-default instances with a different `ports.base` work correctly - Add `Settings.get_platform_port()` alongside the existing `get_kas_port()` - Simplify metadata name update: `instance.metadata.name = name` (frozen=False) - Use `shlex.join(cmd)` for display in cli_scenario.py - Add `"Instance | None"` return type to `load_instance` via TYPE_CHECKING - Drop unused `Path` import in cli.py, stale `os` import in test file Co-Authored-By: Claude Sonnet 4.6 --- otdf-local/src/otdf_local/cli.py | 7 +++---- otdf-local/src/otdf_local/cli_instance.py | 4 +--- otdf-local/src/otdf_local/cli_scenario.py | 3 ++- otdf-local/src/otdf_local/config/settings.py | 13 ++++++++++++- otdf-local/tests/test_multi_instance.py | 1 - 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/otdf-local/src/otdf_local/cli.py b/otdf-local/src/otdf_local/cli.py index 422daa65a..efccb289d 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -5,7 +5,6 @@ import shutil import sys import time -from pathlib import Path from typing import Annotated, Optional import httpx @@ -189,7 +188,7 @@ def up( with status_spinner("Waiting for Platform..."): try: wait_for_health( - f"http://localhost:{Ports.PLATFORM}/healthz", + f"http://localhost:{settings.get_platform_port()}/healthz", timeout=120, service_name="Platform", ) @@ -221,8 +220,8 @@ def up( raise typer.Exit(1) with status_spinner("Waiting for KAS instances..."): - for kas_name in Ports.all_kas_names(): - port = Ports.get_kas_port(kas_name) + for kas_name in kas_manager._instances: + port = settings.get_kas_port(kas_name) try: wait_for_health( f"http://localhost:{port}/healthz", diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py index 712dc2967..94e4d1a95 100644 --- a/otdf-local/src/otdf_local/cli_instance.py +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -79,9 +79,7 @@ def _init_from_scenario(name: str, scenario_path: Path, instance_dir: Path) -> N else: raise typer.BadParameter(f"{scenario_path} has unknown kind {kind!r}") # Ensure the metadata name matches the chosen directory name. - instance.metadata = Metadata( - **{**instance.metadata.model_dump(exclude_none=True), "name": name} - ) + instance.metadata.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) diff --git a/otdf-local/src/otdf_local/cli_scenario.py b/otdf-local/src/otdf_local/cli_scenario.py index 08e8def65..cd9ecf084 100644 --- a/otdf-local/src/otdf_local/cli_scenario.py +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import shlex import subprocess from pathlib import Path from typing import Annotated @@ -100,6 +101,6 @@ def run( pytest_args.extend(extra) cmd = ["uv", "run", "pytest", *pytest_args] - typer.echo(f" Running: {' '.join(cmd)} (cwd={xtest_root})") + typer.echo(f" Running: {shlex.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/settings.py b/otdf-local/src/otdf_local/config/settings.py index dffc2cefc..f03cc6e58 100644 --- a/otdf-local/src/otdf_local/config/settings.py +++ b/otdf-local/src/otdf_local/config/settings.py @@ -2,8 +2,12 @@ from functools import lru_cache from pathlib import Path +from typing import TYPE_CHECKING from pydantic import Field + +if TYPE_CHECKING: + from otdf_sdk_mgr.schema import Instance from pydantic_settings import BaseSettings, SettingsConfigDict from otdf_local.config.ports import Ports @@ -228,7 +232,14 @@ def get_kas_port(self, name: str) -> int: return Ports.get_kas_port(name, base=instance.ports.base) return Ports.get_kas_port(name) - def load_instance(self): + def get_platform_port(self) -> int: + """Get the platform port, respecting instance ports.base.""" + instance = self.load_instance() + if instance is not None: + return Ports.platform_port_for(instance.ports.base) + return Ports.PLATFORM + + def load_instance(self) -> "Instance | None": """Load the per-instance manifest, or return None when not present.""" if not self.has_instance(): return None diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py index 8c3b44908..04768207c 100644 --- a/otdf-local/tests/test_multi_instance.py +++ b/otdf-local/tests/test_multi_instance.py @@ -8,7 +8,6 @@ from __future__ import annotations -import os from pathlib import Path import pytest From cb5e262edf7e80a729e4c0d61c49ea9e9e1b9cbe Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 11:08:03 -0400 Subject: [PATCH 06/17] fix(otdf-local): address pyright errors in cli and cli_instance Guard platform_dir None-access in env command; replace non-existent PlatformPin.image attribute with "unknown" fallback in ls command. Co-Authored-By: Claude Sonnet 4.6 --- otdf-local/src/otdf_local/cli.py | 10 ++++++---- otdf-local/src/otdf_local/cli_instance.py | 6 +----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/otdf-local/src/otdf_local/cli.py b/otdf-local/src/otdf_local/cli.py index efccb289d..2b22ad8f6 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -581,12 +581,14 @@ def env( # Platform configuration env_vars["PLATFORMURL"] = settings.platform_url - env_vars["PLATFORM_DIR"] = str(settings.platform_dir.resolve()) + if settings.platform_dir is not None: + env_vars["PLATFORM_DIR"] = str(settings.platform_dir.resolve()) # Schema file for manifest validation - schema_file = settings.platform_dir / "sdk" / "schema" / "manifest.schema.json" - if schema_file.exists(): - env_vars["SCHEMA_FILE"] = str(schema_file.resolve()) + if settings.platform_dir is not None: + schema_file = settings.platform_dir / "sdk" / "schema" / "manifest.schema.json" + if schema_file.exists(): + env_vars["SCHEMA_FILE"] = str(schema_file.resolve()) # Log file paths platform_log = settings.logs_dir / "platform.log" diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py index 94e4d1a95..6b6e5da49 100644 --- a/otdf-local/src/otdf_local/cli_instance.py +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -244,11 +244,7 @@ def ls( "name": child.name, "platform": ( inst.platform.dist - or ( - inst.platform.source.ref - if inst.platform.source - else inst.platform.image - ) + or (inst.platform.source.ref if inst.platform.source else "unknown") ), "ports_base": inst.ports.base, "kas": list(inst.kas.keys()), From e764f63694c8ab9f6404c8dc51c5826e0194c90f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 12:55:23 -0400 Subject: [PATCH 07/17] fix(otdf-local): address coderabbit review feedback - cli_scenario: set OTDF_LOCAL_INSTANCE_NAME + clear settings cache before get_settings() so scenario-driven instance name is picked up - cli_instance: add _validate_instance_name() to guard against path traversal in init/rm; add --force flag to init to prevent silent overwrite - kas: add get_instance_names() public method; replace _instances access in cli - keys: generate_ca_jks() now imports cert only (keytool -importcert) so ca.jks is a proper truststore; ensure_keys_exist() guards include cert files alongside private keys to catch partial-init broken state Co-Authored-By: Claude Sonnet 4.6 --- otdf-local/src/otdf_local/cli.py | 2 +- otdf-local/src/otdf_local/cli_instance.py | 25 +++++++++ otdf-local/src/otdf_local/cli_scenario.py | 3 +- otdf-local/src/otdf_local/services/kas.py | 4 ++ otdf-local/src/otdf_local/utils/keys.py | 64 +++++++++-------------- 5 files changed, 57 insertions(+), 41 deletions(-) diff --git a/otdf-local/src/otdf_local/cli.py b/otdf-local/src/otdf_local/cli.py index 2b22ad8f6..a062e62a2 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -220,7 +220,7 @@ def up( raise typer.Exit(1) with status_spinner("Waiting for KAS instances..."): - for kas_name in kas_manager._instances: + for kas_name in kas_manager.get_instance_names(): port = settings.get_kas_port(kas_name) try: wait_for_health( diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py index 6b6e5da49..6fc06f1ea 100644 --- a/otdf-local/src/otdf_local/cli_instance.py +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -22,6 +22,17 @@ instance_app = typer.Typer(help="Manage named test environment instances.") +def _validate_instance_name(name: str) -> None: + """Reject names that could escape the instances root via path traversal.""" + from pathlib import PurePosixPath + + p = PurePosixPath(name) + if not name or p.is_absolute() or len(p.parts) != 1 or name in {".", ".."}: + raise typer.BadParameter( + f"instance name must be a single directory name, got {name!r}" + ) + + @instance_app.command("init") def init( name: Annotated[str, typer.Argument(help="Instance name (used as directory name)")], @@ -41,11 +52,24 @@ def init( Optional[str], typer.Option("--platform", help="Platform dist version (e.g., v0.9.0)"), ] = None, + force: Annotated[ + bool, + typer.Option("--force", help="Overwrite existing instance directory"), + ] = False, ) -> None: """Scaffold a new instance directory at tests/instances//.""" + _validate_instance_name(name) settings = get_settings() instance_dir = settings.instances_root / name + if instance_dir.exists() and not force: + typer.echo( + f"Error: instance '{name}' already exists at {instance_dir}. " + "Pass --force to overwrite.", + err=True, + ) + raise typer.Exit(2) + if from_scenario is not None: _init_from_scenario(name, from_scenario, instance_dir) else: @@ -263,6 +287,7 @@ def rm( yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False, ) -> None: """Remove an instance directory.""" + _validate_instance_name(name) settings = get_settings() instance_dir = settings.instances_root / name if not instance_dir.exists(): diff --git a/otdf-local/src/otdf_local/cli_scenario.py b/otdf-local/src/otdf_local/cli_scenario.py index cd9ecf084..cbd9cb227 100644 --- a/otdf-local/src/otdf_local/cli_scenario.py +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -80,9 +80,10 @@ def run( ) 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 + get_settings.cache_clear() + settings = get_settings() xtest_root = settings.xtest_root if not xtest_root.exists(): diff --git a/otdf-local/src/otdf_local/services/kas.py b/otdf-local/src/otdf_local/services/kas.py index 7c7e5cd6f..582bf98cc 100644 --- a/otdf-local/src/otdf_local/services/kas.py +++ b/otdf-local/src/otdf_local/services/kas.py @@ -251,6 +251,10 @@ def get_all_info(self) -> list[ServiceInfo]: """Get info for all KAS instances.""" return [instance.get_info() for instance in self._instances.values()] + def get_instance_names(self) -> list[str]: + """Return names of all managed KAS instances.""" + return list(self._instances.keys()) + def get_running(self) -> list[str]: """Get names of running KAS instances.""" return [name for name, inst in self._instances.items() if inst.is_running()] diff --git a/otdf-local/src/otdf_local/utils/keys.py b/otdf-local/src/otdf_local/utils/keys.py index 5dd5fe5fe..95cd4c612 100644 --- a/otdf-local/src/otdf_local/utils/keys.py +++ b/otdf-local/src/otdf_local/utils/keys.py @@ -234,41 +234,23 @@ def generate_localhost_cert(key_dir: Path) -> tuple[Path, Path]: def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: - """Convert the keycloak CA into the JKS truststore Keycloak mounts. + """Convert the keycloak CA certificate into a JKS truststore Keycloak mounts. Uses keytool inside the keycloak/keycloak:25.0 image so we don't need a local JDK — docker is already a hard dependency for the test env. Requires generate_localhost_cert() to have run first. + + Only the CA certificate (public) is imported — not the private key — so the + JKS is a proper truststore, not a keystore. """ - ca_key = key_dir / "keycloak-ca-private.pem" ca_cert = key_dir / "keycloak-ca.pem" - if not ca_key.exists() or not ca_cert.exists(): + if not ca_cert.exists(): raise FileNotFoundError( - f"CA files missing in {key_dir}; call generate_localhost_cert() first" + f"CA certificate missing in {key_dir}; call generate_localhost_cert() first" ) - p12 = key_dir / "ca.p12" jks = key_dir / "ca.jks" - subprocess.run( - [ - "openssl", - "pkcs12", - "-export", - "-in", - str(ca_cert), - "-inkey", - str(ca_key), - "-out", - str(p12), - "-nodes", - "-passout", - f"pass:{password}", - ], - check=True, - capture_output=True, - ) - - # keytool -importkeystore via the keycloak image (matches init-temp-keys.sh) + # keytool -importcert via the keycloak image: cert-only truststore entry result = subprocess.run( [ "docker", @@ -281,18 +263,16 @@ def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: "--user", f"{os.getuid()}:{os.getgid()}", "keycloak/keycloak:25.0", - "-importkeystore", - "-srckeystore", - "/keys/ca.p12", - "-srcstoretype", - "PKCS12", - "-destkeystore", + "-importcert", + "-file", + "/keys/keycloak-ca.pem", + "-alias", + "ca", + "-keystore", "/keys/ca.jks", - "-deststoretype", + "-storetype", "JKS", - "-srcstorepass", - password, - "-deststorepass", + "-storepass", password, "-noprompt", ], @@ -301,7 +281,7 @@ def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: ) if result.returncode != 0: raise RuntimeError( - f"keytool failed converting PKCS12 → JKS:\n{result.stderr}\n" + f"keytool failed importing CA cert into JKS truststore:\n{result.stderr}\n" "Ensure Docker is running and `keycloak/keycloak:25.0` is pullable." ) return jks @@ -323,24 +303,30 @@ def ensure_keys_exist(key_dir: Path, force: bool = False) -> bool: True if any keys were generated, False if everything already existed """ rsa_private = key_dir / "kas-private.pem" + rsa_cert = key_dir / "kas-cert.pem" ec_private = key_dir / "kas-ec-private.pem" + ec_cert = key_dir / "kas-ec-cert.pem" localhost_key = key_dir / "localhost.key" + localhost_cert = key_dir / "localhost.crt" ca_jks = key_dir / "ca.jks" if ( not force and rsa_private.exists() + and rsa_cert.exists() and ec_private.exists() + and ec_cert.exists() and localhost_key.exists() + and localhost_cert.exists() and ca_jks.exists() ): return False - if force or not rsa_private.exists(): + if force or not rsa_private.exists() or not rsa_cert.exists(): generate_rsa_keypair(key_dir, "kas") - if force or not ec_private.exists(): + if force or not ec_private.exists() or not ec_cert.exists(): generate_ec_keypair(key_dir, "kas-ec") - if force or not localhost_key.exists(): + if force or not localhost_key.exists() or not localhost_cert.exists(): generate_localhost_cert(key_dir) if force or not ca_jks.exists(): generate_ca_jks(key_dir) From 38963bbf9fa4d5c963432ad3364d8d2b09ae84b3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:50:07 -0400 Subject: [PATCH 08/17] =?UTF-8?q?fix(otdf-local):=20restore=20PKCS12?= =?UTF-8?q?=E2=86=92JKS=20flow=20in=20generate=5Fca=5Fjks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the keytool -importcert change from the previous commit. The PKCS12 + importkeystore approach mirrors init-temp-keys.sh in the platform repo exactly (lines 65-90); Keycloak requires this form of ca.jks and the cert-only truststore broke it. Co-Authored-By: Claude Sonnet 4.6 --- otdf-local/src/otdf_local/utils/keys.py | 53 +++++++++++++++++-------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/otdf-local/src/otdf_local/utils/keys.py b/otdf-local/src/otdf_local/utils/keys.py index 95cd4c612..b0812a0c9 100644 --- a/otdf-local/src/otdf_local/utils/keys.py +++ b/otdf-local/src/otdf_local/utils/keys.py @@ -234,23 +234,42 @@ def generate_localhost_cert(key_dir: Path) -> tuple[Path, Path]: def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: - """Convert the keycloak CA certificate into a JKS truststore Keycloak mounts. + """Convert the keycloak CA into the JKS file Keycloak mounts. + Mirrors the PKCS12 → JKS flow in the platform's init-temp-keys.sh exactly. Uses keytool inside the keycloak/keycloak:25.0 image so we don't need a local JDK — docker is already a hard dependency for the test env. Requires generate_localhost_cert() to have run first. - - Only the CA certificate (public) is imported — not the private key — so the - JKS is a proper truststore, not a keystore. """ + ca_key = key_dir / "keycloak-ca-private.pem" ca_cert = key_dir / "keycloak-ca.pem" - if not ca_cert.exists(): + if not ca_key.exists() or not ca_cert.exists(): raise FileNotFoundError( - f"CA certificate missing in {key_dir}; call generate_localhost_cert() first" + f"CA files missing in {key_dir}; call generate_localhost_cert() first" ) + p12 = key_dir / "ca.p12" jks = key_dir / "ca.jks" - # keytool -importcert via the keycloak image: cert-only truststore entry + subprocess.run( + [ + "openssl", + "pkcs12", + "-export", + "-in", + str(ca_cert), + "-inkey", + str(ca_key), + "-out", + str(p12), + "-nodes", + "-passout", + f"pass:{password}", + ], + check=True, + capture_output=True, + ) + + # keytool -importkeystore via the keycloak image (matches init-temp-keys.sh) result = subprocess.run( [ "docker", @@ -263,16 +282,18 @@ def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: "--user", f"{os.getuid()}:{os.getgid()}", "keycloak/keycloak:25.0", - "-importcert", - "-file", - "/keys/keycloak-ca.pem", - "-alias", - "ca", - "-keystore", + "-importkeystore", + "-srckeystore", + "/keys/ca.p12", + "-srcstoretype", + "PKCS12", + "-destkeystore", "/keys/ca.jks", - "-storetype", + "-deststoretype", "JKS", - "-storepass", + "-srcstorepass", + password, + "-deststorepass", password, "-noprompt", ], @@ -281,7 +302,7 @@ def generate_ca_jks(key_dir: Path, password: str = "password") -> Path: ) if result.returncode != 0: raise RuntimeError( - f"keytool failed importing CA cert into JKS truststore:\n{result.stderr}\n" + f"keytool failed converting PKCS12 → JKS:\n{result.stderr}\n" "Ensure Docker is running and `keycloak/keycloak:25.0` is pullable." ) return jks From 08daf4d014ed08e0f8efe7e05601ba41d13a6d6a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 15:54:14 -0400 Subject: [PATCH 09/17] docs: add simplification design spec for multi-instance PR --- ...06-09-simplify-multi-instance-pr-design.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md diff --git a/docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md b/docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md new file mode 100644 index 000000000..f52d0978a --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md @@ -0,0 +1,76 @@ +# Simplify Multi-Instance PR — Design + +**Date:** 2026-06-09 +**Branch:** DSPX-3302-03-multi-instance +**Scope:** Code-quality cleanup within the existing PR; no scope reduction. + +## Problem + +The multi-instance PR introduces three independent copies of the same binary/worktree resolution logic: + +- `KASService._instance_paths()` in `services/kas.py` +- `PlatformService._instance_dist_paths()` in `services/platform.py` +- `_resolve_platform_worktree()` in `cli_instance.py` + +All three do: load instance manifest → extract dist string → locate binary under `get_platform_dir()/dist//service` → read `.version` file for `worktree=` line → return `(binary, worktree)`. + +Additionally: +- `settings.load_instance()` re-reads and parses YAML from disk on every call; it is invoked on nearly every property access in the service classes. +- `Ports` has two parallel lookup tables (`_KAS_NAMES` mapping names to class attributes, and `KAS_OFFSETS` mapping names to ints) that represent the same domain. The legacy constants are numerically equal to `8080 + offset`, so `_KAS_NAMES` is misleading dead weight. + +## Design + +### 1. `Settings.resolve_binary_worktree(dist: str) -> tuple[Path, Path]` + +Add a single method to `Settings` that encapsulates binary-path resolution: + +1. Compute `binary = get_platform_dir() / "dist" / dist / "service"`. +2. Raise `FileNotFoundError` with a `otdf-sdk-mgr install` hint if the binary is missing. +3. Read `binary.parent / ".version"` and extract the `worktree=` line if present; fall back to `binary.parent` if the file is absent or has no such line. +4. Return `(binary, worktree)`. + +### 2. Cache `load_instance()` on the Settings instance + +`load_instance()` stores its result in a private `_instance_cache` attribute on first call (`None` sentinel, `False` meaning "no instance"). Because `Settings` is already invalidated via `get_settings.cache_clear()` whenever `--instance` is set or `scenario run` overrides the instance name, caching on the instance is safe. + +### 3. Simplify callers + +`KASService._instance_paths()` and `PlatformService._instance_dist_paths()` are reduced to: +- Call `settings.load_instance()` to get the manifest (or `None`). +- Extract the relevant `dist` string (kas pin or platform pin). +- Delegate to `settings.resolve_binary_worktree(dist)`. + +`_resolve_platform_worktree()` in `cli_instance.py` is deleted; its callers use `settings.resolve_binary_worktree(dist_name)` directly. + +The `if instance_paths is not None: ..., worktree = instance_paths[1]; else: platform_dir = self.settings._require_platform_dir()` fallback pattern remains in both service `_generate_config()` methods — it is now 4–5 lines each and clearly readable. + +### 4. Unify `Ports` lookup + +Remove `_KAS_NAMES` (the name → class-attribute map) and the duplicated `ALPHA`, `BETA`, … constants that back it. Rewrite `get_kas_port` to always use `KAS_OFFSETS` with a default `base=8080`: + +```python +@classmethod +def get_kas_port(cls, name: str, *, base: int = 8080) -> int: + offset = cls.KAS_OFFSETS.get(name) + if offset is None: + raise ValueError(f"Unknown KAS instance: {name}") + return base + offset +``` + +The numeric values are unchanged (8080+101=8181, etc.). Any callers that were using the class constants directly (e.g., `Ports.ALPHA`) are updated to `Ports.get_kas_port("alpha")`. + +## Files Changed + +| File | Change | +|------|--------| +| `otdf-local/src/otdf_local/config/settings.py` | Add `resolve_binary_worktree()`, cache `load_instance()` | +| `otdf-local/src/otdf_local/config/ports.py` | Remove `_KAS_NAMES`, unify `get_kas_port` to use `KAS_OFFSETS` | +| `otdf-local/src/otdf_local/services/kas.py` | Shrink `_instance_paths()` to delegate | +| `otdf-local/src/otdf_local/services/platform.py` | Shrink `_instance_dist_paths()` to delegate | +| `otdf-local/src/otdf_local/cli_instance.py` | Delete `_resolve_platform_worktree()`, inline the simpler call | + +## Out of Scope + +- Splitting the PR into smaller PRs (user confirmed code quality only). +- Changing the `InstanceContext` dataclass approach (A chosen over B). +- Touching test files or non-`otdf-local` packages. From 6f9ff603dbcd05d41f2e49bb8abaf9e96d2230dd Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 09:41:13 -0400 Subject: [PATCH 10/17] refactor(otdf-local): unify Ports.get_kas_port to always use KAS_OFFSETS --- otdf-local/src/otdf_local/config/ports.py | 39 +++-------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/otdf-local/src/otdf_local/config/ports.py b/otdf-local/src/otdf_local/config/ports.py index 913f970d0..c14053659 100644 --- a/otdf-local/src/otdf_local/config/ports.py +++ b/otdf-local/src/otdf_local/config/ports.py @@ -15,24 +15,6 @@ class Ports: # Platform PLATFORM: int = 8080 - # KAS instances - KAS_ALPHA: int = 8181 - KAS_BETA: int = 8282 - KAS_GAMMA: int = 8383 - KAS_DELTA: int = 8484 - KAS_KM1: int = 8585 - KAS_KM2: int = 8686 - - # Mapping from KAS name to class attribute name - _KAS_NAMES: ClassVar[dict[str, str]] = { - "alpha": "KAS_ALPHA", - "beta": "KAS_BETA", - "gamma": "KAS_GAMMA", - "delta": "KAS_DELTA", - "km1": "KAS_KM1", - "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]] = { @@ -45,22 +27,11 @@ class Ports: } @classmethod - 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: + def get_kas_port(cls, name: str, *, base: int = 8080) -> int: + offset = cls.KAS_OFFSETS.get(name) + if offset is None: raise ValueError(f"Unknown KAS instance: {name}") - return getattr(cls, attr) + return base + offset @classmethod def platform_port_for(cls, base: int) -> int: @@ -70,7 +41,7 @@ def platform_port_for(cls, base: int) -> int: @classmethod def all_kas_names(cls) -> list[str]: """Return all KAS instance names.""" - return list(cls._KAS_NAMES.keys()) + return list(cls.KAS_OFFSETS.keys()) @classmethod def standard_kas_names(cls) -> list[str]: From 108cb643a9ced161c0532ae756d50315bdee1d01 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 10:22:57 -0400 Subject: [PATCH 11/17] refactor(otdf-local): add resolve_binary_worktree() and cache load_instance() on Settings --- otdf-local/src/otdf_local/config/settings.py | 39 +++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/otdf-local/src/otdf_local/config/settings.py b/otdf-local/src/otdf_local/config/settings.py index f03cc6e58..7de066b68 100644 --- a/otdf-local/src/otdf_local/config/settings.py +++ b/otdf-local/src/otdf_local/config/settings.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from pydantic import Field +from pydantic import Field, PrivateAttr if TYPE_CHECKING: from otdf_sdk_mgr.schema import Instance @@ -108,6 +108,8 @@ class Settings(BaseSettings): extra="ignore", ) + _instance_cache: object = PrivateAttr(default=None) + # Directory paths - computed from xtest_root xtest_root: Path = Field(default_factory=_find_xtest_root) platform_dir: Path | None = Field( @@ -153,6 +155,26 @@ def platform_binary_for(self, dist: str) -> Path: return get_platform_dir() / "dist" / dist / "service" + def resolve_binary_worktree(self, dist: str) -> tuple[Path, Path]: + """Resolve a dist string to (binary, worktree), raising if the binary is missing. + + Reads the `.version` file next to the binary for a `worktree=` line; + falls back to `binary.parent` when the file is absent or has no such line. + """ + binary = self.platform_binary_for(dist) + if not binary.exists(): + raise FileNotFoundError( + f"Binary not found at {binary}. Run `otdf-sdk-mgr install` 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 + @property def logs_dir(self) -> Path: """Logs directory. Per-instance when an instance is selected, falls back to legacy.""" @@ -240,12 +262,17 @@ def get_platform_port(self) -> int: return Ports.PLATFORM def load_instance(self) -> "Instance | None": - """Load the per-instance manifest, or return None when not present.""" - if not self.has_instance(): + """Load the per-instance manifest, cached on first call.""" + if self._instance_cache is None: + if not self.has_instance(): + self._instance_cache = False + else: + from otdf_sdk_mgr.schema import load_instance as _load + + self._instance_cache = _load(self.instance_yaml) + if self._instance_cache is False: return None - from otdf_sdk_mgr.schema import load_instance as _load - - return _load(self.instance_yaml) + return self._instance_cache # type: ignore[return-value] def get_kas_config_path(self, name: str) -> Path: """Get config file path for a KAS instance.""" From 0788dfcea7a4444c6d2a9c94a1a0b0e40c33a23f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 10:33:13 -0400 Subject: [PATCH 12/17] refactor(otdf-local): delegate KASService._instance_paths() to settings.resolve_binary_worktree --- otdf-local/src/otdf_local/services/kas.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/otdf-local/src/otdf_local/services/kas.py b/otdf-local/src/otdf_local/services/kas.py index 582bf98cc..a4b616a63 100644 --- a/otdf-local/src/otdf_local/services/kas.py +++ b/otdf-local/src/otdf_local/services/kas.py @@ -65,20 +65,7 @@ def _instance_paths(self) -> tuple[Path, Path] | None: pin = instance.kas.get(self._kas_name) if pin is None or pin.dist is None: return None - binary = self.settings.platform_binary_for(pin.dist) - if not binary.exists(): - raise FileNotFoundError( - f"KAS {self._kas_name} binary not found at {binary}. " - f"Run `otdf-sdk-mgr install release platform:{pin.dist}`." - ) - worktree = binary.parent - version_file = binary.parent / ".version" - if version_file.exists(): - for line in version_file.read_text().splitlines(): - if line.startswith("worktree="): - worktree = Path(line.split("=", 1)[1].strip()) - break - return binary, worktree + return self.settings.resolve_binary_worktree(pin.dist) def _generate_config(self) -> Path: """Generate the KAS config file from template.""" From 169374307d5f4a011e0b108d2d6ee4b574244287 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 10:33:42 -0400 Subject: [PATCH 13/17] refactor(otdf-local): delegate PlatformService._instance_dist_paths() to settings.resolve_binary_worktree --- .../src/otdf_local/services/platform.py | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/otdf-local/src/otdf_local/services/platform.py b/otdf-local/src/otdf_local/services/platform.py index 3f2ad9cb0..66d61b820 100644 --- a/otdf-local/src/otdf_local/services/platform.py +++ b/otdf-local/src/otdf_local/services/platform.py @@ -54,30 +54,11 @@ 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. - """ + """Return (binary, worktree) for an instance-pinned platform, or None.""" instance = self.settings.load_instance() if instance is None or instance.platform.dist is None: return None - binary = self.settings.platform_binary_for(instance.platform.dist) - if not binary.exists(): - raise FileNotFoundError( - f"Platform binary not found at {binary}. " - f"Run `otdf-sdk-mgr install release platform:{instance.platform.dist}` " - f"or `otdf-sdk-mgr install scenario` to provision it." - ) - worktree = binary.parent # safe fallback - version_file = binary.parent / ".version" - if version_file.exists(): - for line in version_file.read_text().splitlines(): - if line.startswith("worktree="): - worktree = Path(line.split("=", 1)[1].strip()) - break - return binary, worktree + return self.settings.resolve_binary_worktree(instance.platform.dist) def _generate_config(self) -> Path: """Generate the platform config file from template. From 829def28c3beae971357f9a5420642f738f30f9f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 10:34:25 -0400 Subject: [PATCH 14/17] refactor(otdf-local): delete _resolve_platform_worktree(), inline into _provision_instance_dir() --- otdf-local/src/otdf_local/cli_instance.py | 56 +++++------------------ 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/otdf-local/src/otdf_local/cli_instance.py b/otdf-local/src/otdf_local/cli_instance.py index 6fc06f1ea..2463d6a7a 100644 --- a/otdf-local/src/otdf_local/cli_instance.py +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -130,49 +130,6 @@ def _init_minimal( _provision_instance_dir(instance_dir, instance) -def _resolve_platform_worktree(instance: Instance) -> Path: - """Find the platform source worktree for this instance's pin. - - For both `dist` and `source` pins, the platform installer writes a - `.version` file next to the binary with `worktree=`. We follow - that pointer because the binary's parent directory only holds the - built artifact — the YAML templates live in the source tree. - """ - from otdf_sdk_mgr.platform_installer import get_platform_dir - from otdf_sdk_mgr.refs import expand_pr_shorthand, ref_slug - - settings = Settings() - pin = instance.platform - if pin.dist is not None: - dist_name = pin.dist - elif pin.source is not None: - dist_name = ref_slug(expand_pr_shorthand(pin.source.ref)) - else: - raise typer.BadParameter("instance.platform must set dist or source") - - binary = get_platform_dir() / "dist" / dist_name / "service" - if not binary.exists(): - raise FileNotFoundError( - f"Platform binary not found at {binary}. " - f"Run `otdf-sdk-mgr install scenario` (or `install release platform:`) " - f"to provision it before `instance init`." - ) - 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()) - if worktree.is_dir(): - return worktree - # Fallback to sibling platform dir (legacy single-instance layout). - if settings.platform_dir is not None: - return settings.platform_dir - raise FileNotFoundError( - f"Could not resolve platform source worktree from {version_file}; " - f"no sibling platform/ directory available either." - ) - - def _provision_instance_dir(instance_dir: Path, instance: Instance) -> None: """Generate the bootstrap bundle: keys + opentdf.yaml with a fresh root_key. @@ -188,7 +145,18 @@ def _provision_instance_dir(instance_dir: Path, instance: Instance) -> None: if config_path.exists(): return - worktree = _resolve_platform_worktree(instance) + pin = instance.platform + if pin.dist is not None: + dist_name = pin.dist + elif pin.source is not None: + from otdf_sdk_mgr.refs import expand_pr_shorthand, ref_slug + + dist_name = ref_slug(expand_pr_shorthand(pin.source.ref)) + else: + raise typer.BadParameter("instance.platform must set dist or source") + + _, worktree = Settings().resolve_binary_worktree(dist_name) + template = worktree / "opentdf-dev.yaml" if not template.is_file(): template = worktree / "opentdf-example.yaml" From 6810798e4c094b74a51110fbb321760208c73128 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 10:37:28 -0400 Subject: [PATCH 15/17] fixup remove devlocal spec --- ...06-09-simplify-multi-instance-pr-design.md | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md diff --git a/docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md b/docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md deleted file mode 100644 index f52d0978a..000000000 --- a/docs/superpowers/specs/2026-06-09-simplify-multi-instance-pr-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# Simplify Multi-Instance PR — Design - -**Date:** 2026-06-09 -**Branch:** DSPX-3302-03-multi-instance -**Scope:** Code-quality cleanup within the existing PR; no scope reduction. - -## Problem - -The multi-instance PR introduces three independent copies of the same binary/worktree resolution logic: - -- `KASService._instance_paths()` in `services/kas.py` -- `PlatformService._instance_dist_paths()` in `services/platform.py` -- `_resolve_platform_worktree()` in `cli_instance.py` - -All three do: load instance manifest → extract dist string → locate binary under `get_platform_dir()/dist//service` → read `.version` file for `worktree=` line → return `(binary, worktree)`. - -Additionally: -- `settings.load_instance()` re-reads and parses YAML from disk on every call; it is invoked on nearly every property access in the service classes. -- `Ports` has two parallel lookup tables (`_KAS_NAMES` mapping names to class attributes, and `KAS_OFFSETS` mapping names to ints) that represent the same domain. The legacy constants are numerically equal to `8080 + offset`, so `_KAS_NAMES` is misleading dead weight. - -## Design - -### 1. `Settings.resolve_binary_worktree(dist: str) -> tuple[Path, Path]` - -Add a single method to `Settings` that encapsulates binary-path resolution: - -1. Compute `binary = get_platform_dir() / "dist" / dist / "service"`. -2. Raise `FileNotFoundError` with a `otdf-sdk-mgr install` hint if the binary is missing. -3. Read `binary.parent / ".version"` and extract the `worktree=` line if present; fall back to `binary.parent` if the file is absent or has no such line. -4. Return `(binary, worktree)`. - -### 2. Cache `load_instance()` on the Settings instance - -`load_instance()` stores its result in a private `_instance_cache` attribute on first call (`None` sentinel, `False` meaning "no instance"). Because `Settings` is already invalidated via `get_settings.cache_clear()` whenever `--instance` is set or `scenario run` overrides the instance name, caching on the instance is safe. - -### 3. Simplify callers - -`KASService._instance_paths()` and `PlatformService._instance_dist_paths()` are reduced to: -- Call `settings.load_instance()` to get the manifest (or `None`). -- Extract the relevant `dist` string (kas pin or platform pin). -- Delegate to `settings.resolve_binary_worktree(dist)`. - -`_resolve_platform_worktree()` in `cli_instance.py` is deleted; its callers use `settings.resolve_binary_worktree(dist_name)` directly. - -The `if instance_paths is not None: ..., worktree = instance_paths[1]; else: platform_dir = self.settings._require_platform_dir()` fallback pattern remains in both service `_generate_config()` methods — it is now 4–5 lines each and clearly readable. - -### 4. Unify `Ports` lookup - -Remove `_KAS_NAMES` (the name → class-attribute map) and the duplicated `ALPHA`, `BETA`, … constants that back it. Rewrite `get_kas_port` to always use `KAS_OFFSETS` with a default `base=8080`: - -```python -@classmethod -def get_kas_port(cls, name: str, *, base: int = 8080) -> int: - offset = cls.KAS_OFFSETS.get(name) - if offset is None: - raise ValueError(f"Unknown KAS instance: {name}") - return base + offset -``` - -The numeric values are unchanged (8080+101=8181, etc.). Any callers that were using the class constants directly (e.g., `Ports.ALPHA`) are updated to `Ports.get_kas_port("alpha")`. - -## Files Changed - -| File | Change | -|------|--------| -| `otdf-local/src/otdf_local/config/settings.py` | Add `resolve_binary_worktree()`, cache `load_instance()` | -| `otdf-local/src/otdf_local/config/ports.py` | Remove `_KAS_NAMES`, unify `get_kas_port` to use `KAS_OFFSETS` | -| `otdf-local/src/otdf_local/services/kas.py` | Shrink `_instance_paths()` to delegate | -| `otdf-local/src/otdf_local/services/platform.py` | Shrink `_instance_dist_paths()` to delegate | -| `otdf-local/src/otdf_local/cli_instance.py` | Delete `_resolve_platform_worktree()`, inline the simpler call | - -## Out of Scope - -- Splitting the PR into smaller PRs (user confirmed code quality only). -- Changing the `InstanceContext` dataclass approach (A chosen over B). -- Touching test files or non-`otdf-local` packages. From 0d2b31e7e51eaa748acdf3f6cc48b862adde6b97 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 14:35:49 -0400 Subject: [PATCH 16/17] fixup remove low value test that is triggering sonarcloud --- otdf-local/tests/test_multi_instance.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py index 04768207c..c32867451 100644 --- a/otdf-local/tests/test_multi_instance.py +++ b/otdf-local/tests/test_multi_instance.py @@ -69,13 +69,3 @@ def test_settings_loads_instance_when_present(tmp_path: Path) -> None: # 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" - ) From b441b3819bbe38bfae7cb2267e4f62115fef7d7b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 14:37:23 -0400 Subject: [PATCH 17/17] fixup ruff check --fix --- otdf-local/tests/test_multi_instance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py index c32867451..d0fe40129 100644 --- a/otdf-local/tests/test_multi_instance.py +++ b/otdf-local/tests/test_multi_instance.py @@ -10,7 +10,6 @@ from pathlib import Path -import pytest from otdf_sdk_mgr.schema import ( Instance, KasPin,