Skip to content

Commit cb61bed

Browse files
TThanos3000DarKylX
andauthored
Guard DEV API image retention with an exact inventory plan (#830)
Co-authored-by: DarKylX <Play_Station399@mail.ru>
1 parent ee0d9cc commit cb61bed

3 files changed

Lines changed: 392 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: DEV API image retention
2+
3+
on:
4+
push:
5+
branches: [dev]
6+
paths: [.github/workflows/dev-image-retention.yml, scripts/dev_image_retention.py]
7+
workflow_dispatch:
8+
inputs:
9+
mode:
10+
type: choice
11+
options: [plan, apply]
12+
default: plan
13+
required: true
14+
plan_sha256:
15+
description: Exact digest printed by the read-only plan
16+
type: string
17+
required: false
18+
19+
permissions:
20+
contents: read
21+
22+
concurrency:
23+
group: api-deploy-dev
24+
cancel-in-progress: false
25+
26+
jobs:
27+
retention:
28+
if: github.ref == 'refs/heads/dev'
29+
environment: dev
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 10
32+
steps:
33+
- uses: actions/checkout@v4
34+
- uses: actions/setup-node@v4
35+
with:
36+
node-version: '24'
37+
- name: Install verified SSH transport
38+
run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0
39+
- name: Plan or apply exact unused DEV image removals
40+
env:
41+
NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules
42+
DEV_SERVER_HOST: ${{ secrets.DEV_SERVER_HOST }}
43+
DEV_SERVER_USER: ${{ secrets.DEV_SERVER_USER }}
44+
DEV_SERVER_PASSWORD: ${{ secrets.DEV_SERVER_PASSWORD }}
45+
DEV_SSH_HOST_KEY_SHA256: ${{ vars.DEV_SSH_HOST_KEY_SHA256 }}
46+
RETENTION_MODE: ${{ github.event_name == 'push' && 'plan' || inputs.mode }}
47+
PLAN_SHA256: ${{ inputs.plan_sha256 }}
48+
run: |
49+
node <<'NODE'
50+
const fs = require('node:fs');
51+
const mode = process.env.RETENTION_MODE;
52+
const digest = process.env.PLAN_SHA256 || 'none';
53+
if (!['plan', 'apply'].includes(mode)) throw new Error('Invalid mode');
54+
if (!/^(none|[0-9a-f]{64})$/.test(digest)) throw new Error('Invalid plan digest');
55+
if (mode === 'apply' && !/^[0-9a-f]{64}$/.test(digest)) throw new Error('Inspect a plan first');
56+
const source = fs.readFileSync('scripts/dev_image_retention.py', 'utf8');
57+
process.env.DEV_DEPLOY_SCRIPT = `set -euo pipefail\npython3 - ${mode} ${digest} <<'DEV_IMAGE_RETENTION_PY'\n${source}\nDEV_IMAGE_RETENTION_PY\n`;
58+
require('./scripts/dev_ci_ssh.cjs');
59+
NODE

‎scripts/dev_image_retention.py‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""Plan or remove old, reproducible DEV API images; never prune Docker globally."""
2+
3+
import hashlib
4+
import json
5+
import os
6+
import re
7+
import subprocess
8+
import sys
9+
from datetime import datetime, timedelta, timezone
10+
11+
TAG = re.compile(r"procollab-dev-api:([0-9a-f]{40})(?:-[0-9a-f]{12})?$")
12+
REPO = "/root/api"
13+
14+
15+
def command(*args):
16+
return subprocess.check_output(args, text=True).strip()
17+
18+
19+
def require(condition, message):
20+
if not condition:
21+
raise RuntimeError(message)
22+
23+
24+
def output(message):
25+
sys.stdout.write(message + "\n")
26+
sys.stdout.flush()
27+
28+
29+
def digest(value):
30+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
31+
32+
33+
def inventory():
34+
result = {}
35+
for image_id in set(command("docker", "image", "ls", "-aq", "--no-trunc").split()):
36+
value = json.loads(
37+
command(
38+
"docker",
39+
"image",
40+
"inspect",
41+
"--format",
42+
'{"id":{{json .Id}},"tags":{{json .RepoTags}},"created":{{json .Created}}}',
43+
image_id,
44+
)
45+
)
46+
value["tags"] = sorted(value["tags"] or [])
47+
result[image_id] = value
48+
return result
49+
50+
51+
def containers():
52+
result = []
53+
for container_id in command("docker", "ps", "-aq", "--no-trunc").split():
54+
result.append(
55+
json.loads(
56+
command(
57+
"docker",
58+
"inspect",
59+
"--format",
60+
'{"id":{{json .Id}},"image":{{json .Image}},"state":{{json .State.Status}},'
61+
'"started":{{json .State.StartedAt}},"restarts":{{json .RestartCount}}}',
62+
container_id,
63+
)
64+
)
65+
)
66+
return sorted(result, key=lambda value: value["id"])
67+
68+
69+
def volumes():
70+
return sorted(
71+
command(
72+
"docker", "volume", "ls", "--format", "{{.Driver}} {{.Name}}"
73+
).splitlines()
74+
)
75+
76+
77+
def created_at(image):
78+
value = re.sub(r"(\.\d{6})\d+", r"\1", image["created"])
79+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
80+
81+
82+
def plan(images, container_rows, volume_rows, now, reachable):
83+
used = {row["image"] for row in container_rows}
84+
reproducible = []
85+
for image in images.values():
86+
tags = image["tags"]
87+
matches = [TAG.fullmatch(tag) for tag in tags]
88+
if tags and all(matches) and all(reachable(match[1]) for match in matches):
89+
reproducible.append(image)
90+
newest = {
91+
image["id"]
92+
for image in sorted(
93+
reproducible, key=lambda row: (created_at(row), row["id"]), reverse=True
94+
)[:3]
95+
}
96+
candidates = sorted(
97+
(
98+
image
99+
for image in reproducible
100+
if image["id"] not in used | newest
101+
and created_at(image) < now - timedelta(hours=48)
102+
),
103+
key=lambda image: image["id"],
104+
)
105+
proof = {
106+
"images": images,
107+
"containers": container_rows,
108+
"volumes": volume_rows,
109+
"candidates": candidates,
110+
}
111+
return {
112+
"sha256": digest(proof),
113+
"candidates": candidates,
114+
"protected_count": len(images) - len(candidates),
115+
}
116+
117+
118+
def apply(plan_value, expected, images_before, containers_before, volumes_before):
119+
if plan_value["sha256"] != expected:
120+
raise RuntimeError("Image inventory changed; inspect a new plan before cleanup")
121+
remaining = dict(images_before)
122+
for image in plan_value["candidates"]:
123+
if (
124+
containers() != containers_before
125+
or volumes() != volumes_before
126+
or inventory() != remaining
127+
):
128+
raise RuntimeError("Docker state changed; no further images will be removed")
129+
for tag in image["tags"]:
130+
# Non-force removal preserves images referenced by any container.
131+
# --no-prune also prevents removal of untagged parent images.
132+
subprocess.run(["docker", "image", "rm", "--no-prune", tag], check=True)
133+
del remaining[image["id"]]
134+
if (
135+
inventory() != remaining
136+
or containers() != containers_before
137+
or volumes() != volumes_before
138+
):
139+
raise RuntimeError("Docker state differs from the reviewed removal plan")
140+
output(
141+
"PROTECTED_IMAGES_UNCHANGED=true\nCONTAINERS_UNCHANGED=true\nVOLUMES_UNCHANGED=true"
142+
)
143+
144+
145+
def main():
146+
mode, expected = sys.argv[1:]
147+
require(mode in ("plan", "apply"), "Invalid operation")
148+
require(
149+
mode == "plan" or re.fullmatch(r"[0-9a-f]{64}", expected), "Inspect a plan first"
150+
)
151+
require(
152+
os.geteuid() == 0 and os.path.realpath(REPO) == REPO,
153+
"Unexpected host or repository",
154+
)
155+
command(
156+
"git",
157+
"-C",
158+
REPO,
159+
"merge-base",
160+
"--is-ancestor",
161+
"beef71645567128398d0afa5a2eb80dc6bc8dac7",
162+
"HEAD",
163+
)
164+
command(
165+
"grep",
166+
"-Eq",
167+
r"server_name[[:space:]]+dev\.procollab\.ru;",
168+
"/etc/nginx/sites-enabled/dev.procollab.ru",
169+
)
170+
runtime = []
171+
for service in ("web", "celerys"):
172+
container_id = command(
173+
"docker",
174+
"ps",
175+
"-q",
176+
"--no-trunc",
177+
"--filter",
178+
f"label=com.docker.compose.project.working_dir={REPO}",
179+
"--filter",
180+
f"label=com.docker.compose.service={service}",
181+
)
182+
require(
183+
re.fullmatch(r"[0-9a-f]{64}", container_id),
184+
"Expected one running DEV service",
185+
)
186+
reference = command("docker", "inspect", "-f", "{{.Config.Image}}", container_id)
187+
require(TAG.fullmatch(reference), "Unexpected DEV image reference")
188+
runtime.append(command("docker", "inspect", "-f", "{{.Image}}", container_id))
189+
require(len(set(runtime)) == 1, "DEV web and worker image mismatch")
190+
images_before, containers_before, volumes_before = (
191+
inventory(),
192+
containers(),
193+
volumes(),
194+
)
195+
196+
def reachable(sha):
197+
return (
198+
subprocess.run(
199+
["git", "-C", REPO, "merge-base", "--is-ancestor", sha, "HEAD"],
200+
stdout=subprocess.DEVNULL,
201+
stderr=subprocess.DEVNULL,
202+
).returncode
203+
== 0
204+
)
205+
206+
value = plan(
207+
images_before,
208+
containers_before,
209+
volumes_before,
210+
datetime.now(timezone.utc),
211+
reachable,
212+
)
213+
output("DEV_IMAGE_RETENTION_PLAN=" + json.dumps(value, sort_keys=True))
214+
output(command("df", "-h", "/"))
215+
if mode == "apply":
216+
apply(value, expected, images_before, containers_before, volumes_before)
217+
output(command("df", "-h", "/"))
218+
219+
220+
if __name__ == "__main__":
221+
main()

‎tests/test_dev_image_retention.py‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import unittest
2+
from datetime import datetime, timedelta, timezone
3+
from unittest.mock import patch
4+
5+
from scripts import dev_image_retention as service
6+
7+
8+
class DevImageRetentionTests(unittest.TestCase):
9+
now = datetime(2026, 10, 25, tzinfo=timezone.utc)
10+
11+
def image(self, number, age):
12+
return {
13+
"id": f"sha256:{number:064x}",
14+
"tags": [f"procollab-dev-api:{number:040x}"],
15+
"created": (self.now - timedelta(days=age)).isoformat(),
16+
}
17+
18+
def test_plan_keeps_all_container_images_newest_three_and_unknown_images(self):
19+
rows = [self.image(number, number) for number in range(1, 7)]
20+
foreign = self.image(7, 9)
21+
foreign["tags"] = ["other-project:old"]
22+
mixed = self.image(8, 9)
23+
mixed["tags"].append("api-web:latest")
24+
dangling = self.image(9, 9)
25+
dangling["tags"] = []
26+
unreachable = self.image(10, 9)
27+
images = {
28+
row["id"]: row for row in rows + [foreign, mixed, dangling, unreachable]
29+
}
30+
containers = [
31+
{"id": "stopped-container", "image": rows[4]["id"], "state": "exited"}
32+
]
33+
plan = service.plan(
34+
images,
35+
containers,
36+
["volume"],
37+
self.now,
38+
lambda sha: sha != f"{10:040x}",
39+
)
40+
self.assertEqual(
41+
{row["id"] for row in plan["candidates"]}, {rows[3]["id"], rows[5]["id"]}
42+
)
43+
self.assertEqual(plan["protected_count"], 8)
44+
# Time alone does not change the inventory digest while candidates stay identical.
45+
self.assertEqual(
46+
plan["sha256"],
47+
service.plan(
48+
images,
49+
containers,
50+
["volume"],
51+
self.now + timedelta(minutes=1),
52+
lambda sha: sha != f"{10:040x}",
53+
)["sha256"],
54+
)
55+
56+
def test_young_images_are_not_removed_even_when_more_than_three_exist(self):
57+
images = {
58+
row["id"]: row
59+
for row in [self.image(number, 0.1 * number) for number in range(1, 7)]
60+
}
61+
self.assertEqual(
62+
service.plan(images, [], [], self.now, lambda sha: True)["candidates"], []
63+
)
64+
65+
def test_nanosecond_docker_timestamp_is_supported(self):
66+
self.assertEqual(
67+
service.created_at({"created": "2026-10-25T00:00:00.123456789Z"}).microsecond,
68+
123456,
69+
)
70+
71+
@patch.object(service.subprocess, "run")
72+
def test_wrong_review_digest_never_mutates_docker(self, run):
73+
with self.assertRaises(RuntimeError):
74+
service.apply({"sha256": "reviewed", "candidates": []}, "changed", {}, [], [])
75+
run.assert_not_called()
76+
77+
@patch.object(service.subprocess, "run")
78+
@patch.object(service, "containers", return_value=[{"image": "new-container"}])
79+
def test_new_container_aborts_before_removing_any_tag(self, containers, run):
80+
image = self.image(1, 9)
81+
with self.assertRaises(RuntimeError):
82+
service.apply(
83+
{"sha256": "same", "candidates": [image]},
84+
"same",
85+
{image["id"]: image},
86+
[],
87+
[],
88+
)
89+
run.assert_not_called()
90+
91+
@patch.object(service, "containers", return_value=[])
92+
@patch.object(service, "volumes", return_value=["volume"])
93+
@patch.object(service, "inventory")
94+
@patch.object(service.subprocess, "run")
95+
def test_removes_only_reviewed_tags_without_force_or_parent_pruning(
96+
self, run, inventory, volumes, containers
97+
):
98+
image, protected = self.image(1, 9), self.image(2, 1)
99+
image["tags"].append(image["tags"][0] + "-0123456789ab")
100+
before = {row["id"]: row for row in (image, protected)}
101+
inventory.side_effect = [before, {protected["id"]: protected}]
102+
service.apply(
103+
{"sha256": "same", "candidates": [image]}, "same", before, [], ["volume"]
104+
)
105+
self.assertEqual(run.call_count, 2)
106+
for call, tag in zip(run.call_args_list, image["tags"]):
107+
self.assertEqual(call.args, (["docker", "image", "rm", "--no-prune", tag],))
108+
self.assertEqual(call.kwargs, {"check": True})
109+
110+
111+
if __name__ == "__main__":
112+
unittest.main()

0 commit comments

Comments
 (0)