Files
kernalix7--winpodx/tests/test_finish_provisioning.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

827 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)