Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7908beb
feat(otdf-local): multi-instance test environments (DSPX-3302)
dmihalcik-virtru May 15, 2026
2ba28bb
feat(otdf-local): self-provision keys + opentdf.yaml at instance init
dmihalcik-virtru Jun 2, 2026
1f5cf6e
fix(otdf-local): translate scenario suite to pytest argv per actual s…
dmihalcik-virtru Jun 2, 2026
9801860
style(otdf-local): apply ruff format
dmihalcik-virtru Jun 2, 2026
7d14248
fix(otdf-local): address review feedback — instance-aware up ports, c…
dmihalcik-virtru Jun 9, 2026
cb5e262
fix(otdf-local): address pyright errors in cli and cli_instance
dmihalcik-virtru Jun 9, 2026
e764f63
fix(otdf-local): address coderabbit review feedback
dmihalcik-virtru Jun 9, 2026
38963bb
fix(otdf-local): restore PKCS12→JKS flow in generate_ca_jks
dmihalcik-virtru Jun 9, 2026
08daf4d
docs: add simplification design spec for multi-instance PR
dmihalcik-virtru Jun 9, 2026
6f9ff60
refactor(otdf-local): unify Ports.get_kas_port to always use KAS_OFFSETS
dmihalcik-virtru Jun 10, 2026
108cb64
refactor(otdf-local): add resolve_binary_worktree() and cache load_in…
dmihalcik-virtru Jun 10, 2026
0788dfc
refactor(otdf-local): delegate KASService._instance_paths() to settin…
dmihalcik-virtru Jun 10, 2026
1693743
refactor(otdf-local): delegate PlatformService._instance_dist_paths()…
dmihalcik-virtru Jun 10, 2026
829def2
refactor(otdf-local): delete _resolve_platform_worktree(), inline int…
dmihalcik-virtru Jun 10, 2026
6810798
fixup remove devlocal spec
dmihalcik-virtru Jun 10, 2026
0d2b31e
fixup remove low value test that is triggering sonarcloud
dmihalcik-virtru Jun 10, 2026
b441b38
fixup ruff check --fix
dmihalcik-virtru Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Comment thread
dmihalcik-virtru marked this conversation as resolved.
xtest/scenarios/*.installed.json
.claude/tmp/
4 changes: 4 additions & 0 deletions otdf-local/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 34 additions & 9 deletions otdf-local/src/otdf_local/cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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})")
Expand Down Expand Up @@ -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()
Comment thread
dmihalcik-virtru marked this conversation as resolved.


@app.command()
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
270 changes: 270 additions & 0 deletions otdf-local/src/otdf_local/cli_instance.py
Original file line number Diff line number Diff line change
@@ -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/<name>/."""
_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 <dist> 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}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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}")
Loading
Loading