mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
fix(oci): stage private images for the mapped Proxmox reader
This commit is contained in:
@@ -387,3 +387,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||||
|
|
||||||
|
native-image-import:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Exercise private images with isolated mapped Linux readers
|
||||||
|
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ Selecting an application, or giving a definition, opens the same sequence.
|
|||||||
4. **Resolve.** The registry is queried, the architecture of the host is selected and the digest the tag points to at that moment is fixed.
|
4. **Resolve.** The registry is queried, the architecture of the host is selected and the digest the tag points to at that moment is fixed.
|
||||||
5. **Download and verify.** Skopeo downloads the image as an OCI archive. Every layer is checked against its digest before anything is created.
|
5. **Download and verify.** Skopeo downloads the image as an OCI archive. Every layer is checked against its digest before anything is created.
|
||||||
6. **Build.** `pct create` builds the rootfs from the archive and imports `Entrypoint`, `Cmd`, `Env`, `User`, `WorkingDir` and `StopSignal` from the image.
|
6. **Build.** `pct create` builds the rootfs from the archive and imports `Entrypoint`, `Cmd`, `Env`, `User`, `WorkingDir` and `StopSignal` from the image.
|
||||||
|
|
||||||
|
Proxmox reads the image inside the container's user namespace. Readable template-storage archives use the existing path without a copy. If a verified archive or its parents are private, the native create wrapper makes a temporary root-owned copy under `/var/tmp`, readable only by the mapped root group, and removes it after creation, failure or a catchable interruption. The cached image, storage directories, registry credentials and caller permissions stay unchanged. Reflinks are used where available; otherwise this uncommon path needs temporary space for one image copy. Only the `pct create` child receives the normal Proxmox creation umask.
|
||||||
7. **Connect.** The volumes are added as mount points, then the devices, the environment, the network and the security settings the oci-compose declares.
|
7. **Connect.** The volumes are added as mount points, then the devices, the environment, the network and the security settings the oci-compose declares.
|
||||||
8. **Prepare.** Each new volume is made ready for the first start: `lost+found` is removed and the directory is given to the user the application writes with. The files the oci-compose asks to write are written.
|
8. **Prepare.** Each new volume is made ready for the first start: `lost+found` is removed and the directory is given to the user the application writes with. The files the oci-compose asks to write are written.
|
||||||
9. **Start and check.** The container starts, an address is waited for, the settings that need the running application are applied, and the service is checked. A failed first start is reported as a failed installation.
|
9. **Start and check.** The container starts, an address is waited for, the settings that need the running application are applied, and the service is checked. A failed first start is reported as a failed installation.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Give Proxmox's mapped OCI reader access without publishing a private cache.
|
||||||
|
|
||||||
|
Ordinary readable templates use the existing zero-copy path. An inaccessible
|
||||||
|
archive gets a short-lived copy (reflink where supported), owned by root and
|
||||||
|
readable only by the mapped root group. Never chmod/chown the source image,
|
||||||
|
its parents, registry credentials, or the caller's process umask.
|
||||||
|
"""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import errno
|
||||||
|
import ctypes
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
STAGING_ROOT = Path('/var/tmp') # Deliberately independent of a private TMPDIR.
|
||||||
|
FICLONE = 0x40049409
|
||||||
|
|
||||||
|
|
||||||
|
def reader_ids(options):
|
||||||
|
"""Fresh pct creates use PVE's standard idmap, not a restored custom map."""
|
||||||
|
unprivileged = False
|
||||||
|
for index, option in enumerate(options):
|
||||||
|
if option == '--unprivileged':
|
||||||
|
unprivileged = index + 1 == len(options) or options[index + 1] not in ('0', 'false')
|
||||||
|
elif option.startswith('--unprivileged='):
|
||||||
|
unprivileged = option.partition('=')[2] not in ('0', 'false')
|
||||||
|
return (100000, 100000) if unprivileged else (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(path, uid, gid, required):
|
||||||
|
info = path.lstat()
|
||||||
|
if stat.S_ISLNK(info.st_mode):
|
||||||
|
return False
|
||||||
|
shift = 6 if info.st_uid == uid else 3 if info.st_gid == gid else 0
|
||||||
|
return bool((info.st_mode >> shift) & required)
|
||||||
|
|
||||||
|
|
||||||
|
def readable(path, uid, gid):
|
||||||
|
"""Conservative DAC check: an ACL or symlink may make staging unnecessary,
|
||||||
|
but never justify publishing the cache or broadening a storage directory."""
|
||||||
|
path = Path(os.path.abspath(path))
|
||||||
|
if uid == 0:
|
||||||
|
return path.is_file()
|
||||||
|
return (_allowed(path, uid, gid, 4)
|
||||||
|
and all(_allowed(parent, uid, gid, 1) for parent in path.parents))
|
||||||
|
|
||||||
|
|
||||||
|
def _copy(source, target):
|
||||||
|
source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
try:
|
||||||
|
if not stat.S_ISREG(os.fstat(source_fd).st_mode):
|
||||||
|
raise OSError(errno.EINVAL, os.strerror(errno.EINVAL), str(source))
|
||||||
|
with os.fdopen(source_fd, 'rb', closefd=False) as incoming, target.open('xb') as outgoing:
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(outgoing.fileno(), FICLONE, incoming.fileno())
|
||||||
|
except OSError as error:
|
||||||
|
if error.errno not in (errno.EXDEV, errno.EOPNOTSUPP, errno.ENOTTY, errno.EINVAL, errno.ENOSYS):
|
||||||
|
raise
|
||||||
|
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
|
||||||
|
finally:
|
||||||
|
os.close(source_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def trusted_staging_root(uid, gid):
|
||||||
|
base = STAGING_ROOT
|
||||||
|
info = base.lstat()
|
||||||
|
ancestors = (base, *base.parents)
|
||||||
|
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0
|
||||||
|
or any(p.lstat().st_uid != 0 or
|
||||||
|
(p.lstat().st_mode & 0o022 and not p.lstat().st_mode & stat.S_ISVTX)
|
||||||
|
for p in ancestors)
|
||||||
|
or not all(_allowed(p, uid, gid, 1) for p in ancestors)):
|
||||||
|
raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(base))
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def import_archive(source, original, uid, gid):
|
||||||
|
source = Path(source)
|
||||||
|
if readable(source, uid, gid):
|
||||||
|
yield original
|
||||||
|
return
|
||||||
|
base = trusted_staging_root(uid, gid)
|
||||||
|
# mkdtemp is 0700 even if the caller's umask is permissive. Publish group
|
||||||
|
# traversal only after the root-owned, read-only copy is complete.
|
||||||
|
directory = Path(tempfile.mkdtemp(prefix='proxmenux-oci-import-', dir=base))
|
||||||
|
try:
|
||||||
|
target = directory / 'image.tar'
|
||||||
|
_copy(source.resolve(strict=True), target)
|
||||||
|
os.chown(target, 0, gid)
|
||||||
|
target.chmod(0o440)
|
||||||
|
os.chown(directory, 0, gid)
|
||||||
|
directory.chmod(0o750)
|
||||||
|
yield str(target)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def create(vmid, original, source, options):
|
||||||
|
uid, gid = reader_ids(options)
|
||||||
|
with import_archive(source, original, uid, gid) as archive:
|
||||||
|
result = subprocess.run(['pct', 'create', vmid, archive, *options],
|
||||||
|
close_fds=True, umask=0o022)
|
||||||
|
return 128 - result.returncode if result.returncode < 0 else result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# A catchable interruption must unwind the staging context. subprocess.run
|
||||||
|
# terminates its child when the handler raises; no background pct is orphaned.
|
||||||
|
for caught in (signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT):
|
||||||
|
signal.signal(caught, lambda signum, frame: sys.exit(128 + signum))
|
||||||
|
if sys.platform == 'linux':
|
||||||
|
parent = os.getppid()
|
||||||
|
libc = ctypes.CDLL(None, use_errno=True)
|
||||||
|
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
|
||||||
|
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
|
||||||
|
if os.getppid() != parent:
|
||||||
|
return 143
|
||||||
|
try:
|
||||||
|
return create(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
except OSError as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -127,7 +127,7 @@ oci_create_container() {
|
|||||||
total_mib=$(python3 "$VERIFY_OCI_ARCHIVE" --extracted-mib "$path" 2>/dev/null || printf '0')
|
total_mib=$(python3 "$VERIFY_OCI_ARCHIVE" --extracted-mib "$path" 2>/dev/null || printf '0')
|
||||||
[[ $total_mib =~ ^[0-9]+$ ]] || total_mib=0
|
[[ $total_mib =~ ^[0-9]+$ ]] || total_mib=0
|
||||||
fi
|
fi
|
||||||
pct create "$vmid" "$archive" "$@" >>"$log" 2>&1 &
|
python3 "${SCRIPT_DIR}/oci_native_import.py" "$vmid" "$archive" "$path" "$@" >>"$log" 2>&1 &
|
||||||
pid=$!
|
pid=$!
|
||||||
while kill -0 "$pid" 2>/dev/null; do
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
if mountpoint -q "$rootfs" 2>/dev/null; then
|
if mountpoint -q "$rootfs" 2>/dev/null; then
|
||||||
|
|||||||
@@ -1611,7 +1611,7 @@ def _run_remote_install(
|
|||||||
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
|
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
|
||||||
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
|
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
|
||||||
shutil.copy2(project_root / 'remote' / 'oci_image_mountpoints.py', temporary / 'oci_image_mountpoints.py')
|
shutil.copy2(project_root / 'remote' / 'oci_image_mountpoints.py', temporary / 'oci_image_mountpoints.py')
|
||||||
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
|
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_native_import.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
|
||||||
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
|
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
|
||||||
if deployment_kind == 'generic-multi-lxc-stack':
|
if deployment_kind == 'generic-multi-lxc-stack':
|
||||||
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
|
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
|
||||||
@@ -1648,7 +1648,7 @@ def _run_remote_install(
|
|||||||
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
|
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
|
||||||
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
|
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
|
||||||
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
|
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
|
||||||
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
|
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_native_import.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
|
||||||
archive.add(project_root / 'remote' / helper, arcname=helper)
|
archive.add(project_root / 'remote' / helper, arcname=helper)
|
||||||
if deployment_kind == 'generic-multi-lxc-stack':
|
if deployment_kind == 'generic-multi-lxc-stack':
|
||||||
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
|
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Native mapped readers may read an image without exposing its private cache."""
|
||||||
|
import errno
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
spec = importlib.util.spec_from_file_location('native_import_access', ROOT / 'remote/oci_native_import.py')
|
||||||
|
imports = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(imports)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateImageImportTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.root = Path(self.directory.name)
|
||||||
|
self.source = self.root / 'private-image.tar'
|
||||||
|
self.source.write_bytes(b'OCI image fixture\x00private layers')
|
||||||
|
self.source.chmod(0o600)
|
||||||
|
|
||||||
|
def staging(self):
|
||||||
|
return patch.object(imports, 'trusted_staging_root', return_value=self.root)
|
||||||
|
|
||||||
|
def test_normal_readable_image_uses_original_storage_volume_without_copy(self):
|
||||||
|
with patch.object(imports, 'readable', return_value=True), \
|
||||||
|
patch.object(imports, '_copy', side_effect=AssertionError('must stay zero-copy')):
|
||||||
|
with imports.import_archive(self.source, 'local:vztmpl/public.tar', 100000, 100000) as path:
|
||||||
|
self.assertEqual(path, 'local:vztmpl/public.tar')
|
||||||
|
|
||||||
|
def test_private_copy_preserves_source_and_limits_mapped_reader_group(self):
|
||||||
|
before = self.source.stat()
|
||||||
|
with self.staging(), patch.object(imports.os, 'chown') as chown, \
|
||||||
|
patch.object(imports, 'readable', return_value=False):
|
||||||
|
with imports.import_archive(self.source, str(self.source), 100000, 100000) as path:
|
||||||
|
staged = Path(path)
|
||||||
|
self.assertEqual(staged.read_bytes(), self.source.read_bytes())
|
||||||
|
self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o440)
|
||||||
|
self.assertEqual(stat.S_IMODE(staged.parent.stat().st_mode), 0o750)
|
||||||
|
chown.assert_any_call(staged, 0, 100000)
|
||||||
|
chown.assert_any_call(staged.parent, 0, 100000)
|
||||||
|
self.assertFalse(staged.parent.exists())
|
||||||
|
after = self.source.stat()
|
||||||
|
self.assertEqual((before.st_uid, before.st_gid, before.st_mode),
|
||||||
|
(after.st_uid, after.st_gid, after.st_mode))
|
||||||
|
|
||||||
|
def test_copy_error_cleans_stage_and_never_invokes_pct(self):
|
||||||
|
with self.staging(), patch.object(imports, 'readable', return_value=False), \
|
||||||
|
patch.object(imports, '_copy', side_effect=OSError(errno.ENOSPC, 'fixture')), \
|
||||||
|
patch.object(imports.subprocess, 'run') as run:
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
imports.create('9161', str(self.source), str(self.source), ['--unprivileged', '1'])
|
||||||
|
run.assert_not_called()
|
||||||
|
self.assertEqual(list(self.root.iterdir()), [self.source])
|
||||||
|
|
||||||
|
def test_pct_failure_and_interrupt_both_clean_stage(self):
|
||||||
|
for error in (None, KeyboardInterrupt()):
|
||||||
|
with self.staging(), patch.object(imports.os, 'chown'), \
|
||||||
|
patch.object(imports, 'readable', return_value=False), \
|
||||||
|
patch.object(imports.subprocess, 'run', side_effect=error,
|
||||||
|
return_value=types.SimpleNamespace(returncode=23)) as run:
|
||||||
|
if error:
|
||||||
|
with self.assertRaises(KeyboardInterrupt):
|
||||||
|
imports.create('9161', str(self.source), str(self.source), ['--unprivileged', '1'])
|
||||||
|
else:
|
||||||
|
self.assertEqual(imports.create('9161', str(self.source), str(self.source),
|
||||||
|
['--unprivileged', '1']), 23)
|
||||||
|
self.assertEqual(run.call_args.kwargs['umask'], 0o022)
|
||||||
|
self.assertEqual(list(self.root.iterdir()), [self.source])
|
||||||
|
|
||||||
|
def test_standard_map_and_privileged_profile_initial_unprivileged_create(self):
|
||||||
|
for options in (['--unprivileged', '1'], ['--unprivileged=1'], ['--unprivileged', 'true']):
|
||||||
|
self.assertEqual(imports.reader_ids(options), (100000, 100000))
|
||||||
|
self.assertEqual(imports.reader_ids(['--unprivileged', '0']), (0, 0))
|
||||||
|
installer = (ROOT / 'remote/install_oci.sh').read_text()
|
||||||
|
self.assertIn('CREATE_UNPRIVILEGED_FLAG=1', installer)
|
||||||
|
self.assertIn('--unprivileged "$CREATE_UNPRIVILEGED_FLAG"', installer)
|
||||||
|
|
||||||
|
def test_derived_numeric_user_image_is_staged_without_mutating_its_source(self):
|
||||||
|
derived = self.root / 'image.user-databases.123.tar'
|
||||||
|
derived.write_bytes(b'derived OCI image')
|
||||||
|
derived.chmod(0o600)
|
||||||
|
with self.staging(), patch.object(imports.os, 'chown'), \
|
||||||
|
patch.object(imports, 'readable', return_value=False):
|
||||||
|
with imports.import_archive(derived, str(derived), 100000, 100000) as path:
|
||||||
|
self.assertEqual(Path(path).read_bytes(), derived.read_bytes())
|
||||||
|
self.assertEqual(stat.S_IMODE(derived.stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_reflink_unavailable_falls_back_to_an_identical_copy(self):
|
||||||
|
target = self.root / 'copy.tar'
|
||||||
|
with patch.object(imports.fcntl, 'ioctl', side_effect=OSError(errno.EOPNOTSUPP, 'fixture')):
|
||||||
|
imports._copy(self.source, target)
|
||||||
|
self.assertEqual(target.read_bytes(), self.source.read_bytes())
|
||||||
|
|
||||||
|
def test_local_and_ssh_installer_stage_the_native_helper(self):
|
||||||
|
import ast
|
||||||
|
tree = ast.parse((ROOT / 'src/proxmenux_oci/installer.py').read_text())
|
||||||
|
function = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_run_remote_install')
|
||||||
|
lists = [n.iter for n in ast.walk(function) if isinstance(n, ast.For)
|
||||||
|
and isinstance(n.iter, ast.Tuple)
|
||||||
|
and any(isinstance(v, ast.Constant) and v.value == 'oci_ui.sh' for v in n.iter.elts)]
|
||||||
|
self.assertEqual(len(lists), 2)
|
||||||
|
for values in lists:
|
||||||
|
self.assertIn('oci_native_import.py', [v.value for v in values.elts])
|
||||||
|
self.assertIn('oci_native_import.py', (ROOT / 'remote/oci_ui.sh').read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == 'linux' and os.geteuid() == 0,
|
||||||
|
'isolated mapped UID/GID proof runs as root in Linux CI')
|
||||||
|
class MappedReaderProof(unittest.TestCase):
|
||||||
|
def reader(self, path, uid, gid):
|
||||||
|
return subprocess.run([sys.executable, '-c',
|
||||||
|
'import os,sys; os.setgroups([]); os.setgid(int(sys.argv[2])); '
|
||||||
|
'os.setuid(int(sys.argv[1])); print(open(sys.argv[3],"rb").read().decode())',
|
||||||
|
str(uid), str(gid), str(path)], capture_output=True, text=True)
|
||||||
|
|
||||||
|
def test_mapped_reader_only_private_source_and_caller_state_are_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory(dir='/var/tmp') as folder:
|
||||||
|
root = Path(folder)
|
||||||
|
root.chmod(0o700)
|
||||||
|
source = root / 'private.tar'
|
||||||
|
source.write_text('private image contents')
|
||||||
|
source.chmod(0o600)
|
||||||
|
self.assertNotEqual(self.reader(source, 100000, 100000).returncode, 0)
|
||||||
|
previous = os.umask(0o077)
|
||||||
|
try:
|
||||||
|
with imports.import_archive(source, str(source), 100000, 100000) as staged:
|
||||||
|
path = Path(staged)
|
||||||
|
self.assertEqual(self.reader(path, 100000, 100000).stdout.strip(), 'private image contents')
|
||||||
|
self.assertNotEqual(self.reader(path, 100001, 100001).returncode, 0)
|
||||||
|
self.assertFalse(path.stat().st_mode & 0o022)
|
||||||
|
self.assertEqual(path.stat().st_uid, 0)
|
||||||
|
self.assertFalse(path.parent.exists())
|
||||||
|
record = root / 'record.json'
|
||||||
|
record.write_text('private credentials fixture')
|
||||||
|
self.assertEqual(stat.S_IMODE(record.stat().st_mode), 0o600)
|
||||||
|
self.assertEqual(os.umask(0o077), 0o077)
|
||||||
|
self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o600)
|
||||||
|
self.assertEqual(stat.S_IMODE(root.stat().st_mode), 0o700)
|
||||||
|
finally:
|
||||||
|
os.umask(previous)
|
||||||
|
|
||||||
|
def test_sigterm_removes_staging_and_preserves_native_output_and_status(self):
|
||||||
|
with tempfile.TemporaryDirectory(dir='/var/tmp') as folder:
|
||||||
|
root = Path(folder)
|
||||||
|
source = root / 'private.tar'; source.write_text('private image'); source.chmod(0o600)
|
||||||
|
pct = root / 'pct'
|
||||||
|
pct.write_text('#!/bin/sh\nmkdir "$FIXTURE/pve-tmp"\ntouch "$FIXTURE/pve-tmp/pct.conf"\nprintf "%s\\n" "$3" >"$MARKER"\necho native-output\nexec sleep 60\n')
|
||||||
|
pct.chmod(0o700)
|
||||||
|
marker = root / 'marker'
|
||||||
|
env = dict(os.environ, PATH=str(root) + ':' + os.environ['PATH'], MARKER=str(marker), FIXTURE=str(root))
|
||||||
|
process = subprocess.Popen([sys.executable, str(ROOT / 'remote/oci_native_import.py'),
|
||||||
|
'9161', str(source), str(source), '--unprivileged', '1'], env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, umask=0o077)
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
if marker.exists(): break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.assertTrue(marker.exists())
|
||||||
|
staged = Path(marker.read_text().strip())
|
||||||
|
self.assertTrue(staged.exists())
|
||||||
|
self.assertEqual(stat.S_IMODE((root / "pve-tmp").stat().st_mode), 0o755)
|
||||||
|
self.assertEqual(stat.S_IMODE((root / "pve-tmp/pct.conf").stat().st_mode), 0o644)
|
||||||
|
process.send_signal(signal.SIGTERM)
|
||||||
|
# Do not leave a shell sleep grandchild holding capture pipes open.
|
||||||
|
output, error = process.communicate(timeout=5)
|
||||||
|
self.assertIn("native-output", output)
|
||||||
|
self.assertEqual(process.returncode, 143)
|
||||||
|
self.assertFalse(staged.parent.exists())
|
||||||
|
finally:
|
||||||
|
if process.poll() is None: process.kill(); process.wait()
|
||||||
|
process.stdout.close(); process.stderr.close()
|
||||||
Reference in New Issue
Block a user