fix(swarm): roll services whose locally built image changed under the same tag

Swarm deploys with `docker stack deploy --resolve-image never`, so a task
only rolls when its spec changes. Since fdf1a33c5f BookWyrm, Decidim and
the Bluesky broker build under a constant tag (bookwyrm_custom:3.11-slim
and friends): a ref bump rebuilds the image through the build-args digest
and pushes it, but the image string in compose.yml stays the same and the
running tasks keep the old image.

registry_sync now stamps every service that runs a locally built image,
siblings sharing the tag included, with the container label
`infinito.image-id=<local image ID>`. A rebuild changes the ID, the label
changes the ContainerSpec, and swarm rolls the tasks; an unchanged image
leaves the label and the deploy untouched. List and mapping label forms
are both kept intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-28 15:36:35 +01:00
1 parent fdf1a33c5f
commit 320929e17c
2 files changed
+81

No files matched your search

@@ -41,6 +41,33 @@ def local_image_id(image: str) -> str:
return proc.stdout.strip() if proc.returncode == 0 else ""
IMAGE_ID_LABEL = "infinito.image-id"
def _label_image_id(svc: dict, image_id: str) -> bool:
"""Pin the local image ID as a container label; True when it changed.
Args:
svc: one compose service, mutated in place.
image_id: ID of the locally built image the service runs.
"""
labels = svc.get("labels")
if isinstance(labels, list):
entry = f"{IMAGE_ID_LABEL}={image_id}"
if entry in labels:
return False
svc["labels"] = [
*(item for item in labels if not str(item).startswith(f"{IMAGE_ID_LABEL}=")),
entry,
]
return True
current = labels if isinstance(labels, dict) else {}
if current.get(IMAGE_ID_LABEL) == image_id:
return False
svc["labels"] = {**current, IMAGE_ID_LABEL: image_id}
return True
def sync(*, compose_file: Path, prefix: str) -> int:
with compose_file.open() as f:
doc = yaml.safe_load(f) # nocheck: direct-yaml
@@ -106,6 +133,15 @@ def sync(*, compose_file: Path, prefix: str) -> int:
raise RuntimeError(f"docker tag {image} {new_image} failed (rc={rc})")
pulled.append((image, new_image))
for svc in services.values():
if not isinstance(svc, dict) or not isinstance(svc.get("image"), str):
continue
if svc["image"].removeprefix(prefix) not in locally_built:
continue
image_id = local_image_id(svc["image"])
if image_id and _label_image_id(svc, image_id):
changed = True
if changed:
with compose_file.open("w") as f:
yaml.safe_dump(
@@ -60,6 +60,51 @@ class TestSwarmRegistrySync(unittest.TestCase):
self.assertEqual(rc, 0)
return calls
def _synced_services(self, services: dict, image_id: str) -> dict:
with tempfile.TemporaryDirectory() as td:
cf = Path(td) / "compose.yml"
dump_yaml(cf, {"services": services})
with (
patch.object(self.m, "run", return_value=0),
patch.object(self.m, "manifest_exists", return_value=False),
patch.object(self.m, "local_image_id", return_value=image_id),
):
self.m.sync(compose_file=cf, prefix=PREFIX)
return self.m.yaml.safe_load(cf.read_text())["services"]
def test_a_built_image_and_its_siblings_carry_the_local_image_id(self) -> None:
services = self._synced_services(
{
"bookwyrm": {"build": {"context": "."}, "image": "bookwyrm_custom:3.11-slim"},
"worker": {"image": "bookwyrm_custom:3.11-slim"},
"redis": {"image": "redis:8"},
},
image_id="sha256:rebuilt",
)
self.assertEqual(
services["bookwyrm"]["labels"], {"infinito.image-id": "sha256:rebuilt"}
)
self.assertEqual(
services["worker"]["labels"], {"infinito.image-id": "sha256:rebuilt"},
"a sibling running the same tag must roll with the rebuilt image too",
)
self.assertNotIn("labels", services["redis"])
def test_a_rebuild_under_the_same_tag_changes_the_label(self) -> None:
services = self._synced_services(
{
"pretix": {
"build": {"context": "."},
"image": "pretix_custom:3.13",
"labels": ["keep=1", "infinito.image-id=sha256:old"],
},
},
image_id="sha256:new",
)
self.assertEqual(
services["pretix"]["labels"], ["keep=1", "infinito.image-id=sha256:new"]
)
def test_sibling_of_local_build_is_not_pulled(self) -> None:
"""daemon/websocket reference espocrm's already-prefixed build image
without their own build:; that local-only ref must NOT be pulled from