Harden default environment pull behavior

Fix the default one-shot install path so empty bundled profile directories no longer win over OCI-backed environment pulls or leave broken cached symlinks behind.

Treat cached installs as valid only when the manifest and boot artifacts are all present, repair invalid installs on the next pull, and add human-mode phase markers for env pull and run without changing JSON output.

Align the Python lifecycle example and public docs with the current exec_vm/vm_exec auto-clean semantics, and validate the slice with focused pytest coverage, make check, make dist-check, and a real default-path pull/inspect/run smoke.
This commit is contained in:
Thales Maciel 2026-03-11 19:27:09 -03:00
parent 694be0730b
commit 6e16e74fd5
16 changed files with 384 additions and 91 deletions

View file

@ -47,6 +47,12 @@ def _print_run_human(payload: dict[str, Any]) -> None:
)
def _print_phase(prefix: str, *, phase: str, **fields: object) -> None:
details = " ".join(f"{key}={value}" for key, value in fields.items())
suffix = f" {details}" if details else ""
print(f"[{prefix}] phase={phase}{suffix}", file=sys.stderr, flush=True)
def _print_env_list_human(payload: dict[str, Any]) -> None:
print(f"Catalog version: {payload.get('catalog_version', 'unknown')}")
environments = payload.get("environments")
@ -464,10 +470,13 @@ def main() -> None:
_print_env_list_human(list_payload)
return
if args.env_command == "pull":
pull_payload = pyro.pull_environment(args.environment)
if bool(args.json):
pull_payload = pyro.pull_environment(args.environment)
_print_json(pull_payload)
else:
_print_phase("pull", phase="install", environment=args.environment)
pull_payload = pyro.pull_environment(args.environment)
_print_phase("pull", phase="ready", environment=args.environment)
_print_env_detail_human(pull_payload, action="Pulled")
return
if args.env_command == "inspect":
@ -489,26 +498,47 @@ def main() -> None:
return
if args.command == "run":
command = _require_command(args.command_args)
try:
result = pyro.run_in_vm(
environment=args.environment,
command=command,
vcpu_count=args.vcpu_count,
mem_mib=args.mem_mib,
timeout_seconds=args.timeout_seconds,
ttl_seconds=args.ttl_seconds,
network=args.network,
allow_host_compat=args.allow_host_compat,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
try:
result = pyro.run_in_vm(
environment=args.environment,
command=command,
vcpu_count=args.vcpu_count,
mem_mib=args.mem_mib,
timeout_seconds=args.timeout_seconds,
ttl_seconds=args.ttl_seconds,
network=args.network,
allow_host_compat=args.allow_host_compat,
)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
raise SystemExit(1) from exc
_print_json(result)
else:
vm_id: str | None = None
try:
_print_phase("run", phase="create", environment=args.environment)
created = pyro.create_vm(
environment=args.environment,
vcpu_count=args.vcpu_count,
mem_mib=args.mem_mib,
ttl_seconds=args.ttl_seconds,
network=args.network,
allow_host_compat=args.allow_host_compat,
)
vm_id = str(created["vm_id"])
_print_phase("run", phase="start", vm_id=vm_id)
pyro.start_vm(vm_id)
_print_phase("run", phase="execute", vm_id=vm_id)
result = pyro.exec_vm(vm_id, command=command, timeout_seconds=args.timeout_seconds)
except Exception as exc: # noqa: BLE001
if vm_id is not None:
try:
pyro.manager.delete_vm(vm_id, reason="run_vm_error_cleanup")
except ValueError:
pass
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_run_human(result)
exit_code = int(result.get("exit_code", 1))
if exit_code != 0: