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):
|
def verify_hash(path, expected):
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with Path(path).open('rb') as stream:
|
with Path(path).open('rb') as stream:
|
||||||
@@ -421,12 +436,7 @@ fi'''
|
|||||||
snapshot = image.digest(original)
|
snapshot = image.digest(original)
|
||||||
assembler = self.assembly(snapshot)
|
assembler = self.assembly(snapshot)
|
||||||
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
||||||
script = r"""root=/hydra-output/rootfs
|
script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
|
||||||
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"
|
|
||||||
touch "$root/.hydra-export-owned"
|
touch "$root/.hydra-export-owned"
|
||||||
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
|
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
|
||||||
/hydra-source/image/configure-rootfs.sh "$root"
|
/hydra-source/image/configure-rootfs.sh "$root"
|
||||||
|
|||||||
@@ -7,10 +7,27 @@ fi
|
|||||||
root=$1
|
root=$1
|
||||||
python3 /hydra-source/hydra/image.py configure "$root" --source /hydra-source
|
python3 /hydra-source/hydra/image.py configure "$root" --source /hydra-source
|
||||||
# This is a separate rootfs inside the assembly container, never the host root.
|
# 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"
|
mount --bind /dev "$root/dev"
|
||||||
|
mounted+=("$root/dev")
|
||||||
mount -t proc proc "$root/proc"
|
mount -t proc proc "$root/proc"
|
||||||
|
mounted+=("$root/proc")
|
||||||
mount --bind /sys "$root/sys"
|
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
|
for group in wheel kvm video render audio; do
|
||||||
chroot "$root" getent group "$group" >/dev/null || chroot "$root" groupadd -r "$group"
|
chroot "$root" getent group "$group" >/dev/null || chroot "$root" groupadd -r "$group"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -97,6 +97,58 @@ class ImageTests(unittest.TestCase):
|
|||||||
with self.assertRaises(image.ImageError):
|
with self.assertRaises(image.ImageError):
|
||||||
image.kernel_version(self.root)
|
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):
|
def test_export_sanitization_preserves_update_inputs(self):
|
||||||
preserved = ['var/db/pkg/sys-apps/portage-3/USE', 'var/lib/portage/world',
|
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',
|
'var/db/repos/gentoo/profiles/repo_name', 'usr/share/openpgp-keys/gentoo-release.asc',
|
||||||
|
|||||||
Reference in New Issue
Block a user