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..a062e62a2 100644 --- a/otdf-local/src/otdf_local/cli.py +++ b/otdf-local/src/otdf_local/cli.py @@ -1,10 +1,11 @@ """Typer CLI for otdf_local - OpenTDF test environment management.""" import json +import os import shutil import sys import time -from typing import Annotated +from typing import Annotated, Optional import httpx import typer @@ -44,6 +45,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 +88,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() @@ -165,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", ) @@ -197,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.get_instance_names(): + port = settings.get_kas_port(kas_name) try: wait_for_health( f"http://localhost:{port}/healthz", @@ -558,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 new file mode 100644 index 000000000..2463d6a7a --- /dev/null +++ b/otdf-local/src/otdf_local/cli_instance.py @@ -0,0 +1,270 @@ +"""`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 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.") + + +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)")], + 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, + 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: + 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.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: + """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") + _provision_instance_dir(instance_dir, instance) + + +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 + + 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" + 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: + """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 "unknown") + ), + "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.""" + _validate_instance_name(name) + 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..cbd9cb227 --- /dev/null +++ b/otdf-local/src/otdf_local/cli_scenario.py @@ -0,0 +1,107 @@ +"""`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 shlex +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] = list(suite.targets) + + tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path)) + if tokens["encrypt"]: + args.extend(["--sdks-encrypt", " ".join(tokens["encrypt"])]) + if tokens["decrypt"]: + args.extend(["--sdks-decrypt", " ".join(tokens["decrypt"])]) + if suite.containers: + args.extend(["--containers", " ".join(suite.containers)]) + if suite.kexpr: + args.extend(["-k", suite.kexpr]) + 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) + + # 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(): + 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: {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/ports.py b/otdf-local/src/otdf_local/config/ports.py index 21d193358..c14053659 100644 --- a/otdf-local/src/otdf_local/config/ports.py +++ b/otdf-local/src/otdf_local/config/ports.py @@ -15,36 +15,33 @@ 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]] = { + "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.""" - 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: + """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.""" - return list(cls._KAS_NAMES.keys()) + return list(cls.KAS_OFFSETS.keys()) @classmethod def standard_kas_names(cls) -> list[str]: diff --git a/otdf-local/src/otdf_local/config/settings.py b/otdf-local/src/otdf_local/config/settings.py index 96a4c20e8..7de066b68 100644 --- a/otdf-local/src/otdf_local/config/settings.py +++ b/otdf-local/src/otdf_local/config/settings.py @@ -2,12 +2,18 @@ from functools import lru_cache 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 from pydantic_settings import BaseSettings, SettingsConfigDict 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 +86,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.""" @@ -89,46 +108,124 @@ 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 = 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" + + 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.""" + """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 +244,40 @@ 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 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, 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 + 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.""" + 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 +289,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/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/kas.py b/otdf-local/src/otdf_local/services/kas.py index 00de6a2cd..a4b616a63 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,47 @@ 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 + return self.settings.resolve_binary_worktree(pin.dist) + 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 +95,13 @@ 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 +109,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 +173,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 +189,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,23 +219,29 @@ 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]: """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/services/platform.py b/otdf-local/src/otdf_local/services/platform.py index 15f7f4e5e..66d61b820 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, ) @@ -39,6 +40,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,25 +53,50 @@ 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.""" + instance = self.settings.load_instance() + if instance is None or instance.platform.dist is None: + return None + return self.settings.resolve_binary_worktree(instance.platform.dist) + 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 + platform_dir = worktree + else: + platform_dir = self.settings._require_platform_dir() + config_path = self.settings.platform_config - template_path = self.settings.platform_template_config # 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" - # 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) @@ -80,10 +109,16 @@ def _setup_golden_keys(self, config_path: Path) -> None: Extracts keys from extra-keys.json and adds them to the platform config so legacy golden TDFs can be decrypted. """ - # Set up golden key files and get their config entries + # In multi-instance mode, golden keys live alongside the instance + # config; otherwise they go into the legacy platform_dir. + target_dir = ( + self.settings.keys_dir + if self.settings.has_instance() + else (self.settings._require_platform_dir()) + ) golden_keys = setup_golden_keys( self.settings.xtest_root, - self.settings.platform_dir, + target_dir, ) if not golden_keys: @@ -112,15 +147,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 +164,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..b0812a0c9 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,220 @@ 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 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. + """ + 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" + rsa_cert = key_dir / "kas-cert.pem" ec_private = key_dir / "kas-ec-private.pem" - - if not force and rsa_private.exists() and ec_private.exists(): + 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 - generate_rsa_keypair(key_dir, "kas") - generate_ec_keypair(key_dir, "kas-ec") + 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() or not ec_cert.exists(): + generate_ec_keypair(key_dir, "kas-ec") + 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) return True @@ -197,7 +394,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 +404,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_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", + ] diff --git a/otdf-local/tests/test_multi_instance.py b/otdf-local/tests/test_multi_instance.py new file mode 100644 index 000000000..d0fe40129 --- /dev/null +++ b/otdf-local/tests/test_multi_instance.py @@ -0,0 +1,70 @@ +"""Smoke tests for the multi-instance refactor. + +These tests exercise the path resolution and port arithmetic without +requiring a real platform build or running services. The goal is to catch +regressions in the wiring between `otdf-sdk-mgr.schema`, `Settings`, and the +service launchers. +""" + +from __future__ import annotations + +from pathlib import Path + +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" 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]]