diff --git a/docs/agents/action/iteration/roundtrip.md b/docs/agents/action/iteration/roundtrip.md index aad94d9b78..f88464b74a 100644 --- a/docs/agents/action/iteration/roundtrip.md +++ b/docs/agents/action/iteration/roundtrip.md @@ -14,7 +14,7 @@ For debugging the compose deploy of one role, see [Compose Loop](compose.md); fo - With no `apps=`, the loop defaults to **every application, most-complex first** (the `complexity` CLI: `infinito meta roles applications complexity --sort "desc weight" --format string`). That is a large run (one compose plus one swarm deploy per app), so narrow it with `apps=` while iterating. - The mode sequence defaults to `compose swarm` and is overridable with `modes="compose swarm"`. The order is always compose first, so the cheaper mode fails fast before the expensive swarm rebuild. Append `k8s` here once that mode exists. - Per step the output is streamed to `${TMPDIR:-/tmp}/roundtrip--.log`. Tell the operator the exact `tail -f` path for the running step. -- The compose step runs `compose-deploy mode=reinstall apps= full_cycle=true variant=0`; the swarm step runs `swarm-zombie app=`, pinned to `variant:0` and, whenever the role can take it, to the `multi` network mode, so a multi-variant app validates one cluster (parity with the compose step, not the full per-variant CI matrix that runs one isolated runner per variant). The cluster is named `-swarm-mgr-01` etc. via the mandatory `SWARM_NAME` (the app id is the default cluster id). Each validated swarm cluster is released afterwards unless you pass `keep=true`. +- The compose step runs `compose-deploy mode=reinstall apps= full_cycle=true variant=0`; the swarm step runs `swarm-zombie app=`, pinned to `variant:0` and to a network mode: `clearnet` when `disable=` holds the `tor` key, else `multi` whenever the role can take it, so a multi-variant app validates one cluster (parity with the compose step, not the full per-variant CI matrix that runs one isolated runner per variant). The cluster is named `-swarm-mgr-01` etc. via the mandatory `SWARM_NAME` (the app id is the default cluster id). Each validated swarm cluster is released afterwards unless you pass `keep=true`. - `make autoformat` and `make test` MUST be green before a roundtrip; the loop does not re-run them per app. - **A blocked mode is NEVER a reason to silently switch to another.** If a mode cannot run (the swarm NFS wedge below, a wedged daemon, a missing host capability), STOP the whole sweep, report the blocker together with the exact recovery command, and wait for it to be cleared. You MUST NOT fall back to a `modes=compose`-only (or any reduced) sweep to look productive: it hides the unmet swarm parity and never satisfies the compose-AND-swarm gate. Resume the full sweep only once the blocker is gone. diff --git a/scripts/tests/deploy/roundtrip.sh b/scripts/tests/deploy/roundtrip.sh index b969b88212..99f281d247 100755 --- a/scripts/tests/deploy/roundtrip.sh +++ b/scripts/tests/deploy/roundtrip.sh @@ -7,7 +7,8 @@ set -euo pipefail # (complexity clone == false), most complex first, when unset # modes - mode sequence (default "compose swarm"; append "k8s" here once it exists) # keep - true keeps each validated swarm cluster instead of releasing it -# disable - comma-separated provider keys removed from the test inventory +# disable - comma-separated provider keys removed from the test inventory; +# the Tor key pins the swarm step to clearnet _repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" # shellcheck source=scripts/meta/env/load.sh @@ -28,6 +29,10 @@ _log_dir="${TMPDIR:-/tmp}" # nocheck: posix TMPDIR convention read -ra _apps <<<"${apps//$'\n'/ }" read -ra _modes <<<"$modes" +_tor_key="$("${PYTHON}" -c "import sys; sys.path.insert(0, '${_repo_root}'); from utils.github.variant.network import TOR_SERVICE; print(TOR_SERVICE)")" +_disabled=",${disable:-}," +_disabled="${_disabled//[[:space:]]/,}" + for app in "${_apps[@]}"; do app_skip="$("${PYTHON}" -c "import sys; sys.path.insert(0, '${_repo_root}'); from utils.roles.meta_lookup import get_role_skip; print(' '.join(get_role_skip('${app}')))")" for mode in "${_modes[@]}"; do @@ -45,7 +50,9 @@ for app in "${_apps[@]}"; do ;; swarm) network="" - if "${PYTHON}" -m cli.meta.ci.validate --modes swarm --whitelist "${app}#0@swarm+multi" >/dev/null 2>&1; then + if [[ "${_disabled}" == *",${_tor_key},"* ]]; then + network="clearnet" + elif "${PYTHON}" -m cli.meta.ci.validate --modes swarm --whitelist "${app}#0@swarm+multi" >/dev/null 2>&1; then network="multi" fi echo "==> roundtrip: ${app} [swarm] network=${network:-rotation}" diff --git a/scripts/tests/workspace/swarm/02_zombie.sh b/scripts/tests/workspace/swarm/02_zombie.sh index 5f81f90936..a49399be39 100755 --- a/scripts/tests/workspace/swarm/02_zombie.sh +++ b/scripts/tests/workspace/swarm/02_zombie.sh @@ -15,7 +15,7 @@ docker image inspect "${node_image}" >/dev/null 2>&1 || { } echo "Deploying the MariaDB database role ${MARIADB_APP} on a throwaway swarm cluster." -make swarm-zombie app="${MARIADB_APP}" disable="$(variable_services mariadb node nfs-server)" +make swarm-zombie app="${MARIADB_APP}" disable="$(variable_services mariadb node nfs-server)" network=clearnet echo "Releasing the swarm cluster." make swarm-down name="${MARIADB_APP}" diff --git a/tests/unit/python/scripts/tests/deploy/test_roundtrip.py b/tests/unit/python/scripts/tests/deploy/test_roundtrip.py index 9e1ae703c5..5012055a33 100644 --- a/tests/unit/python/scripts/tests/deploy/test_roundtrip.py +++ b/tests/unit/python/scripts/tests/deploy/test_roundtrip.py @@ -1,9 +1,14 @@ -"""Contract between the swarm drill's completion marker and the roundtrip gate.""" +"""The roundtrip gate: its drill-completion contract and the network its swarm step pins.""" from __future__ import annotations +import os import re +import subprocess +import sys +import tempfile import unittest +from pathlib import Path from utils.cache.files import PROJECT_ROOT, read_text @@ -12,6 +17,73 @@ DRILL = ( ) GATE = PROJECT_ROOT / "scripts" / "tests" / "deploy" / "roundtrip.sh" MARKER = "==> swarm drill complete: app=" +APP = "svc-db-postgres" +PYTHON_STUB = ( + "#!/usr/bin/env bash\n" + 'if [ "$1" = -m ]; then exit "${STUB_MULTI_RC}"; fi\n' + 'exec "${REAL_PYTHON}" "$@"\n' +) +MAKE_STUB = ( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >>"${STUB_LOG}"\n' + 'echo "==> swarm drill complete: app=${STUB_APP}"\n' +) + + +def swarm_network(disable: str, *, multi_accepted: bool) -> str: + """Run the roundtrip's swarm step against stubs and return the network it pins. + + Args: + disable: the ``disable`` env the roundtrip runs with. + multi_accepted: whether the stubbed validator accepts the role's + ``multi`` row. + + Returns: + The ``network=`` value the roundtrip hands to ``swarm-zombie``. + """ + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "repo" + script = root / "scripts" / "tests" / "deploy" / "roundtrip.sh" + script.parent.mkdir(parents=True) + script.symlink_to(GATE) + loader = root / "scripts" / "meta" / "env" / "load.sh" + loader.parent.mkdir(parents=True) + loader.write_text("") + bin_dir = Path(tmp) / "bin" + bin_dir.mkdir() + for name, body in (("python", PYTHON_STUB), ("make", MAKE_STUB)): + stub = bin_dir / name + stub.write_text(body) + stub.chmod(0o755) + log = Path(tmp) / "make.log" + log.touch() + env = {k: v for k, v in os.environ.items() if k != "BASH_ENV"} + env.update( + PATH=f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + PYTHON=str(bin_dir / "python"), + REAL_PYTHON=sys.executable, + PYTHONPATH=str(PROJECT_ROOT), + TMPDIR=tmp, + STUB_LOG=str(log), + STUB_APP=APP, + STUB_MULTI_RC="0" if multi_accepted else "1", + apps=APP, + modes="swarm", + keep="true", + disable=disable, + ) + done = subprocess.run( + ["bash", str(script)], + env=env, + capture_output=True, + text=True, + check=False, + ) + if done.returncode != 0: + raise AssertionError(f"roundtrip failed: {done.stderr}") + calls = log.read_text().splitlines() # nocheck: cache-read stub call log + zombie = next(call for call in calls if " swarm-zombie " in call) + return re.search(r"(?:^| )network=(\S*)", zombie).group(1) class TestRoundtripMarker(unittest.TestCase): @@ -44,5 +116,29 @@ class TestRoundtripMarker(unittest.TestCase): self.assertNotIn("provision/deploy/e2e/verify per round", self.gate) +class TestRoundtripSwarmNetwork(unittest.TestCase): + def test_a_role_that_takes_multi_is_pinned_to_multi(self) -> None: + self.assertEqual( + swarm_network("matomo,dashboard", multi_accepted=True), "multi" + ) + + def test_a_role_refused_multi_keeps_the_rotation(self) -> None: + self.assertEqual(swarm_network("", multi_accepted=False), "") + + def test_a_disabled_tor_provider_pins_clearnet(self) -> None: + self.assertEqual( + swarm_network("matomo,tor,zammad", multi_accepted=True), "clearnet" + ) + + def test_a_disabled_tor_provider_pins_clearnet_over_the_rotation(self) -> None: + self.assertEqual(swarm_network("tor", multi_accepted=False), "clearnet") + + def test_a_space_separated_tor_key_pins_clearnet(self) -> None: + self.assertEqual(swarm_network("matomo tor", multi_accepted=True), "clearnet") + + def test_a_key_that_only_starts_with_tor_keeps_multi(self) -> None: + self.assertEqual(swarm_network("tor-smtp", multi_accepted=True), "multi") + + if __name__ == "__main__": unittest.main()