""" Tests for the Kubernetes (entrypoint-as-host) sandbox launcher. The official ``kubernetes`` client is an optional dependency, so the SDK-driven tests inject a small fake package into ``sys.modules`` (no real cluster, no real client). The entrypoint model needs only ``CoreV1Api`` create/read/delete/log fakes — there is no exec transport to fake. """ from __future__ import annotations import json import sys import types from types import SimpleNamespace import click import pytest import omnigent.onboarding.sandboxes.kubernetes as k8s from omnigent.host.identity import ( HOST_ID_ENV_VAR, HOST_NAME_ENV_VAR, HOST_TOKEN_ENV_VAR, ) from omnigent.onboarding.sandboxes.base import SandboxCapabilityError from omnigent.onboarding.sandboxes.kubernetes import ( KubernetesSandboxLauncher, build_pod_manifest, build_token_secret_manifest, ) _TOKEN = "launch-token-xyz" _MANIFEST_KW = { "pod_name": "omnigent-managed-abc-1a2b3c", "namespace": "omnigent-sandboxes", "image": "ghcr.io/omnigent-ai/omnigent-host:latest", "service_account": "omnigent-runner", "host_id": "host_abcdef", "host_name": "managed-abcdef", "server_url": "http://srv.example.com", "token_secret_name": "omnigent-managed-abc-1a2b3c-token", "harness_secret": "omnigent-creds", "env_literals": {}, "node_selector": None, "workspace": "/home/omnigent/workspace", } # ── pure manifest / rendering tests (no SDK) ──────────────── def test_build_pod_manifest_runs_host_under_reaper_as_container_command() -> None: """The main container's command execs the PID-1 reaper, which runs the host.""" manifest = build_pod_manifest(**_MANIFEST_KW) containers = manifest["spec"]["containers"] assert len(containers) == 1 host = containers[0] assert host["name"] == "host" command = host["command"] assert command[:2] == ["bash", "-lc"] script = command[2] # exec the reaper (so it is PID 1) which then runs `omnigent host`. assert "exec python3 -c" in script assert "omnigent host --server http://srv.example.com" in script # The reaper source rides the command (spawns sys.argv[1:] + reaps children). assert "os.wait()" in script def test_build_pod_manifest_init_container_prepares_and_clones_workspace() -> None: """The init container makes the workspace and clones the repo before the host.""" manifest = build_pod_manifest( **{**_MANIFEST_KW, "clone_dir": "/home/omnigent/workspace/repo"}, repo_url="https://github.com/org/repo.git", repo_branch="main", ) init = manifest["spec"]["initContainers"] assert len(init) == 1 assert init[0]["name"] == "workspace-prep" script = init[0]["command"][2] assert "mkdir -p /home/omnigent/workspace" in script assert "git clone --branch main --single-branch -- " in script assert "https://github.com/org/repo.git /home/omnigent/workspace/repo" in script def test_build_pod_manifest_without_repo_has_no_clone() -> None: """No repo → the init container only makes the workspace, no git clone.""" manifest = build_pod_manifest(**_MANIFEST_KW) script = manifest["spec"]["initContainers"][0]["command"][2] assert "mkdir -p /home/omnigent/workspace" in script assert "git clone" not in script def test_build_pod_manifest_token_rides_secret_ref_not_the_spec() -> None: """The launch token is referenced via secretKeyRef, never written into the spec.""" manifest = build_pod_manifest(**_MANIFEST_KW) host_env = manifest["spec"]["containers"][0]["env"] token_entry = next(e for e in host_env if e["name"] == HOST_TOKEN_ENV_VAR) assert token_entry["valueFrom"]["secretKeyRef"] == { "name": "omnigent-managed-abc-1a2b3c-token", "key": HOST_TOKEN_ENV_VAR, } assert "value" not in token_entry # Identity is plain env; the raw token appears nowhere in the manifest. assert {e["name"]: e.get("value") for e in host_env}[HOST_ID_ENV_VAR] == "host_abcdef" assert {e["name"]: e.get("value") for e in host_env}[HOST_NAME_ENV_VAR] == "managed-abcdef" assert _TOKEN not in json.dumps(manifest) def test_build_token_secret_manifest_carries_token_in_stringdata() -> None: """The token Secret holds the raw token under the host-token key, labeled for GC.""" secret = build_token_secret_manifest( secret_name="omnigent-pod-token", namespace="omnigent-sandboxes", token=_TOKEN ) assert secret["stringData"] == {HOST_TOKEN_ENV_VAR: _TOKEN} assert secret["metadata"]["labels"]["app.kubernetes.io/managed-by"] == "omnigent" assert secret["type"] == "Opaque" def test_build_pod_manifest_harness_secret_projects_into_both_containers() -> None: """The harness creds Secret is projected via envFrom on init (for clone) + host.""" manifest = build_pod_manifest(**_MANIFEST_KW) init = manifest["spec"]["initContainers"][0] host = manifest["spec"]["containers"][0] assert init["envFrom"] == [{"secretRef": {"name": "omnigent-creds"}}] assert host["envFrom"] == [{"secretRef": {"name": "omnigent-creds"}}] def test_build_pod_manifest_omits_envfrom_without_harness_secret() -> None: """No harness Secret → no envFrom key on either container.""" manifest = build_pod_manifest(**{**_MANIFEST_KW, "harness_secret": None}) assert "envFrom" not in manifest["spec"]["initContainers"][0] assert "envFrom" not in manifest["spec"]["containers"][0] def test_build_pod_manifest_defaults_to_amd64_node_selector() -> None: """No node_selector → Pods keep the amd64 default placement.""" manifest = build_pod_manifest(**{**_MANIFEST_KW, "node_selector": None}) assert manifest["spec"]["nodeSelector"] == {"kubernetes.io/arch": "amd64"} def test_build_pod_manifest_node_selector_can_override_arch() -> None: """An operator kubernetes.io/arch entry overrides the amd64 default.""" manifest = build_pod_manifest( **{**_MANIFEST_KW, "node_selector": {"disktype": "ssd", "kubernetes.io/arch": "arm64"}} ) selector = manifest["spec"]["nodeSelector"] assert selector["kubernetes.io/arch"] == "arm64" assert selector["disktype"] == "ssd" def test_build_pod_manifest_is_restricted_and_least_privilege() -> None: """The Pod satisfies Pod Security 'restricted' and mounts no SA token.""" manifest = build_pod_manifest(**_MANIFEST_KW) spec = manifest["spec"] assert spec["restartPolicy"] == "Never" assert spec["automountServiceAccountToken"] is False assert spec["securityContext"]["runAsNonRoot"] is True assert spec["securityContext"]["seccompProfile"] == {"type": "RuntimeDefault"} host = spec["containers"][0] assert host["securityContext"]["allowPrivilegeEscalation"] is False assert host["securityContext"]["capabilities"] == {"drop": ["ALL"]} @pytest.mark.parametrize( ("clone_dir", "repo_url", "repo_branch", "expect_clone", "expect_branch"), [ (None, None, None, False, False), ("/ws/repo", "https://x/y.git", None, True, False), ("/ws/repo", "https://x/y.git", "release-1.2", True, True), ], ) def test_render_workspace_prep_command( clone_dir: str | None, repo_url: str | None, repo_branch: str | None, expect_clone: bool, expect_branch: bool, ) -> None: """The init command always mkdir's the workspace and clones only when asked.""" command = k8s._render_workspace_prep_command("/ws", clone_dir, repo_url, repo_branch) script = command[2] assert "mkdir -p /ws" in script assert ("git clone" in script) is expect_clone assert ("--branch release-1.2 --single-branch" in script) is expect_branch def test_new_pod_name_and_token_secret_name() -> None: """Pod names are DNS-label-safe and the token Secret is the pod name + suffix.""" name = k8s._new_pod_name("Managed-ABC_123!") assert name.startswith("omnigent-managed-abc-123-") assert all(c.islower() or c.isdigit() or c == "-" for c in name) assert k8s._token_secret_name(name) == f"{name}-token" def test_resolve_sandbox_env_rejects_reserved_and_credential_and_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Env passthrough rejects reserved names, credential-looking names, and unset vars.""" monkeypatch.setenv("PLAIN_CONFIG", "value") assert KubernetesSandboxLauncher(env=["PLAIN_CONFIG"])._resolve_sandbox_env() == { "PLAIN_CONFIG": "value" } with pytest.raises(click.ClickException, match="reserved"): KubernetesSandboxLauncher(env=["HOME"])._resolve_sandbox_env() with pytest.raises(click.ClickException, match="credential"): KubernetesSandboxLauncher(env=["MY_API_KEY"])._resolve_sandbox_env() with pytest.raises(click.ClickException, match="not set"): KubernetesSandboxLauncher(env=["DEFINITELY_UNSET_VAR_XYZ"])._resolve_sandbox_env() def test_env_var_name_override_is_validated(monkeypatch: pytest.MonkeyPatch) -> None: """An env-var namespace override that isn't a valid RFC 1123 name fails fast.""" monkeypatch.setenv(k8s.NAMESPACE_ENV_VAR, "Not_A_Valid_NS") with pytest.raises(click.ClickException, match="not a valid Kubernetes name"): KubernetesSandboxLauncher()._resolve_namespace() # ── SDK-driven tests (fake kubernetes client) ─────────────── class _FakeApiException(Exception): """Stands in for ``kubernetes.client.rest.ApiException``.""" def __init__(self, *, status: int | None = None, reason: str = "", body: str = "") -> None: super().__init__(reason or body or str(status)) self.status = status self.reason = reason self.body = body class _FakeConfigException(Exception): """Stands in for ``kubernetes.config.ConfigException``.""" class _FakeCore: """Recording stand-in for ``CoreV1Api`` (entrypoint model: no exec).""" def __init__(self) -> None: self.calls: list[str] = [] self.created_secrets: list[dict[str, object]] = [] self.created_pods: list[dict[str, object]] = [] self.deleted_pods: list[str] = [] self.deleted_secrets: list[str] = [] self.events: list[object] = [] self.logs: dict[str, str] = {} self.read_queue: list[object] = [] self.read_default: object = _pod(phase="Pending") self.create_secret_error: Exception | None = None self.create_pod_error: Exception | None = None self.delete_pod_errors: list[Exception | None] = [] def create_namespaced_secret(self, namespace, body, _request_timeout=None): self.calls.append("create_secret") if self.create_secret_error is not None: raise self.create_secret_error self.created_secrets.append(body) def create_namespaced_pod(self, namespace, body, _request_timeout=None): self.calls.append("create_pod") if self.create_pod_error is not None: raise self.create_pod_error self.created_pods.append(body) def read_namespaced_pod(self, name, namespace, _request_timeout=None): self.calls.append("read_pod") resp = self.read_queue.pop(0) if self.read_queue else self.read_default if isinstance(resp, Exception): raise resp return resp def delete_namespaced_pod( self, name, namespace, grace_period_seconds=None, _request_timeout=None ): self.calls.append("delete_pod") if self.delete_pod_errors: err = self.delete_pod_errors.pop(0) if err is not None: raise err self.deleted_pods.append(name) def delete_namespaced_secret(self, name, namespace, _request_timeout=None): self.calls.append("delete_secret") self.deleted_secrets.append(name) def list_namespaced_event(self, namespace, field_selector=None, _request_timeout=None): return SimpleNamespace(items=self.events) def read_namespaced_pod_log( self, name, namespace, container=None, tail_lines=None, _request_timeout=None ): return self.logs.get(container, "") def _pod(phase=None, init_statuses=None, container_statuses=None, conditions=None): """Build a ``V1Pod`` stand-in (the launcher reads only ``status`` via getattr).""" return SimpleNamespace( status=SimpleNamespace( phase=phase, init_container_statuses=init_statuses, container_statuses=container_statuses, conditions=conditions, ) ) def _terminated(exit_code, *, name, reason="Error"): """A container status in the terminated state.""" return SimpleNamespace( name=name, state=SimpleNamespace( terminated=SimpleNamespace(exit_code=exit_code, reason=reason), waiting=None ), ) @pytest.fixture def fake_core(monkeypatch: pytest.MonkeyPatch) -> _FakeCore: """Inject a fake ``kubernetes`` package and return the recording CoreV1Api.""" core = _FakeCore() client_mod = types.ModuleType("kubernetes.client") client_mod.ApiException = _FakeApiException # type: ignore[attr-defined] client_mod.Configuration = lambda: SimpleNamespace() # type: ignore[attr-defined] client_mod.ApiClient = lambda cfg=None: SimpleNamespace( # type: ignore[attr-defined] close=lambda: None ) client_mod.CoreV1Api = lambda api_client=None: core # type: ignore[attr-defined] rest_mod = types.ModuleType("kubernetes.client.rest") rest_mod.ApiException = _FakeApiException # type: ignore[attr-defined] config_mod = types.ModuleType("kubernetes.config") config_mod.load_incluster_config = lambda client_configuration=None: None # type: ignore[attr-defined] config_mod.load_kube_config = ( # type: ignore[attr-defined] lambda config_file=None, client_configuration=None: None ) config_mod.ConfigException = _FakeConfigException # type: ignore[attr-defined] pkg = types.ModuleType("kubernetes") pkg.client = client_mod # type: ignore[attr-defined] pkg.config = config_mod # type: ignore[attr-defined] for name, mod in ( ("kubernetes", pkg), ("kubernetes.client", client_mod), ("kubernetes.client.rest", rest_mod), ("kubernetes.config", config_mod), ): monkeypatch.setitem(sys.modules, name, mod) # No-op the poll/backoff sleeps so the readiness/retry loops run instantly. monkeypatch.setattr(k8s.time, "sleep", lambda _s: None) return core def _launcher() -> KubernetesSandboxLauncher: """A launcher pinned to in-cluster config with explicit, env-free settings.""" return KubernetesSandboxLauncher( in_cluster=True, namespace="omnigent-sandboxes", secret_name="omnigent-creds", env=() ) def test_launch_host_creates_secret_then_pod_and_returns_workspace( fake_core: _FakeCore, ) -> None: """The happy path creates the token Secret BEFORE the Pod and returns the workspace.""" fake_core.read_queue = [_pod(phase="Running")] workspace = _launcher().start_host( "omnigent-pod-1", token=_TOKEN, host_id="host_1", host_name="managed-1", server_url="http://srv.example.com", ) assert workspace == "/home/omnigent/workspace" # Secret is created before the Pod (so the secretKeyRef resolves immediately). assert fake_core.calls.index("create_secret") < fake_core.calls.index("create_pod") assert fake_core.created_secrets[0]["stringData"] == {HOST_TOKEN_ENV_VAR: _TOKEN} assert fake_core.created_pods[0]["metadata"]["name"] == "omnigent-pod-1" # Nothing torn down on success. assert fake_core.deleted_pods == [] def test_launch_host_with_repo_returns_clone_dir(fake_core: _FakeCore) -> None: """With a repo, the returned workspace is the cloned directory under the workspace.""" fake_core.read_queue = [_pod(phase="Running")] workspace = _launcher().start_host( "omnigent-pod-2", token=_TOKEN, host_id="host_2", host_name="managed-2", server_url="http://srv.example.com", repo_url="https://github.com/org/repo.git", repo_name="repo", ) assert workspace == "/home/omnigent/workspace/repo" def test_launch_host_cleans_up_on_create_failure(fake_core: _FakeCore) -> None: """A failed Pod create reaps the already-created token Secret and raises.""" fake_core.create_pod_error = _FakeApiException(status=500, reason="Internal Server Error") with pytest.raises(click.ClickException, match="create sandbox pod"): _launcher().start_host( "omnigent-pod-3", token=_TOKEN, host_id="host_3", host_name="managed-3", server_url="http://srv.example.com", ) assert "omnigent-pod-3-token" in fake_core.deleted_secrets assert "omnigent-pod-3" in fake_core.deleted_pods def test_launch_host_fast_fails_on_clone_failure_with_log_tail( fake_core: _FakeCore, ) -> None: """A non-zero init container (clone failed) fails fast with the git error log tail.""" fake_core.read_queue = [ _pod( phase="Pending", init_statuses=[_terminated(128, name="workspace-prep")], ) ] fake_core.logs["workspace-prep"] = "fatal: repository 'https://x/y.git' not found" with pytest.raises(click.ClickException) as exc: _launcher().start_host( "omnigent-pod-4", token=_TOKEN, host_id="host_4", host_name="managed-4", server_url="http://srv.example.com", repo_url="https://x/y.git", repo_name="y", ) assert "workspace prep failed (exit 128" in exc.value.message assert "repository 'https://x/y.git' not found" in exc.value.message # The orphaned Pod + Secret are reaped. assert "omnigent-pod-4" in fake_core.deleted_pods assert "omnigent-pod-4-token" in fake_core.deleted_secrets def test_launch_host_times_out_with_reason( fake_core: _FakeCore, monkeypatch: pytest.MonkeyPatch ) -> None: """A Pod that never runs times out fast, surfacing the last waiting reason.""" monkeypatch.setattr(k8s, "_POD_READY_TIMEOUT_S", 0.01) fake_core.read_default = _pod( phase="Pending", container_statuses=[ SimpleNamespace( name="host", state=SimpleNamespace( waiting=SimpleNamespace(reason="ImagePullBackOff", message="back-off"), terminated=None, ), ) ], ) with pytest.raises(click.ClickException, match="did not start within"): _launcher().start_host( "omnigent-pod-5", token=_TOKEN, host_id="host_5", host_name="managed-5", server_url="http://srv.example.com", ) def test_terminate_deletes_pod_and_secret(fake_core: _FakeCore) -> None: """Terminate deletes both the Pod and its token Secret.""" _launcher().terminate("omnigent-pod-6") assert fake_core.deleted_pods == ["omnigent-pod-6"] assert fake_core.deleted_secrets == ["omnigent-pod-6-token"] def test_terminate_is_idempotent_on_404(fake_core: _FakeCore) -> None: """A Pod that no longer exists (404) is treated as success, and the Secret too.""" fake_core.delete_pod_errors = [_FakeApiException(status=404, reason="Not Found")] _launcher().terminate("omnigent-pod-7") # must not raise assert fake_core.deleted_secrets == ["omnigent-pod-7-token"] def test_terminate_retries_transient_then_gives_up_best_effort( fake_core: _FakeCore, capsys: pytest.CaptureFixture[str] ) -> None: """A persistent transient delete error is retried, then warned (not raised).""" from urllib3.exceptions import HTTPError fake_core.delete_pod_errors = [HTTPError("timeout")] * k8s._POD_DELETE_MAX_ATTEMPTS _launcher().terminate("omnigent-pod-8") # best-effort: must not raise assert "could not delete Kubernetes pod 'omnigent-pod-8'" in capsys.readouterr().err # The Secret delete still runs after the Pod gives up. assert fake_core.deleted_secrets == ["omnigent-pod-8-token"] def test_provision_reserves_pod_name_and_run_is_unsupported() -> None: """provision reserves a Pod name (no Pod created); run has no exec transport.""" launcher = _launcher() # provision reserves the id — it does NOT create a Pod and does NOT raise. name = launcher.provision("managed-abc") assert name.startswith("omnigent-managed-abc-") # run is unsupported: the host is the Pod entrypoint, there is no exec-in. with pytest.raises(SandboxCapabilityError): launcher.run("sb", "echo hi")