Skip to content
Draft
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
3567fd3
feat(xtest): support platform-embedded otdfctl for migration to monorepo
dmihalcik-virtru Apr 15, 2026
0ea2e5d
fixup ruff format
dmihalcik-virtru Apr 16, 2026
7c20d73
fix(xtest): update remaining otdfctl references for platform monorepo…
dmihalcik-virtru Apr 16, 2026
cd0aec5
fixup pkg.go removes tag prefixes IIRC
dmihalcik-virtru Apr 16, 2026
1f02700
refactor(xtest): consolidate .version and .module-path into single .v…
dmihalcik-virtru Apr 16, 2026
9b9c9d7
feat(sdk-mgr): wire --source option through install artifact command
dmihalcik-virtru Apr 16, 2026
0c1b428
fix(setup-cli-tool): avoid script injection by using env vars for inp…
dmihalcik-virtru Apr 16, 2026
bf739dc
Apply suggestion from @gemini-code-assist[bot]
dmihalcik-virtru Apr 16, 2026
edb5e3a
feat(setup-cli-tool): support multiple platform-source Go versions
dmihalcik-virtru Apr 16, 2026
8f783c3
fix: address PR review findings for platform otdfctl migration
dmihalcik-virtru Apr 16, 2026
1e5fc53
fix(sdk-mgr): accept "standalone" as valid Go source in go_module_path
dmihalcik-virtru Apr 16, 2026
821c02c
docs(xtest): clarify auto mode resolves releases from standalone
dmihalcik-virtru Apr 16, 2026
2901544
fix(setup-cli-tool): require SHA match in auto-detect platform fallback
dmihalcik-virtru Apr 16, 2026
7e99e2d
fix: harden validation and error handling for platform otdfctl migration
dmihalcik-virtru Apr 16, 2026
13385f5
fixup ruff format
dmihalcik-virtru Apr 16, 2026
bfe1119
refactor(xtest): extract composite actions from xtest.yml
dmihalcik-virtru Apr 17, 2026
cfc8fd9
Simplify xtest workflow with local runner
dmihalcik-virtru Apr 17, 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
641 changes: 110 additions & 531 deletions .github/workflows/xtest.yml

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions otdf-local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,34 @@ uv run otdf-local down

## Commands

### `xtest` - Replay CI XTests Locally

Generate a replayable config from refs:

```bash
otdf-local xtest plan \
--platform-ref latest \
--go-ref "main latest" \
--js-ref "main latest" \
--java-ref "main latest" \
--encrypt-sdk go \
--output xtest-repro.yaml
```

Run the same orchestration locally that CI uses:

```bash
otdf-local xtest run --config xtest-repro.yaml
```

Or run directly without a saved config:

```bash
otdf-local xtest run --platform-ref latest --encrypt-sdk go
```

The generated YAML is designed to be pasted directly from the GitHub Actions step summary and replayed locally.

### `up` - Start Environment

Start all or specific services.
Expand Down
223 changes: 223 additions & 0 deletions otdf-local/src/otdf_local/ci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""CI-specific commands for otdf-local.

These commands adapt the local environment management for GitHub Actions CI,
where the platform is already started by an external action and we only need
to start KAS instances as background processes.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Annotated

import typer

from otdf_local.config.ports import Ports
from otdf_local.config.settings import Settings
from otdf_local.health.waits import WaitTimeoutError, wait_for_health
from otdf_local.services import get_kas_manager
from otdf_local.utils.console import (
print_error,
print_info,
print_success,
print_warning,
)
from otdf_local.utils.yaml import load_yaml, save_yaml, set_nested

ci_app = typer.Typer(
name="ci",
help="CI-specific commands for GitHub Actions workflows.",
no_args_is_help=True,
)


def _emit_github_output(key: str, value: str) -> None:
"""Write a key=value pair to $GITHUB_OUTPUT if available, else print to stdout."""
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
f.write(f"{key}={value}\n")
else:
# Fallback for local testing
print(f"{key}={value}", file=sys.stdout)


def _prepare_kas_template(
settings: Settings, root_key: str | None, ec_tdf_enabled: bool
) -> None:
"""Ensure the KAS template config has the right root key and EC TDF settings.

In CI, the platform config may have a root_key that differs from what
we want for additional KAS instances. This updates the platform config
in-place so that KASService._generate_config reads the correct root_key.
"""
if root_key:
config = load_yaml(settings.platform_config)
set_nested(config, "services.kas.root_key", root_key)
if ec_tdf_enabled:
set_nested(config, "services.kas.preview.ec_tdf_enabled", True)
save_yaml(settings.platform_config, config)


@ci_app.command("start-kas")
def start_kas(
platform_dir: Annotated[
Path,
typer.Option(
"--platform-dir",
help="Path to the platform checkout (must contain opentdf-kas-mode.yaml)",
envvar="OTDF_LOCAL_PLATFORM_DIR",
),
],
root_key: Annotated[
str | None,
typer.Option(
"--root-key",
help="Root key for KAS instances (overrides platform config value)",
envvar="OT_ROOT_KEY",
),
] = None,
ec_tdf_enabled: Annotated[
bool,
typer.Option(
"--ec-tdf-enabled/--no-ec-tdf",
help="Enable EC TDF support",
),
] = True,
key_management: Annotated[
bool,
typer.Option(
"--key-management/--no-key-management",
help="Enable key management on km1/km2 instances",
),
] = False,
log_type: Annotated[
str,
typer.Option(
"--log-type",
help="Log format type (json, text)",
),
] = "json",
health_timeout: Annotated[
int,
typer.Option(
"--health-timeout",
help="Seconds to wait for each KAS instance to become healthy",
),
] = 60,
instances: Annotated[
str | None,
typer.Option(
"--instances",
help="Comma-separated KAS instance names (default: all)",
),
] = None,
) -> None:
"""Start KAS instances for CI and emit GitHub Actions outputs.

Expects the platform to already be running (started by start-up-with-containers).
Starts all 6 KAS instances (alpha, beta, gamma, delta, km1, km2) as background
processes, waits for each to pass health checks, and emits log file paths as
GitHub Actions step outputs.

Output keys (written to $GITHUB_OUTPUT):
kas-alpha-log-file, kas-beta-log-file, kas-gamma-log-file,
kas-delta-log-file, kas-km1-log-file, kas-km2-log-file
"""
platform_dir = platform_dir.resolve()
if not platform_dir.is_dir():
print_error(f"Platform directory does not exist: {platform_dir}")
raise typer.Exit(1)

