fix(maintenance): check first, drain before a Docker upgrade, wait 5 min after a drain, see containers back (#6098)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m29s
CI / integration (push) Successful in 2m7s
CI / publish (push) Skipped

On 2026-10-18 an update on VDNT-Docker02 restarted the Docker daemon six
seconds in, before the drain that came only with the reboot, and every
container on the node stopped. Docker's own packages restart the daemon from
their install scripts; needrestart's list-only mode doesn't stop that.

- The helper gains a `check` action (agent 1.24.0): a dry run of the update
  for each package manager, whether Docker is among it, and the running
  containers outside the swarm with their restart policies.
- A swarm node is drained before an update that upgrades Docker, else only
  before a reboot, and at most once a job.
- After a drain the job settles 5 minutes before the update or reboot.
- Once the work is over, the containers that ran outside the swarm must be
  seen running again within 10 minutes, or the job fails naming them.
- An older agent gets no check and its job runs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 10:44:45 -04:00
co-authored by Claude Opus 5.5
parent da81010a75
commit 1ba4784fa6
15 changed files with 671 additions and 67 deletions
@@ -255,3 +255,82 @@ def test_a_pacman_host_needs_a_reboot_once_the_running_kernel_is_replaced(maint,
assert maint.reboot_required() is False
(tmp_path / os.uname().release).rmdir()
assert maint.reboot_required() is True
# ── check: what an update would change (#6098) ──────────────────────────────
APT_SIM = """Reading package lists...
Calculating upgrade...
The following packages will be upgraded:
containerd.io docker-ce docker-ce-cli openssl
Inst docker-ce [5:27.0.3-1~debian.12~bookworm] (5:27.1.1-1~debian.12~bookworm Docker CE:bookworm [amd64])
Inst docker-ce-cli [5:27.0.3-1] (5:27.1.1-1 Docker CE:bookworm [amd64])
Inst openssl [3.0.13-1] (3.0.14-1 Debian-Security:12/stable-security [amd64])
Conf docker-ce (5:27.1.1-1~debian.12~bookworm Docker CE:bookworm [amd64])
"""
def test_a_check_reads_what_each_manager_would_upgrade(maint):
assert maint.pending_names("apt", APT_SIM) == ["docker-ce", "docker-ce-cli", "openssl"]
dnf = ("\nmoby-engine.x86_64 26.1.3-1.fc40 updates\n"
"kernel-core.x86_64 6.10.3-200.fc40 updates\n"
"Obsoleting Packages\n")
assert maint.pending_names("dnf", dnf) == ["moby-engine", "kernel-core"]
table = ("S | Repository | Name | Current Version | Available Version | Arch\n"
"--+------------+--------+-----------------+-------------------+-------\n"
"v | repo-oss | docker | 24.0.7 | 25.0.3 | x86_64\n"
"v | repo-oss | vim | 9.0 | 9.1 | x86_64\n")
assert maint.pending_names("zypper", table) == ["docker", "vim"]
dup = ("The following 2 packages are going to be upgraded:\n"
" containerd runc\n\nThe following package is going to be installed:\n foo\n"
"2 packages to upgrade, 1 new.\n")
assert maint.pending_names("zypper", dup) == ["containerd", "runc", "foo"]
assert maint.pending_names("pacman", "docker 1:27.0-1 -> 1:27.1-1\nlinux 6.10-1 -> 6.10.3-1\n") == [
"docker", "linux"]
@pytest.mark.parametrize("name, restarts_docker", [
("docker-ce", True), ("docker.io", True), ("containerd.io", True), ("moby-engine", True),
("docker", True), ("containerd", True),
("docker-ce-cli", False), ("docker-compose-plugin", False), ("docker-buildx-plugin", False),
("runc", False), ("python3-docker", False)])
def test_only_the_packages_that_restart_the_daemon_count_as_docker(maint, name, restarts_docker):
assert bool(maint.DOCKER_PACKAGES.match(name)) is restarts_docker
def test_the_check_names_the_containers_outside_the_swarm(maint, monkeypatch):
monkeypatch.setattr(maint.shutil, "which", lambda b: "/usr/bin/docker")
inspected = ("/ak-outpost|unless-stopped|\n"
"/traefik_router.abc.def|no|t0k3n\n" # a swarm task: the drain moves it
"/scratch||\n")
def fake_run(cmd, deadline=0, env=None, ok_codes=(0,), keep=0):
return True, ("id1 id2 id3\n" if cmd[1] == "ps" else inspected)
monkeypatch.setattr(maint, "run", fake_run)
assert maint.standalone_containers() == [{"name": "ak-outpost", "restart": "unless-stopped"},
{"name": "scratch", "restart": "no"}]
def test_a_check_says_whether_docker_is_upgraded_and_changes_nothing(maint, monkeypatch):
_manager(monkeypatch, maint, ("apt-get",))
ran = []
def fake_run(cmd, deadline=0, env=None, ok_codes=(0,), keep=0):
ran.append(cmd)
return True, APT_SIM if "-s" in cmd else "Hit:1 http://deb.debian.org bookworm InRelease\n"
monkeypatch.setattr(maint, "run", fake_run)
monkeypatch.setattr(maint, "standalone_containers", lambda: [{"name": "ak-outpost", "restart": "always"}])
got = maint.do_check()
assert got == {"ok": True, "pending": 3, "docker_update": True, "docker_packages": ["docker-ce"],
"containers": [{"name": "ak-outpost", "restart": "always"}]}
assert ran == [["apt-get", "update"], ["apt-get", "-s", "dist-upgrade"]] # a simulation only
assert "check" in maint.ACTIONS
def test_a_check_that_cant_list_says_which_step(maint, monkeypatch):
_manager(monkeypatch, maint, ("dnf",))
monkeypatch.setattr(maint, "run", lambda cmd, **kw: (False, "Curl error (6): Couldn't resolve host"))
got = maint.do_check()
assert not got["ok"] and got["detail"].startswith("dnf check-update failed: Curl error")