286 lines
11 KiB
Python
286 lines
11 KiB
Python
|
|
"""Tests for ``app.afk.ci_watcher`` — the commit → ``CIStatus`` adapter.
|
||
|
|
|
||
|
|
The watcher folds two independent signals into one verdict the state machine
|
||
|
|
reads: the **GHA run** for a pushed commit (build/test/lint) and the
|
||
|
|
**deploy/rollout** that reaches the cluster (Woodpecker pipeline → Keel/k8s
|
||
|
|
rollout). The CI/CD chain is GHA → ghcr → Woodpecker → Keel
|
||
|
|
(``docs/2026-06-14-afk-implementation-pipeline-design.md``), so a commit is only
|
||
|
|
truly GREEN once *both* the build passed AND its image actually rolled out.
|
||
|
|
|
||
|
|
Every test injects FAKE clients — no test ever shells out to ``gh``,
|
||
|
|
``woodpecker``, or ``kubectl``, or reaches the network. The fakes implement the
|
||
|
|
``ci_watcher`` client Protocols and return staged ``StageResult`` values per
|
||
|
|
``(repo, commit)``; the watcher's only job is to query them and fold the result,
|
||
|
|
so the folding table is what these tests pin.
|
||
|
|
"""
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.afk.ci_watcher import (
|
||
|
|
CIWatcher,
|
||
|
|
StageResult,
|
||
|
|
)
|
||
|
|
from app.afk.types import CIStatus
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Fakes for the three injected clients.
|
||
|
|
#
|
||
|
|
# Each maps (repo, commit) → StageResult and records every query, so tests can
|
||
|
|
# assert both the folded verdict AND that short-circuiting skips later stages
|
||
|
|
# (a RED build must not even ask the rollout client).
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
class _FakeStageClient:
|
||
|
|
"""A recording stand-in for any of the three stage clients. ``default`` is
|
||
|
|
returned for an unstaged ``(repo, commit)`` — defaults to ``PENDING`` so an
|
||
|
|
un-seeded stage reads "not done yet", never a false GREEN."""
|
||
|
|
|
||
|
|
def __init__(self, default: StageResult = StageResult.PENDING) -> None:
|
||
|
|
self._results: dict[tuple[str, str], StageResult] = {}
|
||
|
|
self._default = default
|
||
|
|
self.queries: list[tuple[str, str]] = []
|
||
|
|
|
||
|
|
def set(self, repo: str, commit: str, result: StageResult) -> None:
|
||
|
|
self._results[(repo, commit)] = result
|
||
|
|
|
||
|
|
def _lookup(self, repo: str, commit: str) -> StageResult:
|
||
|
|
self.queries.append((repo, commit))
|
||
|
|
return self._results.get((repo, commit), self._default)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeGitHubChecks(_FakeStageClient):
|
||
|
|
def run_conclusion(self, repo: str, commit: str) -> StageResult:
|
||
|
|
return self._lookup(repo, commit)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeWoodpecker(_FakeStageClient):
|
||
|
|
def deploy_conclusion(self, repo: str, commit: str) -> StageResult:
|
||
|
|
return self._lookup(repo, commit)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeRollout(_FakeStageClient):
|
||
|
|
def rollout_status(self, repo: str, commit: str) -> StageResult:
|
||
|
|
return self._lookup(repo, commit)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Fixtures.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
REPO = "infra"
|
||
|
|
COMMIT = "deadbeefcafe"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def gha() -> FakeGitHubChecks:
|
||
|
|
return FakeGitHubChecks()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def woodpecker() -> FakeWoodpecker:
|
||
|
|
return FakeWoodpecker()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def rollout() -> FakeRollout:
|
||
|
|
return FakeRollout()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def watcher(gha, woodpecker, rollout) -> CIWatcher:
|
||
|
|
return CIWatcher(github=gha, woodpecker=woodpecker, rollout=rollout)
|
||
|
|
|
||
|
|
|
||
|
|
def _stage_all(gha, woodpecker, rollout, *, build, deploy, roll) -> None:
|
||
|
|
"""Stage all three clients for the canonical ``(REPO, COMMIT)`` at once."""
|
||
|
|
gha.set(REPO, COMMIT, build)
|
||
|
|
woodpecker.set(REPO, COMMIT, deploy)
|
||
|
|
rollout.set(REPO, COMMIT, roll)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# StageResult vocabulary.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_stageresult_has_the_four_outcomes():
|
||
|
|
assert {s.name for s in StageResult} == {"NONE", "PENDING", "SUCCESS", "FAILURE"}
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# The happy path: every stage green ⇒ GREEN.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_all_stages_success_is_green(watcher, gha, woodpecker, rollout):
|
||
|
|
_stage_all(gha, woodpecker, rollout,
|
||
|
|
build=StageResult.SUCCESS,
|
||
|
|
deploy=StageResult.SUCCESS,
|
||
|
|
roll=StageResult.SUCCESS)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.GREEN
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# GHA build stage gates everything below it.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_build_failure_is_red(watcher, gha):
|
||
|
|
gha.set(REPO, COMMIT, StageResult.FAILURE)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.RED
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("build", [StageResult.NONE, StageResult.PENDING])
|
||
|
|
def test_build_not_yet_concluded_is_pending(watcher, gha, build):
|
||
|
|
# No run yet (NONE) and in-progress (PENDING) both read PENDING — the state
|
||
|
|
# machine waits on either.
|
||
|
|
gha.set(REPO, COMMIT, build)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.PENDING
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_failure_short_circuits_before_deploy_and_rollout(
|
||
|
|
watcher, gha, woodpecker, rollout
|
||
|
|
):
|
||
|
|
gha.set(REPO, COMMIT, StageResult.FAILURE)
|
||
|
|
# Even if later stages would (nonsensically) be green, a red build wins...
|
||
|
|
woodpecker.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
rollout.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.RED
|
||
|
|
# ...and the later clients are never even queried.
|
||
|
|
assert woodpecker.queries == []
|
||
|
|
assert rollout.queries == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_pending_short_circuits_before_deploy_and_rollout(
|
||
|
|
watcher, gha, woodpecker, rollout
|
||
|
|
):
|
||
|
|
gha.set(REPO, COMMIT, StageResult.PENDING)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.PENDING
|
||
|
|
assert woodpecker.queries == []
|
||
|
|
assert rollout.queries == []
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Deploy (Woodpecker) stage — only consulted once the build is green.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_deploy_failure_is_red_even_with_green_build(watcher, gha, woodpecker):
|
||
|
|
gha.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
woodpecker.set(REPO, COMMIT, StageResult.FAILURE)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.RED
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("deploy", [StageResult.NONE, StageResult.PENDING])
|
||
|
|
def test_deploy_not_yet_concluded_is_pending(watcher, gha, woodpecker, deploy):
|
||
|
|
gha.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
woodpecker.set(REPO, COMMIT, deploy)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.PENDING
|
||
|
|
|
||
|
|
|
||
|
|
def test_deploy_failure_short_circuits_before_rollout(
|
||
|
|
watcher, gha, woodpecker, rollout
|
||
|
|
):
|
||
|
|
gha.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
woodpecker.set(REPO, COMMIT, StageResult.FAILURE)
|
||
|
|
rollout.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.RED
|
||
|
|
assert rollout.queries == []
|
||
|
|
# The build WAS consulted (it had to pass to reach deploy).
|
||
|
|
assert gha.queries == [(REPO, COMMIT)]
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Rollout stage — the final gate. Green build + green deploy is still only
|
||
|
|
# PENDING until the image actually reaches the cluster.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_rollout_failure_is_red(watcher, gha, woodpecker, rollout):
|
||
|
|
_stage_all(gha, woodpecker, rollout,
|
||
|
|
build=StageResult.SUCCESS,
|
||
|
|
deploy=StageResult.SUCCESS,
|
||
|
|
roll=StageResult.FAILURE)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.RED
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("roll", [StageResult.NONE, StageResult.PENDING])
|
||
|
|
def test_green_build_and_deploy_but_unfinished_rollout_is_pending(
|
||
|
|
watcher, gha, woodpecker, rollout, roll
|
||
|
|
):
|
||
|
|
_stage_all(gha, woodpecker, rollout,
|
||
|
|
build=StageResult.SUCCESS,
|
||
|
|
deploy=StageResult.SUCCESS,
|
||
|
|
roll=roll)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.PENDING
|
||
|
|
|
||
|
|
|
||
|
|
def test_green_requires_all_three_stages_consulted(
|
||
|
|
watcher, gha, woodpecker, rollout
|
||
|
|
):
|
||
|
|
_stage_all(gha, woodpecker, rollout,
|
||
|
|
build=StageResult.SUCCESS,
|
||
|
|
deploy=StageResult.SUCCESS,
|
||
|
|
roll=StageResult.SUCCESS)
|
||
|
|
assert watcher.status(REPO, COMMIT) is CIStatus.GREEN
|
||
|
|
assert gha.queries == [(REPO, COMMIT)]
|
||
|
|
assert woodpecker.queries == [(REPO, COMMIT)]
|
||
|
|
assert rollout.queries == [(REPO, COMMIT)]
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Plumbing: the commit and repo are passed through verbatim to every client,
|
||
|
|
# and an entirely un-seeded commit reads PENDING (not GREEN, not RED).
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_repo_and_commit_passed_through_to_clients(watcher, gha):
|
||
|
|
gha.set("realestate-crawler", "abc123", StageResult.FAILURE)
|
||
|
|
assert watcher.status("realestate-crawler", "abc123") is CIStatus.RED
|
||
|
|
assert gha.queries == [("realestate-crawler", "abc123")]
|
||
|
|
|
||
|
|
|
||
|
|
def test_unknown_commit_defaults_to_pending(watcher):
|
||
|
|
# Nothing staged anywhere ⇒ the build stage reads PENDING by default ⇒ the
|
||
|
|
# whole verdict is PENDING. A never-pushed/just-pushed commit is never a
|
||
|
|
# false GREEN.
|
||
|
|
assert watcher.status(REPO, "never-seen") is CIStatus.PENDING
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# The default rollout client is OPTIONAL — per the pilot facts, state.sqlite /
|
||
|
|
# kubectl reads are optional, so a CIWatcher built without a rollout client must
|
||
|
|
# still work, treating "build green + deploy green" as the terminal GREEN.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_rollout_client_is_optional_deploy_green_is_green(gha, woodpecker):
|
||
|
|
w = CIWatcher(github=gha, woodpecker=woodpecker) # no rollout client
|
||
|
|
gha.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
woodpecker.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
assert w.status(REPO, COMMIT) is CIStatus.GREEN
|
||
|
|
|
||
|
|
|
||
|
|
def test_rollout_client_optional_still_honours_build_and_deploy_failures(
|
||
|
|
gha, woodpecker
|
||
|
|
):
|
||
|
|
w = CIWatcher(github=gha, woodpecker=woodpecker)
|
||
|
|
gha.set(REPO, COMMIT, StageResult.SUCCESS)
|
||
|
|
woodpecker.set(REPO, COMMIT, StageResult.FAILURE)
|
||
|
|
assert w.status(REPO, COMMIT) is CIStatus.RED
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Full folding table — exhaustive over (build, deploy, rollout) so the
|
||
|
|
# precedence rules (FAILURE short-circuits red; otherwise any PENDING/NONE keeps
|
||
|
|
# it pending; all-success ⇒ green) can never silently drift.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
_N, _P, _S, _F = (
|
||
|
|
StageResult.NONE,
|
||
|
|
StageResult.PENDING,
|
||
|
|
StageResult.SUCCESS,
|
||
|
|
StageResult.FAILURE,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _expected(build: StageResult, deploy: StageResult, roll: StageResult) -> CIStatus:
|
||
|
|
# Reference fold, independent of the implementation, evaluated stage by stage.
|
||
|
|
for stage in (build, deploy, roll):
|
||
|
|
if stage is _F:
|
||
|
|
return CIStatus.RED
|
||
|
|
if stage in (_N, _P):
|
||
|
|
return CIStatus.PENDING
|
||
|
|
return CIStatus.GREEN
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("build", [_N, _P, _S, _F])
|
||
|
|
@pytest.mark.parametrize("deploy", [_N, _P, _S, _F])
|
||
|
|
@pytest.mark.parametrize("roll", [_N, _P, _S, _F])
|
||
|
|
def test_full_folding_table(watcher, gha, woodpecker, rollout, build, deploy, roll):
|
||
|
|
_stage_all(gha, woodpecker, rollout, build=build, deploy=deploy, roll=roll)
|
||
|
|
assert watcher.status(REPO, COMMIT) is _expected(build, deploy, roll)
|