Skip to content

Commit

Permalink
python: Add script to work with systemd-nspawn
Browse files Browse the repository at this point in the history
Signed-off-by: Nathan Chancellor <nathan@kernel.org>
  • Loading branch information
nathanchance committed Dec 20, 2024
1 parent 1a7f927 commit b02675a
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 2 deletions.
7 changes: 7 additions & 0 deletions fish/functions/nspawnm.fish
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
4 changes: 2 additions & 2 deletions python/lib/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,15 @@ def run(*args, **kwargs):
raise err


def run_as_root(full_cmd):
def run_as_root(full_cmd, **kwargs):
cmd_copy = [full_cmd] if isinstance(full_cmd, (str, os.PathLike)) else full_cmd.copy()

if os.geteuid() != 0:
cmd_copy.insert(0, 'sudo')

# If we have to escalate via 'sudo', print the command so it can be audited
# if necessary.
run(cmd_copy, show_cmd_location=cmd_copy[0] == 'sudo')
run(cmd_copy, show_cmd_location=cmd_copy[0] == 'sudo', **kwargs)


def run_check_rc_zero(*args, **kwargs):
Expand Down
247 changes: 247 additions & 0 deletions python/scripts/nspawnm.py
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()

0 comments on commit b02675a

Please sign in to comment.