Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
9cfc673
fix: add OT_ROOT_KEY env and guard fromJson on empty outputs
dmihalcik-virtru Apr 17, 2026
5e8ab2d
feat(xtest): unify local and CI matrix orchestration via otdf-local s…
dmihalcik-virtru Apr 17, 2026
ca607a1
fix(suite): resolve syntax error in generate-shard command signature
dmihalcik-virtru Apr 17, 2026
0cac095
fix(suite): use uv run for otdf-sdk-mgr calls in runner
dmihalcik-virtru Apr 17, 2026
dd8b417
fix(suite): correct otdf-sdk-mgr path relative to xtest_root
dmihalcik-virtru Apr 17, 2026
00dc688
fix(suite): handle missing platform by checking it out via otdf-sdk-mgr
dmihalcik-virtru Apr 17, 2026
19524cd
fix(suite): use opentdf-dev.yaml as template and improve platform det…
dmihalcik-virtru Apr 17, 2026
374bd6b
fix(suite): improve config generation resilience and fallback to exam…
dmihalcik-virtru Apr 17, 2026
2f1957a
fix(suite): ensure services are ready before platform start and fix m…
dmihalcik-virtru Apr 17, 2026
c332a6e
feat(suite): add --verbose flag to follow logs during setup
dmihalcik-virtru Apr 17, 2026
2bdf2a9
fix(suite): resolve syntax error in runner try/finally block
dmihalcik-virtru Apr 17, 2026
cc5e12d
fix(suite): improve service startup stability and log follow accuracy
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
417 changes: 42 additions & 375 deletions .github/workflows/xtest.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions otdf-local/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requires-python = ">=3.11"
dependencies = [
"httpx>=0.27.0",
"pydantic-settings>=2.2.0",
"pyyaml>=6.0.3",
"rich>=13.7.0",
"ruamel.yaml>=0.18.0",
"typer>=0.12.0",
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
Comment on lines +135 to +154
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 fallback logic for kas_template and platform_config updates local variables that are never used or passed to the Settings object. This means the discovered paths are ignored, and the Settings instance will use its own default paths, potentially leading to errors if the expected files are missing or named differently (e.g., opentdf.yaml vs opentdf-dev.yaml).


# Build settings with CI-specific overrides
# We use a fresh xtest_root derived from this package's location
settings = Settings(
platform_dir=platform_dir,
)
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,8 @@
from rich.live import Live

from otdf_local import __version__
from otdf_local.ci import ci_app
from otdf_local.suite.cli import suite_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 Down Expand Up @@ -43,6 +45,9 @@
pretty_exceptions_enable=sys.stderr.isatty(),
)

app.add_typer(ci_app, name="ci")
app.add_typer(suite_app, name="suite")


def _show_provision_error(result: ProvisionResult, target: str) -> None:
"""Display provisioning error with stderr details."""
Expand Down
76 changes: 66 additions & 10 deletions otdf-local/src/otdf_local/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,29 +54,76 @@
def _find_platform_dir(xtest_root: Path) -> Path:
"""Find the platform directory by searching for a sibling of an ancestor.

Searches up the directory tree from xtest_root looking for a 'platform' directory
that has the expected shape (contains docker-compose.yaml and opentdf-dev.yaml).
Searches for a 'platform' directory in:
1. Ancestor siblings (for local development checkouts)
2. The otdf-sdk-mgr managed location (xtest/sdk/platform/src/main)
3. Last resort: attempts to checkout 'main' via otdf-sdk-mgr

Raises:
FileNotFoundError: If platform directory is not found with expected shape.
"""
# Start from xtest_root and walk up
# 1. Search for sibling 'platform' checkouts
current = xtest_root
while current != current.parent:
# Check siblings at this level
platform_candidate = current.parent / "platform"
if platform_candidate.exists() and platform_candidate.is_dir():
# Verify it has the expected shape
has_compose = (platform_candidate / "docker-compose.yaml").exists()
has_config = (platform_candidate / "opentdf-dev.yaml").exists()
has_config = (platform_candidate / "opentdf-dev.yaml").exists() or (
platform_candidate / "opentdf.yaml"
).exists()
if has_compose and has_config:
return platform_candidate
current = current.parent

