From aa413033c969522dea50812af2d8285e2422a0d4 Mon Sep 17 00:00:00 2001 From: p5-vbnekit Date: Wed, 31 Jan 2024 20:58:59 +0300 Subject: [PATCH] first commit --- .ci/bookworm.py | 104 ++++++ .ci/image.py | 349 ++++++++++++++++++ .ci/inspect.py | 30 ++ .ci/mount-idmapped.py | 46 +++ .ci/packages.py | 38 ++ .ci/packages/debian.py | 186 ++++++++++ .ci/packages/python.py | 29 ++ .ci/task.concourse.yml | 31 ++ .github/actions/build/Dockerfile | 9 + .github/actions/build/action.yml | 38 ++ .github/actions/build/run.py | 41 ++ .github/workflows/main.yml | 17 + .gitignore | 0 .old/rbind.py | 164 ++++++++ LICENSE | 121 ++++++ README.md | 95 +++++ pyproject.toml | 28 ++ python3/__init__.py | 27 ++ python3/_runtime/__init__.py | 29 ++ python3/_runtime/common/__init__.py | 30 ++ python3/_runtime/common/lazy_attributes.py | 146 ++++++++ python3/_runtime/common/property_collector.py | 26 ++ python3/_runtime/id_map.py | 91 +++++ python3/_runtime/system_calls/__init__.py | 31 ++ python3/_runtime/system_calls/exit.py | 59 +++ python3/_runtime/system_calls/pivot_root.py | 47 +++ python3/_runtime/system_calls/unshare.py | 65 ++++ python3/scripts/__init__.py | 27 ++ python3/scripts/init/__init__.py | 29 ++ python3/scripts/init/__main__.py | 24 ++ python3/scripts/init/_cli.py | 68 ++++ python3/scripts/init/_config.py | 278 ++++++++++++++ python3/scripts/init/_run.py | 169 +++++++++ python3/scripts/init/_setup_algorithm.py | 45 +++ .../init/_setup_algorithms/__init__.py | 27 ++ .../_setup_algorithms/libvirt/__init__.py | 27 ++ .../init/_setup_algorithms/libvirt/lxc.py | 307 +++++++++++++++ python3/scripts/initctl.py | 112 ++++++ python3/scripts/overlay.py | 157 ++++++++ python3/scripts/tolerant.py | 142 +++++++ 40 files changed, 3289 insertions(+) create mode 100644 .ci/bookworm.py create mode 100644 .ci/image.py create mode 100644 .ci/inspect.py create mode 100644 .ci/mount-idmapped.py create mode 100644 .ci/packages.py create mode 100644 .ci/packages/debian.py create mode 100644 .ci/packages/python.py create mode 100644 .ci/task.concourse.yml create mode 100644 .github/actions/build/Dockerfile create mode 100644 .github/actions/build/action.yml create mode 100644 .github/actions/build/run.py create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .old/rbind.py create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 python3/__init__.py create mode 100644 python3/_runtime/__init__.py create mode 100644 python3/_runtime/common/__init__.py create mode 100644 python3/_runtime/common/lazy_attributes.py create mode 100644 python3/_runtime/common/property_collector.py create mode 100644 python3/_runtime/id_map.py create mode 100644 python3/_runtime/system_calls/__init__.py create mode 100644 python3/_runtime/system_calls/exit.py create mode 100644 python3/_runtime/system_calls/pivot_root.py create mode 100644 python3/_runtime/system_calls/unshare.py create mode 100644 python3/scripts/__init__.py create mode 100644 python3/scripts/init/__init__.py create mode 100644 python3/scripts/init/__main__.py create mode 100644 python3/scripts/init/_cli.py create mode 100644 python3/scripts/init/_config.py create mode 100644 python3/scripts/init/_run.py create mode 100644 python3/scripts/init/_setup_algorithm.py create mode 100644 python3/scripts/init/_setup_algorithms/__init__.py create mode 100644 python3/scripts/init/_setup_algorithms/libvirt/__init__.py create mode 100644 python3/scripts/init/_setup_algorithms/libvirt/lxc.py create mode 100644 python3/scripts/initctl.py create mode 100644 python3/scripts/overlay.py create mode 100644 python3/scripts/tolerant.py diff --git a/.ci/bookworm.py b/.ci/bookworm.py new file mode 100644 index 0000000..207a1e4 --- /dev/null +++ b/.ci/bookworm.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import shlex +import shutil +import pathlib +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + +assert 0 == os.getuid() +assert 0 == os.getgid() +assert 0 == os.geteuid() +assert 0 == os.getegid() + +_current_directory = pathlib.Path(".").resolve(strict = True) + +_snapshot_directory = pathlib.Path(__file__).resolve(strict = True) +assert _snapshot_directory.is_file() +_snapshot_directory = _snapshot_directory.parent.parent + +pathlib.Path("/etc/apt/sources.list").unlink(missing_ok = True) +shutil.rmtree("/etc/apt/sources.list.d") +pathlib.Path("/etc/apt/sources.list.d").mkdir(parents = False, exist_ok = False) + +with open("/etc/apt/sources.list.d/debian.sources", "w") as _stream: print(""" +Types: deb deb-src +URIs: http://deb.debian.org/debian +Suites: bookworm bookworm-updates bookworm-proposed-updates bookworm-backports bookworm-backports-sloppy +Components: main contrib non-free non-free-firmware + +Types: deb deb-src +URIs: http://deb.debian.org/debian-security +Suites: bookworm-security +Components: main contrib non-free non-free-firmware +""".strip(), file = _stream) + + +def _shell(script: str, user = None, group = None): + assert isinstance(script, str) + if str is not type(script): script = str(script) + script = script.strip() + assert script + assert 0 == subprocess.run( + ("/bin/sh", "-xe"), input = f"{script}\n".encode("ascii"), + user = user, group = group + ).returncode + + +_quoted_current_directory = shlex.quote(_current_directory.as_posix()) +_quoted_snapshot_directory = shlex.quote(_snapshot_directory.as_posix()) + +_shell(script = f""" + apt update --assume-yes + apt-mark showmanual | xargs --no-run-if-empty -- apt-mark auto -- + apt install --assume-yes apt-utils + apt install --assume-yes git gcc xz-utils devscripts debootstrap guestfs-tools + apt install --assume-yes python3-venv python3-build python3-stdeb + apt install --assume-yes dh-python libpython3-dev libapt-pkg-dev + apt full-upgrade --assume-yes + apt autoremove --assume-yes + apt purge --assume-yes '~c' + + adduser \ + --disabled-login --disabled-password --no-create-home \ + --home={_quoted_current_directory} --shell=/bin/false --gecos "" \ + -- build + + chown --recursive build:build -- {_quoted_current_directory} {_quoted_snapshot_directory} +""") + + +def _resolve_script(name: str): + assert isinstance(name, str) + if str is not type(name): name = str(name) + assert name + _path = _snapshot_directory / f".ci/{name}.py" + assert _path.resolve(strict = True) == _path + return _path.as_posix() + + +_shell(script = f""" + export HOME={_quoted_current_directory} + + {shlex.quote(sys.executable)} -m venv --system-site-packages -- ./venv + venv/bin/python -m pip install --upgrade pip + venv/bin/pip install --upgrade pyproject-flake8 + venv/bin/pip install --upgrade git+https://salsa.debian.org/apt-team/python-apt.git@2.7.5 + + venv/bin/python {shlex.quote(_resolve_script(name = "inspect"))} + venv/bin/python {shlex.quote(_resolve_script(name = "packages"))} + venv/bin/python {shlex.quote(_resolve_script(name = "mount-idmapped"))} +""", user = "build", group = "build") + +subprocess.check_call(("venv/bin/python", _resolve_script(name = "image"))) diff --git a/.ci/image.py b/.ci/image.py new file mode 100644 index 0000000..64e7817 --- /dev/null +++ b/.ci/image.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import shlex +import typing +import shutil +import apt_pkg +import pathlib +import tempfile +import contextlib +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + +_current = pathlib.Path(".").resolve(strict = True) +assert _current.is_dir() + +_snapshot = pathlib.Path(__file__).resolve(strict = True) +assert _snapshot.is_file() +_snapshot = _snapshot.parent.parent + + +def _debian_path(): + _path = _current / "packages/debian" + assert _path.resolve(strict = True) == _path + assert _path.is_dir() + _path, = _path.glob("python3-*_*-*_all.deb") + assert _path.resolve(strict = True) == _path + assert _path.is_file() + return _path + + +_debian_path = _debian_path() +_debian_name = _debian_path.name.split("_") +assert "all.deb" == _debian_name.pop(-1) +_debian_version = _debian_name.pop(-1) +assert _debian_version +_debian_name, = _debian_name +assert _debian_name +_python_name = _debian_name.split("-") +assert "python3" == _python_name.pop(0) +_python_name, = _python_name +assert _python_name +_python_version = _debian_version.split("-") +assert "1" == _python_version.pop(-1) +_python_version, = _python_version +assert 3 == len(_python_version.split(".")) + +_destination = _current / "image" +assert _destination.resolve(strict = False) == _destination +_destination.mkdir(parents = False, exist_ok = False) +assert _destination.resolve(strict = True) == _destination +assert _destination.is_dir() + +_unnecessary_paths = tuple(""" +opt +media +etc/apt +etc/gss +etc/opt +etc/rmt +etc/motd +etc/fstab +etc/group +etc/pam.d +etc/init.d +etc/shells +etc/subuid +etc/subgid +etc/shadow +etc/passwd +etc/default +etc/gshadow +etc/profile +etc/inputrc +etc/systemd +etc/security +etc/hostname +etc/.pwd.lock +etc/netconfig +etc/cron.daily +etc/xattr.conf +etc/ld.so.cache +etc/resolv.conf +etc/logrotate.d +etc/environment +etc/alternatives +etc/ld.so.conf.d +etc/nsswitch.conf +etc/dpkg/origins +etc/dpkg/dpkg.cfg.d +usr/local +usr/include +usr/lib/udev +usr/lib/systemd +usr/lib/valgrind +usr/share/doc +usr/share/man +usr/share/bug +usr/share/info +usr/share/menu +usr/share/locale +usr/share/binfmts +usr/share/lintian +usr/share/doc-base +usr/share/readline +usr/share/util-linux +usr/share/debianutils +usr/share/applications +usr/share/bash-completion +usr/share/pixmaps +usr/share/polkit-1 +var/log +var/opt +var/mail +var/spool +var/lib/systemd +var/lib/dpkg/alternatives +root +""".strip().splitlines(keepends = False)) + + +@contextlib.contextmanager +def _mount_manager(bundle: pathlib.Path): + assert isinstance(bundle, pathlib.Path) + bundle = pathlib.Path(bundle.as_posix()) + assert bundle.resolve(strict = True) == bundle + assert bundle.is_dir() + + _mounted = list() + _items = ("dev", "sys", "run", "proc") + + def _bind(source: str): + assert isinstance(source, str) + if str is not type(source): source = str(source) + assert source + source = pathlib.Path(source) + assert not source.is_absolute() + assert source.parts[0] not in {"/", ".", ".."} + _target = bundle / source + assert _target == _target.resolve(strict = True) + assert _target.is_dir() + _target = _target.as_posix() + source = (pathlib.Path("/") / source).resolve(strict = True) + assert source.is_dir() + source = source.as_posix() + subprocess.check_call(("mount", "--rbind", "--", source, _target)) + _mounted.insert(0, _target) + + def _umount(): subprocess.check_call(("umount", "--recursive", "--", *_mounted)) + + _temporary = bundle / "tmp" + assert _temporary.resolve(strict = True) == _temporary + assert _temporary.is_dir() + _temporary = _temporary.as_posix() + + try: + subprocess.check_call(("mount", "--types=tmpfs", "--", "none", _temporary)) + _mounted.append(_temporary) + for _source in ("dev", "sys", "run", "proc"): _bind(source = _source) + yield + + finally: _umount() + + +def _shell(script: str, chroot: pathlib.Path = None): + assert isinstance(script, str) + if str is not type(script): script = str(script) + script = script.strip() + assert script + script = dict(input = f"{script}\n".encode("ascii")) + if chroot is not None: + assert isinstance(chroot, pathlib.Path) + chroot = pathlib.Path(chroot.as_posix()).resolve(strict = True) + if 1 < len(chroot.parts): + def _chroot(): + os.chdir(chroot) + os.chroot(chroot) + os.chdir("/") + script.update(preexec_fn = _chroot) + assert 0 == subprocess.run(("/bin/sh", "-xe"), **script).returncode + + +def _unnecessary_packages(bundle: pathlib.Path, keep: typing.Iterable[str]): + _keep = set() + + _database = bundle / "var/lib/dpkg/status" + assert _database.resolve(strict = True) == _database + assert _database.is_file() + _database = _database.as_posix() + + def _generate_dependencies(section: apt_pkg.TagSection): + for _key in "Depends", "Pre-Depends": + try: _field = section[_key] + except KeyError: continue + _field = apt_pkg.parse_depends(_field) + for _field in _field: + for _dependency in _field: yield _dependency[0] + + with apt_pkg.TagFile(_database) as _dpkg_sections: + _database = dict() + for _dpkg_section in _dpkg_sections: + _package = _dpkg_section["Package"] + assert _package not in _database + _database[_package] = tuple(_generate_dependencies(section = _dpkg_section)) + + def _collect(package: str): + if package in _keep: return + _keep.add(package) + try: package = _database[package] + except KeyError: return + for package in package: _collect(package = package) + + for keep in keep: _collect(package = keep) + yield from filter(lambda package: package not in _keep, _database.keys()) + + +def _remove(path: pathlib.Path): + assert isinstance(path, pathlib.Path) + path = pathlib.Path(path.as_posix()) + assert path.is_symlink() or path.exists() + if path.is_symlink(): path.unlink(missing_ok = False) + elif path.is_dir(): shutil.rmtree(path) + else: path.unlink(missing_ok = False) + + +def _tar(): + with tempfile.TemporaryDirectory(dir = _destination, prefix = "tmp.") as _temporary: + _temporary = pathlib.Path(_temporary) + assert _temporary.resolve(strict = True) == _temporary + assert _temporary.is_dir() + _bundle = _temporary / "bundle" + _bundle.mkdir(parents = False, exist_ok = False) + + subprocess.check_call(("debootstrap", "--variant=minbase", "bookworm", _bundle.as_posix())) + + _path = "etc/apt/sources.list" + (_bundle / _path).unlink(missing_ok = True) + _path = f"{_path}.d" + _remove(path = _bundle / _path) + (_bundle / _path).mkdir(parents = False, exist_ok = False) + _path = f"{_path}/debian.sources" + shutil.copy(f"/{_path}", _bundle / _path) + + _required_packages = _debian_name, "python3", "iproute2", "dash", "mount", "coreutils", "util-linux" + _temporary_packages = ( + "grep", "debconf", "diffutils", "findutils", + "libc-bin", "perl-base", "systemctl", "init-system-helpers" + ) + + with _mount_manager(bundle = _bundle): + shutil.copy(_debian_path, _bundle / "tmp" / _debian_path.name) + + _shell(script = f""" +apt update --assume-yes +apt-mark showmanual | xargs --no-run-if-empty -- apt-mark auto -- +apt install --assume-yes -- /tmp/{shlex.quote(_debian_path.name)} +apt install --assume-yes -- {" ".join([shlex.quote(_p) for _p in (*_required_packages, *_temporary_packages)])} +apt autoremove --assume-yes --allow-remove-essential --allow-change-held-packages +apt purge --assume-yes '~c' +apt full-upgrade --assume-yes +apt clean --assume-yes + """, chroot = _bundle) + + subprocess.check_call(( + "chroot", _bundle.as_posix(), "dpkg", "--purge", + "--force-remove-essential", "--", + *_unnecessary_packages(bundle = _bundle, keep = (*_required_packages, *_temporary_packages)) + )) + + subprocess.check_call(( + "chroot", _bundle.as_posix(), + "dpkg", "--purge", "--force-remove-essential", "--force-remove-protected", "--", + *_unnecessary_packages(bundle = _bundle, keep = _required_packages) + )) + + for _path in _unnecessary_paths: + _path = _bundle / _path + assert _path.is_symlink() or _path.exists(), _path.as_posix() + _remove(path = _bundle / _path) + + with open(_bundle / "var/lib/shells.state", "w") as _stream: print("/bin/sh", file = _stream) + for _path in _bundle.rglob("*-"): _remove(path = _path) + for _path in _bundle.rglob("*-old"): _remove(path = _path) + for _path in ( + *((_bundle / "etc").glob("rc*.d")), + *((_bundle / "var/log").glob("*")), + *((_bundle / "var/cache").glob("*")) + ): _remove(path = _path) + for _path in _bundle.rglob("__pycache__"): + assert _path.resolve(strict = True) == _path + assert _path.is_dir() + _remove(path = _path) + + for _path in ("init", "initctl", "overlay"): (_bundle / f"sbin/{_path}").symlink_to(f"../bin/p5.lcboot.{_path}") + + _path = _current / "mount-idmapped" + assert _path.resolve(strict = True) == _path + assert _path.is_file() + _path = (_bundle / "sbin").resolve(strict = True) / _path.name + assert _path.parent.is_dir() + assert _path.parent.is_relative_to(_bundle) + assert not (_path.is_symlink() or _path.exists()) + shutil.copyfile(_path.name, _path) + assert _path.resolve(strict = True) == _path + assert _path.is_file() + os.chown(_path, uid = 0, gid = 0) + _path.chmod(0o755) + + _path = _destination / f"{_python_name}-{_python_version}.tar" + assert _path.resolve(strict = False) == _path + assert not (_path.is_symlink() or _path.exists()) + + subprocess.check_call(( + "tar", "--create", "--same-owner", "--same-permissions", f"--file={_path.as_posix()}", + f"--directory={_bundle.as_posix()}", "--", *[_p.name for _p in _bundle.glob("*")] + )) + + assert _path.resolve(strict = True) == _path + assert _path.is_file() + return _path + + +_tar = _tar() + +_ext4 = _destination / f"{_python_name}-{_python_version}.ext4" +assert _ext4.resolve(strict = False) == _ext4 +assert not (_ext4.is_symlink() or _ext4.exists()) +subprocess.check_call(( + "virt-make-fs", "--format=raw", "--type=ext4", "--size=+16M", + f"--label={_python_name}", "--", _tar.as_posix(), _ext4.as_posix() +)) +assert _ext4.resolve(strict = True) == _ext4 +assert _ext4.is_file() + +subprocess.check_call(("xz", "-9", "--", _tar.as_posix(), _ext4.as_posix())) +assert not (_tar.is_symlink() or _tar.exists()) +_tar = _tar.parent / f"{_tar.name}.xz" +assert _tar.resolve(strict = True) == _tar +assert _tar.is_file() diff --git a/.ci/inspect.py b/.ci/inspect.py new file mode 100644 index 0000000..d474e77 --- /dev/null +++ b/.ci/inspect.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import pathlib +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + +_snapshot = pathlib.Path(__file__).resolve(strict = True) +assert _snapshot.is_file() +_snapshot = _snapshot.parent.parent + +_path = _snapshot / "pyproject.toml" +assert _path == _path.resolve(strict = True) +assert _path.is_file() +_command = [sys.executable, "-m", "pflake8", "--statistics", "--show-source", f"--config={_path.as_posix()}"] +_path = _snapshot / "python3" +assert _path == _path.resolve(strict = True) +assert _path.is_dir() +_command.extend(("--", _path.as_posix())) +subprocess.check_call(_command) diff --git a/.ci/mount-idmapped.py b/.ci/mount-idmapped.py new file mode 100644 index 0000000..8190c3d --- /dev/null +++ b/.ci/mount-idmapped.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import pathlib +import tempfile +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + +_name = "mount-idmapped" + +_destination = pathlib.Path(".").resolve(strict = True) +assert _destination.is_dir() + +with tempfile.TemporaryDirectory(dir = _destination, prefix = f"{_name}.src.tmp.") as _temporary: + _source = pathlib.Path(_temporary) + assert _source.resolve(strict = True) == _source + assert _source.is_dir() + + _source = _source / "src" + assert not (_source.is_symlink() or _source.exists()) + + subprocess.check_call(( + "git", "clone", "--depth=1", "--", f"https://github.com/brauner/{_name}.git", _source.as_posix() + ), cwd = _destination) + assert _source.resolve(strict = True) == _source + assert _source.is_dir() + _source = _source / f"{_name}.c" + assert _source.resolve(strict = True) == _source + assert _source.is_file() + + _destination = _destination / _name + assert not (_destination.is_symlink() or _destination.exists()) + + subprocess.check_call(( + "gcc", "-g0", "-O3", "-DNDEBUG", f"-o{_destination.as_posix()}", _source.as_posix() + )) diff --git a/.ci/packages.py b/.ci/packages.py new file mode 100644 index 0000000..54b0ac8 --- /dev/null +++ b/.ci/packages.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import pathlib +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + +_snapshot = pathlib.Path(__file__).resolve(strict = True) +assert _snapshot.is_file() +_snapshot = _snapshot.parent.parent + +_destination = pathlib.Path("packages") +_destination.mkdir(parents = False, exist_ok = False) +_destination = _destination.resolve(strict = True) +assert _destination.is_dir() + + +def _call_script(name: str): + assert isinstance(name, str) + if str is not type(name): name = str + assert name + _path = _snapshot / ".ci/packages" / f"{name}.py" + assert _path.resolve(strict = True) == _path + assert _path.is_file() + subprocess.check_call((sys.executable, _path.as_posix()), cwd = _destination) + + +for _name in ("python", "debian"): _call_script(name = _name) diff --git a/.ci/packages/debian.py b/.ci/packages/debian.py new file mode 100644 index 0000000..615e87c --- /dev/null +++ b/.ci/packages/debian.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import shutil +import pathlib +import tempfile +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + + +def _python(): + _path = pathlib.Path("python").resolve(strict = True) + assert _path.is_dir() + _path, = _path.glob("*.tar.*") + assert _path == _path.resolve(strict=True) + assert _path.is_file() + _name, _version = _path.name.split("-") + assert _name == _name.strip() + _name, = _name.splitlines() + _version = list(_version.split(".tar.")) + assert "." not in _version.pop(1) + _version, = _version + assert _version + assert _version == _version.strip() + _version, = _version.splitlines() + return dict(name = _name, version = _version, path = _path.as_posix()) + + +_python = _python() +_destination = pathlib.Path("debian") +_destination.mkdir(parents = False, exist_ok = False) +_destination = _destination.resolve(strict = True) +assert _destination.is_dir() + +with tempfile.TemporaryDirectory(dir = _destination, prefix = "tmp.") as _temporary: + _temporary = pathlib.Path(_temporary) + assert _temporary.resolve(strict = True) == _temporary + assert _temporary.is_dir() + + def _prepare(): + _source = _python["path"] + assert isinstance(_source, str) + _source = pathlib.Path(_source) + assert _source.is_absolute() + _source = _source.resolve(strict = True) + assert _source.is_file() + + _path = _temporary / "working" + _path.mkdir(parents = False, exist_ok = False) + assert _path.resolve(strict = True) == _path + assert _path.is_dir() + subprocess.check_call(( + "tar", "--extract", f"--file={_source.as_posix()}" + ), cwd = _path) + _working, = _path.glob("*") + assert _working.name == "-".join((_python["name"], _python["version"])) + _path = _working / "setup.py" + assert _path.resolve(strict = False) == _path + assert not _path.exists() + with open(_path, "w") as _stream: print(""" +from setuptools import setup +if "__main__" == __name__: setup(url = "https://github.com/p5-vbnekit/lcboot") + """.strip(), file = _stream) + _path = _path.parent + _source = _temporary / "source.tar.gz" + assert _source.resolve(strict = False) == _source + assert not _source.exists() + subprocess.check_call(( + "tar", "--create", "--gzip", f"--file={_source.as_posix()}", "--", _path.name + ), cwd = _path.parent) + _path = _path.parent + shutil.rmtree(_path) + _path.mkdir(parents = False, exist_ok = False) + subprocess.check_call(( + "py2dsc", "--compat=13", "--maintainer=p5-vbnekit ", + f"--dist-dir=.", "--", _source.as_posix() + ), cwd = _path) + _source.unlink() + return _path + + def _build(): + _path = _prepare() + + _upstream_name = _python["name"] + _upstream_version = _python["version"] + + _dash_name = _upstream_name.replace(".", "-") + _binary_name = f"python3-{_upstream_name}" + + _path, = filter(lambda path: path.is_dir(), _path.glob("*")) + assert _path.resolve(strict = True) == _path + assert _path.name == "-".join((_dash_name, _upstream_version)) + + with open(_path / "debian/control", "r") as _control: _control = _control.read() + assert _control + + with open(_path / "debian/control", "w") as _stream: + for _control in _control.splitlines(): + if _control.startswith("Build-Depends: "): _control = _control.replace("debhelper (>= 9)", "debhelper (>= 13)") + print(_control, file = _stream) + + with open(_path / "debian/copyright", "w") as _stream: print(f""" +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: {_dash_name} +Upstream-Contact: p5-vbnekit + +Files: * +Copyright: public-domain +License: public-domain + +License: public-domain + The work is in the public domain. To the extent permitted by law, no + copyrights apply. + """.strip(), file = _stream) + + with open(_path / f"debian/{_binary_name}.lintian-overrides", "w") as _stream: print("\n".join(( + "no-manual-page", + "initial-upload-closes-no-bugs", + "extended-description-line-too-long", + "possible-unindented-list-in-extended-description" + )), file = _stream) + + subprocess.check_call(("debuild", "-uc", "-us"), cwd = _path) + + shutil.rmtree(_path) + _path.mkdir(parents = False, exist_ok = False) + + _archive, = _path.parent.glob(f"{_dash_name}_{_upstream_version}.orig.tar.*") + assert _archive.resolve(strict = True) == _archive + assert _archive.is_file() + subprocess.check_call(("tar", "--extract", f"--file={_archive.as_posix()}"), cwd = _path) + _path, = _path.glob("*") + assert _path.resolve(strict = True) == _path + assert _path.is_dir() + if _path.name != _path.parent.name: + _path.replace(_path.parent / _path.parent.name) + _path = _path.parent / _path.parent.name + _archive = ".".join(_archive.name.split(".")[:-1]) + assert _archive + _archive = _destination / _archive + subprocess.check_call(( + "tar", "--create", f"--file={_archive.as_posix()}", "--", f"./{_path.name}" + ), cwd = _path.parent) + subprocess.check_call(( + "xz", "-9", "--", f"./{_archive.name}" + ), cwd = _archive.parent) + shutil.rmtree(_path) + _path = _path.parent + + _archive, = _path.parent.glob(f"{_dash_name}_{_upstream_version}-1.debian.tar.*") + assert _archive.resolve(strict = True) == _archive + assert _archive.is_file() + subprocess.check_call(("tar", "--extract", f"--file={_archive.as_posix()}"), cwd = _path) + _path, = _path.glob("*") + assert _path.name == "debian" + assert _path.resolve(strict = True) == _path + assert _path.is_dir() + _archive = ".".join(_archive.name.split(".")[:-1]) + assert _archive + _archive = _destination / _archive + subprocess.check_call(( + "tar", "--create", f"--file={_archive.as_posix()}", "--", f"./{_path.name}" + ), cwd = _path.parent) + subprocess.check_call(( + "xz", "-9", "--", f"./{_archive.name}" + ), cwd = _archive.parent) + _path = _path.parent + shutil.rmtree(_path) + + _path = _path.parent / f"{_binary_name}_{_upstream_version}-1_all.deb" + assert _path.resolve(strict = True) == _path + assert _path.is_file() + _path.replace(_destination / _path.name) + + + _build() diff --git a/.ci/packages/python.py b/.ci/packages/python.py new file mode 100644 index 0000000..720e8b8 --- /dev/null +++ b/.ci/packages/python.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import pathlib +import subprocess + +assert "__main__" == __name__ + +_stdin = sys.stdin +if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) +del _stdin + +_path = pathlib.Path("python") +_path.mkdir(parents = False, exist_ok = False) +_path = _path.resolve(strict = True) +assert _path.is_dir() + +_snapshot = pathlib.Path(__file__).resolve(strict = True) +assert _snapshot.is_file() +_snapshot = _snapshot.parent.parent.parent +assert _snapshot.is_dir() +subprocess.check_call(( + sys.executable, "-m", "build", "--sdist", f"--outdir={_path.as_posix()}", "--", _snapshot.as_posix() +)) diff --git a/.ci/task.concourse.yml b/.ci/task.concourse.yml new file mode 100644 index 0000000..a9a0006 --- /dev/null +++ b/.ci/task.concourse.yml @@ -0,0 +1,31 @@ +platform: linux + +image_resource: + type: registry-image + source: + repository: debian + tag: bookworm + +inputs: +- name: snapshot + +outputs: +- name: destination + +run: + path: sh + args: + - -exc + - | + export LANG=C + export LC_ALL=C + export DEBIAN_FRONTEND=noninteractive + + apt update --assume-yes + apt install --assume-yes python3 + + mkdir -- ./build + (cd -- ./build && python3 ../snapshot/.ci/bookworm.py) + + tar --create --owner=`id --user` --group=`id --group` --directory=build -- image packages \ + | tar --extract --directory=destination diff --git a/.github/actions/build/Dockerfile b/.github/actions/build/Dockerfile new file mode 100644 index 0000000..d1d39c0 --- /dev/null +++ b/.github/actions/build/Dockerfile @@ -0,0 +1,9 @@ +FROM debian:bookworm + +ENV LANG=C LC_ALL=C DEBIAN_FRONTEND=noninteractive +RUN apt update --assume-yes && apt install --assume-yes -- python3 + +COPY run.py /sbin/run.py +RUN chmod +x /sbin/run.py + +ENTRYPOINT ["/sbin/run.py"] diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml new file mode 100644 index 0000000..2003907 --- /dev/null +++ b/.github/actions/build/action.yml @@ -0,0 +1,38 @@ +inputs: + build: { default: "build" } + snapshot: { default: "snapshot" } + +runs: + using: composite + steps: + - shell: sh -e -- {0} + run: docker build --pull --tag=build -- "${{ github.action_path }}" + + - shell: python3 -- {0} + run: | + # docker run + import os + import sys + import shutil + import pathlib + _stdin = sys.stdin + if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) + del _stdin + _build_directory = pathlib.Path("${{ inputs.build }}").resolve(strict = False) + _snapshot_directory = pathlib.Path("${{ inputs.snapshot }}").resolve(strict = True) + _build_directory.mkdir(parents = True, exist_ok = True) + _build_directory = _build_directory.resolve(strict = True) + assert _build_directory.is_dir() + assert _snapshot_directory.is_dir() + _docker = shutil.which("docker") + assert _docker + os.execv(_docker, ( + "docker", "run", "--rm", "--privileged", + f"--volume={_build_directory.as_posix()}:/mnt/build", + f"--volume={_snapshot_directory.as_posix()}:/mnt/snapshot", + "--", "build", "/mnt/build", "/mnt/snapshot" + )) + raise RuntimeError("exec call returned") diff --git a/.github/actions/build/run.py b/.github/actions/build/run.py new file mode 100644 index 0000000..731ee9b --- /dev/null +++ b/.github/actions/build/run.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +import pathlib +import subprocess + +assert "__main__" == __name__ + +_interpreter = pathlib.Path(sys.executable).resolve(strict = True) +assert _interpreter.is_file() +_interpreter = _interpreter.as_posix() + +_build_directory, _snapshot_directory = sys.argv[1:] + +assert _build_directory +_build_directory = pathlib.Path(_build_directory) +_build_directory.mkdir(parents = True, exist_ok = True) +_build_directory = _build_directory.resolve(strict = True) +assert _build_directory.is_dir() + +assert _snapshot_directory +_snapshot_directory = pathlib.Path(_snapshot_directory).resolve(strict = True) +assert _snapshot_directory.is_dir() + +_uid = _snapshot_directory.stat() +_uid, _gid = _uid.st_uid, _uid.st_gid +assert isinstance(_uid, int) and (0 <= _uid) +assert isinstance(_gid, int) and (0 <= _gid) + +_script_path = _snapshot_directory / ".ci/bookworm.py" +assert _script_path.resolve(strict = True) == _script_path +assert _script_path.is_file() +_script_path = _script_path.as_posix() + +try: subprocess.check_call((_interpreter, _script_path), cwd = _build_directory) +finally: subprocess.check_call(( + "chown", "--recursive", f"{_uid}:{_gid}", "--", + _build_directory.as_posix(), + _snapshot_directory.as_posix() +)) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..7bf049b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,17 @@ +on: push + +jobs: + main: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { path: snapshot } + + - uses: ./snapshot/.github/actions/build + + - uses: actions/upload-artifact@v4 + with: + name: build + path: | + build/image + build/packages diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.old/rbind.py b/.old/rbind.py new file mode 100644 index 0000000..dc33217 --- /dev/null +++ b/.old/rbind.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" == __name__ + + +def _run(): + import os + import re + import sys + import typing + import pathlib + import argparse + import subprocess + + import _library as _library_module + + _mounts = _library_module.mounts.make() + _make_property_collector = _library_module.property_collector.make + + _stdin = sys.stdin + if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) + del _stdin + + def _config(): + _arguments = argparse.ArgumentParser() + _arguments.add_argument("destination", nargs = "?", default = "/mnt/root") + _arguments.add_argument("--source", default = "/") + _arguments.add_argument("--exclude", action = "append", metavar = "PATH") + _arguments.add_argument("--rexclude", action = "append", metavar = "REGULAR_EXPRESSION") + + _arguments = list(_arguments.parse_known_args(sys.argv[1:])) + assert not _arguments.pop(-1), "unknown arguments" + _arguments, = _arguments + _arguments = vars(_arguments) + + def _validate_directory(value: str): + assert isinstance(value, str) + if str is not type(value): value = str(value) + assert value + value = pathlib.Path(value).resolve() + if value.exists(): assert value.is_dir() + return value + + _source = _validate_directory(value = _arguments["source"]) + _destination = _validate_directory(value = _arguments["destination"]) + + assert _source.exists() + assert not _source.is_relative_to(_destination) + + def _validate_exclude(value: typing.Optional[typing.Iterable[str]]): + if value is None: return tuple() + + def _generator(): + _unique = set() + for _directory in value: + assert isinstance(_directory, str) + if str is not type(_directory): _directory = str(_directory) + assert _directory + _directory = pathlib.Path(_directory) + _parts = _directory.parts + if _parts and (_parts[0] not in {".", ".."}): _directory = _source / _directory + _directory = _directory.resolve(strict = True) + assert _directory != _source + assert _directory.is_relative_to(_source) + assert not _directory.is_relative_to(_destination) + _posix = _directory.as_posix() + assert _posix not in _unique + _unique.add(_posix) + yield _directory + + return tuple(_generator()) + + def _validate_rexclude(value: typing.Optional[typing.Iterable[str]]): + if value is None: return tuple() + _list = list() + for _pattern in value: + assert isinstance(_pattern, str) + if str is not type(_pattern): _pattern = str(_pattern) + assert _pattern + _list.append(re.compile(_pattern)) + return tuple(_list) + + return _make_property_collector( + source = _source, destination = _destination, + exclude = _validate_exclude(value = _arguments["exclude"]), + rexclude = _validate_rexclude(value = _arguments["rexclude"]) + ) + + _config = _config() + + def _collect(unique: bool = True): + _filter = {_config.source.as_posix(), _config.destination.as_posix()} + for _target in _mounts: + if _target in _filter: continue + _path = pathlib.Path(_target) + assert _path.is_absolute() + assert _path.resolve(strict = True) == _path + if unique: _filter.add(_target) + yield _path + + def _drop_children(source: typing.Iterable[pathlib.Path]): + source = tuple(source) + if not source: return + + _passed = list() + + def _condition(value: pathlib.Path): + for _base in _passed: + if value.is_relative_to(_base): return False + return True + + for _path in filter(_condition, sorted(source, key = lambda v: v.as_posix())): _passed.append(_path.as_posix()) + _passed = set(_passed) + del _condition + + def _condition(value: pathlib.Path): return value.as_posix() in _passed + for _path in filter(_condition, source): yield _path + + def _is_expected(value: pathlib.Path): + if not value.is_relative_to(_config.source): return False + if value.is_relative_to(_config.destination): return False + for _pattern in _config.exclude: + if value.is_relative_to(_pattern): return False + for _pattern in _config.rexclude: + for _match in _pattern.finditer(value.relative_to(_config.source).as_posix()): return False + return True + + def _make_unexpected(): + _value = list() + for _destination in _collect(unique = False): + if not _destination.is_relative_to(_config.destination): continue + _source = _config.source / _destination.relative_to(_config.destination) + if _is_expected(value = _source): continue + _value.insert(0, _destination) + return _value + + def _mount(): + for _source in _drop_children(source = filter(_is_expected, _collect(unique = True))): + _destination = _config.destination / _source.relative_to(_config.source) + if not _destination.exists(): + if _source.is_dir(): _destination.mkdir(parents = True, exist_ok = False) + else: + _destination.parent.mkdir(parents = True, exist_ok = True) + _destination.touch() + _destination.mkdir(parents = True, exist_ok = True) + subprocess.check_call(( + "mount", "--rbind", "--", _source.as_posix(), + (_config.destination / _source.relative_to(_config.source)).as_posix() + ), stdin = subprocess.DEVNULL) + + for _destination in _drop_children(source = _make_unexpected()): subprocess.check_call(( + "umount", "--recursive", "--", _destination.as_posix(), + ), stdin = subprocess.DEVNULL) + + assert not _make_unexpected() + + _mount() + + +_run() diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md new file mode 100644 index 0000000..402874d --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# p5-lcboot +Minimalistic file system for booting guests (qemu, lxc, etc.) + +### Motivation +There are many pain with rootless [`libvirt_lxc` containers](https://libvirt.org/drvlxc.html). +They cannot use `/dev/loop`, `overlay`, `user xattr`, etc. +This toolbox was primarily developed as a way to downgrade privileges to rootless inside a rootfull `lxc-libvirt` container. + +### Features +- Configurable `/sbin/init` - `p5.lcboot.init` script. + - hooking in "tolerant" mode - `p5.lcboot.tolerant` script. + - re-`unshare` namespaces - `uts`, `ipc`, `user` (`uid`/`gid` mapping), `mount`, `cgroup` (isolate it), `network`. +- Trivial `/dev/initctl` provider implementation - `p5.lcboot.initctl` (`/sbin/initctl`) script. +- `/sbin/overlay` - `p5.lcboot.overlay` helper script. +- [`/sbin/mount-idmapped`](https://github.com/brauner/mount-idmapped) binary. + +### Typical launch +- Provide `p5.lcboot` image into `/` of your container (read-only if you wish). +- Provide target (next hop, real guest) root into `/mnt/root` of your container (read-only if you wish). +- Provide read/write cache directory (ram, host fs, etc.) into `/mnt/cache` of your container. +- Set container entry point to `/sbin/init`. +- Enjoy. + +### Advanced configuration +- Set container entry point to `/bin/sh`. +- Use `--help` key for any `p5.lcboot.*` executable (["python3/scripts" directory in source code](python3/scripts)). +- Check out the [sources](python3), there is nothing complicated there. =) +- Configure boot via your own `/mnt/init.yml`. + +#### Example of `/mnt/init.yml`: +```yaml +# "Next hop" root file system path. +# For example, may be mounted to guest via `/dev/loop` readonly source. +# And/or may usually be (re)mounted several times during "setup.before" step. +root: /mnt/root # default is {path: "/mnt/root", mode: "pivot"} + +# Let's enable uid/gid mapping for unshare system call (disabled by default). +id_map: + users: 0 1000000 65536 + groups: + - 0 1000000 # `internal` `external` (size = 1 by default) + - 1 1000001 1 # `internal` `external` `size` + - internal: 2 + external: 1000002 + # size: 1 # default too + - internal: 3 + external: 1000003 + size: 65533 + +initctl: false # default; replace to `true` if you want to spawn `/dev/initctl` (via `p5.lcboot.initctl`) right now + +# First step is "setup". +# At this step `p5.lcboot.init` will: +# - mount /mnt/root/proc`; +# - invoke `unshare` system call; +# - mount new `/mnt/root/sys`, `/mnt/root/dev`. +# After this step `p5.lcboot.init` will change root to `/mnt/root` via `pivot_root` system call. +# You may change root.mode to `chroot` or `none`. +setup: + # Let's set `setup` mode. Replace it to `none` for skip it and do some in `before` and `exec.before`. + mode: auto # default + + # Custom "setup" instructions before this step. + # In this case we hope that `/mnt/root` is already populated by container owner, + # but we want to remap some uids/gids and apply some fs layers. + before: + # Let's remap users/groups on `/mnt/root`. + - mount-idmapped --map-mount=b:0:1000000:65536 /mnt/root /mnt/root + + # Remount `/mnt/root` as `overlayfs` with: + # - `lower` ro source layers: `/mnt/root` under `mnt/layers/0` under `/mnt/layers/1`; + # - `upper` rw layer: `/mnt/cache/overlay/upper`; + # - `workdir`: `/mnt/cache/overlay/temp/w`; + # - destination: `/mnt/root`. + - overlay -- /mnt/layers/0 /mnt/layers/1 + +# Last step is "exec". +exec: + # Of course, you can do something before `exec`. + before: + - echo "Hello, 'next hop' root!" + - ["echo", "We are ready to boot `systemd` now."] + - command: bash -e # we have `bash` on "next hop" root + input: | + echo 'Yeah! Only now '"it's"' interpreted by real shell ('"$0"')!' + echo 'This script received via stdin.' + echo "Date is `date`" + - - sh + - -ec + - "echo 'This script received via `cli` key.'; echo Shell is \"$0\"; echo \"Date is `date`\"" + + # Let's set custom `exec` system call command. + # Also, you can override it or append arguments via `cli`, use `--help` key. + command: /lib/systemd/systemd --system # "/sbin/init" by default +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c96a9df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "p5.lcboot" +readme = "README.md" +version = "0.0.0" +license = {file = "LICENSE"} +authors = [{name = "p5-vbnekit", email = "vbnekit@gmail.com"}] +description = "p5.lcboot python3 package" +maintainers = [{name = "p5-vbnekit", email = "vbnekit@gmail.com"}] +dependencies = ["PyYAML"] + +[project.urls] +homepage = "https://github.com/p5-vbnekit/lcboot" + +[project.scripts] +"p5.lcboot.init" = "p5.lcboot.scripts.init:run" +"p5.lcboot.overlay" = "p5.lcboot.scripts.overlay:run" +"p5.lcboot.initctl" = "p5.lcboot.scripts.initctl:run" +"p5.lcboot.tolerant" = "p5.lcboot.scripts.tolerant:run" + +[project.optional-dependencies] +dev = ["pyproject-flake8"] + +[tool.flake8] +extend-ignore = ["E251", "E701"] +max-line-length = 128 + +[tool.setuptools] +package-dir = {"p5.lcboot" = "python3"} diff --git a/python3/__init__.py b/python3/__init__.py new file mode 100644 index 0000000..d9318bd --- /dev/null +++ b/python3/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from . import _runtime as _runtime_module + + _make_lazy = _runtime_module.common.lazy_attributes.make_getter + _make_property_collector = _runtime_module.common.property_collector.make + + return _make_property_collector(lazy = _make_lazy()) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/_runtime/__init__.py b/python3/_runtime/__init__.py new file mode 100644 index 0000000..88ba618 --- /dev/null +++ b/python3/_runtime/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from . import common as _common_module + + _make_lazy = _common_module.lazy_attributes.make_getter + _make_property_collector = _common_module.property_collector.make + + return _make_property_collector(lazy = _make_lazy(dictionary = dict( + IdMap = lambda module: module.id_mapping.Class + ))) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/_runtime/common/__init__.py b/python3/_runtime/common/__init__.py new file mode 100644 index 0000000..35450f0 --- /dev/null +++ b/python3/_runtime/common/__init__.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from . import lazy_attributes as _lazy_attributes_module + from . import property_collector as _property_collector_module + + _make_lazy = _lazy_attributes_module.make_getter + _make_property_collector = _property_collector_module.make + + return _make_property_collector(lazy = _make_lazy(dictionary = dict( + PropertyCollector = lambda: _property_collector_module.Class + ))) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/_runtime/common/lazy_attributes.py b/python3/_runtime/common/lazy_attributes.py new file mode 100644 index 0000000..3288304 --- /dev/null +++ b/python3/_runtime/common/lazy_attributes.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + def _make_base(): + import sys + import typing + import inspect + import importlib + + class _Types(object): + Factory = typing.Callable + ModuleName = str + Dictionary = typing.Dict[typing.Union[str, type(None)], typing.Union[Factory, type(None)]] + + class _ValidateAndMake(object): + @staticmethod + def module_name(value: typing.Union[type(None), _Types.ModuleName], make_default: typing.Callable): + if value is None: return make_default() + assert isinstance(value, _Types.ModuleName) and bool(value) and (not value.startswith(".")) + return value + + @classmethod + def dictionary(cls, value: typing.Union[type(None), _Types.Dictionary], make_default: typing.Callable): + if value is None: return make_default() + _collector = {_key: cls.__factory(key = _key, value = _value) for _key, _value in dict(value).items()} + for _key, _value in make_default().items(): _collector.setdefault(_key, _value) + return _collector + + @staticmethod + def __factory(key: typing.Union[str, type(None)], value: typing.Union[_Types.Factory, type(None)]): + if key is None: + if value is None: return None + else: assert isinstance(key, str) and bool(key) and (1 == len(key.split("."))) + _parameters = inspect.signature(value).parameters + _parameters_count = len(_parameters) + if 1 > _parameters_count: return lambda module, name: value() + if 2 > _parameters_count: + _parameter = next(iter(_parameters.values())) + if _parameter.VAR_KEYWORD == _parameter.kind: + return lambda module, name: value(module = module, name = name) + if _parameter.VAR_POSITIONAL == _parameter.kind: return lambda module, name: value(name) + _parameter = _parameter.name + if "name" == _parameter: return lambda module, name: value(name = name) + if "module" == _parameter: return lambda module, name: value(module = module) + return lambda module, name: value() + if 3 > _parameters_count: return lambda module, name: value(module = module, name = name) + raise ValueError("unsupported signature") + + class _Meta(type): + dictionary: _Types.Dictionary = None + module_name: _Types.ModuleName = None + + @property + def get(cls): return cls.__getter + + def __init__(cls, *args, **kwargs): + super().__init__(*args, **kwargs) + + _module_name = _ValidateAndMake.module_name(value = cls.module_name, make_default = lambda: cls.__module__) + _module = sys.modules[_module_name] + + def _make_default_dictionary(): + def _factory(module: type(_module), name: str): return importlib.import_module( + "{}.{}".format(_module_name, name) + ) + return {None: _factory} + + _dictionary = _ValidateAndMake.dictionary(value = cls.dictionary, make_default = _make_default_dictionary) + + try: _default_factory = _dictionary[None] + except KeyError: _default_factory = None + + _recursion_protector = set() + + def _getter(name: str): + assert isinstance(name, str) and bool(name) + assert 1 == len(name.split(".")) + if name in _recursion_protector: + try: raise RuntimeError("recursion rejected") + finally: raise AttributeError(name) + _recursion_protector.add(name) + try: + _factory = _dictionary.get(name, _default_factory) + if _factory is None: raise AttributeError(name) + return _factory(module = _module, name = name) + finally: _recursion_protector.remove(name) + + cls.__module = _module + cls.__getter = _getter + + class _Class(metaclass = _Meta): + Types = _Types + + return _Class + + _Base = _make_base() + + def _make_class(**kwargs): + _valid_keys = {"dictionary", "module_name"} + for _key in kwargs.keys(): + if not (_key in _valid_keys): raise ValueError("unknown argument: {}".format(_key)) + + def _make_dictionary(): + try: return kwargs["dictionary"] + except KeyError: pass + return Base.dictionary + + def _make_module_name(): + try: return kwargs["module_name"] + except KeyError: pass + import inspect + _frame = inspect.stack()[2] + _module = inspect.getmodule(_frame[0]) + return _module.__name__ + + class _Result(Base): + dictionary = _make_dictionary() + module_name = _make_module_name() + + return _Result + + def _make_getter(**kwargs): + if not ("module_name" in kwargs): + import inspect + _frame = inspect.stack()[2] + _module = inspect.getmodule(_frame[0]) + kwargs["module_name"] = _module.__name__ + + _class = _make_class(**kwargs) + _class.get.keys = _class.dictionary.keys() if isinstance(_class.dictionary, dict) else tuple() + return _class.get + + from . import property_collector as _property_collector_module + return _property_collector_module.make(Base = _Base, make_class = _make_class, make_getter = _make_getter) + + +try: + _private = _private() + Base = _private.Base + make_class = _private.make_class + make_getter = _private.make_getter +finally: del _private diff --git a/python3/_runtime/common/property_collector.py b/python3/_runtime/common/property_collector.py new file mode 100644 index 0000000..e117af1 --- /dev/null +++ b/python3/_runtime/common/property_collector.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + class _Class(object): + def __init__(self, **keywords): + super().__init__() + for _key, _value in keywords.items(): + assert isinstance(_key, str) + assert _key + assert _key == _key.strip() + _key, = _key.splitlines() + assert not _key.startswith("_") + self.__dict__.update(keywords) + + return _Class + + +try: Class = _private() +finally: del _private + + +def make(*args, **kwargs): return Class(*args, **kwargs) diff --git a/python3/_runtime/id_map.py b/python3/_runtime/id_map.py new file mode 100644 index 0000000..213f306 --- /dev/null +++ b/python3/_runtime/id_map.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import io + import typing + + from . import common as _common_module + + _make_property_collector = _common_module.property_collector.make + + def _make_item(internal: int, external: int, size: int = 1): + assert isinstance(internal, int) + assert isinstance(external, int) + assert isinstance(size, int) + if int is not type(internal): internal = int(internal) + if int is not type(external): external = int(external) + if int is not type(size): size = int(size) + assert 0 <= internal + assert 0 <= external + assert 0 < size + return _make_property_collector(internal = internal, external = external, size = size) + + def _find(value, mapping, reverse): + assert isinstance(value, int) + if int is not type(value): value = int(value) + assert 0 <= value + + if reverse: _key = lambda: mapping.external + else: _key = lambda: mapping.internal + + for mapping in mapping: + _min = _key() + if value < _min: continue + _max = _min + mapping.size - 1 + if value > _max: continue + return mapping + + return None + + def _validate_sequence(sequence): + sequence = [_make_item(**_item) for _item in sequence] + + _internal = sorted(sequence, key = lambda item: item.internal) + _iterator = iter(_internal) + _item = next(_iterator) + _previous = _item + for _item in _iterator: + assert _previous.internal + _previous.size <= _item.internal, "internal interception" + _previous = _item + + _iterator = iter(sorted(sequence, key = lambda item: item.external)) + _item = next(_iterator) + _previous = _item + for _item in _iterator: + assert _previous.external + _previous.size <= _item.external, "external interception" + _previous = _item + + return tuple(_internal) + + def _make_text(sequence): + with io.StringIO() as _stream: + for _item in sequence: print(f"{_item.internal} {_item.external} {_item.size}", file = _stream) + return _stream.getvalue() + + class _Class(object): + @property + def text(self): return self.__text + + def __call__(self, value: int): + _item = _find(value = value, mapping = self.__sequence, reverse = False) + if _item is None: return None + return (value - _item.internal) + _item.external + + def __init__(self, sequence: typing.Iterable[typing.Dict[str, int]]): + super().__init__() + sequence = _validate_sequence(sequence = sequence) + self.__text = _make_text(sequence = sequence) + self.__sequence = sequence + + return _make_property_collector(Class = _Class) + + +try: Class = _private().Class +finally: del _private + + +def make(*args, **kwargs): return Class(*args, **kwargs) diff --git a/python3/_runtime/system_calls/__init__.py b/python3/_runtime/system_calls/__init__.py new file mode 100644 index 0000000..9ee12da --- /dev/null +++ b/python3/_runtime/system_calls/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from ... import _runtime as _runtime_module + + _make_lazy = _runtime_module.common.lazy_attributes.make_getter + _make_property_collector = _runtime_module.common.property_collector.make + + return _make_property_collector(lazy = _make_lazy(dictionary = dict( + Exit = lambda module: module.exit.Class, + Unshare = lambda module: module.unshare.Class, + PivotRoot = lambda module: module.pivot_root.Class + ))) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/_runtime/system_calls/exit.py b/python3/_runtime/system_calls/exit.py new file mode 100644 index 0000000..82fff62 --- /dev/null +++ b/python3/_runtime/system_calls/exit.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import os + import sys + import ctypes + import traceback + import contextlib + + from .. import common as _common_module + + _make_property_collector = _common_module.property_collector.make + + _libc = ctypes.CDLL(None).exit + _libc.restype = None + + try: _os = getattr(os, "_exit") + except AttributeError: _os = None + + try: _sys = sys.exit + except AttributeError: _sys = None + + def _validate_code(value: int): + assert isinstance(value, int) + if int is not type(value): value = int(value) + return value + + @contextlib.contextmanager + def _exception_manager(): + try: yield + except BaseException: + print(traceback.print_exc(), file = sys.stderr, flush = True) + raise + finally: return + + def _invoke(code: int): + code = _validate_code(value = code) + with _exception_manager(): _libc(ctypes.c_int(code)) + if _os is not None: + with _exception_manager: _os(code) + if _sys is not None: + with _exception_manager: _sys(code) + exit(code) + + class _Class(object): + def __call__(self, code: int = 0): _invoke(code = code) + + return _make_property_collector(Class = _Class) + + +try: Class = _private().Class +finally: del _private + + +def make(*args, **kwargs): return Class(*args, **kwargs) diff --git a/python3/_runtime/system_calls/pivot_root.py b/python3/_runtime/system_calls/pivot_root.py new file mode 100644 index 0000000..9006a28 --- /dev/null +++ b/python3/_runtime/system_calls/pivot_root.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import ctypes + import typing + import pathlib + + from .. import common as _common_module + + _make_property_collector = _common_module.property_collector.make + + _library = ctypes.CDLL(None).pivot_root + _library.restype = ctypes.c_int + + def _validate_path(value: typing.Union[str, pathlib.Path]): + if isinstance(value, pathlib.Path): value = value.as_posix() + else: assert isinstance(value, str) + value = pathlib.Path(value).resolve(strict = True) + assert value.is_dir() + assert 1 < len(value.parts) + return value + + class _Class(object): + def __call__(self, new: typing.Union[str, pathlib.Path], old: typing.Union[str, pathlib.Path] = None): + new = _validate_path(value = new) + if old is None: old = new + else: + old = _validate_path(value = old) + assert old.is_relative_to(new) + _current = pathlib.Path(".").resolve(strict = True) + assert _current.is_dir() + new = ctypes.c_char_p(new.as_posix().encode("utf-8")) + old = ctypes.c_char_p(old.as_posix().encode("utf-8")) + assert 0 == _library(new, old) + + return _make_property_collector(Class = _Class) + + +try: Class = _private().Class +finally: del _private + + +def make(*args, **kwargs): return Class(*args, **kwargs) diff --git a/python3/_runtime/system_calls/unshare.py b/python3/_runtime/system_calls/unshare.py new file mode 100644 index 0000000..88f6b95 --- /dev/null +++ b/python3/_runtime/system_calls/unshare.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import ctypes + import typing + + from .. import common as _common_module + + _make_property_collector = _common_module.property_collector.make + + _library = ctypes.CDLL(None).unshare + _library.restype = ctypes.c_int + + _flags = _make_property_collector( + CLONE_NEWNS = 0x00020000, + CLONE_NEWCGROUP = 0x02000000, + CLONE_NEWUTS = 0x04000000, + CLONE_NEWIPC = 0x08000000, + CLONE_NEWUSER = 0x10000000, + CLONE_NEWNET = 0x40000000 + ) + + def _validate_natural(value: int): + assert isinstance(value, int) + if int is not type(value): value = int(value) + assert 0 < value + return value + + def _make_mask(value: typing.Iterable[int]): + _collector = 0 + for _flag in value: + _flag = _validate_natural(value = _flag) + assert not (_flag & _collector) + _collector |= _flag + return _collector + + _mask = _make_mask(value = vars(_flags).values()) + + def _validate_flags(value: typing.Union[int, typing.Iterable[int]]): + if not isinstance(value, int): value = _make_mask(value = value) + else: value = _validate_natural(value = value) + assert (value & _mask) == value + return value + + class _Class(object): + flags = _flags + + @staticmethod + def make_mask(*flags: int): return _validate_flags(value = flags) + + def __call__(self, flags: typing.Union[int, typing.Iterable[int]]): + assert 0 == _library(ctypes.c_int(_validate_flags(value = flags))) + + return _make_property_collector(Class = _Class) + + +try: Class = _private().Class +finally: del _private + + +def make(*args, **kwargs): return Class(*args, **kwargs) diff --git a/python3/scripts/__init__.py b/python3/scripts/__init__.py new file mode 100644 index 0000000..a3e1032 --- /dev/null +++ b/python3/scripts/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from .. import _runtime as _runtime_module + + _make_lazy = _runtime_module.common.lazy_attributes.make_getter + _make_property_collector = _runtime_module.common.property_collector.make + + return _make_property_collector(lazy = _make_lazy()) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/scripts/init/__init__.py b/python3/scripts/init/__init__.py new file mode 100644 index 0000000..e390c0d --- /dev/null +++ b/python3/scripts/init/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from ... import _runtime as _runtime_module + + _make_lazy = _runtime_module.common.lazy_attributes.make_getter + _make_property_collector = _runtime_module.common.property_collector.make + + return _make_property_collector(lazy = _make_lazy(dictionary = dict( + run = lambda module: getattr(module, "_run").run + ))) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/scripts/init/__main__.py b/python3/scripts/init/__main__.py new file mode 100644 index 0000000..cab668f --- /dev/null +++ b/python3/scripts/init/__main__.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" == __name__ + + +def _run(): + import os + import sys + + from . import _run as _module + + _stdin = sys.stdin + if _stdin is not None: + _stdin = [_stdin.fileno(), _stdin] + _stdin.pop().close() + os.close(_stdin.pop()) + del _stdin + + _module.run() + + +try: _run() +finally: del _run diff --git a/python3/scripts/init/_cli.py b/python3/scripts/init/_cli.py new file mode 100644 index 0000000..06ac454 --- /dev/null +++ b/python3/scripts/init/_cli.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import sys + import typing + import pathlib + import argparse + + from ... import _runtime as _runtime_module + + _make_property_collector = _runtime_module.common.property_collector.make + + def _parse(): + _arguments = argparse.ArgumentParser() + _arguments.add_argument( + "--config", required = False, metavar = "path", help = "config path (default: `/mnt/init.yml`)" + ) + _arguments.add_argument("--override-exec", action = "store_true", help = "override exec command") + _arguments.add_argument( + "exec", nargs = "*", metavar = "command", help = "exec: override command/append arguments" + ) + + _arguments = list(_arguments.parse_known_args(sys.argv[1:])) + assert not _arguments.pop(-1), "unknown arguments" + _arguments, = _arguments + _arguments = vars(_arguments) + + def _validate_config(value: typing.Optional[str]): + if value is None: return None + assert isinstance(value, str) + if str is not type(value): value = str(value) + value = pathlib.Path(value).resolve(strict = True) + assert value.is_file() + return value + + def _validate_exec(override: bool, command: typing.Optional[typing.Iterable[str]]): + assert isinstance(override, bool) + if bool is not type(override): override = bool(override) + + if command is None: + assert override is False + return _make_property_collector(override = override, command = None) + + def _validate_item(item: str): + assert isinstance(item, str) + if str is not type(item): item = str(item) + return item + + command = [_validate_item(item = command) for command in command] + command = tuple(command) if command else None + if override: assert command[0] + + return _make_property_collector(override = override, command = command) + + return _make_property_collector( + exec = _validate_exec(override = _arguments["override_exec"], command = _arguments["exec"]), + config = _validate_config(value = _arguments["config"]) + ) + + return _make_property_collector(parse = _parse) + + +try: parse = _private().parse +finally: del _private diff --git a/python3/scripts/init/_config.py b/python3/scripts/init/_config.py new file mode 100644 index 0000000..26d180c --- /dev/null +++ b/python3/scripts/init/_config.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import re + import shlex + import typing + import pathlib + + from ... import _runtime as _runtime_module + + _make_id_map = _runtime_module.id_map.make + _make_property_collector = _runtime_module.common.property_collector.make + + def _make_default(): return _make_property_collector( + root = _make_property_collector(path = pathlib.Path("/mnt/root"), mode = "pivot"), + id_map = None, initctl = False, devices = _make_property_collector(exclude = tuple(), rexclude = tuple()), + setup = _make_property_collector(mode = "guess", before = tuple()), + exec = _make_property_collector(before = tuple(), command = None) + ) + + def _validate_root(value): + if isinstance(value, str): return _validate_root(value = dict(path = value)) + assert isinstance(value, dict) + if dict is type(value): value = value.copy() + else: value = dict(value) + try: _path = value.pop("path") + except KeyError: _path = "/mnt/root" + else: + assert isinstance(_path, str) + if str is not type(_path): _path = str(_path) + assert _path + _path = pathlib.Path(_path) + try: _mode = value.pop("mode") + except KeyError: _mode = "pivot" + else: + if _mode is not None: + assert isinstance(_mode, str) + if str is not type(_mode): _mode = str(_mode) + assert _mode in {"pivot", "chroot"} + assert not value, "unknown keys" + return _make_property_collector(path = _path, mode = _mode) + + _number_pattern = re.compile(r"^[0-9]+$") + _id_map_split_pattern = re.compile(r"\s+") + + def _parse_id_map_text(value): + assert isinstance(value, str) + if str is not type(value): value = str(value) + value = list(_id_map_split_pattern.split(value)) + if 2 == len(value): (_internal, _external), _size = value, 1 + else: + _internal, _external, _size = value + assert _number_pattern.match(_size) is not None + _size = int(_size) + assert 0 < _size + assert _number_pattern.match(_internal) is not None + assert _number_pattern.match(_external) is not None + return dict(internal = int(_internal), external = int(_external), size = _size) + + def _parse_id_map_item(value): + if isinstance(value, str): return _parse_id_map_text(value = value) + if isinstance(value, list): + if 2 == len(value): (_internal, _external), _size = value, 1 + else: + _internal, _external, _size = value + assert 0 < _size + assert 0 <= _internal + assert 0 <= _external + return dict(internal = int(_internal), external = int(_external), size = _size) + assert isinstance(value, dict) + if dict is type(value): value = value.copy() + else: value = dict(value) + _internal = value.pop("internal") + assert isinstance(_internal, int) + if int is not type(_internal): _internal = int(_internal) + assert 0 <= _internal + _external = value.pop("external") + if int is not type(_external): _external = int(_external) + assert 0 <= _external + try: _size = value.pop("size") + except KeyError: _size = 1 + else: + assert isinstance(_size, int) + if int is not type(_size): _size = int(_size) + assert 0 < _size + assert not value, "unknown keys" + return dict(internal = int(_internal), external = int(_external), size = _size) + + def _validate_id_map_section(value): + if isinstance(value, (str, dict)): return _make_id_map(sequence = [_parse_id_map_item(value = value)]) + assert isinstance(value, list) + return _make_id_map(sequence = [_parse_id_map_item(value = value) for value in value]) + + def _validate_id_map(value): + if value is False: return None + + assert isinstance(value, dict) + assert value + + if dict is type(value): value = value.copy() + else: value = dict(value) + + try: _users = value.pop("users") + except KeyError: _users = None + else: _users = _validate_id_map_section(value = _users) + + try: _groups = value.pop("groups") + except KeyError: _groups = None + else: _groups = _validate_id_map_section(value = _groups) + + assert not value, "unknown keys" + + if _users is None: assert _groups is not None + elif _groups is None: assert _users is not None + + return _make_property_collector(users = _users, groups = _groups) + + def _validate_initctl(value): + assert isinstance(value, bool) + if bool is not type(value): value = bool(value) + return value + + def _validate_devices_exclude(value): + if isinstance(value, str): return _validate_devices_exclude(value = [value]) + + assert isinstance(value, list) + + def _validate_item(item): + assert isinstance(item, str) + if str is not type(item): item = str(item) + assert item + return item + + return tuple([_validate_item(item = _item) for _item in value]) + + def _validate_devices_rexclude(value): return tuple([ + re.compile(_item) for _item in _validate_devices_exclude(value = value) + ]) + + def _validate_devices(value): + assert isinstance(value, dict) + if dict is type(value): value = value.copy() + else: value = dict(value) + + try: _exclude = value.pop("exclude") + except KeyError: _exclude = tuple() + else: _exclude = _validate_devices_exclude(value = _exclude) + + try: _rexclude = value.pop("rexclude") + except KeyError: _rexclude = tuple() + else: _rexclude = _validate_devices_rexclude(value = _rexclude) + + assert not value, "unknown keys" + return _make_property_collector(exclude = _exclude, rexclude = _rexclude) + + def _validate_command_item(value): + assert isinstance(value, str) + if str is not type(value): value = str(value) + return value + + def _validate_command(value: typing.Union[str, list, dict]): + if isinstance(value, (str, list)): return _validate_command(value = dict(command = value)) + + assert isinstance(value, dict) + if dict is type(value): value = value.copy() + else: value = dict(value) + + _command = value.pop("command") + + if isinstance(_command, str): + assert _command + _command = shlex.split(_command) + else: + assert isinstance(_command, list) + if list is type(_command): _command = _command.copy() + else: _command = list(_command) + + _command = tuple([_validate_command_item(_command) for _command in _command]) + assert _command[0] + + try: _input = value.pop("input") + except KeyError: _input = None + else: _input = _validate_command_item(value = _input) + + assert not value, "unknown keys" + return _make_property_collector(command = _command, input = _input) + + def _validate_commands(value): + if value is False: return tuple() + if isinstance(value, (str, dict)): return _validate_command(value = value), + return tuple([_validate_command(value = value) for value in value]) + + def _validate_setup_mode(value): + if value is not None: + assert isinstance(value, str) + if str is not type(value): value = str(value) + assert value + return value + + def _validate_setup(value): + if not isinstance(value, dict): return _validate_setup(value = dict(mode = None, before = value)) + assert isinstance(value, dict) + if dict is type(value): value = value.copy() + else: value = dict(value) + try: _before = value.pop("before") + except KeyError: _before = tuple() + else: _before = _validate_commands(value = _before) + try: _mode = value.pop("mode") + except KeyError: _mode = "guess" + else: _mode = _validate_setup_mode(value = _mode) + assert not value, "unknown keys" + return _make_property_collector(mode = _mode, before = _before) + + def _validate_exec(value): + if not isinstance(value, dict): return _validate_exec(value = dict(command = value)) + + if dict is type(value): value = value.copy() + else: value = dict(value) + assert value + + try: _before = value.pop("before") + except KeyError: _before = tuple() + else: _before = _validate_commands(value = _before) + + try: _command = value.pop("command") + except KeyError: _command = None + else: + _command = _validate_command(value = _command) + assert _command.input is None + _command = _command.command + + assert not value, "unknown keys" + return _make_property_collector(before = _before, command = _command) + + def _parse(source: typing.Optional[typing.Dict[str, typing.Any]]): + _value = _make_default() + if source is None: return _value + + assert isinstance(source, dict) + if dict is type(source): source = source.copy() + else: source = dict(source) + + try: _value.root = source.pop("root") + except KeyError: pass + else: _value.root = _validate_root(value = _value.root) + + try: _value.id_map = source.pop("id_map") + except KeyError: pass + else: _value.id_map = _validate_id_map(value = _value.id_map) + + try: _value.initctl = source.pop("initctl") + except KeyError: pass + else: _value.initctl = _validate_initctl(value = _value.initctl) + + try: _value.devices = source.pop("devices") + except KeyError: pass + else: _value.devices = _validate_devices(value = _value.devices) + + try: _value.setup = source.pop("setup") + except KeyError: pass + else: _value.setup = _validate_setup(value = _value.setup) + + try: _value.exec = source.pop("exec") + except KeyError: pass + else: _value.exec = _validate_exec(value = _value.exec) + + assert not source, "unknown keys" + return _value + + return _make_property_collector(parse = _parse) + + +try: parse = _private().parse +finally: del _private diff --git a/python3/scripts/init/_run.py b/python3/scripts/init/_run.py new file mode 100644 index 0000000..b9edc57 --- /dev/null +++ b/python3/scripts/init/_run.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import os + import sys + import yaml + import pathlib + import functools + import traceback + import subprocess + + from . import _cli as _cli_module + from . import _config as _config_module + from . import _setup_algorithm as _setup_algorithm_module + from .. import initctl as _initctl_module + from .. import tolerant as _tolerant_module + from ... import _runtime as _runtime_module + + _parse_cli = _cli_module.parse + _parse_config = _config_module.parse + _tolerant_exec = _tolerant_module.execute + _tolerant_spawn = _tolerant_module.spawn + _setup_algorithm_factory = _setup_algorithm_module.make + _make_property_collector = _runtime_module.common.property_collector.make + + _system_calls = _make_property_collector( + exit = _runtime_module.system_calls.exit.make(), + unshare = _runtime_module.system_calls.unshare.make(), + pivot_root = _runtime_module.system_calls.pivot_root.make() + ) + + _system_calls.unshare = functools.partial(_system_calls.unshare, flags = _system_calls.unshare.flags.CLONE_NEWNS) + + def _validate_identification(): + def _require_root(value: int): + assert isinstance(value, int) + assert int is type(value) + assert 0 == value + + _values = _make_property_collector( + user = _make_property_collector(), + group = _make_property_collector() + ) + + _values.user.real, _values.user.effective, _values.user.saved = os.getresuid() + _values.group.real, _values.group.effective, _values.group.saved = os.getresgid() + + _require_root(value = _values.user.real) + _require_root(value = _values.user.effective) + _require_root(value = _values.user.saved) + + _require_root(value = _values.group.real) + _require_root(value = _values.group.effective) + _require_root(value = _values.group.saved) + + def _guess_setup_mode(): + try: _container = os.environ["container"] + except KeyError: pass + else: + if "lxc-libvirt" == _container: return _container + return None + + def _make_config(): + _cli = _parse_cli() + + def _yaml(): + _path = _cli.config + if _path is None: + _path = pathlib.Path("/mnt/init.yml") + if not (_path.is_symlink() or _path.exists()): return _parse_config(source = None) + assert isinstance(_path, pathlib.Path) + _path = _path.resolve(strict = True) + assert _path.is_file() + with open(_path, "r") as _stream: + _data = yaml.safe_load(_stream) + assert not _stream.read(1) + assert isinstance(_data, dict) + return _parse_config(source = _data) + + _yaml = _yaml() + + if "guess" == _yaml.setup.mode: + _yaml.setup.mode = _guess_setup_mode() + assert _yaml.setup.mode is not None, "unable to guess setup mode" + + _yaml.exec.command = list() if _yaml.exec.command is None else list(_yaml.exec.command) + if _cli.exec.override: _yaml.exec.command.clear() + if _cli.exec.command: _yaml.exec.command.extend(_cli.exec.command) + if not _yaml.exec.command: _yaml.exec.command.append("/sbin/init") + _yaml.exec.command = tuple(_yaml.exec.command) + + return _yaml + + def _make_setup_algorithm(mode): + if mode is None: return None + return _setup_algorithm_factory(key = mode) + + def _spawn_command(command): + if command.input is None: _tolerant_spawn(arguments = command.command, options = dict(stdin = subprocess.DEVNULL)) + else: _tolerant_spawn(arguments = command.command, options = dict(text = True, input = command.input)) + + def _spawn_initctl(): + _main_pid = os.getpid() + assert isinstance(_main_pid, int) + assert 0 <= _main_pid + + _child_pid = os.fork() + assert isinstance(_child_pid, int) + + if 0 == _child_pid: + try: + os.setsid() + _tolerant_exec(path = sys.executable, arguments = ("-m", _initctl_module.__name__, "--", f"{_main_pid}")) + raise RuntimeError("invalid state") + + except BaseException: + print(traceback.format_exc(), file = sys.stderr, flush = True) + raise + + finally: _system_calls.exit(1) + + assert 0 < _child_pid + + def _pivot(path: pathlib.Path): + os.chdir("/") + _tolerant_spawn(arguments = ("mount", "--make-rprivate", "--", "/")) + _tolerant_spawn(arguments = ("mount", "--rbind", "--", path.as_posix(), path.as_posix())) + _system_calls.pivot_root(new = path) + os.chdir("/") + _tolerant_spawn(arguments = ("umount", "--lazy", "--", "/")) + + def _chroot(path: pathlib.Path): + _system_calls.pivot_root(new = path) + os.chdir("/") + + def _run(): + _validate_identification() + + _config = _make_config() + _setup_algorithm = _make_setup_algorithm(mode = _config.setup.mode) + + _system_calls.unshare() + + for _command in _config.setup.before: _spawn_command(command = _command) + + if not _config.root.path.is_mount(): subprocess.check_call(( + "mount", "--rbind", "--", _config.root.path.as_posix(), _config.root.path.as_posix() + ), stdin = subprocess.DEVNULL) + + if _config.initctl: _spawn_initctl() + + if _setup_algorithm is not None: _setup_algorithm(config = _config) + + if "pivot" == _config.root.mode: _pivot(path = _config.root.path) + elif "chroot" == _config.root.mode: _chroot(path = _config.root.path) + else: assert _config.root.mode is None, "unknown root mode" + + for _command in _config.exec.before: _spawn_command(command = _command) + _tolerant_exec(arguments = _config.exec.command) + + return _make_property_collector(run = _run) + + +try: run = _private().run +finally: del _private diff --git a/python3/scripts/init/_setup_algorithm.py b/python3/scripts/init/_setup_algorithm.py new file mode 100644 index 0000000..3cb5ef8 --- /dev/null +++ b/python3/scripts/init/_setup_algorithm.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from . import _setup_algorithms as _implementations_module + from ... import _runtime as _runtime_module + + _make_property_collector = _runtime_module.common.property_collector.make + + _known = dict() + + def _make_decorator(key): + assert isinstance(key, str) + if str is not type(key): key = str(key) + assert key + + def _result(delegate): + assert key not in _known + _known[key] = delegate + + return _result + + @_make_decorator(key = "lxc-libvirt") + def _(): return _implementations_module.libvirt.lxc.run + + class _Class(object): + def __call__(self, *args, **kwargs): return self.__implementation(*args, **kwargs) + + def __init__(self, key: str): + super().__init__() + assert isinstance(key, str) + if str is not type(key): key = str(key) + self.__implementation = _known[key]() + + return _make_property_collector(Class = _Class) + + +try: Class = _private().Class +finally: del _private + + +def make(*args, **kwargs): return Class(*args, **kwargs) diff --git a/python3/scripts/init/_setup_algorithms/__init__.py b/python3/scripts/init/_setup_algorithms/__init__.py new file mode 100644 index 0000000..386889b --- /dev/null +++ b/python3/scripts/init/_setup_algorithms/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from .... import _runtime as _runtime_module + + _make_lazy = _runtime_module.common.lazy_attributes.make_getter + _make_property_collector = _runtime_module.common.property_collector.make + + return _make_property_collector(lazy = _make_lazy()) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/scripts/init/_setup_algorithms/libvirt/__init__.py b/python3/scripts/init/_setup_algorithms/libvirt/__init__.py new file mode 100644 index 0000000..9b0999d --- /dev/null +++ b/python3/scripts/init/_setup_algorithms/libvirt/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + from ..... import _runtime as _runtime_module + + _make_lazy = _runtime_module.common.lazy_attributes.make_getter + _make_property_collector = _runtime_module.common.property_collector.make + + return _make_property_collector(lazy = _make_lazy()) + + +_private = _private() + +__all__ = _private.lazy.keys +__date__ = None +__author__ = None +__version__ = None +__credits__ = None +_fields = tuple() +__bases__ = tuple() + + +def __getattr__(name: str): return _private.lazy(name = name) diff --git a/python3/scripts/init/_setup_algorithms/libvirt/lxc.py b/python3/scripts/init/_setup_algorithms/libvirt/lxc.py new file mode 100644 index 0000000..7488eb7 --- /dev/null +++ b/python3/scripts/init/_setup_algorithms/libvirt/lxc.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +assert "__main__" != __name__ + + +def _private(): + import os + import re + import sys + import typing + import signal + import pathlib + import functools + import subprocess + + from ..... import _runtime as _runtime_module + + _make_property_collector = _runtime_module.common.property_collector.make + + _PropertyCollector = _runtime_module.common.PropertyCollector + + _system_calls = _make_property_collector( + exit = _runtime_module.system_calls.exit.make(), + unshare = _runtime_module.system_calls.unshare.make() + ) + + def _parse_environment(): + _type = os.environ["container"] + assert isinstance(_type, str) and (str is type(_type)) + assert "lxc-libvirt" == os.environ["container"] + _name = os.environ["LIBVIRT_LXC_NAME"] + assert isinstance(_name, str) and (str is type(_name)) + assert _name.strip() == _name + _name, = _name.splitlines() + _uuid = os.environ["LIBVIRT_LXC_UUID"] + assert isinstance(_uuid, str) and (str is type(_uuid)) + assert re.match(r"^[0-9a-f][\-0-9a-f]{34}[0-9a-f]$", _uuid, re.IGNORECASE) is not None + return _name + + def _resolve_next(path): + assert isinstance(path, pathlib.Path) + assert path.is_absolute() + assert path.is_symlink() + _path = pathlib.Path(os.readlink(path)) + if not _path.is_absolute(): _path = path.parent / _path + return _path + + def _generate_network_interfaces(): + _path = pathlib.Path("/sys/class/net").resolve(strict = True) + assert _path.is_dir() + for _path in _path.glob("*"): + if "lo" == _path.name: continue + yield _path.name + + def _mount_devices(root: pathlib.Path): + _base_path = root / "dev" + subprocess.check_call(("mount", "--types=tmpfs", "--", "devfs", _base_path.as_posix()), stdin = subprocess.DEVNULL) + + _path = _base_path / "pts" + _path.mkdir(parents = False, exist_ok = False) + subprocess.check_call(("mount", "--types=devpts", "--", "devpts", _path.as_posix()), stdin = subprocess.DEVNULL) + + _path = _base_path / "shm" + _path.mkdir(parents = False, exist_ok = False) + subprocess.check_call(("mount", "--types=tmpfs", "--", "shm", _path.as_posix()), stdin = subprocess.DEVNULL) + + _path = _base_path / "mqueue" + _path.mkdir(parents = False, exist_ok = False) + subprocess.check_call(("mount", "--types=mqueue", "--", "mqueue", _path.as_posix()), stdin = subprocess.DEVNULL) + + pathlib.Path(_base_path / "ptmx").symlink_to("pts/ptmx") + + def _iteration(source: pathlib.Path): + _destination = root / source.relative_to("/") + if _destination.is_symlink(): return + if _destination.exists(): return + if _source.is_symlink(): + _destination.symlink_to(os.readlink(_source)) + return + if _source.is_dir(): + _destination.mkdir(parents = False, exist_ok = False) + if _source.is_mount(): + subprocess.check_call(( + "mount", "--rbind", "--", _source.as_posix(), _destination.as_posix() + ), stdin = subprocess.DEVNULL) + return + for source in source.glob("*"): _iteration(source = source) + return + _destination.touch() + subprocess.check_call(( + "mount", "--bind", "--", _source.as_posix(), _destination.as_posix() + ), stdin = subprocess.DEVNULL) + + for _source in pathlib.Path("/dev").glob("*"): _iteration(source = _source) + + def _make_id_map(source: typing.Optional[_PropertyCollector]): + _result = _make_property_collector( + users = _make_property_collector(text = None, root = None), + groups = _make_property_collector(text = None, root = None) + ) + + if source is not None: + if source.users is not None: + _result.users.text = source.users.text.strip() + _result.users.root = source.users(value = 0) + if _result.users.root is None: + _result.users.text = f"0 0 1\n{_result.users.text}" + _result.users.root = 0 + + if source.groups is not None: + _result.groups.text = source.groups.text.strip() + _result.groups.root = source.groups(value = 0) + if _result.groups.root is None: + _result.groups.text = f"0 0 1\n{_result.groups.text}" + _result.groups.root = 0 + + return _result + + def _make_owner_changer(id_map: _PropertyCollector): + _kwargs = dict() + _id = id_map.users.root + if (_id is not None) and (0 < _id): _kwargs.update(uid = _id) + _id = id_map.groups.root + if (_id is not None) and (0 < _id): _kwargs.update(gid = _id) + if not _kwargs: return None + + def _result(path: str | pathlib.PurePath): + if isinstance(path, pathlib.PurePath): path = pathlib.Path(path.as_posix()) + else: path = pathlib.Path(path) + assert path.exists() + + _unique = set() + + def _routine(cursor: pathlib.Path): + os.lchown(cursor, **_kwargs) + if cursor.is_symlink(): + _routine(cursor = _resolve_next(cursor)) + return + cursor = cursor.resolve(strict = False) + if not cursor.exists(): return + if not cursor.is_dir(): return + _posix = cursor.as_posix() + if _posix in _unique: return + _unique.add(_posix) + for cursor in cursor.glob("*"): _routine(cursor = cursor) + + _routine(cursor = path) + + return _result + + def _prepare_cgroup(change_owner: typing.Optional[typing.Callable]): + with open("/proc/self/cgroup", mode = "r", encoding = "utf-8") as _content: + _content, = _content.read().splitlines(keepends = False) + _content = list(_content.split(":")) + assert "0" == _content.pop(0) + assert not _content.pop(0) + assert _content + _content = ":".join(_content) + assert "/" == _content[0] + _content = _content[1:] + assert _content[0] not in {"/", ".", ".."} + assert pathlib.Path(_content).as_posix() == _content + _path = pathlib.Path("/sys/fs/cgroup") + assert _path.resolve(strict = True) == _path + assert _path.is_dir() and _path.is_mount() + _path = _path / _content + assert _path.resolve(strict = True) == _path + assert _path.is_dir() + _content = tuple(_path.glob("*")) + assert _content + _content = tuple(_content for _content in _content if _content.is_dir()) + assert not _content + _pid = os.getpid() + assert isinstance(_pid, int) + assert 0 < _pid + with open(_path / "cgroup.procs", mode = "r", encoding = "ascii") as _content: _content, = ( + _content for _content in _content.read().splitlines(keepends = False) if "0" != _content + ) + assert _content == str(_pid) + if change_owner is None: return + change_owner(path = _path) + + def _prepare_devices(change_owner: typing.Callable): + for _device in ( + "null", "full", "zero", + "random", "urandom", + "console", "tty", "tty1" + ): change_owner(path = f"/dev/{_device}") + + def _unshare(id_map: _PropertyCollector, network_interfaces: typing.Iterable[str]): + network_interfaces = tuple(network_interfaces) + + _system_call = _system_calls.unshare + + _flags = [ + _system_call.flags.CLONE_NEWCGROUP, + _system_call.flags.CLONE_NEWUTS, + _system_call.flags.CLONE_NEWIPC, + _system_call.flags.CLONE_NEWNET + ] + + if id_map.users.text or id_map.groups.text: _flags.extend(( + _system_call.flags.CLONE_NEWNS, _system_call.flags.CLONE_NEWUSER + )) + + _system_call = functools.partial(_system_call, flags = _flags) + del _flags + + if not (id_map.users.text or id_map.groups.text or network_interfaces): + _system_call() + return + + _target_pid = os.getpid() + assert isinstance(_target_pid, int) + assert 0 < _target_pid + _target_pid = pathlib.Path(f"/proc/{_target_pid}") + assert _target_pid.resolve(strict = True) == _target_pid + assert _target_pid.is_dir() + + _magic = 42 + _descriptor = os.eventfd(0, 0) + assert isinstance(_descriptor, int) + assert 0 <= _descriptor + + _target_uid = id_map.users.root + _target_gid = id_map.groups.root + if _target_uid is None: _target_uid = 0 + if _target_gid is None: _target_gid = 0 + + try: + _status = 1 + _pid = os.fork() + assert isinstance(_pid, int) + if 0 == _pid: + try: + assert _magic == os.eventfd_read(_descriptor) + if id_map.users.text: + with open(_target_pid / "uid_map", "w") as _stream: print(id_map.users.text, file = _stream) + if id_map.groups.text: + with open(_target_pid / "gid_map", "w") as _stream: print(id_map.groups.text, file = _stream) + for _interface in network_interfaces: subprocess.check_call(( + "ip", "link", "set", _interface, "netns", (_target_pid / "ns/net").as_posix() + ), stdin = subprocess.DEVNULL) + except BaseException: + import traceback + print(traceback.format_exc(), file = sys.stderr, flush = True) + raise + else: _status = 0 + finally: _system_calls.exit(_status) + + assert 0 < _pid + _system_call() + os.eventfd_write(_descriptor, _magic) + _target_pid, _status = os.waitpid(_pid, 0) + assert isinstance(_target_pid, int) + assert _target_pid == _pid + _status = os.waitstatus_to_exitcode(_status) + assert isinstance(_status, int) + assert 0 == _status + + finally: os.close(_descriptor) + + def _run(config: _PropertyCollector): + _root = config.root.path + assert isinstance(_root, pathlib.Path) + _root = _root.resolve(strict = True) + assert _root.is_dir() + assert 1 < len(_root.parts) + + _id_map = _make_id_map(source = config.id_map) + + if config.devices.exclude: raise NotImplementedError("config.devices.exclude") + if config.devices.rexclude: raise NotImplementedError("config.devices.rexclude") + + # clear any inherited settings, see `unshare.c` from util-linux source code + signal.signal(signal.SIGCHLD, signal.SIG_DFL) + + _change_owner = _make_owner_changer(id_map = _id_map) + _prepare_cgroup(change_owner = _change_owner) + _network_interfaces = tuple(_generate_network_interfaces()) + if _change_owner is not None: _prepare_devices(change_owner = _change_owner) + + subprocess.check_call(("mount", "--types=proc", "--", "proc", (_root / "proc").as_posix()), stdin = subprocess.DEVNULL) + + _unshare(id_map = _id_map, network_interfaces = _network_interfaces) + + os.setgid(0) + os.setuid(0) + + _mount_devices(root = _root) + + subprocess.check_call(( + "mount", "--types=sysfs", "--options=ro,nodev,nosuid,noexec", + "--", "sysfs", (_root / "sys").as_posix() + ), stdin = subprocess.DEVNULL) + + subprocess.check_call(( + "mount", "--types=cgroup2", "--options=rw,nodev,nosuid,noexec", + "--", "cgroup2", (_root / "sys/fs/cgroup").as_posix() + ), stdin = subprocess.DEVNULL) + + return _make_property_collector(run = _run) + + +try: run = _private().run +finally: del _private diff --git a/python3/scripts/initctl.py b/python3/scripts/initctl.py new file mode 100644 index 0000000..ded13d6 --- /dev/null +++ b/python3/scripts/initctl.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +def _private(): + import os + import sys + import signal + import ctypes + import typing + import argparse + + from .. import _runtime as _runtime_module + + _make_property_collector = _runtime_module.common.property_collector.make + + def _run(): + def _config(): + _arguments = argparse.ArgumentParser() + _arguments.add_argument("pid", nargs = "?", metavar = "PID") + + _arguments = list(_arguments.parse_known_args(sys.argv[1:])) + assert not _arguments.pop(-1), "unknown arguments" + _arguments, = _arguments + _arguments = vars(_arguments) + + def _validate_pid(value: typing.Optional[str]): + if value is None: return None + assert isinstance(value, str) + if str is not type(value): value = str(value) + if not value: return 0 + _value = int(value) + assert str(_value) == value + assert 0 < _value + return _value + + return _make_property_collector(pid = _validate_pid(value = _arguments["pid"])) + + _config = _config() + + def _pid(): + _my = os.getpid() + assert isinstance(_my, int) + assert 0 < _my + + _parent = os.getppid() + assert isinstance(_parent, int) + if 0 == _parent: assert 1 == _my + else: assert 0 < _parent + + if 0 == _config.pid: return None + if _config.pid is not None: + assert 1 < _my + return _config.pid + + if 0 < _parent: return _parent + return None + + _pid = _pid() + + """ + // from systemd-initctl: https://github.com/systemd/systemd/tree/main/src/initctl + #define INIT_CMD_RUNLVL 1 + + struct init_request { + int magic; /* Magic number */ + int cmd; /* What kind of request */ + int runlevel; /* Runlevel to change to */ + int sleeptime; /* Time between TERM and KILL */ + union { + struct init_request_bsd bsd; + char data[368]; + } i; + }; + """ + class _RequestHead(ctypes.Structure): pass + _RequestHead._fields_ = ( + ("magic", ctypes.c_int), + ("command", ctypes.c_int), + ("level", ctypes.c_int) + ) + + _request_size = ctypes.sizeof(_RequestHead) + 369 + _request_magic = 0x03091969 + _request_command = 1 # INIT_CMD_RUNLVL + _request_levels = {ord(_level) for _level in ("0", "6")} + + _path = "/dev/initctl" + + assert os.mkfifo(_path) is None + + try: + with open("/dev/initctl", mode = "rb") as _stream: + while True: + _request = _stream.read(_request_size) + assert isinstance(_request, bytes) + assert _request_size == len(_request) + _request = _RequestHead.from_buffer_copy(_request) + assert _request_magic == _request.magic + if _request_command != _request.command: continue + if _request.level in _request_levels: break + + finally: os.remove(_path) + + if _pid is not None: assert os.kill(_pid, signal.SIGTERM) is None + + return _make_property_collector(run = _run) + + +try: run = _private().run +finally: del _private + +if "__main__" == __name__: run() diff --git a/python3/scripts/overlay.py b/python3/scripts/overlay.py new file mode 100644 index 0000000..a1be2ba --- /dev/null +++ b/python3/scripts/overlay.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +def _private(): + import os + import sys + import shutil + import typing + import pathlib + import argparse + import contextlib + import subprocess + + from .. import _runtime as _runtime_module + + _make_property_collector = _runtime_module.common.property_collector.make + + def _make_config(): + _arguments = argparse.ArgumentParser() + _arguments.add_argument("layers", nargs = "*") + _arguments.add_argument("--path", type = str, default = "/mnt/root", metavar = "DIRECTORY_PATH") + _arguments.add_argument("--cache", default = "/mnt/cache/overlay", metavar = "DIRECTORY_PATH") + + _arguments = list(_arguments.parse_known_args(sys.argv[1:])) + assert not _arguments.pop(-1), "unknown arguments" + _arguments, = _arguments + _arguments = vars(_arguments) + + def _validate_directory(value: str, strict: bool = False): + assert isinstance(value, str) + if str is not type(value): value = str(value) + assert value + value = pathlib.Path(value).resolve(strict = False) + if strict or value.exists(): assert value.is_dir() + return value + + _path = _validate_directory(value = _arguments["path"], strict = True) + _cache = _validate_directory(value = _arguments["cache"]) + + def _check_cache_conflicts(path: pathlib.Path): + if _cache.is_relative_to(path): return False + if not path.is_relative_to(_cache): return True + if path.is_relative_to(_cache / "temp"): return False + if path.is_relative_to(_cache / "upper"): return False + return True + + assert _check_cache_conflicts(path = _path) + + def _validate_layers(value: typing.Iterable[str]): + _list = list() + for value in value: + value = _validate_directory(value = value, strict = True) + for _base in _list: assert not value.is_relative_to(_base) + else: assert not value.is_relative_to(_path) + assert _check_cache_conflicts(path = _path) + _list.append(value) + return tuple(_list) + + return _make_property_collector( + path = _path, cache = _cache, + layers = _validate_layers(value = _arguments["layers"]) + ) + + @contextlib.contextmanager + def _manager(): + _config = _make_config() + + def _ids(): + _value = _config.path.stat() + _value = _make_property_collector(user = _value.st_uid, group = _value.st_gid) + assert isinstance(_value.user, int) + assert isinstance(_value.group, int) + assert 0 <= _value.user + assert 0 <= _value.group + _value = _make_property_collector(source = _value, effective = _make_property_collector( + user = os.geteuid(), group = os.getegid() + )) + assert isinstance(_value.effective.user, int) + assert isinstance(_value.effective.group, int) + assert 0 <= _value.effective.user + assert 0 <= _value.effective.group + return _value + + _ids = _ids() + + def _update_permissions(path: pathlib.Path, mode: int = 0o700, effective: bool = True): + assert isinstance(path, pathlib.Path) + path = pathlib.Path(path.as_posix()).resolve(strict = True) + assert isinstance(mode, int) + if int is not type(mode): mode = int(mode) + assert isinstance(effective, bool) + _id_source = _ids.effective if effective else _ids.source + os.chown(path, uid = _id_source.user, gid = _id_source.group) + path.chmod(mode = mode) + + _cache = _config.cache + _cache.mkdir(parents = True, exist_ok = True) + _update_permissions(path = _cache) + _cache = _make_property_collector( + temp = _cache / "temp", upper = _cache / "upper" + ) + assert not _cache.temp.is_mount() + assert not _cache.upper.is_mount() + if _cache.temp.exists(): shutil.rmtree(_cache.temp) + _cache.temp.mkdir(parents = False, exist_ok = False) + _cache.upper.mkdir(parents = False, exist_ok = True) + _update_permissions(path = _cache.temp) + _update_permissions(path = _cache.upper, mode = 0o755, effective = False) + + _cache.temp = _make_property_collector( + path = _cache.temp, layers = list(), links = 0 + ) + + (_cache.temp.path / "w").mkdir(parents = False, exist_ok = False) + _update_permissions(path = _cache.temp.path / "w") + + def _make_link(path: pathlib.Path): + _name = "{:x}".format(_cache.temp.links) + (_cache.temp.path / _name).symlink_to(path) + _cache.temp.links += 1 + return _name + + def _remove_link(name: str): + _path = _cache.temp.path / name + assert _cache.temp.path == _path.parent + assert _path.is_symlink() + _path.unlink() + + def _finally(): + _remove_link(name = "u") + for _name in _cache.temp.layers: _remove_link(name = _name) + + try: + (_cache.temp.path / "u").symlink_to(f"../{_cache.upper.name}") + _cache.temp.layers.append(_make_link(path = _config.path)) + _cache.temp.layers.extend([_make_link(path = _path) for _path in _config.layers]) + yield _make_property_collector( + path = _cache.temp.path, work = "w", + layers = tuple(_cache.temp.layers), upper = "u" + ) + + finally: _finally() + + def _run(): + with _manager() as _context: + _command = ":".join(reversed(_context.layers)) + _command = ",".join((f"workdir={_context.work}", f"lowerdir={_command}", f"upperdir={_context.upper}")) + _command = ("mount", "--types=overlay", f"--options={_command}", "--", "overlay", f"./{_context.layers[0]}") + subprocess.check_call(_command, stdin = subprocess.DEVNULL, cwd = _context.path) + + return _make_property_collector(run = _run) + + +try: run = _private().run +finally: del _private + +if "__main__" == __name__: run() diff --git a/python3/scripts/tolerant.py b/python3/scripts/tolerant.py new file mode 100644 index 0000000..feb3266 --- /dev/null +++ b/python3/scripts/tolerant.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +def _private(): + import os + import re + import sys + import shutil + import typing + import pathlib + import subprocess + + from .. import _runtime as _runtime_module + + _make_property_collector = _runtime_module.common.property_collector.make + + def _which(value): + assert value not in {"/", ".", ".."} + _value = shutil.which(value) + if not (isinstance(_value, str) and _value): raise FileNotFoundError(value) + return pathlib.Path(_value).resolve(strict = True) + + def _validate_path(value): + _which_condition = False + + if isinstance(value, str): + if str is not type(value): value = str(value) + assert value + assert "\0" not in value + _which_condition = "/" not in value + value = pathlib.Path(value) + if _which_condition: _which_condition = value.parts and value.parts[0] not in {".", ".."} + + else: + assert isinstance(value, pathlib.Path) + value = pathlib.Path(value.as_posix()) + + value = _make_property_collector(original = value) + if _which_condition: + value.resolved = _which(value = value.original.as_posix()) + assert value.resolved.is_file() + else: value.resolved = value.original.resolve(strict = True) + value.original = value.original.as_posix() + + return value + + def _validate_arguments(value): + def _generate(): + for _value in value: + assert isinstance(_value, str) + if str is not type(_value): _value = str(value) + yield _value + + return tuple(_generate()) + + def _parse_shebang(path): + with open(path, "rb") as _command: + assert b"#!" == _command.read(2) + _command = _command.readline() + + _command = _command.strip().decode("utf-8") + assert _command + _command = re.compile(r"\s").split(_command) + _command = _command.pop(0), _command + assert _command[0] + return _command + + def _resolve(path, arguments, walk): + if arguments is None: arguments = list() + else: arguments = list(arguments) + if path is None: path = _validate_path(value = arguments.pop(0)) + else: path = _validate_path(value = path) + + _recursion_protector = set() + + def _generate(command): + if command.path.resolved.is_dir(): + _posix = command.path.resolved.as_posix() + if not walk: raise IsADirectoryError(_posix) + assert _posix not in _recursion_protector + _recursion_protector.add(_posix) + try: + for _path in sorted(command.path.resolved.glob("*"), key = lambda p: p.name): yield from _generate( + command = _make_property_collector(path = _validate_path(value = _path), arguments = command.arguments) + ) + finally: _recursion_protector.remove(_posix) + return + + assert command.path.resolved.is_file() + if os.access(command.path.resolved, os.X_OK): + yield command + return + + _executable, _arguments = _parse_shebang(path = command.path.resolved) + _executable = _validate_path(value = _executable) + _arguments.append(command.path.original) + _arguments.extend(command.arguments) + yield _make_property_collector(path = _executable, arguments = _arguments) + + return tuple(_generate(command = _make_property_collector(path = path, arguments = arguments))) + + def _run(): + _command = _resolve(path = None, arguments = _validate_arguments(value = sys.argv[1:]), walk = True) + assert _command + if 1 == len(_command): + _command, = _command + assert _command.path.resolved.is_file() + _command.arguments.insert(0, _command.path.original) + os.execv(_command.path.resolved, _command.arguments) + raise RuntimeError("unexpected state") + for _command in _command: subprocess.check_call((_command.path.resolved.as_posix(), *_command.arguments)) + + def _spawn( + path: typing.Union[str, pathlib.Path] = None, + arguments: typing.Iterable[str] = None, + options: typing.Dict[str, typing.Any] = None + ): + if options is None: options = dict() + else: assert isinstance(options, dict) and options + for _command in _resolve( + path = path, arguments = _validate_arguments(value = arguments), walk = True + ): assert 0 == subprocess.run((_command.path.resolved.as_posix(), *_command.arguments), **options).returncode + + def _execute(path: typing.Union[str, pathlib.Path] = None, arguments: typing.Iterable[str] = None): + _command, = _resolve(path = path, arguments = _validate_arguments(value = arguments), walk = False) + assert _command.path.resolved.is_file() + _command.arguments.insert(0, _command.path.original) + os.execv(_command.path.resolved, _command.arguments) + raise RuntimeError("unexpected state") + + return _make_property_collector(run = _run, spawn = _spawn, execute = _execute) + + +_private = _private() + +try: + run = _private.run + spawn = _private.spawn + execute = _private.execute +finally: del _private + +if "__main__" == __name__: run()