Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 93 additions & 8 deletions otdf-sdk-mgr/src/otdf_sdk_mgr/cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
install_app = typer.Typer(help="Install SDK CLI artifacts from registries or source.")


def _register_scenario_cmd() -> None:
"""Defer scenario import so pydantic is only imported when needed."""
from otdf_sdk_mgr.cli_scenario import install_scenario_cmd

install_app.command("scenario")(install_scenario_cmd)


_register_scenario_cmd()


@install_app.command()
def stable(
sdks: Annotated[
Expand All @@ -32,9 +42,27 @@ def lts(
] = None,
) -> None:
"""Install LTS versions for each SDK."""
from otdf_sdk_mgr.config import LTS_VERSIONS
from otdf_sdk_mgr.installers import cmd_lts

cmd_lts(sdks or ALL_SDKS)
from otdf_sdk_mgr.platform_installer import (
PlatformInstallError,
install_platform_release,
)

requested = sdks or ALL_SDKS
sdk_targets = [s for s in requested if s != "platform"]
if "platform" in requested:
version = LTS_VERSIONS.get("platform")
if version is None:
typer.echo("Warning: no LTS version defined for platform; skipping", err=True)
else:
try:
install_platform_release(version)
except PlatformInstallError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
if sdk_targets:
cmd_lts(sdk_targets)
Comment on lines +52 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for handling the platform target is duplicated here and in the tip command (lines 82-91). This pattern makes the CLI code harder to maintain. Consider refactoring this into a shared helper function or extending the cmd_lts/cmd_tip functions to handle the platform service internally, similar to how other SDKs are handled.



@install_app.command()
Expand All @@ -46,23 +74,80 @@ def tip(
) -> None:
"""Source checkout + build from main."""
from otdf_sdk_mgr.installers import cmd_tip

cmd_tip(sdks or ALL_SDKS)
from otdf_sdk_mgr.platform_installer import (
PlatformInstallError,
install_platform_source,
)

requested = sdks or ALL_SDKS
sdk_targets = [s for s in requested if s != "platform"]
if "platform" in requested:
try:
install_platform_source("main", dist_name="tip")
except PlatformInstallError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
if sdk_targets:
cmd_tip(sdk_targets)


@install_app.command()
def release(
specs: Annotated[
list[str],
typer.Argument(help="Version specs as SDK:VERSION (e.g., go:v0.24.0)"),
typer.Argument(help="Version specs as SDK:VERSION (e.g., go:v0.24.0, platform:v0.9.0)"),
],
) -> None:
"""Install specific released versions."""
"""Install specific released versions.

`sdk` may be one of go/js/java or the literal `platform`. Platform is
built from source against the `service/<version>` tag in the
`opentdf/platform` monorepo.
"""
from otdf_sdk_mgr.installers import InstallError, cmd_release
from otdf_sdk_mgr.platform_installer import (
PlatformInstallError,
install_platform_release,
)

sdk_specs: list[str] = []
for spec in specs:
if ":" not in spec:
typer.echo(f"Error: invalid spec '{spec}'. Use SDK:VERSION.", err=True)
raise typer.Exit(1)
sdk, version = spec.split(":", 1)
if sdk == "platform":
try:
install_platform_release(version)
except PlatformInstallError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
else:
sdk_specs.append(spec)
if sdk_specs:
try:
cmd_release(sdk_specs)
except InstallError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)


@install_app.command()
def scripts(
branch: Annotated[
str,
typer.Option(help="Branch of opentdf/platform to pull scripts from"),
] = "main",
) -> None:
"""Refresh shared platform helper scripts under xtest/platform/scripts/."""
from otdf_sdk_mgr.platform_installer import (
PlatformInstallError,
install_helper_scripts,
)

try:
cmd_release(specs)
except InstallError as e:
install_helper_scripts(branch)
except PlatformInstallError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)

Expand Down
104 changes: 104 additions & 0 deletions otdf-sdk-mgr/src/otdf_sdk_mgr/cli_scenario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Scenario-driven install command.

