From a6c3c1cc91ac5b8dbd77f06aede55b21543abea3 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 20 Oct 2026 12:09:48 +0200 Subject: [PATCH] fix(apparmor): keep docker-default loaded when tearing AppArmor down `security-apparmor-teardown` ran `aa-teardown`, which unloads every profile including dockerd's in-memory `docker-default`. dockerd only loads that profile when it starts, so every container start afterwards that requests it fails until dockerd restarts. Since #1067 runs the teardown on the GitHub runners, BuildKit failed each RUN of the swarm node image three times (run 39332343490, 39 of 40 swarm jobs): runc run failed: unable to start container process: error during container init: unable to apply apparmor profile: apparmor failed to apply profile: write .../attr/apparmor/exec: no such file or directory The teardown now removes each loaded profile by name through securityfs and skips `docker-default` and child profiles, which leave with their parent. Dev hosts gain the same: no dockerd restart is needed after `make environment-bootstrap`. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/system/apparmor/common.sh | 4 ++ scripts/system/apparmor/teardown.sh | 13 +++-- .../scripts/system/apparmor/__init__.py | 0 .../scripts/system/apparmor/test_common.py | 48 +++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/unit/python/scripts/system/apparmor/__init__.py create mode 100644 tests/unit/python/scripts/system/apparmor/test_common.py diff --git a/scripts/system/apparmor/common.sh b/scripts/system/apparmor/common.sh index eae3bf51d5..d6c252267c 100755 --- a/scripts/system/apparmor/common.sh +++ b/scripts/system/apparmor/common.sh @@ -43,6 +43,10 @@ apparmor_should_manage() { apparmor_is_installed && apparmor_is_enabled } +apparmor_removable_profiles() { + sed -E 's/ \([a-z]+\)$//' | grep -v -E '//|^docker-default$' +} + systemd_is_operational() { command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]] } diff --git a/scripts/system/apparmor/teardown.sh b/scripts/system/apparmor/teardown.sh index 1b86288baa..f09ee0bf35 100755 --- a/scripts/system/apparmor/teardown.sh +++ b/scripts/system/apparmor/teardown.sh @@ -18,12 +18,15 @@ else echo "[apparmor] apparmor.service not available or systemd inactive; skipping service stop" fi -if command -v aa-teardown >/dev/null 2>&1; then - aa-teardown || apparmor_warn "[apparmor] aa-teardown returned non-zero; continuing" -elif command -v apparmor_parser >/dev/null 2>&1 && compgen -G '/etc/apparmor.d/*' >/dev/null; then - apparmor_parser -R /etc/apparmor.d/* || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error +APPARMOR_FS=/sys/kernel/security/apparmor +if [[ -w "${APPARMOR_FS}/.remove" ]]; then + mapfile -t profiles < <(apparmor_removable_profiles <"${APPARMOR_FS}/profiles") + for profile in "${profiles[@]}"; do + printf '%s' "${profile}" >"${APPARMOR_FS}/.remove" + done + echo "[apparmor] unloaded ${#profiles[@]} profile(s); docker-default stays loaded" else - echo "[apparmor] no AppArmor profile tooling available; skipping profile unload" + echo "[apparmor] ${APPARMOR_FS}/.remove is not writable; skipping profile unload" fi echo "[apparmor] teardown complete" diff --git a/tests/unit/python/scripts/system/apparmor/__init__.py b/tests/unit/python/scripts/system/apparmor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/system/apparmor/test_common.py b/tests/unit/python/scripts/system/apparmor/test_common.py new file mode 100644 index 0000000000..8b4440f1e4 --- /dev/null +++ b/tests/unit/python/scripts/system/apparmor/test_common.py @@ -0,0 +1,48 @@ +"""The AppArmor teardown must keep docker-default loaded. + +Run 39332343490 lost 39 swarm jobs: the runner's teardown unloaded every +profile, and BuildKit then failed each node image RUN step with +`apparmor failed to apply profile: write .../attr/apparmor/exec: no such file`. +""" + +from __future__ import annotations + +import subprocess +import unittest + +from utils.cache.files import PROJECT_ROOT + +COMMON = PROJECT_ROOT / "scripts" / "system" / "apparmor" / "common.sh" + +PROFILES = """wg-quick (enforce) +wg-quick//ip (enforce) +docker-default (enforce) +/usr/bin/man (enforce) +unprivileged_userns (unconfined) +docker-defaults (complain) +""" + + +class TestRemovableProfiles(unittest.TestCase): + def test_keeps_docker_default_and_child_profiles(self) -> None: + result = subprocess.run( + ["bash", "-c", f'source "{COMMON}"; apparmor_removable_profiles'], + input=PROFILES, + capture_output=True, + text=True, + check=True, + ) + + self.assertEqual( + [ + "wg-quick", + "/usr/bin/man", + "unprivileged_userns", + "docker-defaults", + ], + result.stdout.splitlines(), + ) + + +if __name__ == "__main__": + unittest.main()