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) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-20 12:11:14 +02:00
1 parent 0edc8fa470
commit a6c3c1cc91
4 files changed
+60 -5

No files matched your search

+4
View File
@@ -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 ]]
}
+8 -5
View File
@@ -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"
Whitespace-only changes.
@@ -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()