1818REPO = Path (__file__ ).resolve ().parents [2 ]
1919CLEANUP_SCRIPT = ".github/scripts/cleanup_app_engine_versions.sh"
2020STOP_SCRIPT = ".github/scripts/stop_app_engine_version.sh"
21+ CR_IMAGES_SCRIPT = ".github/scripts/cleanup_cloud_run_images.sh"
2122
2223# A stub `gcloud` that answers `app versions list` from MOCK_* env vars (each a
2324# newline-joined, newest-first id list) and appends any stop/delete calls to
2829 case "$a" in --filter=*) filter="${a#--filter=}";; esac
2930done
3031case "$*" in
32+ *"artifacts docker images list"*) [ -n "${MOCK_AR_IMAGES:-}" ] && printf '%s\\ n' "$MOCK_AR_IMAGES";;
33+ *"artifacts docker images delete"*) echo "IMG_DELETE $*" >> "$GCLOUD_CALLS";;
3134 *"versions list"*)
3235 case "$filter" in
3336 *"traffic_split>0"*) [ -n "${MOCK_TRAFFIC:-}" ] && printf '%s\\ n' "$MOCK_TRAFFIC";;
@@ -48,6 +51,7 @@ def _run(
4851 serving : list [str ] | None = None ,
4952 traffic : list [str ] | None = None ,
5053 stopped : list [str ] | None = None ,
54+ ar_images : list [str ] | None = None ,
5155 extra_env : dict [str , str ] | None = None ,
5256) -> tuple [subprocess .CompletedProcess [str ], Path ]:
5357 stub_dir = tmp_path / "bin"
@@ -68,6 +72,7 @@ def _run(
6872 "MOCK_SERVING" : "\n " .join (serving or []),
6973 "MOCK_TRAFFIC" : "\n " .join (traffic or []),
7074 "MOCK_STOPPED" : "\n " .join (stopped or []),
75+ "MOCK_AR_IMAGES" : "\n " .join (ar_images or []),
7176 }
7277 env .update (extra_env or {})
7378
@@ -94,13 +99,99 @@ def _list_after(label: str, stdout: str) -> list[str]:
9499
95100
96101def test_cleanup_scripts_are_shell_syntax_valid ():
97- for script in (CLEANUP_SCRIPT , STOP_SCRIPT ):
102+ for script in (CLEANUP_SCRIPT , STOP_SCRIPT , CR_IMAGES_SCRIPT ):
98103 result = subprocess .run (
99104 ["bash" , "-n" , script ], cwd = REPO , capture_output = True , text = True
100105 )
101106 assert result .returncode == 0 , f"{ script } : { result .stderr } "
102107
103108
109+ # --- cloud run image cleanup (keep 15) -----------------------------------
110+
111+ _CR_PKG = (
112+ "us-central1-docker.pkg.dev/policyengine-api/policyengine-api/policyengine-api"
113+ )
114+
115+
116+ def _ar_rows (digests : list [str ], package : str = _CR_PKG ) -> list [str ]:
117+ """`createTime|package|version` rows, oldest first (day 01 = oldest). The
118+ script sorts by createTime descending itself, so order here is irrelevant."""
119+ return [
120+ f"2026-01-{ i + 1 :02d} T00:00:00|{ package } |sha256:{ d } "
121+ for i , d in enumerate (digests )
122+ ]
123+
124+
125+ def test_cloud_run_images_keeps_newest_15 (tmp_path ):
126+ digests = [f"d{ i :02d} " for i in range (18 )] # d00 oldest ... d17 newest
127+ result , calls = _run (CR_IMAGES_SCRIPT , tmp_path , ar_images = _ar_rows (digests ))
128+ assert result .returncode == 0 , result .stderr
129+ assert "Images: 18 (keeping newest 15)" in result .stdout
130+ assert "Deleting: 3" in result .stdout
131+ # The 3 oldest digests are the ones deleted; the newest is kept.
132+ for old in ("sha256:d00" , "sha256:d01" , "sha256:d02" ):
133+ assert f"would delete { _CR_PKG } @{ old } " in result .stdout
134+ assert "sha256:d17" not in result .stdout .split ("Deleting:" )[1 ]
135+ assert calls .read_text () == "" # DRY_RUN performs no mutations
136+
137+
138+ def test_cloud_run_images_no_delete_when_within_keep (tmp_path ):
139+ result , _ = _run (
140+ CR_IMAGES_SCRIPT , tmp_path , ar_images = _ar_rows ([f"d{ i } " for i in range (10 )])
141+ )
142+ assert result .returncode == 0 , result .stderr
143+ assert "Deleting: 0" in result .stdout
144+ assert "would delete" not in result .stdout
145+
146+
147+ def test_cloud_run_images_respects_keep_override (tmp_path ):
148+ result , _ = _run (
149+ CR_IMAGES_SCRIPT ,
150+ tmp_path ,
151+ ar_images = _ar_rows ([f"d{ i } " for i in range (10 )]),
152+ extra_env = {"KEEP" : "5" },
153+ )
154+ assert "keeping newest 5" in result .stdout
155+ assert "Deleting: 5" in result .stdout
156+
157+
158+ # --- app engine cleanup also prunes each deleted version's image ----------
159+
160+
161+ def test_app_engine_cleanup_deletes_gae_flexible_image_per_deleted_version (tmp_path ):
162+ result , _ = _run (
163+ CLEANUP_SCRIPT ,
164+ tmp_path ,
165+ serving = ["prod-110" , "prod-108" ],
166+ traffic = ["prod-110" ],
167+ stopped = [f"prod-{ n } " for n in range (106 , 84 , - 2 )] # 11 prod -> 1 deleted
168+ + ["staging-90" , "staging-88" , "staging-86" , "staging-84" ], # 1 deleted
169+ )
170+ assert result .returncode == 0 , result .stderr
171+ deleted = _list_after ("Deleting" , result .stdout )
172+ assert deleted , "expected some versions to be deleted"
173+ # AR_IMAGE_PROJECT defaults to APP_ENGINE_PROJECT, which _run sets.
174+ repo = "us-central1-docker.pkg.dev/test-project/gae-flexible/default."
175+ # Every deleted version has a matching gae-flexible image deletion line.
176+ for v in deleted :
177+ assert f"would delete image { repo } { v } " in result .stdout
178+ # No image deletion for a kept (live/warm) version.
179+ assert f"{ repo } prod-110" not in result .stdout
180+
181+
182+ def test_app_engine_cleanup_image_pruning_can_be_disabled (tmp_path ):
183+ result , _ = _run (
184+ CLEANUP_SCRIPT ,
185+ tmp_path ,
186+ serving = ["prod-110" , "prod-108" ],
187+ traffic = ["prod-110" ],
188+ stopped = [f"prod-{ n } " for n in range (106 , 84 , - 2 )],
189+ extra_env = {"CLEANUP_APP_ENGINE_IMAGES" : "0" },
190+ )
191+ assert result .returncode == 0 , result .stderr
192+ assert "would delete image" not in result .stdout
193+
194+
104195# --- cleanup: stop selection ---------------------------------------------
105196
106197
0 commit comments