diff --git a/src/jlmkr/actions/create.py b/src/jlmkr/actions/create.py new file mode 100644 index 0000000..b91fe6a --- /dev/null +++ b/src/jlmkr/actions/create.py @@ -0,0 +1,303 @@ +# SPDX-FileCopyrightText: © 2024 Jip-Hop and the Jailmakers +# +# SPDX-License-Identifier: LGPL-3.0-only + +import contextlib +import os +import re + +from inspect import cleandoc +from pathlib import Path, PurePath +from textwrap import dedent +from donor.jlmkr import DISCLAIMER +from utils.chroot import Chroot +from utils.config_parser import KeyValueParser, DEFAULT_CONFIG +from utils.console import YELLOW, BOLD, NORMAL, eprint +from utils.download import run_lxc_download_script +from utils.files import stat_chmod, get_mount_point +from utils.jail_dataset import check_jail_name_valid, check_jail_name_available +from utils.jail_dataset import get_jail_config_path, get_jail_rootfs_path +from utils.jail_dataset import get_jail_path, get_zfs_dataset, create_zfs_dataset, cleanup +from utils.paths import COMMAND_NAME, JAILS_DIR_PATH, SCRIPT_NAME, SCRIPT_DIR_PATH + + +def create_jail(**kwargs): + print(DISCLAIMER) + + if os.path.basename(SCRIPT_DIR_PATH) != "jailmaker": + eprint( + dedent( + f""" + {COMMAND_NAME} needs to create files. + Currently it can not decide if it is safe to create files in: + {SCRIPT_DIR_PATH} + Please create a dedicated dataset called "jailmaker", store {SCRIPT_NAME} there and try again.""" + ) + ) + return 1 + + if not PurePath(get_mount_point(SCRIPT_DIR_PATH)).is_relative_to("/mnt"): + print( + dedent( + f""" + {YELLOW}{BOLD}WARNING: BEWARE OF DATA LOSS{NORMAL} + + {SCRIPT_NAME} should be on a dataset mounted under /mnt (it currently is not). + Storing it on the boot-pool means losing all jails when updating TrueNAS. + Jails will be stored under: + {SCRIPT_DIR_PATH} + """ + ) + ) + + jail_name = kwargs.pop("jail_name") + start_now = False + + if not check_jail_name_valid(jail_name): + return 1 + + if not check_jail_name_available(jail_name): + return 1 + + start_now = kwargs.pop("start", start_now) + jail_config_path = kwargs.pop("config") + + config = KeyValueParser() + + if jail_config_path: + # TODO: fallback to default values for e.g. distro and release if they are not in the config file + if jail_config_path == "-": + print(f"Creating jail {jail_name} from config template passed via stdin.") + config.read_string(sys.stdin.read()) + else: + print(f"Creating jail {jail_name} from config template {jail_config_path}.") + if jail_config_path not in config.read(jail_config_path): + eprint(f"Failed to read config template {jail_config_path}.") + return 1 + else: + print(f"Creating jail {jail_name} with default config.") + config.read_string(DEFAULT_CONFIG) + + for option in [ + "distro", + "gpu_passthrough_intel", + "gpu_passthrough_nvidia", + "release", + "seccomp", + "startup", + "systemd_nspawn_user_args", + ]: + value = kwargs.pop(option) + if ( + value is not None + # String, non-empty list of args or int + and (isinstance(value, int) or len(value)) + and value is not config.my_get(option, None) + ): + # TODO: this will wipe all systemd_nspawn_user_args from the template... + # Should there be an option to append them instead? + print(f"Overriding {option} config value with {value}.") + config.my_set(option, value) + + jail_path = get_jail_path(jail_name) + + distro = config.my_get("distro") + release = config.my_get("release") + + # Cleanup in except, but only once the jail_path is final + # Otherwise we may cleanup the wrong directory + try: + # Create the dir or dataset where to store the jails + if not os.path.exists(JAILS_DIR_PATH): + if get_zfs_dataset(SCRIPT_DIR_PATH): + # Creating "jails" dataset if "jailmaker" is a ZFS Dataset + create_zfs_dataset(JAILS_DIR_PATH) + else: + os.makedirs(JAILS_DIR_PATH, exist_ok=True) + stat_chmod(JAILS_DIR_PATH, 0o700) + + # Creating a dataset for the jail if the jails dir is a dataset + if get_zfs_dataset(JAILS_DIR_PATH): + create_zfs_dataset(jail_path) + + jail_config_path = get_jail_config_path(jail_name) + jail_rootfs_path = get_jail_rootfs_path(jail_name) + + # Create directory for rootfs + os.makedirs(jail_rootfs_path, exist_ok=True) + # LXC download script needs to write to this file during install + # but we don't need it so we will remove it later + open(jail_config_path, "a").close() + + if ( + returncode := run_lxc_download_script( + jail_name, jail_path, jail_rootfs_path, distro, release + ) + != 0 + ): + cleanup(jail_path) + return returncode + + # Assuming the name of your jail is "myjail" + # and "machinectl shell myjail" doesn't work + # Try: + # + # Stop the jail with: + # machinectl stop myjail + # And start a shell inside the jail without the --boot option: + # systemd-nspawn -q -D jails/myjail/rootfs /bin/sh + # Then set a root password with: + # In case of amazonlinux you may need to run: + # yum update -y && yum install -y passwd + # passwd + # exit + # Then you may login from the host via: + # machinectl login myjail + # + # You could also enable SSH inside the jail to login + # + # Or if that doesn't work (e.g. for alpine) get a shell via: + # nsenter -t $(machinectl show myjail -p Leader --value) -a /bin/sh -l + # But alpine jails made with jailmaker have other issues + # They don't shutdown cleanly via systemctl and machinectl... + + with Chroot(jail_rootfs_path): + # Use chroot to correctly resolve absolute /sbin/init symlink + init_system_name = os.path.basename(os.path.realpath("/sbin/init")) + + if ( + init_system_name != "systemd" + and parse_os_release(jail_rootfs_path).get("ID") != "nixos" + ): + print( + dedent( + f""" + {YELLOW}{BOLD}WARNING: DISTRO NOT SUPPORTED{NORMAL} + + Chosen distro appears not to use systemd... + + You probably will not get a shell with: + machinectl shell {jail_name} + + You may get a shell with this command: + nsenter -t $(machinectl show {jail_name} -p Leader --value) -a /bin/sh -l + + Read about the downsides of nsenter: + https://github.com/systemd/systemd/issues/12785#issuecomment-503019081 + + {BOLD}Using this distro with {COMMAND_NAME} is NOT recommended.{NORMAL} + """ + ) + ) + + print("Autostart has been disabled.") + print("You need to start this jail manually.") + config.my_set("startup", 0) + start_now = False + + # Remove config which systemd handles for us + with contextlib.suppress(FileNotFoundError): + os.remove(os.path.join(jail_rootfs_path, "etc/machine-id")) + with contextlib.suppress(FileNotFoundError): + os.remove(os.path.join(jail_rootfs_path, "etc/resolv.conf")) + + # https://github.com/systemd/systemd/issues/852 + print( + "\n".join([f"pts/{i}" for i in range(0, 11)]), + file=open(os.path.join(jail_rootfs_path, "etc/securetty"), "w"), + ) + + network_dir_path = os.path.join(jail_rootfs_path, "etc/systemd/network") + + # Modify default network settings, if network_dir_path exists + if os.path.isdir(network_dir_path): + default_host0_network_file = os.path.join( + jail_rootfs_path, "lib/systemd/network/80-container-host0.network" + ) + + # Check if default host0 network file exists + if os.path.isfile(default_host0_network_file): + override_network_file = os.path.join( + network_dir_path, "80-container-host0.network" + ) + + # Override the default 80-container-host0.network file (by using the same name) + # This config applies when using the --network-bridge option of systemd-nspawn + # Disable LinkLocalAddressing on IPv4, or else the container won't get IP address via DHCP + # But keep it enabled on IPv6, as SLAAC and DHCPv6 both require a local-link address to function + print( + Path(default_host0_network_file) + .read_text() + .replace("LinkLocalAddressing=yes", "LinkLocalAddressing=ipv6"), + file=open(override_network_file, "w"), + ) + + # Setup DHCP for macvlan network interfaces + # This config applies when using the --network-macvlan option of systemd-nspawn + # https://www.debian.org/doc/manuals/debian-reference/ch05.en.html#_the_modern_network_configuration_without_gui + print( + cleandoc( + """ + [Match] + Virtualization=container + Name=mv-* + + [Network] + DHCP=yes + LinkLocalAddressing=ipv6 + + [DHCPv4] + UseDNS=true + UseTimezone=true + """ + ), + file=open(os.path.join(network_dir_path, "mv-dhcp.network"), "w"), + ) + + # Setup DHCP for veth-extra network interfaces + # This config applies when using the --network-veth-extra option of systemd-nspawn + # https://www.debian.org/doc/manuals/debian-reference/ch05.en.html#_the_modern_network_configuration_without_gui + print( + cleandoc( + """ + [Match] + Virtualization=container + Name=vee-* + + [Network] + DHCP=yes + LinkLocalAddressing=ipv6 + + [DHCPv4] + UseDNS=true + UseTimezone=true + """ + ), + file=open(os.path.join(network_dir_path, "vee-dhcp.network"), "w"), + ) + + # Override preset which caused systemd-networkd to be disabled (e.g. fedora 39) + # https://www.freedesktop.org/software/systemd/man/latest/systemd.preset.html + # https://github.com/lxc/lxc-ci/blob/f632823ecd9b258ed42df40449ec54ed7ef8e77d/images/fedora.yaml#L312C5-L312C38 + + preset_path = os.path.join(jail_rootfs_path, "etc/systemd/system-preset") + os.makedirs(preset_path, exist_ok=True) + print( + "enable systemd-networkd.service", + file=open(os.path.join(preset_path, "00-jailmaker.preset"), "w"), + ) + + with open(jail_config_path, "w") as fp: + config.write(fp) + + os.chmod(jail_config_path, 0o600) + + # Cleanup on any exception and rethrow + except BaseException as error: + cleanup(jail_path) + raise error + + if start_now: + return start_jail(jail_name) + + return 0 diff --git a/src/jlmkr/actions/list.py b/src/jlmkr/actions/list.py index 6b3ca4b..a0b9fad 100644 --- a/src/jlmkr/actions/list.py +++ b/src/jlmkr/actions/list.py @@ -8,8 +8,7 @@ import subprocess from collections import defaultdict from utils.console import NORMAL, UNDERLINE from utils.config_parser import parse_config_file -from utils.jail_dataset import get_jail_config_path, get_jail_rootfs_path, parse_os_release -from utils.parent_dataset import get_all_jail_names +from utils.jail_dataset import get_all_jail_names, get_jail_config_path, get_jail_rootfs_path, parse_os_release def list_jails(): diff --git a/src/jlmkr/donor/jlmkr.py b/src/jlmkr/donor/jlmkr.py index 1f866dc..7d419df 100755 --- a/src/jlmkr/donor/jlmkr.py +++ b/src/jlmkr/donor/jlmkr.py @@ -26,7 +26,6 @@ import sys import tempfile import time import urllib.request -from inspect import cleandoc from pathlib import Path, PurePath from textwrap import dedent @@ -70,6 +69,7 @@ class CustomSubParser(argparse.ArgumentParser): from utils.chroot import Chroot from utils.console import eprint, fail +from utils.jail_dataset import check_jail_name_valid, check_jail_name_available from utils.jail_dataset import get_jail_path, get_jail_config_path, get_jail_rootfs_path from actions.exec import exec_jail @@ -80,139 +80,10 @@ from actions.start import start_jail from actions.restart import restart_jail from actions.images import run_lxc_download_script - -def cleanup(jail_path): - """ - Cleanup jail. - """ - - if get_zfs_dataset(jail_path): - eprint(f"Cleaning up: {jail_path}.") - remove_zfs_dataset(jail_path) - - elif os.path.isdir(jail_path): - # Workaround for https://github.com/python/cpython/issues/73885 - # Should be fixed in Python 3.13 https://stackoverflow.com/a/70549000 - def _onerror(func, path, exc_info): - exc_type, exc_value, exc_traceback = exc_info - if issubclass(exc_type, PermissionError): - # Update the file permissions with the immutable and append-only bit cleared - subprocess.run(["chattr", "-i", "-a", path]) - # Reattempt the removal - func(path) - elif not issubclass(exc_type, FileNotFoundError): - raise exc_value - - eprint(f"Cleaning up: {jail_path}.") - shutil.rmtree(jail_path, onerror=_onerror) - - +from utils.jail_dataset import cleanup, check_jail_name_valid, check_jail_name_available +from utils.download import run_lxc_download_script from utils.files import stat_chmod - - -def get_mount_point(path): - """ - Return the mount point on which the given path resides. - """ - path = os.path.abspath(path) - while not os.path.ismount(path): - path = os.path.dirname(path) - return path - - -def get_relative_path_in_jailmaker_dir(absolute_path): - return PurePath(absolute_path).relative_to(SCRIPT_DIR_PATH) - - -def get_zfs_dataset(path): - """ - Get ZFS dataset path. - """ - - def clean_field(field): - # Put back spaces which were encoded - # https://github.com/openzfs/zfs/issues/11182 - return field.replace("\\040", " ") - - path = os.path.realpath(path) - with open("/proc/mounts", "r") as f: - for line in f: - fields = line.split() - if "zfs" == fields[2] and path == clean_field(fields[1]): - return clean_field(fields[0]) - - -def get_zfs_base_path(): - """ - Get ZFS dataset path for jailmaker directory. - """ - zfs_base_path = get_zfs_dataset(SCRIPT_DIR_PATH) - if not zfs_base_path: - fail("Failed to get dataset path for jailmaker directory.") - - return zfs_base_path - - -def create_zfs_dataset(absolute_path): - """ - Create a ZFS Dataset inside the jailmaker directory at the provided absolute path. - E.g. "/mnt/mypool/jailmaker/jails" or "/mnt/mypool/jailmaker/jails/newjail"). - """ - relative_path = get_relative_path_in_jailmaker_dir(absolute_path) - dataset_to_create = os.path.join(get_zfs_base_path(), relative_path) - eprint(f"Creating ZFS Dataset {dataset_to_create}") - subprocess.run(["zfs", "create", dataset_to_create], check=True) - - -def remove_zfs_dataset(absolute_path): - """ - Remove a ZFS Dataset inside the jailmaker directory at the provided absolute path. - E.g. "/mnt/mypool/jailmaker/jails/oldjail". - """ - relative_path = get_relative_path_in_jailmaker_dir(absolute_path) - dataset_to_remove = os.path.join((get_zfs_base_path()), relative_path) - eprint(f"Removing ZFS Dataset {dataset_to_remove}") - subprocess.run(["zfs", "destroy", "-r", dataset_to_remove], check=True) - - -def check_jail_name_valid(jail_name, warn=True): - """ - Return True if jail name matches the required format. - """ - if ( - re.match(r"^[.a-zA-Z0-9-]{1,64}$", jail_name) - and not jail_name.startswith(".") - and ".." not in jail_name - ): - return True - - if warn: - eprint( - dedent( - f""" - {YELLOW}{BOLD}WARNING: INVALID NAME{NORMAL} - - A valid name consists of: - - allowed characters (alphanumeric, dash, dot) - - no leading or trailing dots - - no sequences of multiple dots - - max 64 characters""" - ) - ) - return False - - -def check_jail_name_available(jail_name, warn=True): - """ - Return True if jail name is not yet taken. - """ - if not os.path.exists(get_jail_path(jail_name)): - return True - - if warn: - print() - eprint("A jail with this name already exists.") - return False +from utils.jail_dataset import get_zfs_dataset, create_zfs_dataset, remove_zfs_dataset def get_text_editor(): @@ -229,288 +100,7 @@ def get_text_editor(): ) -def create_jail(**kwargs): - print(DISCLAIMER) - - if os.path.basename(SCRIPT_DIR_PATH) != "jailmaker": - eprint( - dedent( - f""" - {COMMAND_NAME} needs to create files. - Currently it can not decide if it is safe to create files in: - {SCRIPT_DIR_PATH} - Please create a dedicated dataset called "jailmaker", store {SCRIPT_NAME} there and try again.""" - ) - ) - return 1 - - if not PurePath(get_mount_point(SCRIPT_DIR_PATH)).is_relative_to("/mnt"): - print( - dedent( - f""" - {YELLOW}{BOLD}WARNING: BEWARE OF DATA LOSS{NORMAL} - - {SCRIPT_NAME} should be on a dataset mounted under /mnt (it currently is not). - Storing it on the boot-pool means losing all jails when updating TrueNAS. - Jails will be stored under: - {SCRIPT_DIR_PATH} - """ - ) - ) - - jail_name = kwargs.pop("jail_name") - start_now = False - - if not check_jail_name_valid(jail_name): - return 1 - - if not check_jail_name_available(jail_name): - return 1 - - start_now = kwargs.pop("start", start_now) - jail_config_path = kwargs.pop("config") - - config = KeyValueParser() - - if jail_config_path: - # TODO: fallback to default values for e.g. distro and release if they are not in the config file - if jail_config_path == "-": - print(f"Creating jail {jail_name} from config template passed via stdin.") - config.read_string(sys.stdin.read()) - else: - print(f"Creating jail {jail_name} from config template {jail_config_path}.") - if jail_config_path not in config.read(jail_config_path): - eprint(f"Failed to read config template {jail_config_path}.") - return 1 - else: - print(f"Creating jail {jail_name} with default config.") - config.read_string(DEFAULT_CONFIG) - - for option in [ - "distro", - "gpu_passthrough_intel", - "gpu_passthrough_nvidia", - "release", - "seccomp", - "startup", - "systemd_nspawn_user_args", - ]: - value = kwargs.pop(option) - if ( - value is not None - # String, non-empty list of args or int - and (isinstance(value, int) or len(value)) - and value is not config.my_get(option, None) - ): - # TODO: this will wipe all systemd_nspawn_user_args from the template... - # Should there be an option to append them instead? - print(f"Overriding {option} config value with {value}.") - config.my_set(option, value) - - jail_path = get_jail_path(jail_name) - - distro = config.my_get("distro") - release = config.my_get("release") - - # Cleanup in except, but only once the jail_path is final - # Otherwise we may cleanup the wrong directory - try: - # Create the dir or dataset where to store the jails - if not os.path.exists(JAILS_DIR_PATH): - if get_zfs_dataset(SCRIPT_DIR_PATH): - # Creating "jails" dataset if "jailmaker" is a ZFS Dataset - create_zfs_dataset(JAILS_DIR_PATH) - else: - os.makedirs(JAILS_DIR_PATH, exist_ok=True) - stat_chmod(JAILS_DIR_PATH, 0o700) - - # Creating a dataset for the jail if the jails dir is a dataset - if get_zfs_dataset(JAILS_DIR_PATH): - create_zfs_dataset(jail_path) - - jail_config_path = get_jail_config_path(jail_name) - jail_rootfs_path = get_jail_rootfs_path(jail_name) - - # Create directory for rootfs - os.makedirs(jail_rootfs_path, exist_ok=True) - # LXC download script needs to write to this file during install - # but we don't need it so we will remove it later - open(jail_config_path, "a").close() - - if ( - returncode := run_lxc_download_script( - jail_name, jail_path, jail_rootfs_path, distro, release - ) - != 0 - ): - cleanup(jail_path) - return returncode - - # Assuming the name of your jail is "myjail" - # and "machinectl shell myjail" doesn't work - # Try: - # - # Stop the jail with: - # machinectl stop myjail - # And start a shell inside the jail without the --boot option: - # systemd-nspawn -q -D jails/myjail/rootfs /bin/sh - # Then set a root password with: - # In case of amazonlinux you may need to run: - # yum update -y && yum install -y passwd - # passwd - # exit - # Then you may login from the host via: - # machinectl login myjail - # - # You could also enable SSH inside the jail to login - # - # Or if that doesn't work (e.g. for alpine) get a shell via: - # nsenter -t $(machinectl show myjail -p Leader --value) -a /bin/sh -l - # But alpine jails made with jailmaker have other issues - # They don't shutdown cleanly via systemctl and machinectl... - - with Chroot(jail_rootfs_path): - # Use chroot to correctly resolve absolute /sbin/init symlink - init_system_name = os.path.basename(os.path.realpath("/sbin/init")) - - if ( - init_system_name != "systemd" - and parse_os_release(jail_rootfs_path).get("ID") != "nixos" - ): - print( - dedent( - f""" - {YELLOW}{BOLD}WARNING: DISTRO NOT SUPPORTED{NORMAL} - - Chosen distro appears not to use systemd... - - You probably will not get a shell with: - machinectl shell {jail_name} - - You may get a shell with this command: - nsenter -t $(machinectl show {jail_name} -p Leader --value) -a /bin/sh -l - - Read about the downsides of nsenter: - https://github.com/systemd/systemd/issues/12785#issuecomment-503019081 - - {BOLD}Using this distro with {COMMAND_NAME} is NOT recommended.{NORMAL} - """ - ) - ) - - print("Autostart has been disabled.") - print("You need to start this jail manually.") - config.my_set("startup", 0) - start_now = False - - # Remove config which systemd handles for us - with contextlib.suppress(FileNotFoundError): - os.remove(os.path.join(jail_rootfs_path, "etc/machine-id")) - with contextlib.suppress(FileNotFoundError): - os.remove(os.path.join(jail_rootfs_path, "etc/resolv.conf")) - - # https://github.com/systemd/systemd/issues/852 - print( - "\n".join([f"pts/{i}" for i in range(0, 11)]), - file=open(os.path.join(jail_rootfs_path, "etc/securetty"), "w"), - ) - - network_dir_path = os.path.join(jail_rootfs_path, "etc/systemd/network") - - # Modify default network settings, if network_dir_path exists - if os.path.isdir(network_dir_path): - default_host0_network_file = os.path.join( - jail_rootfs_path, "lib/systemd/network/80-container-host0.network" - ) - - # Check if default host0 network file exists - if os.path.isfile(default_host0_network_file): - override_network_file = os.path.join( - network_dir_path, "80-container-host0.network" - ) - - # Override the default 80-container-host0.network file (by using the same name) - # This config applies when using the --network-bridge option of systemd-nspawn - # Disable LinkLocalAddressing on IPv4, or else the container won't get IP address via DHCP - # But keep it enabled on IPv6, as SLAAC and DHCPv6 both require a local-link address to function - print( - Path(default_host0_network_file) - .read_text() - .replace("LinkLocalAddressing=yes", "LinkLocalAddressing=ipv6"), - file=open(override_network_file, "w"), - ) - - # Setup DHCP for macvlan network interfaces - # This config applies when using the --network-macvlan option of systemd-nspawn - # https://www.debian.org/doc/manuals/debian-reference/ch05.en.html#_the_modern_network_configuration_without_gui - print( - cleandoc( - """ - [Match] - Virtualization=container - Name=mv-* - - [Network] - DHCP=yes - LinkLocalAddressing=ipv6 - - [DHCPv4] - UseDNS=true - UseTimezone=true - """ - ), - file=open(os.path.join(network_dir_path, "mv-dhcp.network"), "w"), - ) - - # Setup DHCP for veth-extra network interfaces - # This config applies when using the --network-veth-extra option of systemd-nspawn - # https://www.debian.org/doc/manuals/debian-reference/ch05.en.html#_the_modern_network_configuration_without_gui - print( - cleandoc( - """ - [Match] - Virtualization=container - Name=vee-* - - [Network] - DHCP=yes - LinkLocalAddressing=ipv6 - - [DHCPv4] - UseDNS=true - UseTimezone=true - """ - ), - file=open(os.path.join(network_dir_path, "vee-dhcp.network"), "w"), - ) - - # Override preset which caused systemd-networkd to be disabled (e.g. fedora 39) - # https://www.freedesktop.org/software/systemd/man/latest/systemd.preset.html - # https://github.com/lxc/lxc-ci/blob/f632823ecd9b258ed42df40449ec54ed7ef8e77d/images/fedora.yaml#L312C5-L312C38 - - preset_path = os.path.join(jail_rootfs_path, "etc/systemd/system-preset") - os.makedirs(preset_path, exist_ok=True) - print( - "enable systemd-networkd.service", - file=open(os.path.join(preset_path, "00-jailmaker.preset"), "w"), - ) - - with open(jail_config_path, "w") as fp: - config.write(fp) - - os.chmod(jail_config_path, 0o600) - - # Cleanup on any exception and rethrow - except BaseException as error: - cleanup(jail_path) - raise error - - if start_now: - return start_jail(jail_name) - - return 0 - - +from actions.create import create_jail from utils.jail_dataset import jail_is_running @@ -574,8 +164,7 @@ def remove_jail(jail_name): return 1 -from utils.parent_dataset import get_all_jail_names -from utils.jail_dataset import parse_os_release +from utils.jail_dataset import get_all_jail_names, parse_os_release from actions.list import list_jails diff --git a/src/jlmkr/utils/files.py b/src/jlmkr/utils/files.py index a328223..1c15158 100644 --- a/src/jlmkr/utils/files.py +++ b/src/jlmkr/utils/files.py @@ -12,3 +12,13 @@ def stat_chmod(file_path, mode): """ if mode != stat.S_IMODE(os.stat(file_path).st_mode): os.chmod(file_path, mode) + + +def get_mount_point(path): + """ + Return the mount point on which the given path resides. + """ + path = os.path.abspath(path) + while not os.path.ismount(path): + path = os.path.dirname(path) + return path diff --git a/src/jlmkr/utils/jail_dataset.py b/src/jlmkr/utils/jail_dataset.py index 7cad961..d61bbdd 100644 --- a/src/jlmkr/utils/jail_dataset.py +++ b/src/jlmkr/utils/jail_dataset.py @@ -4,10 +4,15 @@ import os.path import platform +import re +import shutil import subprocess +from pathlib import PurePath +from textwrap import dedent from utils.chroot import Chroot -from utils.paths import JAILS_DIR_PATH, JAIL_CONFIG_NAME, JAIL_ROOTFS_NAME +from utils.console import eprint, YELLOW, BOLD, NORMAL +from utils.paths import JAILS_DIR_PATH, JAIL_CONFIG_NAME, JAIL_ROOTFS_NAME, SCRIPT_DIR_PATH def get_jail_path(jail_name): @@ -22,6 +27,50 @@ def get_jail_rootfs_path(jail_name): return os.path.join(get_jail_path(jail_name), JAIL_ROOTFS_NAME) +def get_relative_path_in_jailmaker_dir(absolute_path): + return PurePath(absolute_path).relative_to(SCRIPT_DIR_PATH) + + +def check_jail_name_valid(jail_name, warn=True): + """ + Return True if jail name matches the required format. + """ + if ( + re.match(r"^[.a-zA-Z0-9-]{1,64}$", jail_name) + and not jail_name.startswith(".") + and ".." not in jail_name + ): + return True + + if warn: + eprint( + dedent( + f""" + {YELLOW}{BOLD}WARNING: INVALID NAME{NORMAL} + + A valid name consists of: + - allowed characters (alphanumeric, dash, dot) + - no leading or trailing dots + - no sequences of multiple dots + - max 64 characters""" + ) + ) + return False + + +def check_jail_name_available(jail_name, warn=True): + """ + Return True if jail name is not yet taken. + """ + if not os.path.exists(get_jail_path(jail_name)): + return True + + if warn: + print() + eprint("A jail with this name already exists.") + return False + + def parse_os_release(new_root): result = {} with Chroot(new_root): @@ -48,3 +97,89 @@ def jail_is_running(jail_name): ).returncode == 0 ) + + +def get_zfs_dataset(path): + """ + Get ZFS dataset path. + """ + + def clean_field(field): + # Put back spaces which were encoded + # https://github.com/openzfs/zfs/issues/11182 + return field.replace("\\040", " ") + + path = os.path.realpath(path) + with open("/proc/mounts", "r") as f: + for line in f: + fields = line.split() + if "zfs" == fields[2] and path == clean_field(fields[1]): + return clean_field(fields[0]) + + +def get_zfs_base_path(): + """ + Get ZFS dataset path for jailmaker directory. + """ + zfs_base_path = get_zfs_dataset(SCRIPT_DIR_PATH) + if not zfs_base_path: + fail("Failed to get dataset path for jailmaker directory.") + + return zfs_base_path + + +def get_all_jail_names(): + try: + jail_names = os.listdir(JAILS_DIR_PATH) + except FileNotFoundError: + jail_names = [] + + return jail_names + + +def create_zfs_dataset(absolute_path): + """ + Create a ZFS Dataset inside the jailmaker directory at the provided absolute path. + E.g. "/mnt/mypool/jailmaker/jails" or "/mnt/mypool/jailmaker/jails/newjail"). + """ + relative_path = get_relative_path_in_jailmaker_dir(absolute_path) + dataset_to_create = os.path.join(get_zfs_base_path(), relative_path) + eprint(f"Creating ZFS Dataset {dataset_to_create}") + subprocess.run(["zfs", "create", dataset_to_create], check=True) + + +def remove_zfs_dataset(absolute_path): + """ + Remove a ZFS Dataset inside the jailmaker directory at the provided absolute path. + E.g. "/mnt/mypool/jailmaker/jails/oldjail". + """ + relative_path = get_relative_path_in_jailmaker_dir(absolute_path) + dataset_to_remove = os.path.join((get_zfs_base_path()), relative_path) + eprint(f"Removing ZFS Dataset {dataset_to_remove}") + subprocess.run(["zfs", "destroy", "-r", dataset_to_remove], check=True) + + +def cleanup(jail_path): + """ + Cleanup jail. + """ + + if get_zfs_dataset(jail_path): + eprint(f"Cleaning up: {jail_path}.") + remove_zfs_dataset(jail_path) + + elif os.path.isdir(jail_path): + # Workaround for https://github.com/python/cpython/issues/73885 + # Should be fixed in Python 3.13 https://stackoverflow.com/a/70549000 + def _onerror(func, path, exc_info): + exc_type, exc_value, exc_traceback = exc_info + if issubclass(exc_type, PermissionError): + # Update the file permissions with the immutable and append-only bit cleared + subprocess.run(["chattr", "-i", "-a", path]) + # Reattempt the removal + func(path) + elif not issubclass(exc_type, FileNotFoundError): + raise exc_value + + eprint(f"Cleaning up: {jail_path}.") + shutil.rmtree(jail_path, onerror=_onerror) diff --git a/src/jlmkr/utils/parent_dataset.py b/src/jlmkr/utils/parent_dataset.py deleted file mode 100644 index fd68893..0000000 --- a/src/jlmkr/utils/parent_dataset.py +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-FileCopyrightText: © 2024 Jip-Hop and the Jailmakers -# -# SPDX-License-Identifier: LGPL-3.0-only - -import os - -from utils.paths import JAILS_DIR_PATH - - -def get_all_jail_names(): - try: - jail_names = os.listdir(JAILS_DIR_PATH) - except FileNotFoundError: - jail_names = [] - - return jail_names