Every compose invocation used -p decnet so fleet + every topology lived in one docker compose project. --remove-orphans, run during fleet pre-up cleanup and on every topology teardown / rollback, then swept every container in the project not listed in the current compose file — wiping sibling topologies and the flat fleet along with the intended target. Parameterize project on _compose / _compose_with_retry / _compose_ps (default FLEET_COMPOSE_PROJECT="decnet"). Add _topology_compose_project that returns decnet-topo-<id8>, and pass it through every topology compose call site (master deploy_topology + rollback + post-deploy ps, master teardown_topology, agent apply, agent teardown, all four live service mutations on topology deckies). Fleet calls keep the default and are unaffected. Migration: live containers from before this fix remain in the shared "decnet" project and need a one-time manual cleanup before they're reachable to the new topology code paths.
123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""Each topology runs under its own docker compose project.
|
|
|
|
The shared ``-p decnet`` project meant that ``--remove-orphans`` on
|
|
either a fleet redeploy or a topology teardown swept every container in
|
|
the project — wiping sibling topologies and the flat fleet along with
|
|
the intended target. Each topology now gets ``decnet-topo-<id8>`` so
|
|
the orphan sweep is scoped to that one topology.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from decnet.engine.deployer import (
|
|
_compose,
|
|
_compose_with_retry,
|
|
_topology_compose_project,
|
|
FLEET_COMPOSE_PROJECT,
|
|
)
|
|
|
|
|
|
def test_topology_project_name_is_per_topology():
|
|
p1 = _topology_compose_project("abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")
|
|
p2 = _topology_compose_project("cafef00d-1111-2222-3333-444444444444")
|
|
assert p1 == "decnet-topo-abcdef12"
|
|
assert p2 == "decnet-topo-cafef00d"
|
|
assert p1 != FLEET_COMPOSE_PROJECT
|
|
assert p2 != FLEET_COMPOSE_PROJECT
|
|
assert p1 != p2
|
|
|
|
|
|
def _run_compose_capturing_cmd(**kwargs):
|
|
"""Invoke _compose with subprocess.run mocked and return the argv."""
|
|
fake = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
with patch("decnet.engine.deployer.subprocess.run", return_value=fake) as mr:
|
|
_compose("down", **kwargs)
|
|
assert mr.called
|
|
return list(mr.call_args[0][0])
|
|
|
|
|
|
def test_compose_defaults_to_fleet_project():
|
|
cmd = _run_compose_capturing_cmd()
|
|
assert "-p" in cmd
|
|
assert cmd[cmd.index("-p") + 1] == FLEET_COMPOSE_PROJECT
|
|
|
|
|
|
def test_compose_accepts_topology_project():
|
|
project = _topology_compose_project("deadbeef-0000-0000-0000-000000000000")
|
|
cmd = _run_compose_capturing_cmd(project=project)
|
|
assert "-p" in cmd
|
|
assert cmd[cmd.index("-p") + 1] == project
|
|
assert cmd[cmd.index("-p") + 1] != FLEET_COMPOSE_PROJECT
|
|
|
|
|
|
def test_compose_with_retry_uses_passed_project():
|
|
project = _topology_compose_project("feedface-0000-0000-0000-000000000000")
|
|
fake = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
with patch("decnet.engine.deployer.subprocess.run", return_value=fake) as mr:
|
|
_compose_with_retry("up", "-d", project=project)
|
|
cmd = list(mr.call_args[0][0])
|
|
assert "-p" in cmd
|
|
assert cmd[cmd.index("-p") + 1] == project
|
|
|
|
|
|
@pytest.fixture
|
|
async def repo(tmp_path):
|
|
from decnet.web.db.factory import get_repository
|
|
r = get_repository(db_path=str(tmp_path / "iso.db"))
|
|
await r.initialize()
|
|
return r
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_teardown_topology_uses_per_topo_project(repo, tmp_path, monkeypatch):
|
|
"""The real teardown path must pass the per-topology project so the
|
|
fleet (-p decnet) is untouched by the orphan sweep."""
|
|
from decnet.engine.deployer import teardown_topology
|
|
from decnet.topology.generator import generate
|
|
from decnet.topology.persistence import persist, transition_status
|
|
from decnet.topology.status import TopologyStatus
|
|
from decnet.topology.config import TopologyConfig
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
plan = generate(TopologyConfig(
|
|
name="iso", depth=2, branching_factor=2,
|
|
deckies_per_lan_min=1, deckies_per_lan_max=1,
|
|
cross_edge_probability=0.0, randomize_services=False,
|
|
services_explicit=["ssh"], seed=7,
|
|
))
|
|
tid = await persist(repo, plan)
|
|
await transition_status(repo, tid, TopologyStatus.DEPLOYING)
|
|
await transition_status(repo, tid, TopologyStatus.ACTIVE)
|
|
|
|
# Drop a compose file so teardown's `if compose_path.exists()` branch
|
|
# fires and we capture the project argument.
|
|
from decnet.engine.deployer import _topology_compose_path
|
|
compose_path = _topology_compose_path(tid)
|
|
compose_path.write_text("services: {}\n")
|
|
|
|
expected_project = _topology_compose_project(tid)
|
|
|
|
class _StubClient:
|
|
def __init__(self):
|
|
self.networks = self
|
|
def list(self, names=None, filters=None): # noqa: ARG002
|
|
return []
|
|
|
|
captured_projects: list[str] = []
|
|
|
|
def _fake_compose(*args, compose_file=None, env=None, project=FLEET_COMPOSE_PROJECT): # noqa: ARG001
|
|
captured_projects.append(project)
|
|
|
|
with patch("decnet.engine.deployer.docker.from_env", return_value=_StubClient()):
|
|
with patch("decnet.engine.deployer._compose", side_effect=_fake_compose):
|
|
await teardown_topology(repo, tid)
|
|
|
|
assert captured_projects, "teardown should have invoked compose"
|
|
assert all(p == expected_project for p in captured_projects), (
|
|
f"teardown leaked into another project: {captured_projects}"
|
|
)
|