mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user