mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
1 parent
9fd7b87b5f
commit
310663ae8c
5 files changed
+191
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user