Files
cc-ci/tests/unit/test_deps.py
autonomic-bot 0aa46dbe72
Some checks failed
continuous-integration/drone/push Build is failing
fix(drone-dep): ADV-drone-02 — teardown fallback when SSO enrichment fails after deploy
When _enrich_deps_with_sso raises after deploy_deps succeeds (e.g., gitea API
call fails), deps_state stays {} and the finally block's `if deps_state:` guard
skips teardown, orphaning the dep at its deterministic domain.

Fix: add an `else` branch after the `if deps_state:` block that reads
$CCCI_DEPS_FILE (the legacy-list written by deploy_deps) and calls
teardown_deps on the cold entries so no dep is left running.

Unit tests: test_load_run_state_provides_fallback_for_enrichment_failure and
test_fallback_skips_warm_entries verify the data-flow that the fallback relies on.
19/19 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:03:29 +00:00

129 lines
5.7 KiB
Python

"""Unit tests for runner/harness/deps.py (Phase 2 §4.2 / Q2.3).
Pure-Python: no real deploys. Tests the declarative parts of the dep resolver — DEPS declaration
(read through the single meta loader since rcust P1), the per-dep domain derivation, and write/load
of the run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against
cc-ci (Q2.4 acceptance).
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import deps # noqa: E402
from harness import meta as meta_mod # noqa: E402
def test_declared_deps_empty_for_no_meta(monkeypatch, tmp_path):
"""A recipe with no recipe_meta.py declares no deps (rcust P1: DEPS via meta.load)."""
monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests"))
assert meta_mod.load("ccci-no-meta").DEPS == []
def test_declared_deps_reads_DEPS_list(tmp_path, monkeypatch):
"""A recipe_meta.py with `DEPS = [...]` surfaces the list on the loaded meta (the orchestrator
reads meta.DEPS — the successor of the deleted deps.declared_deps loader)."""
recipe_dir = tmp_path / "tests" / "ccci-with-deps"
recipe_dir.mkdir(parents=True)
(recipe_dir / "recipe_meta.py").write_text('HEALTH_PATH = "/"\nDEPS = ["keycloak", "redis"]\n')
monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests"))
assert meta_mod.load("ccci-with-deps").DEPS == ["keycloak", "redis"]
def test_dep_domain_distinct_per_dep():
"""Two deps of different kinds (same parent/pr/ref) get distinct per-run domains."""
parent = "lasuite-docs"
pr = "42"
ref = "abc123"
d1 = deps.dep_domain(parent, pr, ref, "keycloak")
d2 = deps.dep_domain(parent, pr, ref, "redis")
assert d1 != d2
# Both must look like the standard run-app pattern: <recipe[:4]>-<6hex>.ci.commoninternet.net
assert d1.endswith(".ci.commoninternet.net")
assert d2.endswith(".ci.commoninternet.net")
# The hash is determined by (parent, pr, ref, dep) — same inputs = same domain (idempotent)
assert deps.dep_domain(parent, pr, ref, "keycloak") == d1
def test_dep_domain_distinct_per_parent():
"""The same dep deployed by two different parent recipes (same dep, pr, ref) gets distinct
domains — proves the dep is parent-scoped not just dep-name-scoped."""
d1 = deps.dep_domain("lasuite-docs", "42", "abc", "keycloak")
d2 = deps.dep_domain("cryptpad", "42", "abc", "keycloak")
assert d1 != d2
def test_write_and_load_run_state(tmp_path, monkeypatch):
"""write_run_state writes JSON to $CCCI_DEPS_FILE; load_run_state reads it back."""
state_path = tmp_path / "deps.json"
monkeypatch.setenv("CCCI_DEPS_FILE", str(state_path))
state = [
{"recipe": "keycloak", "domain": "kc-deadbe.ci.commoninternet.net"},
{"recipe": "redis", "domain": "redi-abc123.ci.commoninternet.net"},
]
deps.write_run_state(state)
loaded = deps.load_run_state()
assert loaded == state
def test_load_run_state_missing_env_returns_empty(monkeypatch):
"""No $CCCI_DEPS_FILE -> empty list."""
monkeypatch.delenv("CCCI_DEPS_FILE", raising=False)
assert deps.load_run_state() == []
def test_write_run_state_no_env_is_noop(monkeypatch):
"""write_run_state silently no-ops without $CCCI_DEPS_FILE (so standalone helper use doesn't
require setting up the env)."""
monkeypatch.delenv("CCCI_DEPS_FILE", raising=False)
deps.write_run_state([{"recipe": "x", "domain": "y"}]) # must not raise
# ---------------------------------------------------------------------------
# ADV-drone-02 fallback: load_run_state provides teardown data when deps_state={}
# ---------------------------------------------------------------------------
def test_load_run_state_provides_fallback_for_enrichment_failure(tmp_path, monkeypatch):
"""ADV-drone-02: deploy_deps writes legacy-list to $CCCI_DEPS_FILE; if _enrich_deps_with_sso
raises, deps_state = {} in main(). The fallback in the finally block reads load_run_state()
to recover the deployed-but-unenriched entries for teardown.
This test verifies that load_run_state() returns the entries written by deploy_deps (legacy
list shape) so the fallback teardown receives the correct cold entries."""
state_path = tmp_path / "deps.json"
monkeypatch.setenv("CCCI_DEPS_FILE", str(state_path))
# deploy_deps writes the legacy list shape (before enrichment converts to dict)
deployed_by_deploy_deps = [
{"recipe": "gitea", "domain": "gite-aabbcc.ci.commoninternet.net"},
]
deps.write_run_state(deployed_by_deploy_deps)
raw = deps.load_run_state()
assert isinstance(raw, list), "load_run_state must return the legacy list shape"
assert len(raw) == 1
cold_raw = [e for e in raw if isinstance(e, dict) and not e.get("warm")]
assert len(cold_raw) == 1
assert cold_raw[0]["recipe"] == "gitea"
assert cold_raw[0]["domain"] == "gite-aabbcc.ci.commoninternet.net"
def test_fallback_skips_warm_entries(tmp_path, monkeypatch):
"""ADV-drone-02 fallback filtering: warm entries (keycloak live-warm provider) must NOT be
undeployed — only cold entries should be in the teardown list."""
state_path = tmp_path / "deps.json"
monkeypatch.setenv("CCCI_DEPS_FILE", str(state_path))
mixed = [
{"recipe": "keycloak", "domain": "keyc-live.ci.commoninternet.net", "warm": True},
{"recipe": "gitea", "domain": "gite-aabbcc.ci.commoninternet.net"},
]
deps.write_run_state(mixed)
raw = deps.load_run_state()
cold_raw = [e for e in (raw if isinstance(raw, list) else list(raw.values()))
if isinstance(e, dict) and not e.get("warm")]
assert len(cold_raw) == 1
assert cold_raw[0]["recipe"] == "gitea"