Skip to content
Merged
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
83 changes: 73 additions & 10 deletions graph_os/deployment/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
*fallback* — needed when no prebuilt wheel exists for the host arch/libc, or in
an air-gapped install. The engine check reflects that: missing binary → "install
the wheel"; ``cargo`` presence is reported as informational, never required.
* **Profile-scoped.** Docker is only needed once you leave the ``tiny`` (zero-infra)
profile. ``tiny`` needs nothing but Python.
* **Profile-scoped.** Compose needs Docker; Kubernetes chart deployments need
kubectl and Helm client tools. These probes do not establish cluster access or
first-boot acceptance. Named environments use the existing profile validator.
* **Component-scoped.** Node+pnpm are only checked when ``agent-webui`` is selected;
Qt system libs + a display only when ``geniusbot`` is selected. The core deploy
never drags those in.
Expand All @@ -38,10 +39,16 @@
from agent_utilities.core.config import setting

from .doctor import _RANK, _result

# Profiles that require a container runtime (everything above zero-infra tiny).
_DOCKER_PROFILES = {"single-node-prod", "enterprise"}
PROFILES = ("tiny", "single-node-prod", "enterprise")
from .genesis_environments import EnvironmentProfileError, load_environment_profile

# Current deployment profiles from genesis.yaml; named environments are loaded
# separately and never fall back to one of these profiles.
_PROFILE_TARGETS = {
"tiny": "bare-metal",
"single-node-prod": "docker-compose",
"enterprise": "kubernetes",
}
PROFILES = tuple(_PROFILE_TARGETS)
COMPONENTS = ("agent-terminal-ui", "agent-webui", "geniusbot")


Expand Down Expand Up @@ -177,9 +184,9 @@ def _check_engine() -> dict[str, Any]:
)


def _check_docker(profile: str) -> dict[str, Any]:
def _check_docker(profile: str, *, required: bool) -> dict[str, Any]:
docker = shutil.which("docker")
if profile not in _DOCKER_PROFILES:
if not required:
return _result(
"docker", "skip", f"not required for profile {profile!r} (zero-infra)"
)
Expand All @@ -194,6 +201,61 @@ def _check_docker(profile: str) -> dict[str, Any]:
)


