fix(image): clean partial mounts and refuse mounted export deletion
This commit is contained in:
+16
-6
@@ -58,6 +58,21 @@ sys.exit(code)
|
||||
'''
|
||||
|
||||
|
||||
def rootfs_retry_guard_script():
|
||||
"""Refuse deletion of a still-mounted export, including nested mountpoints."""
|
||||
return r"""mounts=$(findmnt --list --raw --noheadings --output TARGET)
|
||||
while IFS= read -r mountpoint; do
|
||||
case "$mountpoint" in
|
||||
"$root"|"$root/"*) echo 'Refusing to remove a mounted rootfs export' >&2; exit 1 ;;
|
||||
esac
|
||||
done <<< "$mounts"
|
||||
if [ -e "$root" ] || [ -L "$root" ]; then
|
||||
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
|
||||
rm -rf -- "$root"
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
def verify_hash(path, expected):
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open('rb') as stream:
|
||||
@@ -421,12 +436,7 @@ fi'''
|
||||
snapshot = image.digest(original)
|
||||
assembler = self.assembly(snapshot)
|
||||
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
||||
script = r"""root=/hydra-output/rootfs
|
||||
if [ -e "$root" ] || [ -L "$root" ]; then
|
||||
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
|
||||
rm -rf -- "$root"
|
||||
fi
|
||||
mkdir -p "$root"
|
||||
script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
|
||||
touch "$root/.hydra-export-owned"
|
||||
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
|
||||
/hydra-source/image/configure-rootfs.sh "$root"
|
||||
|
||||
@@ -7,10 +7,27 @@ fi
|
||||
root=$1
|
||||
python3 /hydra-source/hydra/image.py configure "$root" --source /hydra-source
|
||||
# This is a separate rootfs inside the assembly container, never the host root.
|
||||
mounted=()
|
||||
cleanup_mounts() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
cleanup_failed=0
|
||||
for ((index=${#mounted[@]}-1; index>=0; index--)); do
|
||||
umount "${mounted[index]}" || cleanup_failed=1
|
||||
done
|
||||
if ((cleanup_failed)); then
|
||||
echo 'configure-rootfs: temporary mount cleanup failed; refusing success' >&2
|
||||
status=1
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_mounts EXIT
|
||||
mount --bind /dev "$root/dev"
|
||||
mounted+=("$root/dev")
|
||||
mount -t proc proc "$root/proc"
|
||||
mounted+=("$root/proc")
|
||||
mount --bind /sys "$root/sys"
|
||||
trap 'umount "$root/sys" "$root/proc" "$root/dev"' EXIT
|
||||
mounted+=("$root/sys")
|
||||
for group in wheel kvm video render audio; do
|
||||
chroot "$root" getent group "$group" >/dev/null || chroot "$root" groupadd -r "$group"
|
||||
done
|
||||
|
||||
@@ -97,6 +97,58 @@ class ImageTests(unittest.TestCase):
|
||||
with self.assertRaises(image.ImageError):
|
||||
image.kernel_version(self.root)
|
||||
|
||||
def test_mount_faults_clean_only_successful_bindings(self):
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
tools = Path(self.tmp.name) / 'tools'
|
||||
tools.mkdir()
|
||||
for name in ('python3', 'chroot', 'ln', 'systemctl'):
|
||||
script = tools / name
|
||||
script.write_text('#!/bin/sh\nexit 0\n')
|
||||
script.chmod(0o755)
|
||||
mount = tools / 'mount'
|
||||
mount.write_text('#!/bin/sh\nn=$(cat "$STATE/count" 2>/dev/null || echo 0)\n'
|
||||
'n=$((n+1)); echo "$n" > "$STATE/count"\n'
|
||||
'test "$n" != "$FAIL_MOUNT"\n')
|
||||
mount.chmod(0o755)
|
||||
unmount = tools / 'umount'
|
||||
unmount.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$STATE/unmounts"\n'
|
||||
'test "$FAIL_UNMOUNT" != 1\n')
|
||||
unmount.chmod(0o755)
|
||||
for failed, cleanup_failed in ((1, 0), (2, 0), (3, 0), (0, 0), (0, 1)):
|
||||
with self.subTest(failed=failed, cleanup_failed=cleanup_failed):
|
||||
state = Path(self.tmp.name) / f'state-{failed}-{cleanup_failed}'
|
||||
state.mkdir()
|
||||
env = dict(os.environ, PATH=str(tools) + ':' + os.environ['PATH'],
|
||||
STATE=str(state), FAIL_MOUNT=str(failed), FAIL_UNMOUNT=str(cleanup_failed))
|
||||
result = subprocess.run(['bash', str(source / 'image/configure-rootfs.sh'), '/hydra-output/rootfs'],
|
||||
env=env, capture_output=True, text=True)
|
||||
expected = ['/hydra-output/rootfs/dev', '/hydra-output/rootfs/proc', '/hydra-output/rootfs/sys']
|
||||
expected = list(reversed(expected[:failed - 1 if failed else 3]))
|
||||
actual = (state / 'unmounts').read_text().splitlines() if (state / 'unmounts').exists() else []
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertEqual(result.returncode == 0, not failed and not cleanup_failed)
|
||||
|
||||
def test_retry_refuses_removing_owned_rootfs_with_stale_child_mount(self):
|
||||
from hydra.build import rootfs_retry_guard_script
|
||||
marker = self.file('.hydra-export-owned')
|
||||
preserved = self.file('dev/guest-data')
|
||||
tools = Path(self.tmp.name) / 'guard-tools'
|
||||
tools.mkdir()
|
||||
findmnt = tools / 'findmnt'
|
||||
findmnt.write_text('#!/bin/sh\nprintf "%s\\n" / "$STALE_MOUNT"\n')
|
||||
findmnt.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(tools) + ':' + os.environ['PATH'], STALE_MOUNT=str(self.root / 'dev'))
|
||||
script = 'set -eu\nroot=' + shlex.quote(str(self.root)) + '\n' + rootfs_retry_guard_script()
|
||||
result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(marker.is_file())
|
||||
self.assertTrue(preserved.is_file())
|
||||
self.assertIn('mounted', result.stderr)
|
||||
env['STALE_MOUNT'] = str(Path(self.tmp.name) / 'different-rootfs')
|
||||
result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(self.root.exists())
|
||||
|
||||
def test_export_sanitization_preserves_update_inputs(self):
|
||||
preserved = ['var/db/pkg/sys-apps/portage-3/USE', 'var/lib/portage/world',
|
||||
'var/db/repos/gentoo/profiles/repo_name', 'usr/share/openpgp-keys/gentoo-release.asc',
|
||||
|
||||
Reference in New Issue
Block a user