openenv/echo_env
6
1# SPDX-License-Identifier: BSD-3-Clause2 3"""OpenEnv validate command."""4 5import json6from pathlib import Path7from typing import Annotated, Any8 9import typer10from openenv.cli._validation import validate_running_environment11from openenv.validation import (12 Level,13 load_policy,14 PolicyError,15 run_validation,16 SignatureError,17 UnsupportedPackageError,18 ValidationReport,19 Verdict,20 write_report,21)22 23 24EXIT_PASS = 025EXIT_FAIL = 126EXIT_UNSUPPORTED = 227EXIT_INTERNAL = 328 29_LEVELS = {30 "static": Level.STATIC,31 "runtime": Level.RUNTIME,32 "semantic": Level.SEMANTIC,33}34 35 36def _looks_like_url(value: str) -> bool:37 candidate = value.strip().lower()38 return candidate.startswith("http://") or candidate.startswith("https://")39 40 41def _render_report(report: ValidationReport | dict[str, Any]) -> str:42 if isinstance(report, dict):43 lines = [44 f"Validation report for {report.get('target', '')} (profile: {report.get('standard_profile', 'running_environment')})",45 f" standard version: {report.get('standard_version', 'unknown')} · mode: {report.get('mode', 'unknown')}",46 ]47 for criterion in report.get("criteria", []):48 status = "PASS " if criterion.get("passed") else "FAIL "49 check_id = criterion.get("id", "")50 lines.append(f" {status} {check_id}")51 if not criterion.get("passed"):52 details = criterion.get("details")53 if details:54 lines.append(f" {details}")55 expected = criterion.get("expected")56 if expected is not None:57 lines.append(f" expected: {expected}")58 actual = criterion.get("actual")59 if actual is not None:60 lines.append(f" actual: {actual}")61 verdict = "PASS" if report.get("passed", False) else "FAIL"62 lines.append(f"Verdict: {verdict}")63 return "\n".join(lines)64 65 lines = [66 f"Validation report for {report.target} (signature: {report.signature.value})",67 f" policy {report.policy_version} · levels run: "68 + ", ".join(level.name.lower() for level in report.levels_run),69 ]70 for result in report.results:71 lines.append(72 f" {result.status.value.upper():5s} {result.check_id} ({result.duration_s:.2f}s)"73 )74 if result.status.value in ("fail", "error", "skip"):75 for line in result.evidence:76 lines.append(f" {line}")77 if result.remediation:78 lines.append(f" remediation: {result.remediation}")79 lines.append(f"Verdict: {report.verdict.value.upper()}")80 return "\n".join(lines)81 82 83def _write_runtime_report(report: dict[str, Any], path: Path | None = None) -> str:84 payload = json.dumps(report, indent=2)85 if path is not None:86 path.write_text(payload + "\n")87 return payload88 89 90def validate(91 target: Annotated[92 str | None,93 typer.Argument(94 help=(95 "Path to the package directory (default: current directory) "96 "or a running OpenEnv URL (http://... or https://...)"97 ),98 ),99 ] = None,100 url: Annotated[101 str | None,102 typer.Option(103 "--url",104 help="Validate a running OpenEnv server by base URL (e.g. http://localhost:8000)",105 ),106 ] = None,107 level: Annotated[108 str,109 typer.Option(110 "--level",111 help="Validation level ceiling: static, runtime, or semantic",112 ),113 ] = "semantic",114 skip_build: Annotated[115 bool,116 typer.Option(117 "--skip-build",118 help="Skip the image build; build-dependent checks are SKIPped with a reason",119 ),120 ] = False,121 local: Annotated[122 bool,123 typer.Option(124 "--local",125 help="Explicitly select the default local-package mode (incompatible with --url)",126 ),127 ] = False,128 policy_version: Annotated[129 str | None,130 typer.Option(131 "--policy", help="Severity policy version (static: v1; runtime: v2)"132 ),133 ] = None,134 json_output: Annotated[135 bool,136 typer.Option("--json", help="Print the validation report as JSON"),137 ] = False,138 output: Annotated[139 Path | None,140 typer.Option("--output", help="Write the JSON report to a file"),141 ] = None,142 timeout: Annotated[143 float,144 typer.Option(145 "--timeout",146 help="HTTP timeout in seconds for --url runtime validation",147 min=0.1,148 ),149 ] = 5.0,150) -> None:151 """152 Validate a local package or a running OpenEnv server.153 154 Local validation detects the package format by its well-known file (the155 formats this build can parse; currently `openenv.yaml`), parses it into the156 normalized manifest, runs the applicable graders up to the requested level,157 applies the severity policy, and emits a report.158 159 Exit codes: 0 pass/warn · 1 fail · 2 unrecognized/unsupported package · 3160 internal or policy error.161 162 Examples:163 164 ```bash165 # Validate the current directory up to the semantic level166 openenv validate167 168 # Fast inner loop: static checks only, no image build169 openenv validate envs/echo_env --level static --skip-build170 171 # Machine-readable report172 openenv validate envs/echo_env --json173 174 # Probe a running server (legacy runtime probe)175 openenv validate --url http://localhost:8000176 ```177 """178 runtime_target = url179 if (180 runtime_target is not None181 and target is not None182 and not _looks_like_url(target)183 ):184 typer.echo(185 "Error: Cannot combine a local path argument with --url runtime validation",186 err=True,187 )188 raise typer.Exit(EXIT_FAIL)189 190 if target is not None and _looks_like_url(target):191 if runtime_target is not None and runtime_target != target:192 typer.echo(193 "Error: Conflicting runtime targets provided via argument and --url",194 err=True,195 )196 raise typer.Exit(EXIT_FAIL)197 runtime_target = target198 199 if runtime_target is not None:200 if local:201 typer.echo("Error: --local cannot be combined with a running URL", err=True)202 raise typer.Exit(EXIT_FAIL)203 try:204 report = validate_running_environment(runtime_target, timeout_s=timeout)205 except ValueError as exc:206 typer.echo(f"Error: {exc}", err=True)207 raise typer.Exit(EXIT_FAIL) from exc208 209 try:210 report_json = _write_runtime_report(report, output)211 except Exception as exc:212 typer.echo(f"Internal error: {exc}", err=True)213 raise typer.Exit(EXIT_INTERNAL) from exc214 if json_output:215 typer.echo(report_json)216 else:217 typer.echo(_render_report(report))218 219 if not report.get("passed", False):220 raise typer.Exit(EXIT_FAIL)221 return222 223 if level not in _LEVELS:224 typer.echo(225 f"Error: unknown level {level!r}; expected one of {sorted(_LEVELS)}",226 err=True,227 )228 raise typer.Exit(EXIT_INTERNAL)229 230 package_root = Path(target) if target is not None else Path.cwd()231 if not package_root.is_dir():232 typer.echo(f"Error: not a package directory: {package_root}", err=True)233 raise typer.Exit(EXIT_UNSUPPORTED)234 235 try:236 if output is not None and not output.parent.is_dir():237 raise OSError("report output directory does not exist")238 validation_report = run_validation(239 package_root,240 max_level=_LEVELS[level],241 skip_build=skip_build,242 policy=load_policy(policy_version) if policy_version else None,243 artifacts_dir=(244 output.parent / (output.stem + ".artifacts") if output else None245 ),246 )247 report_json = write_report(validation_report, output)248 except (SignatureError, UnsupportedPackageError) as exc:249 typer.echo(f"Error: {exc}", err=True)250 raise typer.Exit(EXIT_UNSUPPORTED) from exc251 except PolicyError as exc:252 typer.echo(f"Internal error: {exc}", err=True)253 raise typer.Exit(EXIT_INTERNAL) from exc254 except Exception as exc:255 typer.echo(f"Internal error: {exc}", err=True)256 raise typer.Exit(EXIT_INTERNAL) from exc257 258 if json_output:259 typer.echo(report_json)260 else:261 typer.echo(_render_report(validation_report))262 263 if validation_report.verdict is Verdict.FAIL:264 raise typer.Exit(EXIT_FAIL)265 