Extract create action
This commit is contained in:
parent
fbf6e4d9a5
commit
1c0a7a46d4
|
@ -0,0 +1,303 @@
|
||||||
|
# SPDX-FileCopyrightText: © 2024 Jip-Hop and the Jailmakers <https://github.com/Jip-Hop/jailmaker>
|
||||||
|
#
|
||||||
|
# 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
|
|
@ -8,8 +8,7 @@ import subprocess
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from utils.console import NORMAL, UNDERLINE
|
from utils.console import NORMAL, UNDERLINE
|
||||||
from utils.config_parser import parse_config_file
|
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.jail_dataset import get_all_jail_names, get_jail_config_path, get_jail_rootfs_path, parse_os_release
|
||||||
from utils.parent_dataset import get_all_jail_names
|
|
||||||
|
|
||||||
|
|
||||||
def list_jails():
|
def list_jails():
|
||||||
|
|
|
@ -26,7 +26,6 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from inspect import cleandoc
|
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
|
||||||
|
@ -70,6 +69,7 @@ class CustomSubParser(argparse.ArgumentParser):
|
||||||
|
|
||||||
from utils.chroot import Chroot
|
from utils.chroot import Chroot
|
||||||
from utils.console import eprint, fail
|
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 utils.jail_dataset import get_jail_path, get_jail_config_path, get_jail_rootfs_path
|
||||||
|
|
||||||
from actions.exec import exec_jail
|
from actions.exec import exec_jail
|
||||||
|
@ -80,139 +80,10 @@ from actions.start import start_jail
|
||||||
from actions.restart import restart_jail
|
from actions.restart import restart_jail
|
||||||
from actions.images import run_lxc_download_script
|
from actions.images import run_lxc_download_script
|
||||||
|
|
||||||
|
from utils.jail_dataset import cleanup, check_jail_name_valid, check_jail_name_available
|
||||||
def cleanup(jail_path):
|
from utils.download import run_lxc_download_script
|
||||||
"""
|
|
||||||
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.files import stat_chmod
|
from utils.files import stat_chmod
|
||||||
|
from utils.jail_dataset import get_zfs_dataset, create_zfs_dataset, remove_zfs_dataset
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def get_text_editor():
|
def get_text_editor():
|
||||||
|
@ -229,288 +100,7 @@ def get_text_editor():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_jail(**kwargs):
|
from actions.create import create_jail
|
||||||
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 utils.jail_dataset import jail_is_running
|
from utils.jail_dataset import jail_is_running
|
||||||
|
|
||||||
|
|
||||||
|
@ -574,8 +164,7 @@ def remove_jail(jail_name):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
from utils.parent_dataset import get_all_jail_names
|
from utils.jail_dataset import get_all_jail_names, parse_os_release
|
||||||
from utils.jail_dataset import parse_os_release
|
|
||||||
from actions.list import list_jails
|
from actions.list import list_jails
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,3 +12,13 @@ def stat_chmod(file_path, mode):
|
||||||
"""
|
"""
|
||||||
if mode != stat.S_IMODE(os.stat(file_path).st_mode):
|
if mode != stat.S_IMODE(os.stat(file_path).st_mode):
|
||||||
os.chmod(file_path, 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
|
||||||
|
|
|
@ -4,10 +4,15 @@
|
||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from pathlib import PurePath
|
||||||
|
from textwrap import dedent
|
||||||
from utils.chroot import Chroot
|
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):
|
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)
|
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):
|
def parse_os_release(new_root):
|
||||||
result = {}
|
result = {}
|
||||||
with Chroot(new_root):
|
with Chroot(new_root):
|
||||||
|
@ -48,3 +97,89 @@ def jail_is_running(jail_name):
|
||||||
).returncode
|
).returncode
|
||||||
== 0
|
== 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)
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
# SPDX-FileCopyrightText: © 2024 Jip-Hop and the Jailmakers <https://github.com/Jip-Hop/jailmaker>
|
|
||||||
#
|
|
||||||
# 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
|
|
Loading…
Reference in New Issue