Skip to content

Commit 4fc4523

Browse files
committed
fix(ops): stop VMs before recreating them, with the real grace period
recreate-vm.sh recreates with `docker compose up -d --force-recreate`, whose implicit stop uses compose's own --timeout -- 10 SECONDS by default -- rather than the service's declared stop_grace_period. A dockurr/windows guest cannot shut down in ten seconds, so compose stops waiting and goes straight to removing a container that is still running: Error response from daemon: cannot remove container "1d5e3c2f...": container is running: stop the container before removing or force remove The script then exits 1 and the VM is left unhealthy with its network_mode sidecar stranded on a dead netns -- the exact outcome recreate-vm.sh exists to prevent. It is timing-dependent, which is why it can look fine for a while. On the deployment where this was found the script recreated two VMs successfully three times inside one hour, then failed on the fourth attempt when the guest took longer than ten seconds to go down. The targets are now stopped explicitly first with a timeout that matches the grace period, and the same value is passed to `up` so its implicit stop cannot fall back to 10s. RECREATE_STOP_TIMEOUT overrides the 120s default; anything calling this script on a timeout of its own should stay above it. The script had no direct test coverage. tests/test_recreate_vm_script.py covers it through --dry-run, so it needs no Docker daemon: stop-before-up ordering, the timeout default and its override, and the sidecar expansion that is the reason the script exists. Four of the seven fail against the current version. Worth noting for #15: that watchdog delegates recovery to this script, so merging it without this fix ships an automated recovery path that hits the failure above. Dockerfile.test copies a named subset of scripts/ and recreate-vm.sh was not in it, so the new tests could not see the script. It is added to that COPY line; nothing else about the image changes.
1 parent 5264ef6 commit 4fc4523

3 files changed

Lines changed: 150 additions & 5 deletions

File tree

Dockerfile.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ COPY tests ./tests
2525
# docker-compose.yml.j2 is here because the compose-generation test renders the
2626
# REAL template — a stub would assert nothing about what actually ships.
2727
COPY requirements-api.txt requirements-mcpunifier.txt docker-compose.yml.example docker-compose.yml.j2 run.sh ./
28-
COPY scripts/config_helper.py scripts/start.bat scripts/check_health.py scripts/healthcheck.sh scripts/verify_binaries.py scripts/wickworks-healthcheck.py ./scripts/
28+
COPY scripts/config_helper.py scripts/start.bat scripts/check_health.py scripts/healthcheck.sh scripts/verify_binaries.py scripts/wickworks-healthcheck.py scripts/recreate-vm.sh ./scripts/
2929
COPY assets/binaries.lock.json ./assets/
3030

3131
ENV PYTHONPATH=/app

scripts/recreate-vm.sh

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,31 @@
3333
# ./scripts/recreate-vm.sh mt5 # recreate mt5 + its sidecars
3434
# ./scripts/recreate-vm.sh mt5 mt5-b # recreate both VMs + their sidecars
3535
#
36+
# STOPPING BEFORE RECREATING
37+
# --------------------------
38+
# `docker compose up --force-recreate` stops each container using compose's OWN
39+
# --timeout, which defaults to 10 SECONDS, not the service's stop_grace_period.
40+
# A dockurr/windows VM needs far longer than that to shut down (ours declare
41+
# `stop_grace_period: 2m`), so compose gave up waiting and went straight to
42+
# removing a container that was still running:
43+
#
44+
# Error response from daemon: cannot remove container "...":
45+
# container is running: stop the container before removing or force remove
46+
#
47+
# The watchdog then recorded a failed recreate and backed off, leaving the VM
48+
# unhealthy and its sidecars stranded — the exact outcome this script exists to
49+
# prevent. So the targets are stopped explicitly first, with a timeout that
50+
# matches the grace period, and the same value is passed to `up` so its implicit
51+
# stop can never fall back to 10s.
52+
#
3653
# ENV
3754
# ---
38-
# COMPOSE_FILE compose file to read services from
39-
# (default: ./docker-compose.yml in the repo root)
55+
# COMPOSE_FILE compose file to read services from
56+
# (default: ./docker-compose.yml in the repo root)
57+
# RECREATE_STOP_TIMEOUT seconds to allow each container to stop
58+
# (default: 120, matching stop_grace_period: 2m).
59+
# Keep this below WATCHDOG_RECREATE_TIMEOUT (300s) or
60+
# the watchdog kills the script mid-recreate.
4061
#
4162
# The script never touches services it was not asked to recreate, and never
4263
# uses --no-deps in a way that skips the named sidecars.
@@ -47,6 +68,7 @@ trap 'echo "[ERROR] ${BASH_SOURCE[0]}:${LINENO} - command failed (exit $?)" >&2'
4768

4869
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4970
COMPOSE_FILE="${COMPOSE_FILE:-${DIR}/docker-compose.yml}"
71+
STOP_TIMEOUT="${RECREATE_STOP_TIMEOUT:-120}"
5072
DRY_RUN=0
5173