# Check for required template files
kas_template = platform_dir / "opentdf-kas-mode.yaml"
platform_config = platform_dir / "opentdf-dev.yaml"
if not kas_template.exists():
# Fall back to opentdf.yaml if opentdf-kas-mode.yaml doesn't exist
kas_template_alt = platform_dir / "opentdf.yaml"
if kas_template_alt.exists():
print_info(
f"Using {kas_template_alt} as KAS template (opentdf-kas-mode.yaml not found)"
)
else:
print_error(
f"Neither opentdf-kas-mode.yaml nor opentdf.yaml found in {platform_dir}"
)
raise typer.Exit(1)

if not platform_config.exists():
# Try opentdf.yaml as fallback
platform_config_alt = platform_dir / "opentdf.yaml"
if platform_config_alt.exists():
platform_config = platform_config_alt

# Build settings with CI-specific overrides
# We use a fresh xtest_root derived from this package's location
settings = Settings(
platform_dir=platform_dir,
)
Comment on lines +135 to +160
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.

high

The fallback logic for kas_template and platform_config appears to be disconnected from the rest of the orchestration.

  1. The kas_template variable (lines 135-148) is checked for existence but never used again in the function. If the intention is to use this template for KAS instances, it should be passed to the Settings or the KASManager.
  2. The platform_config variable (lines 150-154) is updated with a fallback path, but it is not passed to the Settings constructor (lines 158-160). If the Settings class defaults to opentdf-dev.yaml, it will ignore the fallback found here, causing _prepare_kas_template to fail or modify the wrong file.

settings.ensure_directories()

# Update root key in platform config if provided
if root_key:
_prepare_kas_template(settings, root_key, ec_tdf_enabled)

# Determine which instances to start
if instances:
kas_names = [n.strip() for n in instances.split(",")]
for name in kas_names:
if name not in Ports.all_kas_names():
print_error(f"Unknown KAS instance: {name}")
raise typer.Exit(1)
else:
kas_names = Ports.all_kas_names()

# Start KAS instances
print_info(f"Starting KAS instances: {', '.join(kas_names)}...")
kas_manager = get_kas_manager(settings)

failed = []
for name in kas_names:
kas = kas_manager.get(name)
if kas is None:
print_error(f"KAS instance {name} not found in manager")
failed.append(name)
continue
if not kas.start():
print_error(f"Failed to start KAS {name}")
failed.append(name)

if failed:
print_error(f"Failed to start: {', '.join(failed)}")
raise typer.Exit(1)

# Wait for health
print_info("Waiting for KAS health checks...")
unhealthy = []
for name in kas_names:
port = Ports.get_kas_port(name)
try:
wait_for_health(
f"http://localhost:{port}/healthz",
timeout=health_timeout,
service_name=f"KAS {name}",
)
except WaitTimeoutError as e:
print_warning(str(e))
unhealthy.append(name)

if unhealthy:
print_error(f"KAS instances failed health check: {', '.join(unhealthy)}")
raise typer.Exit(1)

print_success(f"All {len(kas_names)} KAS instances are healthy")

# Emit outputs
for name in kas_names:
log_path = settings.get_kas_log_path(name)
output_key = f"kas-{name}-log-file"
_emit_github_output(output_key, str(log_path))

print_success("CI KAS startup complete")
5 changes: 5 additions & 0 deletions otdf-local/src/otdf_local/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from rich.live import Live

from otdf_local import __version__
from otdf_local.ci import ci_app
from otdf_local.config.ports import Ports
from otdf_local.config.settings import get_settings
from otdf_local.health.waits import WaitTimeoutError, wait_for_health, wait_for_port
Expand All @@ -35,6 +36,7 @@
status_spinner,
)
from otdf_local.utils.yaml import get_nested, load_yaml
from otdf_local.xtest import xtest_app

app = typer.Typer(
name="otdf-local",
Expand All @@ -43,6 +45,9 @@
pretty_exceptions_enable=sys.stderr.isatty(),
)

app.add_typer(ci_app, name="ci")
app.add_typer(xtest_app, name="xtest")


def _show_provision_error(result: ProvisionResult, target: str) -> None:
"""Display provisioning error with stderr details."""
Expand Down
8 changes: 8 additions & 0 deletions otdf-local/src/otdf_local/utils/yaml.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""YAML manipulation utilities using ruamel.yaml."""

from io import StringIO
from pathlib import Path
from typing import Any

Expand All @@ -23,6 +24,13 @@ def save_yaml(path: Path, data: dict[str, Any]) -> None:
_yaml.dump(data, f)


def dump_yaml(data: dict[str, Any]) -> str:
"""Serialize YAML data to a string."""
stream = StringIO()
_yaml.dump(data, stream)
return stream.getvalue()


def get_nested(data: dict[str, Any], path: str, default: Any = None) -> Any:
"""Get a nested value from a dict using dot notation.

Expand Down
Loading
Loading