Skip to content
Closed
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
17 changes: 17 additions & 0 deletions envs/echo_env/openenv.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
spec_version: 1
name: echo_env
version: 0.1.0
type: space
runtime: fastapi
app: server.app:app
port: 8000
validation:
reward:
range: [0.0, 1.0]
oracle_tolerance: 0.0
floor_margin: 0.5
resources:
cpu: 1.0
memory_mb: 1024
disk_mb: 512
episode_timeout_s: 60.0
capabilities:
verifier:
kind: reward_channel
declared_tools: [echo_message, echo_with_length]
types:
tags: [demo]
223 changes: 120 additions & 103 deletions src/openenv/cli/commands/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,39 @@
"""
OpenEnv validate command.

This module provides the 'openenv validate' command to check if environments
are properly configured for multi-mode deployment.
Local packages run through the RFC 008 validation pipeline (signature -> parse ->
grade -> severity policy -> report). Running servers are probed via --url (this
legacy probe folds into the pipeline's runtime level in a later slice).
"""

import json
from pathlib import Path
from typing import Annotated

import typer
from openenv.cli._validation import (
build_local_validation_json_report,
format_validation_report,
get_deployment_modes,
validate_multi_mode_deployment,
validate_running_environment,
from openenv.cli._validation import validate_running_environment
from openenv.validation import (
Level,
load_policy,
PolicyError,
SignatureError,
UnsupportedPackageError,
ValidationReport,
Verdict,
)
from openenv.validation.runner import run_validation

# Exit-code contract (RFC 008 §8):
EXIT_PASS = 0 # verdict PASS or WARN
EXIT_FAIL = 1 # verdict FAIL
EXIT_UNSUPPORTED = 2 # SignatureError / UnsupportedPackageError
EXIT_INTERNAL = 3 # internal error

_LEVELS = {
"static": Level.STATIC,
"runtime": Level.RUNTIME,
"semantic": Level.SEMANTIC,
}


def _looks_like_url(value: str) -> bool:
Expand All @@ -27,12 +44,32 @@ def _looks_like_url(value: str) -> bool:
return candidate.startswith("http://") or candidate.startswith("https://")


def _render_report(report: ValidationReport) -> str:
"""Human-readable report summary."""
lines = [
f"Validation report for {report.target} (signature: {report.signature.value})",
f" policy {report.policy_version} · levels run: "
+ ", ".join(level.name.lower() for level in report.levels_run),
]
for result in report.results:
lines.append(
f" {result.status.value.upper():5s} {result.check_id} ({result.duration_s:.2f}s)"
)
if result.status.value in ("fail", "error", "skip"):
for line in result.evidence:
lines.append(f" {line}")
if result.remediation:
lines.append(f" remediation: {result.remediation}")
lines.append(f"Verdict: {report.verdict.value.upper()}")
return "\n".join(lines)


def validate(
target: Annotated[
str | None,
typer.Argument(
help=(
"Path to the environment directory (default: current directory) "
"Path to the package directory (default: current directory) "
"or a running OpenEnv URL (http://... or https://...)"
),
),
Expand All @@ -44,54 +81,67 @@ def validate(
help="Validate a running OpenEnv server by base URL (e.g. http://localhost:8000)",
),
] = None,
json_output: Annotated[
level: Annotated[
str,
typer.Option(
"--level",
help="Validation level ceiling: static, runtime, or semantic",
),
] = "semantic",
skip_build: Annotated[
bool,
typer.Option(
"--json",
help="Output local validation report as JSON (runtime validation is JSON by default)",
"--skip-build",
help="Skip the image build; build-dependent checks are SKIPped with a reason",
),
] = False,
policy_version: Annotated[
str,
typer.Option("--policy", help="Severity policy version to apply"),
] = "v1",
json_output: Annotated[
bool,
typer.Option("--json", help="Print the validation report as JSON"),
] = False,
output: Annotated[
Path | None,
typer.Option("--output", help="Write the JSON report to a file"),
] = None,
timeout: Annotated[
float,
typer.Option(
"--timeout",
help="HTTP timeout in seconds for runtime validation",
help="HTTP timeout in seconds for --url runtime validation",
min=0.1,
),
] = 5.0,
verbose: Annotated[
bool, typer.Option("--verbose", "-v", help="Show detailed information")
] = False,
) -> None:
"""
Validate local environments and running OpenEnv servers.
Validate a local package or a running OpenEnv server.

Local validation checks if an environment is properly configured with:
- Required files (pyproject.toml, openenv.yaml, server/app.py, etc.)
- Docker deployment support
- uv run server capability
- python -m module execution
Local validation detects the package format by its well-known file (the
formats this build can parse; currently openenv.yaml), parses it into the
normalized manifest, runs the applicable graders up to the requested level,
applies the severity policy, and emits a report.

Runtime validation checks if a live OpenEnv server conforms to the
versioned runtime API contract and returns a criteria-based JSON report.
Exit codes: 0 pass/warn - 1 fail - 2 unrecognized/unsupported package - 3
internal error.

Examples:

```bash
# Validate current directory (recommended)
$ cd my_env
$ openenv validate
```bash
# Validate the current directory up to the semantic level
openenv validate

# Validate a running environment and return JSON criteria
$ openenv validate --url http://localhost:8000
$ openenv validate https://my-env.hf.space
# Fast inner loop: static checks only, no image build
openenv validate envs/echo_env --level static --skip-build

# Validate with detailed output
$ openenv validate --verbose
# Machine-readable report
openenv validate envs/echo_env --json

# Validate specific environment
$ openenv validate envs/echo_env
```
# Probe a running server (legacy runtime probe)
openenv validate --url http://localhost:8000
```
"""
runtime_target = url
if (
Expand All @@ -103,95 +153,62 @@ def validate(
"Error: Cannot combine a local path argument with --url runtime validation",
err=True,
)
raise typer.Exit(1)
raise typer.Exit(EXIT_FAIL)

if target is not None and _looks_like_url(target):
if runtime_target is not None and runtime_target != target:
typer.echo(
"Error: Conflicting runtime targets provided via argument and --url",
err=True,
)
raise typer.Exit(1)
raise typer.Exit(EXIT_FAIL)
runtime_target = target

if runtime_target is not None:
try:
report = validate_running_environment(runtime_target, timeout_s=timeout)
except ValueError as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1) from exc
raise typer.Exit(EXIT_FAIL) from exc

typer.echo(json.dumps(report, indent=2))
if not report.get("passed", False):
raise typer.Exit(1)
raise typer.Exit(EXIT_FAIL)
return

# Determine environment path (default to current directory)
if target is None:
env_path_obj = Path.cwd()
else:
env_path_obj = Path(target)

if not env_path_obj.exists():
typer.echo(f"Error: Path does not exist: {env_path_obj}", err=True)
raise typer.Exit(1)

if not env_path_obj.is_dir():
typer.echo(f"Error: Path is not a directory: {env_path_obj}", err=True)
raise typer.Exit(1)

# Check for openenv.yaml to confirm this is an environment directory
openenv_yaml = env_path_obj / "openenv.yaml"
if not openenv_yaml.exists():
if level not in _LEVELS:
typer.echo(
f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}",
f"Error: unknown level {level!r}; expected one of {sorted(_LEVELS)}",
err=True,
)
typer.echo(
"Hint: Run this command from the environment root directory or specify the path",
err=True,
raise typer.Exit(EXIT_INTERNAL)

package_root = Path(target) if target is not None else Path.cwd()
if not package_root.is_dir():
typer.echo(f"Error: not a package directory: {package_root}", err=True)
raise typer.Exit(EXIT_UNSUPPORTED)

try:
validation_report = run_validation(

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.

Tier 1 (CI-blocking): this rewrite replaces the old local-validation path (validate_multi_mode_deployment / build_local_validation_json_report, [OK] output, main()-guard + dependency checks) with the RFC 008 pipeline, but the tests that assert the old behavior in tests/test_cli/test_validate.py were not updated. 8 of them now fail on HEAD (all 12 pass on the base commit). Please delete or rewrite the obsolete local-path tests to match the new pipeline contract — the --url/runtime tests and the mixed-path guard test still pass and should be kept.

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.

Tier 1 (test regression): this rewrite replaces the old local-validation path (validate_multi_mode_deployment / build_local_validation_json_report, [OK] output, main()-guard + dependency checks) with the RFC 008 pipeline, but the tests asserting the old behavior in tests/test_cli/test_validate.py weren't updated — 8 of them now fail on HEAD (all 12 pass on the base commit). Note test.yml doesn't run on this stacked PR (base is a feature branch), so this won't turn the PR's checks red, but it will block when the stack retargets to main. Please delete or rewrite the obsolete local-path tests to match the new pipeline contract; keep the --url/runtime tests and the mixed-path guard test.

package_root,
max_level=_LEVELS[level],
skip_build=skip_build,
policy=load_policy(policy_version),
)
raise typer.Exit(1)

env_name = env_path_obj.name
if env_name.endswith("_env"):
base_name = env_name[:-4]
else:
base_name = env_name

# Run validation
is_valid, issues = validate_multi_mode_deployment(env_path_obj)
modes = get_deployment_modes(env_path_obj)

except (SignatureError, UnsupportedPackageError) as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(EXIT_UNSUPPORTED) from exc
except PolicyError as exc:
typer.echo(f"Internal error: {exc}", err=True)
raise typer.Exit(EXIT_INTERNAL) from exc

report_json = validation_report.model_dump_json(indent=2)
if output is not None:
output.write_text(report_json + "\n")
if json_output:
report = build_local_validation_json_report(
env_name=base_name,
env_path=env_path_obj,
is_valid=is_valid,
issues=issues,
deployment_modes=modes if verbose else None,
)
typer.echo(json.dumps(report, indent=2))
if not is_valid:
raise typer.Exit(1)
return
typer.echo(report_json)
else:
typer.echo(_render_report(validation_report))

# Show validation report
report = format_validation_report(base_name, is_valid, issues)
typer.echo(report)

# Show deployment modes if verbose
if verbose:
typer.echo("\nSupported deployment modes:")
for mode, supported in modes.items():
status = "[YES]" if supported else "[NO]"
typer.echo(f" {status} {mode}")

if is_valid:
typer.echo("\nUsage examples:")
typer.echo(f" cd {env_path_obj.name} && uv run server")
typer.echo(f" cd {env_path_obj.name} && openenv build")
typer.echo(f" cd {env_path_obj.name} && openenv push")

if not is_valid:
raise typer.Exit(1)
if validation_report.verdict is Verdict.FAIL:
raise typer.Exit(EXIT_FAIL)
6 changes: 6 additions & 0 deletions src/openenv/validation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .manifest import (
CapabilitiesSpec,
JudgePin,
ManifestError,
NetworkPolicy,
NormalizedManifest,
OracleDeclaration,
Expand All @@ -30,7 +31,9 @@
)
from .providers import ExecResult, RunningSubject, ValidationProvider
from .report import CheckResult, ValidationReport
from .runner import run_validation
from .signature import (
detect_signature,
SignatureError,
UNSUPPORTED_CATEGORIES,
UnsupportedPackageError,
Expand Down Expand Up @@ -60,6 +63,7 @@
"JudgePin",
"Lane",
"Level",
"ManifestError",
"NetworkPolicy",
"NormalizedManifest",
"OracleDeclaration",
Expand All @@ -84,5 +88,7 @@
"Verdict",
"VerifierBinding",
"apply_policy",
"detect_signature",
"load_policy",
"run_validation",
]
5 changes: 5 additions & 0 deletions src/openenv/validation/graders/static_/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Static-level (L1) graders."""

from .manifest import StaticManifestGrader

__all__ = ["StaticManifestGrader"]
Loading
Loading