feat(compose): recreate the stack network without losing volumes or worktree stacks

When the IPAM config of a compose project's network changes (#999 added
`ip_range`), compose recreates the network on the next `up`. With a
foreign endpoint attached, as every worktree stack is on the primary's
network, compose first stops the project's own containers, then fails
with `error while removing network: ... has active endpoints`, and a
repeated `up` or `up --force-recreate` does not recover. On 2026-10-19
this took the shared registry/package cache and coredns down for every
local session.

`make compose-network-recreate` (alias `i8conere`) runs the sequence that
recovered it: detach every running container of another project from the
network, `docker compose down --remove-orphans` without `-v` so the
caches keep their volumes, the normal `up`, then attach the detached
containers again, which draw addresses from the dynamic pool. If `up`
fails, the still-detached containers are listed instead of reattached.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-20 13:45:37 +02:00
1 parent 9fd7b87b5f
commit 310663ae8c
5 files changed
+191

No files matched your search

+5
View File
@@ -227,6 +227,11 @@ compose-inner-run:
compose-inventory-refresh:
@bash scripts/tests/deploy/local/reset/inventory.sh
.PHONY: compose-network-recreate
# Recreate the stack's default network after an IPAM change, keeping volumes and reattaching foreign stacks.
compose-network-recreate: install
@"$${PYTHON}" -m cli.administration.deploy.development recreate
.PHONY: compose-playwright
# Rerun a role-local Playwright spec against the live running stack (no redeploy).
# Usage: make compose-playwright role=<role> [pw="--grep <pattern>"] [keep=true]
+1
View File
@@ -27,6 +27,7 @@ alias i8co2='m console'
alias i8coape='m compose-app-exec'
alias i8codo='m compose-down'
alias i8cone='m compose-entity-purge'
alias i8conere='m compose-network-recreate'
alias i8coni='m compose-inner-run'
alias i8conire='m compose-inventory-refresh'
alias i8copa='m compose-playwright'
@@ -53,6 +53,7 @@ def _build_parser(base_pkg: str) -> argparse.ArgumentParser:
"run",
"exec",
"logs",
"recreate",
]
for _, mod in _iter_command_modules(base_pkg, command_names):
@@ -0,0 +1,74 @@
from __future__ import annotations
import argparse
import json
import subprocess
from .common import make_compose
from .up import handler as up_handler
def foreign_endpoints(rows: str, project: str) -> list[str]:
"""Return the running containers of other projects on the network.
Args:
rows: ``docker ps`` lines of ``<name>\\t<compose project>``.
project: compose project that owns the network.
"""
names = []
for row in rows.splitlines():
name, _, owner = row.partition("\t")
if name and owner != project:
names.append(name)
return names
def _docker(*args: str, capture: bool = False) -> str:
result = subprocess.run(
["docker", *args], check=True, capture_output=capture, text=True
)
return result.stdout if capture else ""
def add_parser(sub: argparse._SubParsersAction) -> None:
p = sub.add_parser(
"recreate",
help="Recreate the default network, keeping volumes and foreign stacks.",
)
p.set_defaults(_handler=handler)
def handler(args: argparse.Namespace) -> int:
compose = make_compose()
rendered = compose.run(["config", "--format", "json"], capture=True).stdout
config = json.loads(rendered)
project = config["name"]
network = config["networks"]["default"]["name"]
rows = _docker(
"ps",
"--filter",
f"network={network}",
"--format",
'{{.Names}}\t{{.Label "com.docker.compose.project"}}',
capture=True,
)
detached = foreign_endpoints(rows, project)
for name in detached:
print(f">>> Detaching {name} from {network}")
_docker("network", "disconnect", network, name)
rc = 1
try:
compose.run(["down", "--remove-orphans"])
rc = up_handler(argparse.Namespace(skip_entry_init=False, when_down=False))
finally:
if rc != 0 and detached:
print(f">>> Still detached from {network}: {' '.join(detached)}")
if rc != 0:
return rc
for name in detached:
print(f">>> Attaching {name} to {network}")
_docker("network", "connect", network, name)
return 0
@@ -0,0 +1,110 @@
from __future__ import annotations
import argparse
import json
import subprocess
import unittest
from unittest.mock import MagicMock, patch
from cli.administration.deploy.development import recreate
_CONFIG = json.dumps(
{"name": "infinito-nexus-core", "networks": {"default": {"name": "core_default"}}}
)
_ROWS = (
"infinito_nexus_debian\tinfinito-nexus-core\n"
"infinito_nexus_fix_a\tfix-a\n"
"infinito-coredns\tinfinito-nexus-core\n"
"infinito_nexus_feature_b\tfeature-b\n"
)
class TestForeignEndpoints(unittest.TestCase):
def test_keeps_only_other_projects(self) -> None:
self.assertEqual(
["infinito_nexus_fix_a", "infinito_nexus_feature_b"],
recreate.foreign_endpoints(_ROWS, "infinito-nexus-core"),
)
class TestHandler(unittest.TestCase):
def _run(self, up_rc: int) -> tuple[int, list[tuple[str, ...]]]:
calls: list[tuple[str, ...]] = []
compose = MagicMock()
def compose_run(args, **_kwargs):
calls.append(("compose", *args))
return subprocess.CompletedProcess(args, 0, stdout=_CONFIG)
def docker(*args, capture=False):
calls.append(("docker", *args))
return _ROWS if capture else ""
def up(_args):
calls.append(("up",))
return up_rc
compose.run.side_effect = compose_run
with (
patch.object(recreate, "make_compose", return_value=compose),
patch.object(recreate, "_docker", side_effect=docker),
patch.object(recreate, "up_handler", side_effect=up),
):
rc = recreate.handler(argparse.Namespace())
return rc, calls
def test_detaches_recreates_without_volumes_and_reattaches(self) -> None:
rc, calls = self._run(up_rc=0)
self.assertEqual(0, rc)
self.assertEqual(
[
(
"docker",
"network",
"disconnect",
"core_default",
"infinito_nexus_fix_a",
),
(
"docker",
"network",
"disconnect",
"core_default",
"infinito_nexus_feature_b",
),
("compose", "down", "--remove-orphans"),
("up",),
(
"docker",
"network",
"connect",
"core_default",
"infinito_nexus_fix_a",
),
(
"docker",
"network",
"connect",
"core_default",
"infinito_nexus_feature_b",
),
],
[
c
for c in calls
if c[:2] != ("compose", "config") and c[:2] != ("docker", "ps")
],
)
def test_failed_up_leaves_foreign_stacks_detached_and_reports(self) -> None:
rc, calls = self._run(up_rc=3)
self.assertEqual(3, rc)
self.assertNotIn(
"connect", [c[2] for c in calls if c[:2] == ("docker", "network")]
)
if __name__ == "__main__":
unittest.main()