def _check_kubernetes_tool(tool: str, arguments: tuple[str, ...]) -> dict[str, Any]:
"""Probe a local client only, discarding output and never selecting a context."""
executable = shutil.which(tool)
if executable is None:
return _result(
tool,
"fail",
f"Kubernetes chart deployment requires {tool} on PATH",
remediation=f"install the {tool} client before deploying",
)
try:
result = subprocess.run( # nosec B603 -- resolved client, fixed local version flags
[executable, *arguments],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return _result(tool, "fail", f"{tool} client prerequisite probe failed")
if result.returncode != 0:
return _result(tool, "fail", f"{tool} client prerequisite probe failed")
return _result(
tool, "ok", f"{tool} client available; cluster and first boot remain unverified"
)


def _profile_prerequisites(profile: str) -> list[dict[str, Any]]:
try:
target = (
_PROFILE_TARGETS[profile]
if profile in _PROFILE_TARGETS
else load_environment_profile(profile).target.orchestrator
)
except (EnvironmentProfileError, OSError, ValueError, TypeError, KeyError):
return [
_result(
"deployment_target", "fail", "Unknown or invalid deployment profile"
)
]
if target == "kubernetes":
# The current GraphOS deployment skill renders its chart (R013).
return [
_check_kubernetes_tool("kubectl", ("version", "--client=true")),
_check_kubernetes_tool("helm", ("version", "--short")),
]
if target in {"bare-metal", "docker-compose", "docker-swarm"}:
return [_check_docker(profile, required=target != "bare-metal")]
return [
_result(
"deployment_target", "fail", "No prerequisite check for selected target"
)
]


def _node_version() -> tuple[int, ...] | None:
node = shutil.which("node")
if not node:
Expand Down Expand Up @@ -290,15 +352,16 @@ def run_preflight(
"""Run the host dependency preflight for a profile + optional UI components.

Args:
profile: ``tiny`` | ``single-node-prod`` | ``enterprise``.
profile: a current deployment profile or validated named environment.
Kubernetes selects the chart toolchain, not a live cluster probe.
components: any of ``agent-webui`` / ``geniusbot`` / ``agent-terminal-ui``.
"""
components = components or []
results: list[dict[str, Any]] = [
_check_python(),
_check_installer(),
_check_engine(),
_check_docker(profile),
*_profile_prerequisites(profile),
]
for comp in components:
fn = _COMPONENT_CHECKS.get(comp)
Expand Down
199 changes: 196 additions & 3 deletions tests/deployment/test_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,35 @@

from __future__ import annotations

import json
import subprocess
from argparse import Namespace
from types import SimpleNamespace

import pytest
import yaml

from graph_os.deployment import genesis_environments as environments
from graph_os.deployment import preflight as P


@pytest.fixture(autouse=True)
def no_engine_process(monkeypatch):
monkeypatch.setattr(P, "_engine_binary_path", lambda: None)


@pytest.fixture
def core_ready(monkeypatch):
for function, name in (
("_check_python", "python"),
("_check_installer", "installer"),
("_check_engine", "engine_binary"),
):
monkeypatch.setattr(
P, function, lambda name=name: P._result(name, "ok", "fixture")
)


def test_report_shape_and_python_check():
rep = P.run_preflight("tiny")
assert set(rep) >= {
Expand All @@ -22,12 +48,12 @@ def test_report_shape_and_python_check():
assert rep["status"] in ("ready", "warnings", "blocked")


def test_docker_required_above_tiny(monkeypatch):
def test_docker_required_for_single_node_profile(monkeypatch):
monkeypatch.setattr(P.shutil, "which", lambda name: None) # nothing on PATH
tiny = {c["name"]: c for c in P.run_preflight("tiny")["checks"]}
ent = {c["name"]: c for c in P.run_preflight("enterprise")["checks"]}
single = {c["name"]: c for c in P.run_preflight("single-node-prod")["checks"]}
assert tiny["docker"]["status"] == "skip"
assert ent["docker"]["status"] == "fail"
assert single["docker"]["status"] == "fail"


def test_engine_is_wheel_first_rust_only_fallback(monkeypatch):
Expand Down Expand Up @@ -75,3 +101,170 @@ def test_component_checks_never_raise():
res = fn()
assert set(res) >= {"name", "status", "detail"}
assert res["status"] in ("ok", "warn", "fail", "skip", "error")


@pytest.mark.parametrize("profile", ["dev", "test", "prod", "enterprise"])
@pytest.mark.parametrize("missing", ["kubectl", "helm", "both"])
def test_kubernetes_missing_tools_block_despite_docker(
profile, missing, core_ready, monkeypatch
):
def which(name):
return (
None
if name == missing or missing == "both" and name != "docker"
else f"/tools/{name}"
)

monkeypatch.setattr(P.shutil, "which", which)
monkeypatch.setattr(
P.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0)
)
report = P.run_preflight(profile)
checks = {check["name"]: check for check in report["checks"]}
assert report["status"] == "blocked"
assert "docker" not in checks
for tool in ("kubectl", "helm"):
assert checks[tool]["status"] == ("fail" if missing in (tool, "both") else "ok")


@pytest.mark.parametrize("profile", ["dev", "test", "prod", "enterprise"])
def test_kubernetes_probes_are_client_only_bounded_and_not_acceptance(
profile, core_ready, monkeypatch
):
calls = []
monkeypatch.setattr(
P.shutil,
"which",
lambda name: f"/tools/{name}" if name in {"kubectl", "helm"} else None,
)

def run(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(returncode=0)

monkeypatch.setattr(P.subprocess, "run", run)
report = P.run_preflight(profile)
assert report["status"] == "ready"
assert [call[0] for call in calls] == [
["/tools/kubectl", "version", "--client=true"],
["/tools/helm", "version", "--short"],
]
assert all(
kwargs
== {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"timeout": 5,
"check": False,
}
for _, kwargs in calls
)
for check in report["checks"][-2:]:
assert "cluster and first boot remain unverified" in check["detail"]


@pytest.mark.parametrize("tool", ["kubectl", "helm"])
@pytest.mark.parametrize("failure", ["exit", "timeout", "oserror"])
def test_kubernetes_probe_failures_are_blocking_and_private(
tool, failure, core_ready, monkeypatch
):
monkeypatch.setattr(P.shutil, "which", lambda name: f"/private/{name}")

def run(argv, **kwargs):
if argv[0].endswith(tool):
if failure == "timeout":
raise subprocess.TimeoutExpired(argv, 5, output="private-output")
if failure == "oserror":
raise OSError("private-output")
return SimpleNamespace(returncode=1)
return SimpleNamespace(returncode=0)

monkeypatch.setattr(P.subprocess, "run", run)
report = P.run_preflight("prod")
assert report["status"] == "blocked"
assert (
next(check for check in report["checks"] if check["name"] == tool)["status"]
== "fail"
)
assert "private-output" not in json.dumps(report)
assert "/private/" not in json.dumps(report)


def test_named_extension_uses_validated_target_not_its_name(
tmp_path, core_ready, monkeypatch
):
raw = yaml.safe_load(
(environments.BUILTIN_ENVIRONMENTS_DIR / "dev.yaml").read_text()
)
raw["environment"]["name"] = "custom"
(tmp_path / "custom.yaml").write_text(yaml.safe_dump(raw))
monkeypatch.setattr(environments, "_extension_dir", lambda: tmp_path)
monkeypatch.setattr(P.shutil, "which", lambda name: None)
report = P.run_preflight("custom")
assert report["status"] == "blocked"
assert {check["name"] for check in report["checks"]} >= {"kubectl", "helm"}


@pytest.mark.parametrize("failure", ["unknown", "malformed"])
def test_invalid_profiles_do_not_fall_back(failure, tmp_path, core_ready, monkeypatch):
if failure == "malformed":
(tmp_path / "invalid.yaml").write_text("private_value: secret-fixture\n")
monkeypatch.setattr(environments, "_extension_dir", lambda: tmp_path)
monkeypatch.setattr(
P.shutil,
"which",
lambda name: pytest.fail("invalid profile must not probe tools"),
)
report = P.run_preflight("invalid")
assert report["status"] == "blocked"
assert report["checks"][-1]["name"] == "deployment_target"
assert "secret-fixture" not in json.dumps(report)
assert str(tmp_path) not in json.dumps(report)


def test_doctor_cli_propagates_kubernetes_prerequisite_refusal(
core_ready, monkeypatch, capsys
):
from graph_os.deployment.doctor import _run_preflight_cli

monkeypatch.setattr(P.shutil, "which", lambda name: None)
result = _run_preflight_cli(Namespace(profile="prod", components=None, json=True))
report = json.loads(capsys.readouterr().out)
assert result == 1
assert report["profile"] == "prod"
assert report["status"] == "blocked"
assert {
check["name"] for check in report["checks"] if check["status"] == "fail"
} == {"kubectl", "helm"}


@pytest.mark.parametrize(
"target,expected",
[
("bare-metal", "ready"),
("docker-compose", "blocked"),
("docker-swarm", "blocked"),
("podman", "blocked"),
],
)
def test_named_non_kubernetes_target_never_runs_chart_probes(
target, expected, tmp_path, core_ready, monkeypatch
):
raw = yaml.safe_load(
(environments.BUILTIN_ENVIRONMENTS_DIR / "dev.yaml").read_text()
)
raw["environment"]["name"] = "custom"
raw["target"]["orchestrator"] = target
(tmp_path / "custom.yaml").write_text(yaml.safe_dump(raw))
monkeypatch.setattr(environments, "_extension_dir", lambda: tmp_path)
monkeypatch.setattr(P.shutil, "which", lambda name: None)
monkeypatch.setattr(
P.subprocess,
"run",
lambda *args, **kwargs: pytest.fail("no chart probe for this target"),
)
report = P.run_preflight("custom")
assert report["status"] == expected
assert not {"kubectl", "helm"} & {check["name"] for check in report["checks"]}
if target == "podman":
assert report["checks"][-1]["name"] == "deployment_target"
Loading