Reads a `scenarios.yaml` (or standalone `instance.yaml`) and installs every
artifact referenced — platform service binary, per-KAS binaries (each at
its own pinned version), and encrypt/decrypt SDK CLIs. Writes
`installed.json` next to the manifest so downstream tools (`otdf-local`,
plugin skills) can locate the dist paths without re-resolving.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Annotated

import typer

from otdf_sdk_mgr.installers import InstallError, install_release
from otdf_sdk_mgr.platform_installer import (
PlatformInstallError,
install_helper_scripts,
install_platform_release,
install_platform_source,
)
from otdf_sdk_mgr.schema import KasPin, PlatformPin, Scenario, load_instance, load_scenario


def _install_platform_pin(pin: PlatformPin | KasPin, label: str) -> dict[str, str]:
if pin.image is not None:
raise typer.BadParameter(
f"{label}: container-image platform pins are not supported in v1; use dist or source"
)
if pin.dist is not None:
dist_dir = install_platform_release(pin.dist)
return {"kind": "dist", "version": pin.dist, "path": str(dist_dir)}
assert pin.source is not None # by schema invariant
dist_dir = install_platform_source(pin.source.ref)
return {"kind": "source", "ref": pin.source.ref, "path": str(dist_dir)}


def install_scenario_cmd(
path: Annotated[Path, typer.Argument(help="Path to scenarios.yaml or instance.yaml")],
skip_scripts: Annotated[
bool,
typer.Option("--skip-scripts", help="Skip refreshing helper scripts from main"),
] = False,
) -> None:
"""Install every artifact declared by a scenarios.yaml or instance.yaml."""
if not path.exists():
typer.echo(f"Error: {path} not found", err=True)
raise typer.Exit(1)

raw_kind = _peek_kind(path)
scenario: Scenario | None = None
if raw_kind == "Scenario":
scenario = load_scenario(path)
instance = scenario.instance
elif raw_kind == "Instance":
instance = load_instance(path)
else:
typer.echo(f"Error: {path} has unknown kind {raw_kind!r}", err=True)
raise typer.Exit(1)
Comment on lines +53 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manifest file is being parsed twice: first in _peek_kind to determine the kind, and then again in load_scenario or load_instance. For better performance and cleaner code, you should parse the YAML once and then use the resulting dictionary to decide which Pydantic model to validate against.


installed: dict[str, object] = {"manifest": str(path), "platform": None, "kas": {}, "sdks": {}}

try:
installed["platform"] = _install_platform_pin(instance.platform, "platform")
for kas_name, kas_pin in instance.kas.items():
installed["kas"][kas_name] = _install_platform_pin(kas_pin, f"kas.{kas_name}")
if not skip_scripts:
install_helper_scripts()
except PlatformInstallError as e:
typer.echo(f"Error installing platform artifacts: {e}", err=True)
raise typer.Exit(1)

if scenario is not None:
sdks = scenario.sdks.union()
for sdk_name, sdk_pin in sdks.items():
try:
dist_dir = install_release(sdk_name, sdk_pin.version, source=sdk_pin.source)
installed["sdks"][sdk_name] = {
"version": sdk_pin.version,
"source": sdk_pin.source,
"path": str(dist_dir),
}
except InstallError as e:
typer.echo(f"Error installing SDK {sdk_name}: {e}", err=True)
raise typer.Exit(1)

out = path.parent / f"{path.stem}.installed.json"
out.write_text(json.dumps(installed, indent=2) + "\n")
typer.echo(f" Wrote {out}")


def _peek_kind(path: Path) -> str | None:
"""Cheap pre-validation read so we can dispatch to the right model loader."""
from ruamel.yaml import YAML

y = YAML(typ="safe")
raw = y.load(path.read_text())
if isinstance(raw, dict):
kind = raw.get("kind")
return kind if isinstance(kind, str) else None
return None
Loading
Loading