-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
python: Add script to work with systemd-nspawn
Signed-off-by: Nathan Chancellor <nathan@kernel.org>
- Loading branch information
1 parent
1a7f927
commit b02675a
Showing
3 changed files
with
256 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
#!/usr/bin/env fish | ||
# SPDX-License-Identifier: MIT | ||
# Copyright (C) 2024 Nathan Chancellor | ||
|
||
function nspawnm -d "Wrapper for nspawnm.py" | ||
$PYTHON_SCRIPTS_FOLDER/nspawnm.py $argv | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,247 @@ | ||
#!/usr/bin/env python3 | ||
|
||
from argparse import ArgumentParser | ||
from collections import UserDict | ||
import os | ||
from pathlib import Path | ||
import platform | ||
import sys | ||
|
||
sys.path.append(str(Path(__file__).resolve().parents[1])) | ||
# pylint: disable=wrong-import-position | ||
import lib.utils | ||
# pylint: enable=wrong-import-position | ||
|
||
# This should not change after import | ||
USER = os.environ['USER'] | ||
|
||
|
||
class NspawnConfig(UserDict): | ||
|
||
def __init__(self, name): | ||
# Initial static defaults | ||
super().__init__({ | ||
'Exec': { | ||
'Boot': 'yes', | ||
# Machine name will be accessed via IMAGE_ID within the | ||
# container, use the host's hostname for easy identification | ||
'Hostname': platform.uname().node, | ||
'PrivateUsers': 'pick', | ||
# Necessary for 'perf record' | ||
'SystemCallFilter': 'perf_event_open', | ||
}, | ||
'Files': { | ||
# Bind my /home directory and user into the container | ||
'BindUser': USER, | ||
# Mounts will be added dynamically | ||
'Bind': [], | ||
'BindReadOnly': [], | ||
'PrivateUsersOwnership': 'auto', | ||
}, | ||
'Network': { | ||
# Use host networking, as my use of nspawn is not around | ||
# isolation | ||
'VirtualEthernet': 'no', | ||
}, | ||
}) | ||
|
||
# Add dynamic bind mounts | ||
self._add_dynamic_mounts(name) | ||
|
||
# Set machine path based on the name | ||
self.machine_dir = Path('/var/lib/machines', name) | ||
|
||
def _add_dynamic_mounts(self, name): | ||
rw_mounts = { | ||
'/dev/kvm', | ||
# We may be in a virtual machine | ||
os.environ['HOST_FOLDER'], | ||
os.environ['NVME_FOLDER'], | ||
} | ||
ro_mounts = { | ||
# Allow interacting with the host tmux socket | ||
f"/tmp/tmux-{os.getuid()}", # noqa: S108 | ||
} | ||
|
||
if 'arch' in name: | ||
# Share the host's mirrorlist so that reflector does not have to be | ||
# run in the container (even if it could) | ||
ro_mounts.add('/etc/pacman.d/mirrorlist') | ||
|
||
# This one should really be a tmpfs overlay to avoid polluting the | ||
# host but it does not look like nspawn's overlay configuration | ||
# allows idmapping? https://github.com/systemd/systemd/issues/25886 | ||
rw_mounts.add('/var/cache/pacman/pkg') | ||
|
||
for mount in rw_mounts: | ||
# We need idmapping otherwise to ensure our user in the container | ||
# is treated as the user on the host. | ||
# While idmapping /dev mounts should be possible after | ||
# https://git.kernel.org/linus/7a80e5b8c6fa7d0ae6624bd6aedc4a6a1cfc62fa, | ||
# systemd-nspawn does not appear to support it and it should not be | ||
# necessary due to our kvm.conf. | ||
item = mount if mount.startswith('/dev') else f"{mount}:{mount}:idmap" | ||
|
||
# The mount must exist on the host otherwise the container will not | ||
# start | ||
if Path(mount).exists(): | ||
self.data['Files']['Bind'].append(item) | ||
|
||
for mount in ro_mounts: | ||
if Path(mount).exists(): | ||
self.data['Files']['BindReadOnly'].append(mount) | ||
|
||
def _gen_cfg_str(self): | ||
parts = [] | ||
|
||
for key, values in self.data.items(): | ||
parts.append(f"[{key}]") | ||
for subkey, subval in values.items(): | ||
if isinstance(subval, list): | ||
for item in subval: | ||
parts.append(f"{subkey}={item}") | ||
elif subval: | ||
parts.append(f"{subkey}={subval}") | ||
parts.append('') | ||
|
||
return '\n'.join(parts) | ||
|
||
def _gen_eph_cmd(self): | ||
cfg_to_cmd = { | ||
'Bind': '--bind', | ||
'BindReadOnly': '--bind-ro', | ||
'BindUser': '--bind-user', | ||
'Boot': '--boot', | ||
'Hostname': '--hostname', | ||
'PrivateUsers': '--private-users', | ||
'PrivateUsersOwnership': '--private-users-ownership', | ||
'SystemCallFilter': '--system-call-filter', | ||
} | ||
|
||
# Generate our command line arguments | ||
nspawn_cmd = [ | ||
'systemd-nspawn', | ||
f"--directory={self.machine_dir}", | ||
'--ephemeral', | ||
# This script is the ultimate source of truth for arguments, not | ||
# our configuration files, which may be stale (but should still be | ||
# updated) | ||
'--settings=no', | ||
# Bind mount an empty file to /etc/ephemeral to allow notating via | ||
# our prompt that we are in an ephemeral environment (so any | ||
# changes to /usr or other paths will not be persistent) | ||
'--bind-ro=:/etc/ephemeral', | ||
# Avoid using CBL tools in ephemeral environments by default (they | ||
# can still be manually used via their full path or temporarily be | ||
# added if needed). This allows config.fish to test if the file is | ||
# readable before adding the tools to the environment. | ||
'--inaccessible=/etc/use-cbl', | ||
] | ||
for values in self.data.values(): | ||
for key, value in values.items(): | ||
# If there is no corresponding command line flag, skip it | ||
# This namely affects VirtualEthernet, as host networking is | ||
# the default with systemd-nspawn but virtual networking is the | ||
# default with systemd-nspawn@.service. | ||
if not (flag := cfg_to_cmd.get(key)): | ||
continue | ||
|
||
if isinstance(value, list): | ||
nspawn_cmd += [f"{flag}={item}" for item in value] | ||
# certain configuration options are booleans but the commmand | ||
# line option is just a simple flag | ||
elif flag in ('--boot', ) and value == 'yes': | ||
nspawn_cmd.append(flag) | ||
else: | ||
nspawn_cmd.append(f"{flag}={value}") | ||
|
||
return nspawn_cmd | ||
|
||
def gen_files(self): | ||
lib.utils.print_green('Requesting sudo permissions for file creation...') | ||
lib.utils.run_as_root('true') | ||
|
||
# Allow containers to access /dev/kvm to run accelerated VMs, which | ||
# allows avoiding installing QEMU in the host environment. | ||
if not (kvm_conf := | ||
Path('/etc/systemd/system/systemd-nspawn@.service.d/kvm.conf')).exists(): | ||
kvm_conf_txt = ('[Service]\n' | ||
'DeviceAllow=/dev/kvm rw\n') | ||
if not kvm_conf.parent.exists(): | ||
lib.utils.run_as_root(['mkdir', '-p', kvm_conf.parent]) | ||
lib.utils.run_as_root(['tee', kvm_conf], input=kvm_conf_txt) | ||
|
||
# Allow my user to access 'machinectl shell' without authentication | ||
# rules.d can only be read by root so we need to use sudo to test | ||
polkit_rule = Path('/etc/polkit-1/rules.d', f"50-permit-{USER}-machinectl-shell.rules") | ||
if not lib.utils.run_check_rc_zero(['sudo', 'test', '-e', polkit_rule]): | ||
polkit_rule_txt = ('polkit.addRule(function(action, subject) {\n' | ||
' if (action.id == "org.freedesktop.machine1.shell" &&\n' | ||
f' subject.user == "{USER}") {{\n' | ||
' return polkit.Result.YES;\n' | ||
' }\n' | ||
'});\n') | ||
if not polkit_rule.parent.exists(): | ||
lib.utils.run_as_root(['mkdir', '-p', polkit_rule.parent]) | ||
lib.utils.run_as_root(['tee', polkit_rule], input=polkit_rule_txt) | ||
|
||
# Write configuration file. We allow the user to interactively remove | ||
# an old one if so desired. | ||
if (nspawn_conf := Path('/etc/systemd/nspawn', f"{self.machine_dir.name}.nspawn")).exists(): | ||
answer = input(f"\033[01;33m{nspawn_conf} already exists, remove it? [y/N]\033[0m ") | ||
if answer.lower() in ('y', 'yes'): | ||
lib.utils.run_as_root(['rm', nspawn_conf]) | ||
if not nspawn_conf.exists(): | ||
if not nspawn_conf.parent.exists(): | ||
lib.utils.run_as_root(['mkdir', '-p', nspawn_conf.parent]) | ||
lib.utils.run_as_root(['tee', nspawn_conf], input=self._gen_cfg_str()) | ||
|
||
# Print a warning if the machine does not already exist | ||
# /var/lib/machines can only be read by root so we need to use sudo to test | ||
if not lib.utils.run_check_rc_zero(['sudo', 'test', '-e', self.machine_dir]): | ||
lib.utils.print_yellow( | ||
f"WARNING: {self.machine_dir} does not exist, machine will not start without it") | ||
|
||
def run_eph_cmd(self): | ||
lib.utils.run_as_root(self._gen_eph_cmd()) | ||
|
||
|
||
def parse_arguments(): | ||
parser = ArgumentParser(description='Dynamically generate or run .nspawn files or commands') | ||
|
||
default_machine = { | ||
'x86_64': 'dev-arch', | ||
} | ||
parser.add_argument('-n', | ||
'--name', | ||
default=default_machine.get(platform.machine()), | ||
help='Name of machine (default: %(default)s)') | ||
|
||
mode_group = parser.add_mutually_exclusive_group(required=True) | ||
mode_group.add_argument('-f', '--files', action='store_true', help='Generate .nspawn files') | ||
mode_group.add_argument('-e', | ||
'--ephemeral', | ||
action='store_true', | ||
help='Run "systemd-nspawn -x" command') | ||
|
||
return parser.parse_args() | ||
|
||
|
||
if __name__ == '__main__': | ||
args = parse_arguments() | ||
|
||
if os.geteuid() == 0: | ||
raise RuntimeError('This script should not be run as root!') | ||
|
||
if lib.utils.in_container(): | ||
raise RuntimeError('This script should be run on the host!') | ||
|
||
if not args.name: | ||
raise RuntimeError('No name specified and architecture has no default!') | ||
|
||
config = NspawnConfig(args.name) | ||
|
||
if args.files: | ||
config.gen_files() | ||
if args.ephemeral: | ||
config.run_eph_cmd() |