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:
parent
694be0730b
commit
6e16e74fd5
16 changed files with 384 additions and 91 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -185,6 +185,10 @@ def _serialize_environment(environment: VmEnvironment) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def _artifacts_ready(root: Path) -> bool:
|
||||
return (root / "vmlinux").is_file() and (root / "rootfs.ext4").is_file()
|
||||
|
||||
|
||||
class EnvironmentStore:
|
||||
"""Install and inspect curated environments in a local cache."""
|
||||
|
||||
|
|
@ -228,7 +232,7 @@ class EnvironmentStore:
|
|||
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
||||
install_dir = self._install_dir(spec)
|
||||
metadata_path = install_dir / "environment.json"
|
||||
installed = metadata_path.exists() and (install_dir / "vmlinux").exists()
|
||||
installed = self._load_installed_environment(spec) is not None
|
||||
payload = _serialize_environment(spec)
|
||||
payload.update(
|
||||
{
|
||||
|
|
@ -245,29 +249,12 @@ class EnvironmentStore:
|
|||
def ensure_installed(self, name: str) -> InstalledEnvironment:
|
||||
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
||||
self._platform_dir.mkdir(parents=True, exist_ok=True)
|
||||
install_dir = self._install_dir(spec)
|
||||
metadata_path = install_dir / "environment.json"
|
||||
if metadata_path.exists():
|
||||
kernel_image = install_dir / "vmlinux"
|
||||
rootfs_image = install_dir / "rootfs.ext4"
|
||||
if kernel_image.exists() and rootfs_image.exists():
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
source = str(metadata.get("source", "cache"))
|
||||
raw_digest = metadata.get("source_digest")
|
||||
digest = raw_digest if isinstance(raw_digest, str) else None
|
||||
return InstalledEnvironment(
|
||||
name=spec.name,
|
||||
version=spec.version,
|
||||
install_dir=install_dir,
|
||||
kernel_image=kernel_image,
|
||||
rootfs_image=rootfs_image,
|
||||
source=source,
|
||||
source_digest=digest,
|
||||
installed=True,
|
||||
)
|
||||
installed = self._load_installed_environment(spec)
|
||||
if installed is not None:
|
||||
return installed
|
||||
|
||||
source_dir = self._runtime_paths.artifacts_dir / spec.source_profile
|
||||
if source_dir.exists():
|
||||
if _artifacts_ready(source_dir):
|
||||
return self._install_from_local_source(spec, source_dir)
|
||||
if (
|
||||
spec.oci_registry is not None
|
||||
|
|
@ -313,6 +300,10 @@ class EnvironmentStore:
|
|||
if spec.version != raw_version:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
deleted.append(child.name)
|
||||
continue
|
||||
if self._load_installed_environment(spec, install_dir=child) is None:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
deleted.append(child.name)
|
||||
return {"deleted_environment_dirs": sorted(deleted), "count": len(deleted)}
|
||||
|
||||
def _install_dir(self, spec: VmEnvironment) -> Path:
|
||||
|
|
@ -349,6 +340,33 @@ class EnvironmentStore:
|
|||
installed=True,
|
||||
)
|
||||
|
||||
def _load_installed_environment(
|
||||
self, spec: VmEnvironment, *, install_dir: Path | None = None
|
||||
) -> InstalledEnvironment | None:
|
||||
resolved_install_dir = install_dir or self._install_dir(spec)
|
||||
metadata_path = resolved_install_dir / "environment.json"
|
||||
if not metadata_path.is_file() or not _artifacts_ready(resolved_install_dir):
|
||||
return None
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
source = str(metadata.get("source", "cache"))
|
||||
raw_digest = metadata.get("source_digest")
|
||||
digest = raw_digest if isinstance(raw_digest, str) else None
|
||||
return InstalledEnvironment(
|
||||
name=spec.name,
|
||||
version=spec.version,
|
||||
install_dir=resolved_install_dir,
|
||||
kernel_image=resolved_install_dir / "vmlinux",
|
||||
rootfs_image=resolved_install_dir / "rootfs.ext4",
|
||||
source=source,
|
||||
source_digest=digest,
|
||||
installed=True,
|
||||
)
|
||||
|
||||
def _install_from_archive(self, spec: VmEnvironment, archive_url: str) -> InstalledEnvironment:
|
||||
install_dir = self._install_dir(spec)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue