Skip to content

Commit 4407075

Browse files
anth-volkclaude
andcommitted
Add fail-safe guard and unit tests for App Engine cleanup
- cleanup_app_engine_versions.sh: abort before stopping if versions are SERVING but none report traffic, so a failed/empty traffic query cannot deactivate the live service. Delete already only targets STOPPED, so the live version can never be deleted. - tests/unit/test_app_engine_cleanup_scripts.py: exercise both scripts with a stubbed gcloud (mirroring test_cloud_run_deploy_scripts.py) covering live/warm retention, protection of an older traffic-serving version, delete-only-stopped, the fail-safe guard, missing-staging robustness, and the stop script's required-version check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 19e8c58 commit 4407075

2 files changed

Lines changed: 267 additions & 0 deletions

File tree

.github/scripts/cleanup_app_engine_versions.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ while IFS= read -r v; do
7373
[[ -n "${v}" ]] && serving_all+=("${v}")
7474
done < <(read_versions "version.servingStatus=SERVING")
7575

76+
# Fail safe: if there are SERVING versions but none report traffic, we cannot
77+
# identify the live service. Refuse to stop anything rather than risk
78+
# deactivating production (e.g. if the traffic query returns nothing). The
79+
# delete step never runs either, since we exit here.
80+
if [[ "${#serving_all[@]}" -gt 0 && "${#serving_traffic[@]}" -eq 0 ]]; then
81+
echo "ERROR: ${#serving_all[@]} SERVING version(s) but none are receiving traffic;" >&2
82+
echo "refusing to stop versions (cannot identify the live service). Aborting." >&2
83+
exit 1
84+
fi
85+
7686
# Newest KEEP_WARM_PROD prod-* versions to keep warm for instant rollback.
7787
warm_prod=()
7888
while IFS= read -r v; do
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""Unit tests for the App Engine idle-version cleanup scripts.
2+
3+
`cleanup_app_engine_versions.sh` and `stop_app_engine_version.sh` are exercised
4+
by running them under bash with a stubbed ``gcloud`` on ``PATH`` (mirroring the
5+
approach in ``test_cloud_run_deploy_scripts.py``). The stub returns canned
6+
``gcloud app versions list`` output based on the ``--filter`` and records any
7+
``stop``/``delete`` invocations, so the tests assert the selection logic without
8+
touching real infrastructure. All cleanup runs use ``DRY_RUN=1``.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import re
15+
import subprocess
16+
from pathlib import Path
17+
18+
REPO = Path(__file__).resolve().parents[2]
19+
CLEANUP_SCRIPT = ".github/scripts/cleanup_app_engine_versions.sh"
20+
STOP_SCRIPT = ".github/scripts/stop_app_engine_version.sh"
21+
22+
# A stub `gcloud` that answers `app versions list` from MOCK_* env vars (each a
23+
# newline-joined, newest-first id list) and appends any stop/delete calls to
24+
# $GCLOUD_CALLS so tests can assert nothing mutating ran under DRY_RUN.
25+
_FAKE_GCLOUD = """#!/usr/bin/env bash
26+
filter=""
27+
for a in "$@"; do
28+
case "$a" in --filter=*) filter="${a#--filter=}";; esac
29+
done
30+
case "$*" in
31+
*"versions list"*)
32+
case "$filter" in
33+
*"traffic_split>0"*) [ -n "${MOCK_TRAFFIC:-}" ] && printf '%s\\n' "$MOCK_TRAFFIC";;
34+
*"servingStatus=SERVING"*) [ -n "${MOCK_SERVING:-}" ] && printf '%s\\n' "$MOCK_SERVING";;
35+
*"servingStatus=STOPPED"*) [ -n "${MOCK_STOPPED:-}" ] && printf '%s\\n' "$MOCK_STOPPED";;
36+
esac;;
37+
*"versions stop"*) echo "STOP $*" >> "$GCLOUD_CALLS";;
38+
*"versions delete"*) echo "DELETE $*" >> "$GCLOUD_CALLS";;
39+
esac
40+
exit 0
41+
"""
42+
43+
44+
def _run(
45+
script: str,
46+
tmp_path: Path,
47+
*,
48+
serving: list[str] | None = None,
49+
traffic: list[str] | None = None,
50+
stopped: list[str] | None = None,
51+
extra_env: dict[str, str] | None = None,
52+
) -> tuple[subprocess.CompletedProcess[str], Path]:
53+
stub_dir = tmp_path / "bin"
54+
stub_dir.mkdir(exist_ok=True)
55+
gcloud = stub_dir / "gcloud"
56+
gcloud.write_text(_FAKE_GCLOUD, encoding="utf-8")
57+
gcloud.chmod(0o755)
58+
59+
calls = tmp_path / "gcloud_calls.log"
60+
calls.write_text("", encoding="utf-8")
61+
62+
env = {
63+
"HOME": os.environ.get("HOME", ""),
64+
"PATH": f"{stub_dir}:{os.environ['PATH']}",
65+
"DRY_RUN": "1",
66+
"APP_ENGINE_PROJECT": "test-project",
67+
"GCLOUD_CALLS": str(calls),
68+
"MOCK_SERVING": "\n".join(serving or []),
69+
"MOCK_TRAFFIC": "\n".join(traffic or []),
70+
"MOCK_STOPPED": "\n".join(stopped or []),
71+
}
72+
env.update(extra_env or {})
73+
74+
result = subprocess.run(
75+
["bash", script],
76+
cwd=REPO,
77+
env=env,
78+
text=True,
79+
capture_output=True,
80+
check=False,
81+
)
82+
return result, calls
83+
84+
85+
def _list_after(label: str, stdout: str) -> list[str]:
86+
"""Return the space-separated ids printed after a `Label[ (n)]: ...` line."""
87+
match = re.search(rf"{re.escape(label)}(?: \(\d+\))?:\s*(.*)", stdout)
88+
assert match is not None, f"missing '{label}' line in:\n{stdout}"
89+
body = match.group(1).strip()
90+
return [] if body == "<none>" else body.split()
91+
92+
93+
# --- syntax --------------------------------------------------------------
94+
95+
96+
def test_cleanup_scripts_are_shell_syntax_valid():
97+
for script in (CLEANUP_SCRIPT, STOP_SCRIPT):
98+
result = subprocess.run(
99+
["bash", "-n", script], cwd=REPO, capture_output=True, text=True
100+
)
101+
assert result.returncode == 0, f"{script}: {result.stderr}"
102+
103+
104+
# --- cleanup: stop selection ---------------------------------------------
105+
106+
107+
def test_keeps_live_and_warm_prod_and_stops_the_rest(tmp_path):
108+
result, calls = _run(
109+
CLEANUP_SCRIPT,
110+
tmp_path,
111+
serving=["prod-110", "prod-108", "staging-110", "staging-108", "prod-106"],
112+
traffic=["prod-110"],
113+
stopped=["prod-104", "prod-102"],
114+
)
115+
assert result.returncode == 0, result.stderr
116+
assert _list_after("Serving traffic (never touched)", result.stdout) == ["prod-110"]
117+
assert _list_after("Keeping warm (rollback window)", result.stdout) == [
118+
"prod-110",
119+
"prod-108",
120+
]
121+
# Every SERVING version except the live one and the 2 newest prod is stopped.
122+
assert _list_after("Stopping", result.stdout) == [
123+
"staging-110",
124+
"staging-108",
125+
"prod-106",
126+
]
127+
# The live version is never in the stop or delete sets.
128+
assert "prod-110" not in _list_after("Stopping", result.stdout)
129+
assert "prod-110" not in _list_after("Deleting", result.stdout)
130+
# DRY_RUN performs no mutations.
131+
assert calls.read_text() == ""
132+
133+
134+
def test_traffic_holder_is_protected_even_when_it_is_an_older_version(tmp_path):
135+
# Simulates a manual rollback: traffic sits on prod-106 while prod-110/108
136+
# are newer. The older, traffic-serving version must NOT be stopped.
137+
result, _ = _run(
138+
CLEANUP_SCRIPT,
139+
tmp_path,
140+
serving=["prod-110", "prod-108", "prod-106", "staging-110"],
141+
traffic=["prod-106"],
142+
stopped=[],
143+
)
144+
assert result.returncode == 0, result.stderr
145+
assert "prod-106" not in _list_after("Stopping", result.stdout)
146+
assert _list_after("Serving traffic (never touched)", result.stdout) == ["prod-106"]
147+
148+
149+
# --- cleanup: delete selection / retention -------------------------------
150+
151+
152+
def test_deletes_only_stopped_beyond_retention_window(tmp_path):
153+
stopped_prod = [f"prod-{n}" for n in range(120, 98, -2)] # 11 prod, newest first
154+
stopped_staging = [f"staging-{n}" for n in range(120, 108, -2)] # 6 staging
155+
legacy = ["20260101t120000", "20251201t120000"]
156+
result, _ = _run(
157+
CLEANUP_SCRIPT,
158+
tmp_path,
159+
serving=["prod-130"],
160+
traffic=["prod-130"],
161+
stopped=stopped_prod + stopped_staging + legacy,
162+
)
163+
assert result.returncode == 0, result.stderr
164+
165+
kept_prod = _list_after("Keeping stopped prod", result.stdout)
166+
kept_staging = _list_after("Keeping stopped staging", result.stdout)
167+
deleted = _list_after("Deleting", result.stdout)
168+
169+
assert kept_prod == stopped_prod[:10] # newest 10 prod
170+
assert kept_staging == stopped_staging[:3] # newest 3 staging
171+
# Everything else stopped is deleted: 1 oldest prod + 3 older staging + 2 legacy.
172+
assert set(deleted) == {stopped_prod[10]} | set(stopped_staging[3:]) | set(legacy)
173+
# Never deletes a SERVING version.
174+
assert "prod-130" not in deleted
175+
176+
177+
def test_delete_only_targets_stopped_versions(tmp_path):
178+
# A SERVING (but idle) version must be eligible for STOP, never DELETE.
179+
result, _ = _run(
180+
CLEANUP_SCRIPT,
181+
tmp_path,
182+
serving=["prod-110", "prod-108", "prod-106"],
183+
traffic=["prod-110"],
184+
stopped=[],
185+
)
186+
assert result.returncode == 0, result.stderr
187+
assert _list_after("Stopping", result.stdout) == ["prod-106"]
188+
assert _list_after("Deleting", result.stdout) == []
189+
190+
191+
# --- cleanup: fail-safe guard --------------------------------------------
192+
193+
194+
def test_aborts_when_serving_versions_exist_but_none_serve_traffic(tmp_path):
195+
# If the traffic query returns nothing while versions are SERVING, we must
196+
# NOT stop anything (we cannot identify the live service).
197+
result, calls = _run(
198+
CLEANUP_SCRIPT,
199+
tmp_path,
200+
serving=["prod-104", "prod-102", "prod-100"],
201+
traffic=[],
202+
stopped=["prod-98"],
203+
)
204+
assert result.returncode != 0
205+
assert "refusing to stop" in result.stderr.lower()
206+
assert "Stopping" not in result.stdout
207+
assert calls.read_text() == ""
208+
209+
210+
def test_no_serving_versions_is_not_an_error(tmp_path):
211+
# Nothing serving (all already stopped) is a valid state, not the guard case.
212+
result, _ = _run(
213+
CLEANUP_SCRIPT,
214+
tmp_path,
215+
serving=[],
216+
traffic=[],
217+
stopped=["prod-104", "prod-102"],
218+
)
219+
assert result.returncode == 0, result.stderr
220+
assert _list_after("Stopping", result.stdout) == []
221+
222+
223+
# --- cleanup: robustness -------------------------------------------------
224+
225+
226+
def test_handles_absence_of_staging_versions(tmp_path):
227+
# grep '^staging-' matches nothing; the script must not crash under set -e.
228+
result, _ = _run(
229+
CLEANUP_SCRIPT,
230+
tmp_path,
231+
serving=["prod-110"],
232+
traffic=["prod-110"],
233+
stopped=["prod-108", "prod-106", "20250101t000000"],
234+
)
235+
assert result.returncode == 0, result.stderr
236+
assert _list_after("Keeping stopped staging", result.stdout) == []
237+
assert _list_after("Deleting", result.stdout) == ["20250101t000000"]
238+
239+
240+
# --- stop_app_engine_version.sh ------------------------------------------
241+
242+
243+
def test_stop_script_requires_a_version(tmp_path):
244+
result, _ = _run(STOP_SCRIPT, tmp_path, extra_env={"APP_ENGINE_VERSION": ""})
245+
assert result.returncode != 0
246+
assert "APP_ENGINE_VERSION is required" in result.stderr
247+
248+
249+
def test_stop_script_stops_the_named_version(tmp_path):
250+
result, calls = _run(
251+
STOP_SCRIPT,
252+
tmp_path,
253+
extra_env={"APP_ENGINE_VERSION": "staging-2405-abc1234", "DRY_RUN": "0"},
254+
)
255+
assert result.returncode == 0, result.stderr
256+
logged = calls.read_text()
257+
assert "STOP" in logged and "staging-2405-abc1234" in logged

0 commit comments

Comments
 (0)