5274
usage() {
@@ -117,12 +139,17 @@ main() {
117139
done
118140

119141
if [ "$DRY_RUN" = "1" ]; then
120-
log "DRY-RUN: would run 'docker compose up -d --force-recreate --no-deps ${targets[*]}'"
142+
log "DRY-RUN: would run 'docker compose stop -t ${STOP_TIMEOUT} ${targets[*]}'"
143+
log "DRY-RUN: would run 'docker compose up -d --force-recreate --no-deps -t ${STOP_TIMEOUT} ${targets[*]}'"
121144
return 0
122145
fi
123146

147+
# Stop first, with the real grace period. See STOPPING BEFORE RECREATING.
148+
log "stopping (timeout ${STOP_TIMEOUT}s): ${targets[*]}"
149+
docker compose -f "$COMPOSE_FILE" stop -t "$STOP_TIMEOUT" "${targets[@]}"
150+
124151
log "recreating: ${targets[*]}"
125-
docker compose -f "$COMPOSE_FILE" up -d --force-recreate --no-deps "${targets[@]}"
152+
docker compose -f "$COMPOSE_FILE" up -d --force-recreate --no-deps -t "$STOP_TIMEOUT" "${targets[@]}"
126153
log "recreate done"
127154
}
128155

tests/test_recreate_vm_script.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Tests for scripts/recreate-vm.sh.
2+
3+
The script had no direct coverage, and the gap hid a production failure: it
4+
recreated VMs with `docker compose up --force-recreate`, whose implicit stop
5+
uses compose's own --timeout (10s by default) rather than the service's
6+
stop_grace_period. A dockurr/windows VM cannot shut down in 10s, so compose
7+
tried to remove a still-running container and the recreate failed:
8+
9+
cannot remove container "...": container is running
10+
11+
These exercise --dry-run, so they assert the planned command line without
12+
needing a Docker daemon.
13+
"""
14+
15+
import os
16+
import subprocess
17+
from pathlib import Path
18+
19+
_REPO = Path(__file__).resolve().parents[1]
20+
_SCRIPT = _REPO / "scripts" / "recreate-vm.sh"
21+
22+
COMPOSE = """\
23+
services:
24+
mt5:
25+
image: dockurr/windows:5.14
26+
stop_grace_period: 2m
27+
wickworks:
28+
image: psyb0t/wickworks
29+
network_mode: "service:mt5"
30+
mt5-b:
31+
image: dockurr/windows:5.14
32+
wickworks-b:
33+
image: psyb0t/wickworks
34+
network_mode: "service:mt5-b"
35+
unrelated:
36+
image: nginx
37+
"""
38+
39+
40+
def run(tmp_path, *args, env=None):
41+
compose = tmp_path / "docker-compose.yml"
42+
compose.write_text(COMPOSE, encoding="utf-8")
43+
# Inherit the real PATH: the script's sidecar discovery shells out to
44+
# python3, which is not at a fixed location across host and test image.
45+
full_env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "COMPOSE_FILE": str(compose)}
46+
full_env.update(env or {})
47+
return subprocess.run(
48+
[str(_SCRIPT), "--dry-run", *args],
49+
capture_output=True,
50+
text=True,
51+
env=full_env,
52+
timeout=60,
53+
check=False,
54+
)
55+
56+
57+
def test_stops_before_recreating(tmp_path):
58+
"""The stop must be explicit, or compose removes a running container."""
59+
res = run(tmp_path, "mt5")
60+
assert res.returncode == 0, res.stderr
61+
assert "docker compose stop" in res.stdout
62+
stop_at = res.stdout.index("docker compose stop")
63+
up_at = res.stdout.index("docker compose up")
64+
assert stop_at < up_at, "stop must be planned before the recreate"
65+
66+
67+
def test_stop_timeout_matches_the_grace_period_by_default(tmp_path):
68+
"""10s (compose's default) is far too short for a Windows VM."""
69+
res = run(tmp_path, "mt5")
70+
assert "-t 120" in res.stdout
71+
assert "-t 10 " not in res.stdout
72+
73+
74+
def test_timeout_is_passed_to_up_as_well(tmp_path):
75+
"""`up --force-recreate` does its own stop; it must not use the 10s default."""
76+
res = run(tmp_path, "mt5")
77+
up_line = next(ln for ln in res.stdout.splitlines() if "docker compose up" in ln)
78+
assert "--force-recreate" in up_line
79+
assert "--no-deps" in up_line
80+
assert "-t 120" in up_line
81+
82+
83+
def test_timeout_is_overridable(tmp_path):
84+
res = run(tmp_path, "mt5", env={"RECREATE_STOP_TIMEOUT": "45"})
85+
assert "-t 45" in res.stdout
86+
assert "-t 120" not in res.stdout
87+
88+
89+
def test_sidecars_are_recreated_with_their_vm(tmp_path):
90+
"""The whole point: the sidecar must rejoin the VM's new netns."""
91+
res = run(tmp_path, "mt5")
92+
up_line = next(ln for ln in res.stdout.splitlines() if "docker compose up" in ln)
93+
assert "mt5" in up_line
94+
assert "wickworks" in up_line
95+
assert "unrelated" not in up_line
96+
assert "mt5-b" not in up_line
97+
98+
99+
def test_multiple_vms_expand_to_all_their_sidecars(tmp_path):
100+
res = run(tmp_path, "mt5", "mt5-b")
101+
up_line = next(ln for ln in res.stdout.splitlines() if "docker compose up" in ln)
102+
for expected in ("mt5", "wickworks", "mt5-b", "wickworks-b"):
103+
assert expected in up_line
104+
assert "unrelated" not in up_line
105+
106+
107+
def test_no_arguments_is_an_error(tmp_path):
108+
compose = tmp_path / "docker-compose.yml"
109+
compose.write_text(COMPOSE, encoding="utf-8")
110+
res = subprocess.run(
111+
[str(_SCRIPT)],
112+
capture_output=True,
113+
text=True,
114+
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "COMPOSE_FILE": str(compose)},
115+
timeout=60,
116+
check=False,
117+
)
118+
assert res.returncode != 0

0 commit comments

Comments
 (0)