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
109 changes: 106 additions & 3 deletions otdf-local/src/otdf_local/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
format_status,
print_error,
print_info,
print_json,
print_success,
print_warning,
status_spinner,
Expand Down Expand Up @@ -112,10 +113,26 @@ def up(
start_platform = "platform" in service_list
start_kas = "kas" in service_list

# Step 0: Ensure temporary keys exist
from otdf_local.utils.keys import ensure_all_temp_keys

print_info("Checking temporary keys...")
try:
generated = ensure_all_temp_keys(
settings.platform_dir,
settings.keys_dir,
compose_file=settings.docker_compose_file,
)
if generated:
print_success("Generated missing temporary keys")
except Exception as e:
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

Catching a broad Exception can hide unexpected issues and make debugging harder. It's generally better to catch more specific exceptions (e.g., subprocess.CalledProcessError, FileNotFoundError, etc.) that you anticipate might occur during key generation. If you must catch Exception, consider logging the full traceback for better diagnostics.

print_error(f"Failed to generate temporary keys: {e}")
raise typer.Exit(1) from e

# Step 1: Start Docker services
if start_docker:
print_info("Starting Docker services (Keycloak, PostgreSQL)...")
docker = get_docker_service(settings)
docker = get_docker_service(settings, keys_dir=settings.keys_dir)
if not docker.start():
print_error("Failed to start Docker services")
raise typer.Exit(1)
Expand Down Expand Up @@ -274,7 +291,7 @@ def list_services(

if json_output:
output = [info.to_dict() for info in all_info]
console.print_json(json.dumps(output))
print_json(json.dumps(output))
return

# Table output
Expand Down Expand Up @@ -611,7 +628,7 @@ def env(

# Output in requested format
if format == "json":
console.print_json(json.dumps(env_vars, indent=2))
print_json(json.dumps(env_vars, indent=2))
else:
# Shell export format - use plain print to avoid line wrapping
for key, value in env_vars.items():
Expand All @@ -621,5 +638,91 @@ def env(
print(f"export {key}='{escaped_value}'", file=sys.stdout)


@app.command()
def configure(
feature: Annotated[
str | None,
typer.Argument(help="Feature name to configure (e.g. ec-wrap, key-management)"),
] = None,
enable: Annotated[
bool | None,
typer.Option("--enable/--disable", help="Enable or disable the feature"),
] = None,
) -> None:
"""Configure platform feature flags.

Without arguments, lists all available features and their current state.
With a feature name alone, shows the current state of that feature.
With --enable or --disable, updates the setting persistently.

Changes survive restarts. If the platform config already exists it is
updated immediately; run 'otdf-local restart platform' to pick up the
change in a running environment.

Examples:

otdf-local configure # list all features

otdf-local configure ec-wrap # show ec-wrap state

otdf-local configure ec-wrap --enable # enable EC-wrapped TDF

otdf-local configure ec-wrap --disable # disable EC-wrapped TDF
"""
from rich.table import Table

from otdf_local.config.overrides import FEATURES, apply_overrides, load_overrides, save_overrides
from otdf_local.utils.yaml import load_yaml, save_yaml

settings = get_settings()
overrides = load_overrides(settings.xtest_root)

if feature is None:
# List all features with their current state
table = Table(title="Platform Feature Flags")
table.add_column("Feature", style="cyan")
table.add_column("Description")
table.add_column("State")
for name, (_, description) in FEATURES.items():
if name in overrides:
state = "[green]enabled[/green]" if overrides[name] else "[red]disabled[/red]"
else:
state = "[dim]default[/dim]"
table.add_row(name, description, state)
console.print(table)
return

if feature not in FEATURES:
print_error(f"Unknown feature: {feature}")
print_info(f"Valid features: {', '.join(FEATURES)}")
raise typer.Exit(1)

if enable is None:
# Show current state of this feature
if feature in overrides:
state = "enabled" if overrides[feature] else "disabled"
console.print(f"{feature}: {state} (override)")
else:
console.print(f"{feature}: default (no override set)")
return

# Save override
overrides[feature] = enable
save_overrides(settings.xtest_root, overrides)

# Apply immediately to live config if it already exists
platform_config = settings.platform_config
if platform_config.exists():
data = load_yaml(platform_config)
apply_overrides(data, {feature: enable})
save_yaml(platform_config, data)
action = "enabled" if enable else "disabled"
print_success(f"{feature} {action} (applied to existing config)")
print_info("Run 'otdf-local restart platform' to apply to a running environment")
else:
action = "enabled" if enable else "disabled"
print_success(f"{feature} {action} (will apply on next start)")


if __name__ == "__main__":
app()
47 changes: 47 additions & 0 deletions otdf-local/src/otdf_local/config/overrides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""User-specified feature overrides for platform config."""

from pathlib import Path
from typing import Any

from otdf_local.utils.yaml import load_yaml, save_yaml, set_nested

# Maps CLI feature name -> (yaml_path, description)
FEATURES: dict[str, tuple[str, str]] = {
"ec-wrap": (
"services.kas.preview.ec_tdf_enabled",
"EC-wrapped TDF support (ML-KEM / X-Wing hybrid)",
),
"key-management": (
"services.kas.preview.key_management",
"Key management service",
),
}


def _overrides_path(xtest_root: Path) -> Path:
"""Path to feature overrides file. Lives in tmp/ but outside config/ so it survives --clean."""
return xtest_root / "tmp" / "feature-overrides.yaml"


def load_overrides(xtest_root: Path) -> dict[str, bool]:
"""Load current feature overrides. Returns empty dict if none set."""
path = _overrides_path(xtest_root)
if not path.exists():
return {}
data = load_yaml(path)
return dict(data) if data else {}


def save_overrides(xtest_root: Path, overrides: dict[str, bool]) -> None:
"""Persist feature overrides to disk."""
path = _overrides_path(xtest_root)
path.parent.mkdir(parents=True, exist_ok=True)
save_yaml(path, overrides)


def apply_overrides(config_data: Any, overrides: dict[str, bool]) -> None:
"""Apply feature overrides to a loaded YAML config object (in-place)."""
for feature, enabled in overrides.items():
if feature in FEATURES:
yaml_path, _ = FEATURES[feature]
set_nested(config_data, yaml_path, enabled)
28 changes: 23 additions & 5 deletions otdf-local/src/otdf_local/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from functools import lru_cache
from pathlib import Path

from pydantic import Field
import warnings

from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from otdf_local.config.ports import Ports
Expand Down Expand Up @@ -95,6 +97,22 @@ class Settings(BaseSettings):
default_factory=lambda: _find_platform_dir(_find_xtest_root())
)

@field_validator("platform_dir")
@classmethod
def _validate_platform_dir(cls, v: Path) -> Path:
v = v.resolve()
if not v.exists():
raise ValueError(f"platform_dir does not exist: {v}")
if not (v / "service").is_dir():
raise ValueError(f"platform_dir {v} missing service/ directory")
if not (v / "opentdf-dev.yaml").exists():
warnings.warn(
f"platform_dir {v} missing opentdf-dev.yaml — "
"config generation will create it from template",
stacklevel=2,
)
return v

@property
def logs_dir(self) -> Path:
"""Logs directory."""
Expand All @@ -112,13 +130,13 @@ def config_dir(self) -> Path:

@property
def platform_config(self) -> Path:
"""Platform config file path."""
return self.platform_dir / "opentdf-dev.yaml"
"""Platform config file path (gitignored, generated locally)."""
return self.platform_dir / "opentdf.yaml"

@property
def platform_template_config(self) -> Path:
"""Platform config template path."""
return self.platform_dir / "opentdf.yaml"
"""Platform config template path (committed to git)."""
return self.platform_dir / "opentdf-dev.yaml"

@property
def kas_template_config(self) -> Path:
Expand Down
14 changes: 11 additions & 3 deletions otdf-local/src/otdf_local/services/docker.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Docker compose service management."""

import json
import os
import subprocess
from pathlib import Path

from otdf_local.config.ports import Ports
from otdf_local.config.settings import Settings
Expand All @@ -12,9 +14,10 @@
class DockerService(Service):
"""Manages Docker compose services (Keycloak, PostgreSQL)."""

def __init__(self, settings: Settings) -> None:
def __init__(self, settings: Settings, keys_dir: Path | None = None) -> None:
super().__init__(settings)
self._compose_file = settings.docker_compose_file
self._keys_dir = keys_dir or settings.keys_dir

@property
def name(self) -> str:
Expand All @@ -37,11 +40,13 @@ def start(self) -> bool:
if not self._compose_file.exists():
return False

env = {**os.environ, "KEYS_DIR": str(self._keys_dir.resolve())}
result = subprocess.run(
["docker", "compose", "-f", str(self._compose_file), "up", "-d"],
capture_output=True,
text=True,
cwd=self._compose_file.parent,
env=env,
)
return result.returncode == 0

Expand Down Expand Up @@ -140,10 +145,13 @@ def get_all_info(self) -> list[ServiceInfo]:
]


def get_docker_service(settings: Settings | None = None) -> DockerService:
def get_docker_service(
settings: Settings | None = None,
keys_dir: Path | None = None,
) -> DockerService:
"""Get a DockerService instance."""
if settings is None:
from otdf_local.config.settings import get_settings

settings = get_settings()
return DockerService(settings)
return DockerService(settings, keys_dir)
8 changes: 8 additions & 0 deletions otdf-local/src/otdf_local/services/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
kill_process_on_port,
)
from otdf_local.services.base import Service, ServiceInfo, ServiceType
from otdf_local.config.overrides import apply_overrides, load_overrides
from otdf_local.utils.keys import get_golden_keyring_entries, setup_golden_keys
from otdf_local.utils.yaml import (
append_to_list,
Expand Down Expand Up @@ -69,6 +70,13 @@ def _generate_config(self) -> Path:

copy_yaml_with_updates(template_path, config_path, updates)

# Apply user-specified feature overrides
overrides = load_overrides(self.settings.xtest_root)
if overrides:
data = load_yaml(config_path)
apply_overrides(data, overrides)
save_yaml(config_path, data)

# Set up golden keys for legacy TDF tests
self._setup_golden_keys(config_path)

Expand Down
10 changes: 8 additions & 2 deletions otdf-local/src/otdf_local/utils/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.table import Table

# Global console instance
console = Console()
# Global console instance (stderr for all status/diagnostic output)
console = Console(stderr=True)
_stdout_console = Console() # stdout for machine-readable data output


def print_success(message: str) -> None:
Expand Down Expand Up @@ -71,6 +72,11 @@ def format_health(healthy: bool | None) -> str:
return "[red]✗[/red]"


def print_json(data: str) -> None:
"""Print JSON data to stdout."""
_stdout_console.print_json(data)


def create_progress() -> Progress:
"""Create a progress display for multi-step operations."""
return Progress(
Expand Down
Loading
Loading