-
Notifications
You must be signed in to change notification settings - Fork 2
feat(xtest): otdf-local multi-instance refactor #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dmihalcik-virtru
wants to merge
17
commits into
main
Choose a base branch
from
DSPX-3302-03-multi-instance
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 2ba28bb
feat(otdf-local): self-provision keys + opentdf.yaml at instance init
dmihalcik-virtru 1f5cf6e
fix(otdf-local): translate scenario suite to pytest argv per actual s…
dmihalcik-virtru 9801860
style(otdf-local): apply ruff format
dmihalcik-virtru 7d14248
fix(otdf-local): address review feedback — instance-aware up ports, c…
dmihalcik-virtru cb5e262
fix(otdf-local): address pyright errors in cli and cli_instance
dmihalcik-virtru e764f63
fix(otdf-local): address coderabbit review feedback
dmihalcik-virtru 38963bb
fix(otdf-local): restore PKCS12→JKS flow in generate_ca_jks
dmihalcik-virtru 08daf4d
docs: add simplification design spec for multi-instance PR
dmihalcik-virtru 6f9ff60
refactor(otdf-local): unify Ports.get_kas_port to always use KAS_OFFSETS
dmihalcik-virtru 108cb64
refactor(otdf-local): add resolve_binary_worktree() and cache load_in…
dmihalcik-virtru 0788dfc
refactor(otdf-local): delegate KASService._instance_paths() to settin…
dmihalcik-virtru 1693743
refactor(otdf-local): delegate PlatformService._instance_dist_paths()…
dmihalcik-virtru 829def2
refactor(otdf-local): delete _resolve_platform_worktree(), inline int…
dmihalcik-virtru 6810798
fixup remove devlocal spec
dmihalcik-virtru 0d2b31e
fixup remove low value test that is triggering sonarcloud
dmihalcik-virtru b441b38
fixup ruff check --fix
dmihalcik-virtru File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}") | ||
|
coderabbitai[bot] marked this conversation as resolved.
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}") | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.