3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
827 lines
30 KiB
Python
827 lines
30 KiB
Python
# SPDX-License-Identifier: MIT
|
||
"""Tests for ``core.provisioner.finish_provisioning`` (0.6.0 item B).
|
||
|
||
The unified post-pod-running provisioning chain (wait-ready → agent-settle
|
||
→ apply-fixes → discovery → reverse-open) and its ``winpodx provision`` CLI.
|
||
Every stage is mocked so no real pod / FreeRDP / agent is touched; the tests
|
||
pin each branch + parameter gate.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from winpodx.core import provisioner
|
||
from winpodx.core.config import Config
|
||
from winpodx.core.provisioner import (
|
||
ProvisionAgentUnavailable,
|
||
finish_provisioning,
|
||
)
|
||
|
||
|
||
def _cfg(*, backend: str = "podman", reverse_open: bool = True) -> Config:
|
||
cfg = Config()
|
||
cfg.pod.backend = backend
|
||
cfg.reverse_open.enabled = reverse_open
|
||
return cfg
|
||
|
||
|
||
class _FakeTransport:
|
||
"""Stand-in for AgentTransport whose /health is configurable."""
|
||
|
||
def __init__(self, available: bool) -> None:
|
||
self._available = available
|
||
|
||
def health(self):
|
||
return SimpleNamespace(available=self._available, detail="")
|
||
|
||
|
||
def _patch_stages(
|
||
monkeypatch,
|
||
*,
|
||
wait_ready: bool = True,
|
||
agent_available: bool = True,
|
||
pod_running: bool = True,
|
||
apply_results: dict | None = None,
|
||
discovery_count: int = 7,
|
||
discovery_raises: Exception | None = None,
|
||
reverse_open_raises: Exception | None = None,
|
||
):
|
||
"""Patch every finish_provisioning stage; return a call-record dict."""
|
||
from winpodx.core.pod import PodState
|
||
|
||
calls: dict[str, object] = {
|
||
"wait": [],
|
||
"apply": 0,
|
||
"discovery": [],
|
||
"reverse": 0,
|
||
"sleep": 0,
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
provisioner,
|
||
"wait_for_windows_responsive",
|
||
lambda cfg, timeout: calls["wait"].append(timeout) or wait_ready,
|
||
)
|
||
# The agent-settle gate (and discovery's agent-recovery wait) are gated on
|
||
# pod liveness — they wait while RUNNING and only give up if the pod stops.
|
||
monkeypatch.setattr(
|
||
provisioner,
|
||
"pod_status",
|
||
lambda cfg: SimpleNamespace(state=PodState.RUNNING if pod_running else PodState.STOPPED),
|
||
)
|
||
monkeypatch.setattr(
|
||
"winpodx.core.transport.agent.AgentTransport",
|
||
lambda cfg: _FakeTransport(agent_available),
|
||
)
|
||
monkeypatch.setattr(
|
||
provisioner,
|
||
"apply_windows_runtime_fixes",
|
||
lambda cfg: (
|
||
(calls.__setitem__("apply", calls["apply"] + 1))
|
||
or (apply_results if apply_results is not None else {"max_sessions": "ok"})
|
||
),
|
||
)
|
||
|
||
def fake_discovery(cfg, *, retries, require_agent=False, on_progress=None):
|
||
calls["discovery"].append(retries)
|
||
calls.setdefault("discovery_require_agent", []).append(require_agent)
|
||
if discovery_raises is not None:
|
||
raise discovery_raises
|
||
return discovery_count
|
||
|
||
monkeypatch.setattr(provisioner, "_run_discovery_with_retry", fake_discovery)
|
||
|
||
def fake_reverse(cfg):
|
||
calls["reverse"] = calls["reverse"] + 1
|
||
if reverse_open_raises is not None:
|
||
raise reverse_open_raises
|
||
|
||
monkeypatch.setattr(provisioner, "_run_reverse_open", fake_reverse)
|
||
# Make the soft-settle poll's sleep a no-op + counter (tests never block).
|
||
monkeypatch.setattr(
|
||
provisioner.time, "sleep", lambda s: calls.__setitem__("sleep", calls["sleep"] + 1)
|
||
)
|
||
return calls
|
||
|
||
|
||
# --- backend gate --------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("backend", ["libvirt", "manual"])
|
||
def test_finish_provisioning_skips_non_container_backend(backend, monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
results = finish_provisioning(_cfg(backend=backend))
|
||
assert "backend" in results
|
||
assert "skipped" in results["backend"]
|
||
# No stage ran.
|
||
assert calls["wait"] == []
|
||
assert calls["apply"] == 0
|
||
assert calls["discovery"] == []
|
||
assert calls["reverse"] == 0
|
||
|
||
|
||
# --- stage 1: wait-ready -------------------------------------------------
|
||
|
||
|
||
def test_wait_ready_timeout_short_circuits(monkeypatch):
|
||
calls = _patch_stages(monkeypatch, wait_ready=False)
|
||
results = finish_provisioning(_cfg(), wait_timeout=300)
|
||
assert results["wait_ready"] == "timeout"
|
||
assert calls["wait"] == [300]
|
||
# Nothing downstream runs once RDP never opens.
|
||
assert calls["apply"] == 0
|
||
assert calls["discovery"] == []
|
||
assert calls["reverse"] == 0
|
||
|
||
|
||
def test_wait_timeout_is_forwarded(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
finish_provisioning(_cfg(), wait_timeout=1234)
|
||
assert calls["wait"] == [1234]
|
||
|
||
|
||
# --- stage 2: agent settle ----------------------------------------------
|
||
|
||
|
||
def test_require_agent_raises_only_when_pod_stops(monkeypatch):
|
||
# Agent /health down AND pod no longer RUNNING: the keepalive watchdog can't
|
||
# revive it, so the settle gate gives up (and discovery never runs). This is
|
||
# the ONLY thing that ends the wait — a live pod with a down agent is waited
|
||
# out, not failed (see test_require_agent_waits_for_stable_health).
|
||
calls = _patch_stages(monkeypatch, agent_available=False, pod_running=False)
|
||
with pytest.raises(ProvisionAgentUnavailable):
|
||
finish_provisioning(_cfg(), require_agent=True)
|
||
assert calls["apply"] == 0
|
||
assert calls["discovery"] == []
|
||
assert calls["reverse"] == 0
|
||
|
||
|
||
def test_require_agent_ok_when_health_up(monkeypatch):
|
||
_patch_stages(monkeypatch, agent_available=True)
|
||
results = finish_provisioning(_cfg(), require_agent=True)
|
||
assert results["agent_settle"] == "ok"
|
||
|
||
|
||
def test_soft_settle_proceeds_when_agent_never_up(monkeypatch):
|
||
calls = _patch_stages(monkeypatch, agent_available=False)
|
||
results = finish_provisioning(_cfg(), require_agent=False)
|
||
# Soft poll proceeds (no raise); records the not-up state but continues.
|
||
assert results["agent_settle"].startswith("not-up")
|
||
assert calls["apply"] == 1 # downstream stages still ran
|
||
# 30 poll attempts each slept once.
|
||
assert calls["sleep"] == 30
|
||
|
||
|
||
def test_soft_settle_breaks_early_when_agent_up(monkeypatch):
|
||
calls = _patch_stages(monkeypatch, agent_available=True)
|
||
results = finish_provisioning(_cfg(), require_agent=False)
|
||
assert results["agent_settle"] == "ok"
|
||
assert calls["sleep"] == 0 # broke on first poll, no sleeps
|
||
|
||
|
||
# --- stage 3: apply-fixes ------------------------------------------------
|
||
|
||
|
||
def test_apply_fixes_always_runs_and_results_pass_through(monkeypatch):
|
||
custom = {"max_sessions": "ok", "rdp_timeouts": "failed: boom"}
|
||
calls = _patch_stages(monkeypatch, apply_results=custom)
|
||
results = finish_provisioning(_cfg())
|
||
assert calls["apply"] == 1
|
||
assert results["apply_fixes"] == custom
|
||
|
||
|
||
# --- stage 4: discovery --------------------------------------------------
|
||
|
||
|
||
def test_discovery_on_passes_retries_and_count(monkeypatch):
|
||
calls = _patch_stages(monkeypatch, discovery_count=12)
|
||
results = finish_provisioning(_cfg(), with_discovery=True, retries=3)
|
||
assert calls["discovery"] == [3]
|
||
assert results["discovery"] == "12 apps"
|
||
|
||
|
||
def test_discovery_off_is_skipped(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
results = finish_provisioning(_cfg(), with_discovery=False)
|
||
assert calls["discovery"] == []
|
||
assert results["discovery"] == "skipped"
|
||
|
||
|
||
def test_discovery_failure_is_recorded_not_raised(monkeypatch):
|
||
_patch_stages(monkeypatch, discovery_raises=RuntimeError("agent flaked"))
|
||
results = finish_provisioning(_cfg(), with_discovery=True)
|
||
assert results["discovery"].startswith("failed:")
|
||
# Reverse-open still runs after a discovery failure (best-effort chain).
|
||
assert results["reverse_open"] == "ok"
|
||
|
||
|
||
# --- stage 5: reverse-open ----------------------------------------------
|
||
|
||
|
||
def test_reverse_open_on_when_enabled(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
results = finish_provisioning(_cfg(reverse_open=True), with_reverse_open=True)
|
||
assert calls["reverse"] == 1
|
||
assert results["reverse_open"] == "ok"
|
||
|
||
|
||
def test_reverse_open_flag_off_skips(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
results = finish_provisioning(_cfg(reverse_open=True), with_reverse_open=False)
|
||
assert calls["reverse"] == 0
|
||
assert results["reverse_open"] == "skipped"
|
||
|
||
|
||
def test_reverse_open_gated_on_cfg_enabled(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
results = finish_provisioning(_cfg(reverse_open=False), with_reverse_open=True)
|
||
# Even with with_reverse_open=True, cfg.reverse_open.enabled=False skips.
|
||
assert calls["reverse"] == 0
|
||
assert results["reverse_open"] == "skipped"
|
||
|
||
|
||
def test_reverse_open_failure_recorded(monkeypatch):
|
||
_patch_stages(monkeypatch, reverse_open_raises=RuntimeError("listener died"))
|
||
results = finish_provisioning(_cfg(reverse_open=True), with_reverse_open=True)
|
||
assert results["reverse_open"].startswith("failed:")
|
||
|
||
|
||
# --- progress callback ---------------------------------------------------
|
||
|
||
|
||
def test_on_progress_receives_every_stage(monkeypatch):
|
||
_patch_stages(monkeypatch)
|
||
stages: list[str] = []
|
||
finish_provisioning(_cfg(), on_progress=lambda stage, detail: stages.append(stage))
|
||
# All five stages emit at least one progress event.
|
||
for expected in (
|
||
"wait_ready",
|
||
"agent_settle",
|
||
"apply_fixes",
|
||
"discovery",
|
||
"reverse_open",
|
||
):
|
||
assert expected in stages
|
||
|
||
|
||
def test_on_progress_exception_is_swallowed(monkeypatch):
|
||
_patch_stages(monkeypatch)
|
||
|
||
def boom(stage, detail):
|
||
raise ValueError("callback blew up")
|
||
|
||
# A crashing callback must NOT abort provisioning.
|
||
results = finish_provisioning(_cfg(), on_progress=boom)
|
||
assert results["wait_ready"] == "ok"
|
||
|
||
|
||
# --- discovery retry helper (the 6× behaviour) ---------------------------
|
||
|
||
|
||
def test_discovery_retry_succeeds_after_transient_failures(monkeypatch):
|
||
attempts = {"n": 0}
|
||
|
||
def flaky(cfg, timeout=180):
|
||
attempts["n"] += 1
|
||
if attempts["n"] < 3:
|
||
raise RuntimeError("agent transitioning")
|
||
return ["app1", "app2"]
|
||
|
||
monkeypatch.setattr("winpodx.core.discovery.discover_apps", flaky)
|
||
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda apps: None)
|
||
monkeypatch.setattr("winpodx.cli.app._register_desktop_entries", lambda apps: None)
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
|
||
count = provisioner._run_discovery_with_retry(_cfg(), retries=6)
|
||
assert count == 2
|
||
assert attempts["n"] == 3 # failed twice, succeeded on third
|
||
|
||
|
||
def test_discovery_retry_raises_after_exhausting(monkeypatch):
|
||
def always_fail(cfg, timeout=180):
|
||
raise RuntimeError("never settles")
|
||
|
||
monkeypatch.setattr("winpodx.core.discovery.discover_apps", always_fail)
|
||
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda apps: None)
|
||
monkeypatch.setattr("winpodx.cli.app._register_desktop_entries", lambda apps: None)
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
|
||
with pytest.raises(RuntimeError, match="never settles"):
|
||
provisioner._run_discovery_with_retry(_cfg(), retries=3)
|
||
|
||
|
||
# --- _apply_via_transport transient retry (agent_keepalive hardening) ----
|
||
|
||
|
||
def test_apply_via_transport_retries_then_succeeds(monkeypatch):
|
||
# A TransportError (closed socket / health timeout) right after a
|
||
# container restart is transient; a re-dispatched retry should land.
|
||
from winpodx.core.transport import TransportError
|
||
from winpodx.core.transport.base import ExecResult
|
||
|
||
attempts = {"n": 0}
|
||
|
||
class _FlakyExec:
|
||
def exec(self, script, *, timeout=60, description="x"):
|
||
attempts["n"] += 1
|
||
if attempts["n"] < 2:
|
||
raise TransportError("Remote end closed connection without response")
|
||
return ExecResult(rc=0, stdout="ok", stderr="")
|
||
|
||
monkeypatch.setattr("winpodx.core.transport.dispatch", lambda cfg: _FlakyExec())
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
|
||
result = provisioner._apply_via_transport(_cfg(), "payload", description="apply-test")
|
||
assert result.rc == 0
|
||
assert result.stdout == "ok"
|
||
assert attempts["n"] == 2 # failed once, succeeded on retry
|
||
|
||
|
||
def test_apply_via_transport_raises_after_exhausting(monkeypatch):
|
||
from winpodx.core.transport import TransportError
|
||
from winpodx.core.windows_exec import WindowsExecError
|
||
|
||
attempts = {"n": 0}
|
||
|
||
class _DeadExec:
|
||
def exec(self, script, *, timeout=60, description="x"):
|
||
attempts["n"] += 1
|
||
raise TransportError("socket closed")
|
||
|
||
monkeypatch.setattr("winpodx.core.transport.dispatch", lambda cfg: _DeadExec())
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
|
||
with pytest.raises(WindowsExecError, match="socket closed"):
|
||
provisioner._apply_via_transport(_cfg(), "payload", description="apply-test", attempts=2)
|
||
assert attempts["n"] == 2 # both attempts ran before giving up
|
||
|
||
|
||
def test_apply_via_transport_does_not_retry_on_nonzero_rc(monkeypatch):
|
||
# A payload that runs and returns rc!=0 is a genuine failure, not a
|
||
# transient channel error -- return it on the first try, unretried.
|
||
from winpodx.core.transport.base import ExecResult
|
||
|
||
attempts = {"n": 0}
|
||
|
||
class _NonZeroExec:
|
||
def exec(self, script, *, timeout=60, description="x"):
|
||
attempts["n"] += 1
|
||
return ExecResult(rc=1, stdout="", stderr="boom")
|
||
|
||
monkeypatch.setattr("winpodx.core.transport.dispatch", lambda cfg: _NonZeroExec())
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
|
||
result = provisioner._apply_via_transport(_cfg(), "payload", description="apply-test")
|
||
assert result.rc == 1
|
||
assert attempts["n"] == 1 # returned immediately, no retry
|
||
|
||
|
||
# --- require_agent settle stability (agent_keepalive hardening) ----------
|
||
|
||
|
||
class _FlakyHealthTransport:
|
||
"""AgentTransport stand-in whose /health follows a boolean sequence,
|
||
then sticks on the last value."""
|
||
|
||
def __init__(self, sequence):
|
||
self._seq = list(sequence)
|
||
self._i = 0
|
||
|
||
def health(self):
|
||
val = self._seq[min(self._i, len(self._seq) - 1)]
|
||
self._i += 1
|
||
return SimpleNamespace(available=val, detail="")
|
||
|
||
|
||
def test_require_agent_waits_through_flaps_while_pod_runs(monkeypatch):
|
||
# A single OK then a flicker-down must NOT satisfy the gate; the settle
|
||
# WAITS (no time cap, pod stays RUNNING) until 3 consecutive OK. The agent
|
||
# dying and being revived by the keepalive watchdog mid-wait is waited out,
|
||
# not failed. up, down, up, up, up -> stabilises.
|
||
calls = _patch_stages(monkeypatch, agent_available=True) # pod_running=True
|
||
flaky = _FlakyHealthTransport([True, False, True, True, True])
|
||
monkeypatch.setattr("winpodx.core.transport.agent.AgentTransport", lambda cfg: flaky)
|
||
results = finish_provisioning(_cfg(), require_agent=True)
|
||
assert results["agent_settle"] == "ok"
|
||
assert calls["apply"] == 1 # proceeded only once stable
|
||
|
||
|
||
def test_require_agent_raises_when_pod_dies_mid_wait(monkeypatch):
|
||
# Agent never stabilises AND the pod stops part-way: the keepalive watchdog
|
||
# is gone, so the wait ends and the gate raises (nothing downstream runs).
|
||
# A live pod would be waited out forever instead — see the test above.
|
||
calls = _patch_stages(monkeypatch, agent_available=False)
|
||
from winpodx.core.pod import PodState
|
||
|
||
states = [PodState.RUNNING, PodState.RUNNING, PodState.STOPPED]
|
||
seq = {"i": 0}
|
||
|
||
def fake_pod_status(cfg):
|
||
i = min(seq["i"], len(states) - 1)
|
||
seq["i"] += 1
|
||
return SimpleNamespace(state=states[i])
|
||
|
||
monkeypatch.setattr(provisioner, "pod_status", fake_pod_status)
|
||
with pytest.raises(ProvisionAgentUnavailable):
|
||
finish_provisioning(_cfg(), require_agent=True)
|
||
assert calls["apply"] == 0
|
||
|
||
|
||
# --- ProvisionAgentUnavailable is a ProvisionError ----------------------
|
||
|
||
|
||
def test_agent_unavailable_is_provision_error():
|
||
from winpodx.core.provisioner import ProvisionError
|
||
|
||
assert issubclass(ProvisionAgentUnavailable, ProvisionError)
|
||
|
||
|
||
# === CLI: winpodx provision ==============================================
|
||
|
||
|
||
def test_provision_cli_help_lists_every_flag(capsys):
|
||
from winpodx.cli.main import cli as main
|
||
|
||
with pytest.raises(SystemExit) as exc:
|
||
main(["provision", "--help"])
|
||
assert exc.value.code == 0
|
||
out = capsys.readouterr().out
|
||
for flag in (
|
||
"--wait-timeout",
|
||
"--require-agent",
|
||
"--no-discovery",
|
||
"--no-reverse-open",
|
||
"--retries",
|
||
"--verbose",
|
||
):
|
||
assert flag in out
|
||
|
||
|
||
def test_provision_cli_maps_flags_to_helper(monkeypatch):
|
||
from winpodx.cli import main as main_mod
|
||
|
||
captured: dict = {}
|
||
|
||
def fake_finish(cfg, **kwargs):
|
||
captured.update(kwargs)
|
||
return {"wait_ready": "ok", "reverse_open": "skipped"}
|
||
|
||
monkeypatch.setattr("winpodx.core.config.Config.load", staticmethod(lambda: _cfg()))
|
||
monkeypatch.setattr("winpodx.core.provisioner.finish_provisioning", fake_finish)
|
||
|
||
rc = main_mod._cmd_provision(
|
||
argparse.Namespace(
|
||
wait_timeout=900,
|
||
require_agent=True,
|
||
no_discovery=True,
|
||
no_reverse_open=True,
|
||
retries=2,
|
||
verbose=False,
|
||
)
|
||
)
|
||
assert rc == 0
|
||
assert captured["wait_timeout"] == 900
|
||
assert captured["require_agent"] is True
|
||
assert captured["with_discovery"] is False
|
||
assert captured["with_reverse_open"] is False
|
||
assert captured["retries"] == 2
|
||
|
||
|
||
def test_provision_cli_defaults_match_install_sh(monkeypatch):
|
||
"""No flags == install.sh's post-create defaults (item I AppImage parity)."""
|
||
from winpodx.cli import main as main_mod
|
||
|
||
captured: dict = {}
|
||
|
||
def fake_finish(cfg, **kwargs):
|
||
captured.update(kwargs)
|
||
return {"wait_ready": "ok"}
|
||
|
||
monkeypatch.setattr("winpodx.core.config.Config.load", staticmethod(lambda: _cfg()))
|
||
monkeypatch.setattr("winpodx.core.provisioner.finish_provisioning", fake_finish)
|
||
|
||
rc = main_mod._cmd_provision(
|
||
argparse.Namespace(
|
||
wait_timeout=3600,
|
||
require_agent=False,
|
||
no_discovery=False,
|
||
no_reverse_open=False,
|
||
retries=2,
|
||
verbose=False,
|
||
)
|
||
)
|
||
assert rc == 0
|
||
assert captured["wait_timeout"] == 3600
|
||
assert captured["require_agent"] is False
|
||
assert captured["with_discovery"] is True
|
||
assert captured["with_reverse_open"] is True
|
||
assert captured["retries"] == 2
|
||
|
||
|
||
def test_provision_cli_returns_5_on_agent_unavailable(monkeypatch):
|
||
from winpodx.cli import main as main_mod
|
||
|
||
def boom(cfg, **kwargs):
|
||
raise ProvisionAgentUnavailable("agent down")
|
||
|
||
monkeypatch.setattr("winpodx.core.config.Config.load", staticmethod(lambda: _cfg()))
|
||
monkeypatch.setattr("winpodx.core.provisioner.finish_provisioning", boom)
|
||
|
||
rc = main_mod._cmd_provision(
|
||
argparse.Namespace(
|
||
wait_timeout=3600,
|
||
require_agent=True,
|
||
no_discovery=False,
|
||
no_reverse_open=False,
|
||
retries=6,
|
||
verbose=False,
|
||
)
|
||
)
|
||
assert rc == 5
|
||
|
||
|
||
def test_provision_cli_returns_4_on_wait_timeout(monkeypatch):
|
||
from winpodx.cli import main as main_mod
|
||
|
||
monkeypatch.setattr("winpodx.core.config.Config.load", staticmethod(lambda: _cfg()))
|
||
monkeypatch.setattr(
|
||
"winpodx.core.provisioner.finish_provisioning",
|
||
lambda cfg, **kwargs: {"wait_ready": "timeout"},
|
||
)
|
||
|
||
rc = main_mod._cmd_provision(
|
||
argparse.Namespace(
|
||
wait_timeout=3600,
|
||
require_agent=False,
|
||
no_discovery=False,
|
||
no_reverse_open=False,
|
||
retries=6,
|
||
verbose=False,
|
||
)
|
||
)
|
||
assert rc == 4
|
||
|
||
|
||
def test_provision_cli_rejects_non_container_backend(monkeypatch):
|
||
from winpodx.cli import main as main_mod
|
||
|
||
monkeypatch.setattr(
|
||
"winpodx.core.config.Config.load", staticmethod(lambda: _cfg(backend="manual"))
|
||
)
|
||
rc = main_mod._cmd_provision(
|
||
argparse.Namespace(
|
||
wait_timeout=3600,
|
||
require_agent=False,
|
||
no_discovery=False,
|
||
no_reverse_open=False,
|
||
retries=6,
|
||
verbose=False,
|
||
)
|
||
)
|
||
assert rc == 2
|
||
|
||
|
||
# === --create-only removal ===============================================
|
||
|
||
|
||
def test_setup_help_no_longer_lists_create_only(capsys):
|
||
from winpodx.cli.main import cli as main
|
||
|
||
with pytest.raises(SystemExit) as exc:
|
||
main(["setup", "--help"])
|
||
assert exc.value.code == 0
|
||
out = capsys.readouterr().out
|
||
assert "--create-only" not in out
|
||
|
||
|
||
def test_setup_rejects_create_only_flag(capsys):
|
||
from winpodx.cli.main import cli as main
|
||
|
||
with pytest.raises(SystemExit) as exc:
|
||
main(["setup", "--create-only"])
|
||
# argparse rejects unknown args with exit code 2.
|
||
assert exc.value.code == 2
|
||
err = capsys.readouterr().err
|
||
assert "create-only" in err
|
||
|
||
|
||
# --- 0.6.0 item B follow-up: behaviors restored after the first-cut blind
|
||
# unification regressed them (dynamic wait #126, agent-first #271). ----------
|
||
|
||
|
||
def test_wait_fn_override_used_when_supplied(monkeypatch):
|
||
# The CLI / setup wizard inject a rich (log-streaming) wait via wait_fn.
|
||
# When supplied it must be used INSTEAD of the silent
|
||
# wait_for_windows_responsive (regression: item-B cut ignored it and the
|
||
# fresh-install boot was a silent multi-minute hang).
|
||
calls = _patch_stages(monkeypatch)
|
||
seen: list[int] = []
|
||
|
||
def my_wait(cfg, timeout):
|
||
seen.append(timeout)
|
||
return True
|
||
|
||
finish_provisioning(_cfg(), wait_timeout=4242, with_reverse_open=False, wait_fn=my_wait)
|
||
assert seen == [4242]
|
||
# The silent wait must NOT have run when wait_fn was supplied.
|
||
assert calls["wait"] == []
|
||
|
||
|
||
def test_silent_wait_used_when_no_wait_fn(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
finish_provisioning(_cfg(), wait_timeout=300, with_reverse_open=False, with_discovery=False)
|
||
assert calls["wait"] == [300]
|
||
|
||
|
||
def test_wait_fn_timeout_returns_results_without_downstream(monkeypatch):
|
||
calls = _patch_stages(monkeypatch)
|
||
results = finish_provisioning(_cfg(), wait_fn=lambda cfg, t: False)
|
||
assert results["wait_ready"] == "timeout"
|
||
# wait-ready failed -> apply / discovery never ran.
|
||
assert calls["apply"] == 0
|
||
assert calls["discovery"] == []
|
||
|
||
|
||
def test_require_agent_exports_env_around_apply_and_discovery(monkeypatch):
|
||
# #271: require_agent must export WINPODX_REQUIRE_AGENT=1 so the
|
||
# env-honouring guest-side code (discovery, migrate apply transport)
|
||
# refuses the FreeRDP fallback. The first item-B cut only gated the
|
||
# one-shot settle re-probe, so discovery still fell back to FreeRDP.
|
||
import os
|
||
|
||
seen_env: dict[str, str | None] = {}
|
||
|
||
def record_env_apply(cfg):
|
||
seen_env["apply"] = os.environ.get("WINPODX_REQUIRE_AGENT")
|
||
return {"max_sessions": "ok"}
|
||
|
||
monkeypatch.setattr(provisioner, "apply_windows_runtime_fixes", record_env_apply)
|
||
|
||
def record_env_discovery(cfg, *, retries, require_agent=False, on_progress=None):
|
||
seen_env["discovery"] = os.environ.get("WINPODX_REQUIRE_AGENT")
|
||
return 5
|
||
|
||
monkeypatch.setattr(provisioner, "_run_discovery_with_retry", record_env_discovery)
|
||
monkeypatch.setattr(provisioner, "_run_reverse_open", lambda cfg: None)
|
||
monkeypatch.setattr(
|
||
"winpodx.core.transport.agent.AgentTransport", lambda cfg: _FakeTransport(True)
|
||
)
|
||
monkeypatch.setattr(provisioner, "wait_for_windows_responsive", lambda cfg, timeout: True)
|
||
from winpodx.core.pod import PodState
|
||
|
||
monkeypatch.setattr(
|
||
provisioner, "pod_status", lambda cfg: SimpleNamespace(state=PodState.RUNNING)
|
||
)
|
||
|
||
monkeypatch.delenv("WINPODX_REQUIRE_AGENT", raising=False)
|
||
finish_provisioning(_cfg(), require_agent=True, with_reverse_open=False)
|
||
assert seen_env["apply"] == "1"
|
||
assert seen_env["discovery"] == "1"
|
||
# Restored (unset) after the chain.
|
||
assert os.environ.get("WINPODX_REQUIRE_AGENT") is None
|
||
|
||
|
||
def test_require_agent_false_leaves_env_untouched(monkeypatch):
|
||
import os
|
||
|
||
seen: dict[str, str | None] = {}
|
||
monkeypatch.setattr(
|
||
provisioner,
|
||
"apply_windows_runtime_fixes",
|
||
lambda cfg: (
|
||
seen.__setitem__("env", os.environ.get("WINPODX_REQUIRE_AGENT"))
|
||
or {"max_sessions": "ok"}
|
||
),
|
||
)
|
||
monkeypatch.setattr(provisioner, "_run_discovery_with_retry", lambda cfg, **k: 0)
|
||
monkeypatch.setattr(provisioner, "_run_reverse_open", lambda cfg: None)
|
||
monkeypatch.setattr(
|
||
"winpodx.core.transport.agent.AgentTransport", lambda cfg: _FakeTransport(True)
|
||
)
|
||
monkeypatch.setattr(provisioner, "wait_for_windows_responsive", lambda cfg, timeout: True)
|
||
monkeypatch.delenv("WINPODX_REQUIRE_AGENT", raising=False)
|
||
finish_provisioning(_cfg(), require_agent=False, with_reverse_open=False)
|
||
assert seen["env"] is None
|
||
|
||
|
||
def test_require_agent_discovery_unavailable_raises_provision_unavailable(monkeypatch):
|
||
# require_agent + discovery's agent_unavailable -> ProvisionAgentUnavailable
|
||
# (caller maps to exit 5 / pending), not a generic "failed" record.
|
||
from winpodx.core.discovery import DiscoveryError
|
||
|
||
monkeypatch.setattr(provisioner, "wait_for_windows_responsive", lambda cfg, timeout: True)
|
||
monkeypatch.setattr(
|
||
"winpodx.core.transport.agent.AgentTransport", lambda cfg: _FakeTransport(True)
|
||
)
|
||
from winpodx.core.pod import PodState
|
||
|
||
monkeypatch.setattr(
|
||
provisioner, "pod_status", lambda cfg: SimpleNamespace(state=PodState.RUNNING)
|
||
)
|
||
monkeypatch.setattr(
|
||
provisioner, "apply_windows_runtime_fixes", lambda cfg: {"max_sessions": "ok"}
|
||
)
|
||
monkeypatch.setattr(provisioner, "_run_reverse_open", lambda cfg: None)
|
||
|
||
def boom(cfg, *, retries, require_agent=False, on_progress=None):
|
||
# Mirror _run_discovery_with_retry's own escalation for require_agent.
|
||
if require_agent:
|
||
raise ProvisionAgentUnavailable("agent never came up")
|
||
raise DiscoveryError("x", kind="agent_unavailable")
|
||
|
||
monkeypatch.setattr(provisioner, "_run_discovery_with_retry", boom)
|
||
with pytest.raises(ProvisionAgentUnavailable):
|
||
finish_provisioning(_cfg(), require_agent=True, with_reverse_open=False)
|
||
|
||
|
||
def test_discovery_with_retry_escalates_agent_unavailable_when_require_agent(monkeypatch):
|
||
# Unit-test the real _run_discovery_with_retry: with require_agent, a
|
||
# persistent agent_unavailable DiscoveryError escalates to
|
||
# ProvisionAgentUnavailable rather than re-raising the DiscoveryError.
|
||
from winpodx.core import discovery as disc_mod
|
||
from winpodx.core.discovery import DiscoveryError
|
||
|
||
monkeypatch.setattr(
|
||
disc_mod,
|
||
"discover_apps",
|
||
lambda cfg, timeout=180: (_ for _ in ()).throw(
|
||
DiscoveryError("agent down", kind="agent_unavailable")
|
||
),
|
||
)
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
# Pod is STOPPED, so the agent-recovery wait can't recover (no keepalive)
|
||
# and discovery escalates to ProvisionAgentUnavailable. A RUNNING pod would
|
||
# be waited on indefinitely instead — see the patient-recovery test below.
|
||
from winpodx.core.pod import PodState
|
||
|
||
monkeypatch.setattr(
|
||
provisioner, "pod_status", lambda cfg: SimpleNamespace(state=PodState.STOPPED)
|
||
)
|
||
with pytest.raises(ProvisionAgentUnavailable):
|
||
provisioner._run_discovery_with_retry(_cfg(), retries=2, require_agent=True)
|
||
|
||
|
||
def test_discovery_waits_for_agent_then_succeeds_while_pod_runs(monkeypatch):
|
||
# The reliability fix: require_agent discovery does NOT defer on a timer
|
||
# when the agent is briefly down — it waits (pod-gated) for the keepalive
|
||
# watchdog to revive it, then retries and succeeds in-line.
|
||
from winpodx.core import discovery as disc_mod
|
||
from winpodx.core.discovery import DiscoveryError
|
||
from winpodx.core.pod import PodState
|
||
|
||
# discover_apps raises agent_unavailable until the agent /health is up.
|
||
health_up = {"v": False}
|
||
|
||
def disc(cfg, timeout=180):
|
||
if not health_up["v"]:
|
||
raise DiscoveryError("agent down", kind="agent_unavailable")
|
||
return ["app1", "app2", "app3"]
|
||
|
||
monkeypatch.setattr(disc_mod, "discover_apps", disc)
|
||
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda apps: None)
|
||
monkeypatch.setattr("winpodx.cli.app._register_desktop_entries", lambda apps: None)
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
# Pod stays RUNNING throughout (keepalive will revive the agent).
|
||
monkeypatch.setattr(
|
||
provisioner, "pod_status", lambda cfg: SimpleNamespace(state=PodState.RUNNING)
|
||
)
|
||
|
||
# /health: down for the first 2 probes, then up — and once it's up, flip the
|
||
# discovery result to succeed.
|
||
probes = {"n": 0}
|
||
|
||
class _Recovering:
|
||
def health(self):
|
||
probes["n"] += 1
|
||
up = probes["n"] >= 3
|
||
if up:
|
||
health_up["v"] = True
|
||
return SimpleNamespace(available=up, detail="")
|
||
|
||
monkeypatch.setattr("winpodx.core.transport.agent.AgentTransport", lambda cfg: _Recovering())
|
||
|
||
count = provisioner._run_discovery_with_retry(_cfg(), retries=2, require_agent=True)
|
||
assert count == 3 # waited out the down window, then discovered in-line
|
||
assert probes["n"] >= 3 # actually polled /health until it recovered
|
||
|
||
|
||
def test_discovery_with_retry_reraises_other_errors_even_with_require_agent(monkeypatch):
|
||
from winpodx.core import discovery as disc_mod
|
||
from winpodx.core.discovery import DiscoveryError
|
||
|
||
monkeypatch.setattr(
|
||
disc_mod,
|
||
"discover_apps",
|
||
lambda cfg, timeout=180: (_ for _ in ()).throw(
|
||
DiscoveryError("script broke", kind="script_failed")
|
||
),
|
||
)
|
||
monkeypatch.setattr(provisioner.time, "sleep", lambda s: None)
|
||
# Non-agent error: re-raised as-is, NOT escalated to ProvisionAgentUnavailable.
|
||
with pytest.raises(DiscoveryError):
|
||
provisioner._run_discovery_with_retry(_cfg(), retries=2, require_agent=True)
|