# 2. Search in otdf-sdk-mgr managed location
managed_main = xtest_root / "sdk" / "platform" / "src" / "main"
if managed_main.exists() and managed_main.is_dir():
if (managed_main / "docker-compose.yaml").exists() and (

Check warning on line 83 in otdf-local/src/otdf_local/config/settings.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=opentdf_tests&issues=AZ2blVQc4gRHqgdtdF4p&open=AZ2blVQc4gRHqgdtdF4p&pullRequest=436
(managed_main / "opentdf-dev.yaml").exists()
or (managed_main / "opentdf.yaml").exists()
):
return managed_main

# 3. Last resort: checkout 'main' via otdf-sdk-mgr
sdk_mgr_dir = xtest_root.parent / "otdf-sdk-mgr"
if sdk_mgr_dir.exists():
import subprocess

try:
# We use plain print to avoid circular imports or dependency on rich here
print(
"Platform not found. Attempting to checkout 'main' via otdf-sdk-mgr..."
)
subprocess.check_call(
[
"uv",
"run",
"--project",
str(sdk_mgr_dir),
"otdf-sdk-mgr",
"checkout",
"platform",
"main",
],
cwd=sdk_mgr_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if managed_main.exists() and managed_main.is_dir():
if (managed_main / "docker-compose.yaml").exists() and (

Check warning on line 115 in otdf-local/src/otdf_local/config/settings.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=opentdf_tests&issues=AZ2blVQc4gRHqgdtdF4q&open=AZ2blVQc4gRHqgdtdF4q&pullRequest=436
managed_main / "opentdf-dev.yaml"
).exists():
return managed_main
except Exception:
# Fall through to FileNotFoundError
pass

# If we get here, we didn't find it
raise FileNotFoundError(
f"Could not find platform directory with expected shape "
f"(docker-compose.yaml and opentdf-dev.yaml) searching from {xtest_root}"
f"(docker-compose.yaml and opentdf.yaml or opentdf-dev.yaml) searching from {xtest_root}"
)


Expand All @@ -91,9 +138,18 @@

# Directory paths - computed from xtest_root
xtest_root: Path = Field(default_factory=_find_xtest_root)
platform_dir: Path = Field(
default_factory=lambda: _find_platform_dir(_find_xtest_root())
)
_platform_dir: Path | None = None

@property
def platform_dir(self) -> Path:
"""Platform directory path."""
if self._platform_dir:
return self._platform_dir
return _find_platform_dir(self.xtest_root)

@platform_dir.setter
def platform_dir(self, value: Path) -> None:
self._platform_dir = value
Comment on lines +141 to +152
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

Changing platform_dir from a Pydantic field to a property with a private backing attribute (_platform_dir) breaks Pydantic's ability to initialize this value via the constructor or environment variables. Pydantic BaseSettings ignores properties during initialization, which will cause Settings(platform_dir=...) calls (like the one in ci.py) to fail or ignore the provided path. To maintain dynamic behavior while allowing overrides, consider keeping it as a field and using a model_validator or model_post_init.


@property
def logs_dir(self) -> Path:
Expand All @@ -113,12 +169,12 @@
@property
def platform_config(self) -> Path:
"""Platform config file path."""
return self.platform_dir / "opentdf-dev.yaml"
return self.platform_dir / "opentdf.yaml"

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

@property
def kas_template_config(self) -> Path:
Expand Down
16 changes: 16 additions & 0 deletions otdf-local/src/otdf_local/process/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ def read_new(self) -> list[LogEntry]:
self._position = f.tell()
return entries

def seek_to_end(self) -> None:
"""Move the read position to the end of the file."""
if not self.log_file.exists():
self._position = 0
return

with open(self.log_file) as f:
f.seek(0, 2) # Seek to end
self._position = f.tell()

def follow(self, poll_interval: float = 0.5) -> Iterator[LogEntry]:
"""Continuously yield new log entries."""
while True:
Expand Down Expand Up @@ -182,6 +192,12 @@ def read_tail(
key=lambda e: (e.timestamp is None, e.timestamp or datetime.max),
)

def seek_to_end(self, services: list[str] | None = None) -> None:
"""Seek all (or specified) readers to the end of their files."""
readers = self._get_readers(services)
for reader in readers:
reader.seek_to_end()

def follow(
self,
services: list[str] | None = None,
Expand Down
Loading
Loading