← Back to team overview

curtin-dev team mailing list archive

[Merge] ~ogayot/curtin:nvmet-with-dracut into curtin:master

 

Olivier Gayot has proposed merging ~ogayot/curtin:nvmet-with-dracut into curtin:master.

Commit message:
do not squash

Requested reviews:
  Olivier Gayot (ogayot)

For more details, see:
https://code.launchpad.net/~ogayot/curtin/+git/curtin/+merge/469212

Add support for booting with NVMe/TCP ; using dracut instead of initramfs-tools.

Previously, when dealing with NVMe/TCP, curtin would:
  * install nvne-stas and configure the service
  * configure the initramfs using initramfs-tools scripts/hooks so that the network is brought up and nvme connect-all commands are run in the initramfs ; if necessary (i.e., if the rootfs or other essential FS is on remote storage).

Now, curtin will also handle the full NVMe/TCP story where no local storage is available. If curtin detects that firmware support is needed for booting with NVMe/TCP, it will replace initramfs-tools with dracut ; and ensure that the latter properly passes the network configuration to systemd-networkd.

Known issues
------------
* The system does not completely shutdown after the installation finishes. It ssems to hang after asking to unmount the cdrom. But maybe this should not be treated as a subiquity issue.
* Installing dracut removes ubuntu-server because ubuntu-server depends on initramfs-tools (not directly but still). This is not great but does not seem to prevent the system from properly booting.
* At the end of the installation, subiquity/curtin decide to remove dracut and reinstall initramfs-tools instead. It isn't clear to me why, yet. A workaround is to execute the following command after the installation is done (this should be doable using a late-command):
    $ snap run subiquity.curtin in-target --target /target -- apt -y install dracut

Notes for reviewers
-------------------
* I moved most of the NVMe/TCP bits out of curtin/commands/curthooks.py to curtin/nvmet.py ; and reworked the naming a little bit to use a common prefix "nvmet". Sadly, this increases the diff size/ Reviewing commit by commit would be an option but maybe not ideal either.
-- 
Your team curtin developers is subscribed to branch curtin:master.
diff --git a/curtin/block/deps.py b/curtin/block/deps.py
index d1d9d21..42ad847 100644
--- a/curtin/block/deps.py
+++ b/curtin/block/deps.py
@@ -75,7 +75,7 @@ def detect_required_packages_mapping(osfamily=DISTROS.debian):
             'lvm_volgroup': ['lvm2'],
             'ntfs': ['ntfs-3g'],
             'nvme_controller': [],
-            'nvme_of_controller': ['nvme-cli', 'nvme-stas'],
+            'nvme_of_controller': ['nvme-cli'],
             'raid': ['mdadm'],
             'reiserfs': ['reiserfsprogs'],
             'xfs': ['xfsprogs'],
diff --git a/curtin/commands/curthooks.py b/curtin/commands/curthooks.py
index 3926244..d23ba77 100644
--- a/curtin/commands/curthooks.py
+++ b/curtin/commands/curthooks.py
@@ -8,22 +8,19 @@ import pathlib
 import platform
 import re
 import sys
-import shlex
 import shutil
 import textwrap
-from typing import List, Set, Tuple
-
-import yaml
+from typing import List, Tuple
 
 from curtin import config
 from curtin import block
 from curtin import distro
 from curtin.block import iscsi
 from curtin.block import lvm
-from curtin.block import nvme
 from curtin import net
 from curtin import futil
 from curtin.log import LOG
+from curtin import nvmet
 from curtin import paths
 from curtin import swap
 from curtin import util
@@ -1540,244 +1537,35 @@ def configure_mdadm(cfg, state_etcd, target, osfamily=DISTROS.debian):
                 data=None, target=target)
 
 
-def get_nvme_stas_controller_directives(cfg) -> Set[str]:
-    """Parse the storage configuration and return a set of "controller ="
-    directives to write in the [Controllers] section of a nvme-stas
-    configuration file."""
-    directives = set()
-    for controller in nvme.get_nvme_controllers_from_config(cfg):
-        if controller['transport'] != 'tcp':
-            continue
-        controller_props = {
-            'transport': 'tcp',
-            'traddr': controller["tcp_addr"],
-            'trsvcid': controller["tcp_port"],
-        }
-
-        props_str = ';'.join([f'{k}={v}' for k, v in controller_props.items()])
-        directives.add(f'controller = {props_str}')
-
-    return directives
-
-
-def nvmeotcp_get_nvme_commands(cfg) -> List[Tuple[str]]:
-    """Parse the storage configuration and return a set of commands
-    to run to bring up the NVMe over TCP block devices."""
-    commands: Set[Tuple[str]] = set()
-    for controller in nvme.get_nvme_controllers_from_config(cfg):
-        if controller['transport'] != 'tcp':
-            continue
-
-        commands.add((
-            'nvme', 'connect-all',
-            '--transport', 'tcp',
-            '--traddr', controller['tcp_addr'],
-            '--trsvcid', str(controller['tcp_port']),
-        ))
-
-    return sorted(commands)
-
-
-def nvmeotcp_need_network_in_initramfs(cfg) -> bool:
-    """Parse the storage configuration and check if any of the mountpoints
-    essential for booting requires network."""
-    if 'storage' not in cfg or not isinstance(cfg['storage'], dict):
-        return False
-    storage = cfg['storage']
-    if 'config' not in storage or storage['config'] == 'disabled':
-        return False
-    config = storage['config']
-    for item in config:
-        if item['type'] != 'mount':
-            continue
-        if '_netdev' not in item.get('options', '').split(','):
-            continue
-
-        # We found a mountpoint that requires network. Let's check if it is
-        # essential for booting.
-        path = item['path']
-        if path == '/' or path.startswith('/usr') or path.startswith('/var'):
-            return True
-
-    return False
-
-
-def nvmeotcp_get_ip_commands(cfg) -> List[Tuple[str]]:
-    """Look for the netplan configuration (supplied by subiquity using
-    write_files directives) and attempt to extrapolate a set of 'ip' + 'dhcpcd'
-    commands that would produce more or less the expected network
-    configuration. At the moment, only trivial network configurations are
-    supported, which are ethernet interfaces with or without DHCP and optional
-    static routes."""
-    commands: List[Tuple[str]] = []
-
-    try:
-        content = cfg['write_files']['etc_netplan_installer']['content']
-    except KeyError:
-        return []
-
-    config = yaml.safe_load(content)
-
-    try:
-        ethernets = config['network']['ethernets']
-    except KeyError:
-        return []
-
-    for ifname, ifconfig in ethernets.items():
-        # Handle static IP addresses
-        for address in ifconfig.get('addresses', []):
-            commands.append(('ip', 'address', 'add', address, 'dev', ifname))
-
-        # Handle DHCPv4 and DHCPv6
-        dhcp4 = ifconfig.get('dhcp4', False)
-        dhcp6 = ifconfig.get('dhcp6', False)
-        if dhcp4 and dhcp6:
-            commands.append(('dhcpcd', ifname))
-        elif dhcp4:
-            commands.append(('dhcpcd', '-4', ifname))
-        elif dhcp6:
-            commands.append(('dhcpcd', '-6', ifname))
-        else:
-            commands.append(('ip', 'link', 'set', ifname, 'up'))
-
-        # Handle static routes
-        for route in ifconfig.get('routes', []):
-            cmd = ['ip', 'route', 'add', route['to']]
-            with contextlib.suppress(KeyError):
-                cmd += ['via', route['via']]
-            if route.get('on-link', False):
-                cmd += ['dev', ifname]
-            commands.append(tuple(cmd))
-
-    return commands
-
-
-def configure_nvme_over_tcp(cfg, target):
+def configure_nvme_over_tcp(cfg, target: pathlib.Path) -> None:
     """If any NVMe controller using the TCP transport is present in the storage
     configuration, create a nvme-stas configuration and configure the initramfs
     so that the remote drives can be made available at boot.
     Please note that the NVMe over TCP support in curtin is experimental and in
     active development. Currently, it only works with trivial network
     configurations ; supplied by Subiquity."""
-    controllers = get_nvme_stas_controller_directives(cfg)
+    controllers = nvmet.get_nvme_stas_controller_directives(cfg)
 
     if not controllers:
         return
 
     LOG.info('NVMe-over-TCP configuration found')
-    LOG.info('writing nvme-stas configuration')
-    target = pathlib.Path(target)
-    stas_dir = target / 'etc' / 'stas'
-    stas_dir.mkdir(parents=True, exist_ok=True)
-    with (stas_dir / 'stafd-curtin.conf').open('w', encoding='utf-8') as fh:
-        header = '''\
-# This file was created by curtin.
-# If you make modifications to it, please remember to also update
-# scripts in etc/curtin-nvme-over-tcp and then regenerate the initramfs using
-# the command `update-initramfs -u`.
-'''
-        print(header, file=fh)
-        print('[Controllers]', file=fh)
-        for controller in controllers:
-            print(controller, file=fh)
-
-    with contextlib.suppress(FileNotFoundError):
-        (stas_dir / 'stafd.conf').replace(stas_dir / '.stafd.conf.bak')
-    (stas_dir / 'stafd.conf').symlink_to('stafd-curtin.conf')
-
-    if not nvmeotcp_need_network_in_initramfs(cfg):
-        # nvme-stas should be enough to boot.
-        return
-
-    LOG.info('configuring network in initramfs for NVMe over TCP')
-
-    hook_contents = '''\
-#!/bin/sh
-
-PREREQ="udev"
-
-prereqs()
-{
-    echo "$PREREQ"
-}
-
-case "$1" in
-prereqs)
-    prereqs
-    exit 0
-    ;;
-esac
-
-. /usr/share/initramfs-tools/hook-functions
-
-copy_exec /usr/sbin/nvme /usr/sbin
-copy_file config /etc/nvme/hostid /etc/nvme/
-copy_file config /etc/nvme/hostnqn /etc/nvme/
-copy_file config /etc/curtin-nvme-over-tcp/network-up \\
-    /etc/curtin-nvme-over-tcp/
-copy_file config /etc/curtin-nvme-over-tcp/connect-nvme \\
-    /etc/curtin-nvme-over-tcp/
-
-manual_add_modules nvme-tcp
-'''
-    initramfs_tools_dir = target / 'etc' / 'initramfs-tools'
-
-    initramfs_hooks_dir = initramfs_tools_dir / 'hooks'
-    initramfs_hooks_dir.mkdir(parents=True, exist_ok=True)
-    hook = initramfs_hooks_dir / 'curtin-nvme-over-tcp'
-    with hook.open('w', encoding='utf-8') as fh:
-        print(hook_contents, file=fh)
-    hook.chmod(0o755)
-
-    bootscript_contents = '''\
-#!/bin/sh
-
-    PREREQ=""
-prereqs() { echo "$PREREQ"; }
-case "$1" in
-prereqs)
-    prereqs
-    exit 0
-    ;;
-esac
-
-. /etc/curtin-nvme-over-tcp/network-up
-
-modprobe nvme-tcp
-
-. /etc/curtin-nvme-over-tcp/connect-nvme
-
-'''
-
-    initramfs_scripts_dir = initramfs_tools_dir / 'scripts'
-    initramfs_init_premount_dir = initramfs_scripts_dir / 'init-premount'
-    initramfs_init_premount_dir.mkdir(parents=True, exist_ok=True)
-    bootscript = initramfs_init_premount_dir / 'curtin-nvme-over-tcp'
-    with bootscript.open('w', encoding='utf-8') as fh:
-        print(bootscript_contents, file=fh)
-    bootscript.chmod(0o755)
-
-    curtin_nvme_over_tcp_dir = target / 'etc' / 'curtin-nvme-over-tcp'
-    curtin_nvme_over_tcp_dir.mkdir(parents=True, exist_ok=True)
-    network_up_script = curtin_nvme_over_tcp_dir / 'network-up'
-    connect_nvme_script = curtin_nvme_over_tcp_dir / 'connect-nvme'
-
-    script_header = '''\
-#!/bin/sh
-
-# This file was created by curtin.
-# If you make modifications to it, please remember to regenerate the initramfs
-# using the command `update-initramfs -u`.
-'''
-    with open(connect_nvme_script, 'w', encoding='utf-8') as fh:
-        print(script_header, file=fh)
-        for cmd in nvmeotcp_get_nvme_commands(cfg):
-            print(shlex.join(cmd), file=fh)
-
-    with open(network_up_script, 'w', encoding='utf-8') as fh:
-        print(script_header, file=fh)
-        for cmd in nvmeotcp_get_ip_commands(cfg):
-            print(shlex.join(cmd), file=fh)
+    if nvmet.requires_firmware_support(cfg):
+        # jq is needed for the nvmf dracut module.
+        distro.install_packages(['dracut', 'dracut-network', 'jq'],
+                                target=str(target))
+        # This will take care of reading the network configuration from the
+        # NBFT and pass it to systemd-networkd.
+        nvmet.dracut_add_systemd_network_cmdline(target)
+        # Dracut will automatically call `nvme connect-all --nbft` so no need
+        # to generate `nvme` commands.
+    elif nvmet.need_network_in_initramfs(cfg):
+        nvmet.initramfs_tools_configure(cfg, target)
+    else:
+        # Do not bother configuring the initramfs, everything will be done in
+        # userspace.
+        distro.install_packages('nvme-stas', target=str(target))
+        nvmet.configure_nvme_stas(cfg, target)
 
 
 def handle_cloudconfig(cfg, base_dir=None):
@@ -2046,7 +1834,7 @@ def builtin_curthooks(cfg, target, state):
             name=stack_prefix + '/configuring-nvme-over-tcp',
             reporting_enabled=True, level="INFO",
             description="configuring NVMe over TCP"):
-        configure_nvme_over_tcp(cfg, target)
+        configure_nvme_over_tcp(cfg, pathlib.Path(target))
 
     if osfamily == DISTROS.debian:
         with events.ReportEventStack(
diff --git a/curtin/nvmet.py b/curtin/nvmet.py
new file mode 100644
index 0000000..c9f5779
--- /dev/null
+++ b/curtin/nvmet.py
@@ -0,0 +1,320 @@
+# This file is part of curtin. See LICENSE file for copyright and license info.
+
+'''Module that defines functions useful for dealing with NVMe/TCP'''
+
+import contextlib
+import pathlib
+import shlex
+from typing import List, Optional, Set, Tuple
+
+import yaml
+
+from curtin.block import nvme
+from curtin.log import LOG
+
+
+def get_nvme_stas_controller_directives(cfg) -> Set[str]:
+    """Parse the storage configuration and return a set of "controller ="
+    directives to write in the [Controllers] section of a nvme-stas
+    configuration file."""
+    directives = set()
+    for controller in nvme.get_nvme_controllers_from_config(cfg):
+        if controller['transport'] != 'tcp':
+            continue
+        controller_props = {
+            'transport': 'tcp',
+            'traddr': controller["tcp_addr"],
+            'trsvcid': controller["tcp_port"],
+        }
+
+        props_str = ';'.join([f'{k}={v}' for k, v in controller_props.items()])
+        directives.add(f'controller = {props_str}')
+
+    return directives
+
+
+def get_nvme_commands(cfg) -> List[Tuple[str]]:
+    """Parse the storage configuration and return a set of commands
+    to run to bring up the NVMe over TCP block devices."""
+    commands: Set[Tuple[str]] = set()
+    for controller in nvme.get_nvme_controllers_from_config(cfg):
+        if controller['transport'] != 'tcp':
+            continue
+
+        commands.add((
+            'nvme', 'connect-all',
+            '--transport', 'tcp',
+            '--traddr', controller['tcp_addr'],
+            '--trsvcid', str(controller['tcp_port']),
+        ))
+
+    return sorted(commands)
+
+
+def need_network_in_initramfs(cfg) -> bool:
+    """Parse the storage configuration and check if any of the mountpoints
+    essential for booting requires network."""
+    if 'storage' not in cfg or not isinstance(cfg['storage'], dict):
+        return False
+    storage = cfg['storage']
+    if 'config' not in storage or storage['config'] == 'disabled':
+        return False
+    config = storage['config']
+    for item in config:
+        if item['type'] != 'mount':
+            continue
+        if '_netdev' not in item.get('options', '').split(','):
+            continue
+
+        # We found a mountpoint that requires network. Let's check if it is
+        # essential for booting.
+        path = item['path']
+        if path == '/' or path.startswith('/usr') or path.startswith('/var'):
+            return True
+
+    return False
+
+
+def requires_firmware_support(cfg) -> bool:
+    """Parse the storage configuration and check if the bootfs or ESP are on
+    remote storage. If they are, we need firmware support to reach the initramfs.
+    """
+    rootfs_is_remote = False
+    bootfs_is_remote: Optional[bool] = None
+    esp_is_remote: Optional[bool] = None
+
+    if 'storage' not in cfg or not isinstance(cfg['storage'], dict):
+        return False
+    storage = cfg['storage']
+    if 'config' not in storage or storage['config'] == 'disabled':
+        return False
+    config = storage['config']
+    for item in config:
+        if item['type'] != 'mount':
+            continue
+        path = item['path']
+        if path == '/':
+            rootfs_is_remote = '_netdev' in item.get('options', '').split(',')
+        elif path == '/boot':
+            bootfs_is_remote = '_netdev' in item.get('options', '').split(',')
+            # TODO maybe return true if true
+        elif path == '/boot/efi':
+            esp_is_remote = '_netdev' in item.get('options', '').split(',')
+            # TODO maybe return true if true
+
+    if bootfs_is_remote is None:
+        bootfs_is_remote = rootfs_is_remote
+    if esp_is_remote is None:
+        esp_is_remote = bootfs_is_remote
+
+    return bootfs_is_remote or esp_is_remote
+
+
+def get_ip_commands(cfg) -> List[Tuple[str]]:
+    """Look for the netplan configuration (supplied by subiquity using
+    write_files directives) and attempt to extrapolate a set of 'ip' + 'dhcpcd'
+    commands that would produce more or less the expected network
+    configuration. At the moment, only trivial network configurations are
+    supported, which are ethernet interfaces with or without DHCP and optional
+    static routes."""
+    commands: List[Tuple[str]] = []
+
+    try:
+        content = cfg['write_files']['etc_netplan_installer']['content']
+    except KeyError:
+        return []
+
+    config = yaml.safe_load(content)
+
+    try:
+        ethernets = config['network']['ethernets']
+    except KeyError:
+        return []
+
+    for ifname, ifconfig in ethernets.items():
+        # Handle static IP addresses
+        for address in ifconfig.get('addresses', []):
+            commands.append(('ip', 'address', 'add', address, 'dev', ifname))
+
+        # Handle DHCPv4 and DHCPv6
+        dhcp4 = ifconfig.get('dhcp4', False)
+        dhcp6 = ifconfig.get('dhcp6', False)
+        if dhcp4 and dhcp6:
+            commands.append(('dhcpcd', ifname))
+        elif dhcp4:
+            commands.append(('dhcpcd', '-4', ifname))
+        elif dhcp6:
+            commands.append(('dhcpcd', '-6', ifname))
+        else:
+            commands.append(('ip', 'link', 'set', ifname, 'up'))
+
+        # Handle static routes
+        for route in ifconfig.get('routes', []):
+            cmd = ['ip', 'route', 'add', route['to']]
+            with contextlib.suppress(KeyError):
+                cmd += ['via', route['via']]
+            if route.get('on-link', False):
+                cmd += ['dev', ifname]
+            commands.append(tuple(cmd))
+
+    return commands
+
+
+def dracut_add_systemd_network_cmdline(target: pathlib.Path) -> None:
+    LOG.info('adding curtin-systemd-network-cmdline module to dracut')
+
+    hook_contents = '''\
+#!/bin/bash
+
+type getcmdline > /dev/null 2>&1 || . /lib/dracut-lib.sh
+
+/usr/lib/systemd/systemd-network-generator -- $(getcmdline)
+'''
+    module_setup_contents = '''\
+#!/bin/bash
+
+# called by dracut
+depends() {
+    echo systemd-networkd
+    return 0
+}
+
+# called by dracut
+install() {
+    inst_hook pre-udev 99 "$moddir/networkd-cmdline.sh"
+}
+'''
+
+    dracut_modules_dir = target / 'usr' / 'lib' / 'dracut' / 'modules.d'
+    dracut_curtin_module = dracut_modules_dir / '35curtin-systemd-network-cmdline'
+    dracut_curtin_module.mkdir(parents=True, exist_ok=True)
+
+    hook = dracut_curtin_module / 'networkd-cmdline.sh'
+    with hook.open('w', encoding='utf-8') as fh:
+        print(hook_contents, file=fh)
+    hook.chmod(0o755)
+
+    module_setup = dracut_curtin_module / 'module-setup.sh'
+    with module_setup.open('w', encoding='utf-8') as fh:
+        print(module_setup_contents, file=fh)
+    module_setup.chmod(0o755)
+
+
+def configure_nvme_stas(cfg, target: pathlib.Path) -> None:
+    LOG.info('writing nvme-stas configuration')
+
+    controllers = get_nvme_stas_controller_directives(cfg)
+
+    if not controllers:
+        return
+
+    stas_dir = target / 'etc' / 'stas'
+    stas_dir.mkdir(parents=True, exist_ok=True)
+    with (stas_dir / 'stafd-curtin.conf').open('w', encoding='utf-8') as fh:
+        header = '''\
+# This file was created by curtin.
+'''
+        print(header, file=fh)
+        print('[Controllers]', file=fh)
+        for controller in controllers:
+            print(controller, file=fh)
+
+    with contextlib.suppress(FileNotFoundError):
+        (stas_dir / 'stafd.conf').replace(stas_dir / '.stafd.conf.bak')
+    (stas_dir / 'stafd.conf').symlink_to('stafd-curtin.conf')
+
+
+def initramfs_tools_configure(cfg, target: pathlib.Path) -> None:
+    """Configure initramfs-tools for NVMe/TCP. This is a legacy approach where
+    the network is hardcoded and nvme connect-all commands are manually
+    crafted. However, this implementation does not require firmware support."""
+    LOG.info('configuring initramfs-tools for NVMe over TCP')
+
+    hook_contents = '''\
+#!/bin/sh
+
+PREREQ="udev"
+
+prereqs()
+{
+    echo "$PREREQ"
+}
+
+case "$1" in
+prereqs)
+    prereqs
+    exit 0
+    ;;
+esac
+
+. /usr/share/initramfs-tools/hook-functions
+
+copy_exec /usr/sbin/nvme /usr/sbin
+copy_file config /etc/nvme/hostid /etc/nvme/
+copy_file config /etc/nvme/hostnqn /etc/nvme/
+copy_file config /etc/curtin-nvme-over-tcp/network-up \\
+    /etc/curtin-nvme-over-tcp/
+copy_file config /etc/curtin-nvme-over-tcp/connect-nvme \\
+    /etc/curtin-nvme-over-tcp/
+
+manual_add_modules nvme-tcp
+'''
+    initramfs_tools_dir = target / 'etc' / 'initramfs-tools'
+
+    initramfs_hooks_dir = initramfs_tools_dir / 'hooks'
+    initramfs_hooks_dir.mkdir(parents=True, exist_ok=True)
+    hook = initramfs_hooks_dir / 'curtin-nvme-over-tcp'
+    with hook.open('w', encoding='utf-8') as fh:
+        print(hook_contents, file=fh)
+    hook.chmod(0o755)
+
+    bootscript_contents = '''\
+#!/bin/sh
+
+    PREREQ=""
+prereqs() { echo "$PREREQ"; }
+case "$1" in
+prereqs)
+    prereqs
+    exit 0
+    ;;
+esac
+
+. /etc/curtin-nvme-over-tcp/network-up
+
+modprobe nvme-tcp
+
+. /etc/curtin-nvme-over-tcp/connect-nvme
+
+'''
+
+    initramfs_scripts_dir = initramfs_tools_dir / 'scripts'
+    initramfs_init_premount_dir = initramfs_scripts_dir / 'init-premount'
+    initramfs_init_premount_dir.mkdir(parents=True, exist_ok=True)
+    bootscript = initramfs_init_premount_dir / 'curtin-nvme-over-tcp'
+    with bootscript.open('w', encoding='utf-8') as fh:
+        print(bootscript_contents, file=fh)
+    bootscript.chmod(0o755)
+
+    curtin_nvme_over_tcp_dir = target / 'etc' / 'curtin-nvme-over-tcp'
+    curtin_nvme_over_tcp_dir.mkdir(parents=True, exist_ok=True)
+    network_up_script = curtin_nvme_over_tcp_dir / 'network-up'
+    connect_nvme_script = curtin_nvme_over_tcp_dir / 'connect-nvme'
+
+    script_header = '''\
+#!/bin/sh
+
+# This file was created by curtin.
+# If you make modifications to it, please remember to regenerate the initramfs
+# using the command `update-initramfs -u`.
+'''
+    with open(connect_nvme_script, 'w', encoding='utf-8') as fh:
+        print(script_header, file=fh)
+        for cmd in get_nvme_commands(cfg):
+            print(shlex.join(cmd), file=fh)
+
+    with open(network_up_script, 'w', encoding='utf-8') as fh:
+        print(script_header, file=fh)
+        for cmd in get_ip_commands(cfg):
+            print(shlex.join(cmd), file=fh)
+
diff --git a/tests/unittests/test_curthooks.py b/tests/unittests/test_curthooks.py
index 1543122..778e4f8 100644
--- a/tests/unittests/test_curthooks.py
+++ b/tests/unittests/test_curthooks.py
@@ -1,7 +1,8 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
 
 import os
-from unittest.mock import call, patch
+from pathlib import Path
+from unittest.mock import call, Mock, patch
 import textwrap
 from typing import Optional
 
@@ -1634,7 +1635,7 @@ class TestDetectRequiredPackages(CiTestCase):
             ({'storage': {
                 'version': 1,
                 'items': ('nvme_controller_tcp',)}},
-             ('nvme-cli', 'nvme-stas')),
+             ('nvme-cli', )),
         ))
 
     def test_network_v1_detect(self):
@@ -2183,208 +2184,63 @@ class TestCurthooksGrubDebconf(CiTestCase):
 
 
 class TestCurthooksNVMeOverTCP(CiTestCase):
-    def test_no_nvme_controller(self):
-        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
-                   return_value=[]):
-            self.assertFalse(
-                    curthooks.get_nvme_stas_controller_directives(None))
-            self.assertFalse(curthooks.nvmeotcp_get_nvme_commands(None))
-
-    def test_pcie_controller(self):
-        controllers = [{'type': 'nvme_controller', 'transport': 'pcie'}]
-        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
-                   return_value=controllers):
-            self.assertFalse(
-                    curthooks.get_nvme_stas_controller_directives(None))
-            self.assertFalse(curthooks.nvmeotcp_get_nvme_commands(None))
-
-    def test_tcp_controller(self):
-        stas_expected = {
-            'controller = transport=tcp;traddr=1.2.3.4;trsvcid=1111',
-        }
-        cmds_expected = [(
-            "nvme", "connect-all",
-            "--transport", "tcp",
-            "--traddr", "1.2.3.4",
-            "--trsvcid", "1111",
-            ),
-        ]
-        controllers = [{
-            "type": "nvme_controller",
-            "transport": "tcp",
-            "tcp_addr": "1.2.3.4",
-            "tcp_port": "1111",
-        }]
-
-        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
-                   return_value=controllers):
-            stas_result = curthooks.get_nvme_stas_controller_directives(None)
-            cmds_result = curthooks.nvmeotcp_get_nvme_commands(None)
-        self.assertEqual(stas_expected, stas_result)
-        self.assertEqual(cmds_expected, cmds_result)
-
-    def test_three_nvme_controllers(self):
-        stas_expected = {
-            "controller = transport=tcp;traddr=1.2.3.4;trsvcid=1111",
-            "controller = transport=tcp;traddr=4.5.6.7;trsvcid=1212",
-        }
-        cmds_expected = [(
-            "nvme", "connect-all",
-            "--transport", "tcp",
-            "--traddr", "1.2.3.4",
-            "--trsvcid", "1111",
-            ), (
-            "nvme", "connect-all",
-            "--transport", "tcp",
-            "--traddr", "4.5.6.7",
-            "--trsvcid", "1212",
-            ),
-        ]
-        controllers = [
-            {
-                "type": "nvme_controller",
-                "transport": "tcp",
-                "tcp_addr": "1.2.3.4",
-                "tcp_port": "1111",
-            }, {
-                "type": "nvme_controller",
-                "transport": "tcp",
-                "tcp_addr": "4.5.6.7",
-                "tcp_port": "1212",
-            }, {
-                "type": "nvme_controller",
-                "transport": "pcie",
-            },
-        ]
-
-        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
-                   return_value=controllers):
-            stas_result = curthooks.get_nvme_stas_controller_directives(None)
-            cmds_result = curthooks.nvmeotcp_get_nvme_commands(None)
-        self.assertEqual(stas_expected, stas_result)
-        self.assertEqual(cmds_expected, cmds_result)
-
-    def test_nvmeotcp_get_ip_commands__ethernet_static(self):
-        netcfg = """\
-# This is the network config written by 'subiquity'
-network:
-  ethernets:
-    ens3:
-     addresses:
-     - 10.0.2.15/24
-     nameservers:
-       addresses:
-       - 8.8.8.8
-       - 8.4.8.4
-       search:
-       - foo
-       - bar
-     routes:
-     - to: default
-       via: 10.0.2.2
-  version: 2"""
-
-        cfg = {
-            "write_files": {
-                "etc_netplan_installer": {
-                    "content": netcfg,
-                    "path": "etc/netplan/00-installer-config.yaml",
-                    "permissions": "0600",
-                },
-            },
-        }
-        expected = [
-            ("ip", "address", "add", "10.0.2.15/24", "dev", "ens3"),
-            ("ip", "link", "set", "ens3", "up"),
-            ("ip", "route", "add", "default", "via", "10.0.2.2"),
-        ]
-        self.assertEqual(expected, curthooks.nvmeotcp_get_ip_commands(cfg))
-
-    def test_nvmeotcp_get_ip_commands__ethernet_dhcp4(self):
-        netcfg = """\
-# This is the network config written by 'subiquity'
-network:
-  ethernets:
-    ens3:
-     dhcp4: true
-  version: 2"""
-
-        cfg = {
-            "write_files": {
-                "etc_netplan_installer": {
-                    "content": netcfg,
-                    "path": "etc/netplan/00-installer-config.yaml",
-                    "permissions": "0600",
-                },
-            },
-        }
-        expected = [
-            ("dhcpcd", "-4", "ens3"),
-        ]
-        self.assertEqual(expected, curthooks.nvmeotcp_get_ip_commands(cfg))
-
-    def test_nvmeotcp_need_network_in_initramfs__usr_is_netdev(self):
-        self.assertTrue(curthooks.nvmeotcp_need_network_in_initramfs({
-            "storage": {
-                "config": [
-                    {
-                        "type": "mount",
-                        "path": "/usr",
-                        "options": "default,_netdev",
-                    }, {
-                        "type": "mount",
-                        "path": "/",
-                    }, {
-                        "type": "mount",
-                        "path": "/boot",
-                    },
-                ],
-            },
-        }))
-
-    def test_nvmeotcp_need_network_in_initramfs__rootfs_is_netdev(self):
-        self.assertTrue(curthooks.nvmeotcp_need_network_in_initramfs({
-            "storage": {
-                "config": [
-                    {
-                        "type": "mount",
-                        "path": "/",
-                        "options": "default,_netdev",
-                    }, {
-                        "type": "mount",
-                        "path": "/boot",
-                    },
-                ],
-            },
-        }))
-
-    def test_nvmeotcp_need_network_in_initramfs__only_home_is_netdev(self):
-        self.assertFalse(curthooks.nvmeotcp_need_network_in_initramfs({
-            "storage": {
-                "config": [
-                    {
-                        "type": "mount",
-                        "path": "/home",
-                        "options": "default,_netdev",
-                    }, {
-                        "type": "mount",
-                        "path": "/",
-                    },
-                ],
-            },
-        }))
-
-    def test_nvmeotcp_need_network_in_initramfs__empty_conf(self):
-        self.assertFalse(curthooks.nvmeotcp_need_network_in_initramfs({}))
-        self.assertFalse(curthooks.nvmeotcp_need_network_in_initramfs(
-            {"storage": False}))
-        self.assertFalse(curthooks.nvmeotcp_need_network_in_initramfs(
-            {"storage": {}}))
-        self.assertFalse(curthooks.nvmeotcp_need_network_in_initramfs({
-            "storage": {
-                "config": "disabled",
-            },
-        }))
+    @patch('curtin.nvmet.get_nvme_stas_controller_directives', Mock())
+    @patch('curtin.nvmet.requires_firmware_support', Mock(return_value=True))
+    @patch('curtin.nvmet.dracut_add_systemd_network_cmdline')
+    @patch('curtin.nvmet.initramfs_tools_configure')
+    @patch('curtin.nvmet.need_network_in_initramfs', Mock())
+    @patch('curtin.nvmet.configure_nvme_stas')
+    @patch('curtin.distro.install_packages')
+    def test_configure_nvme_over_tcp__dracut(
+            self, m_install_pkgs, m_config_stas, m_initramfs_tools_config,
+            m_dracut_add_module):
+
+        curthooks.configure_nvme_over_tcp({}, Path('/tmp'))
+
+        m_install_pkgs.assert_called_once_with(
+                ['dracut', 'dracut-network', 'jq'], target='/tmp')
+        m_dracut_add_module.assert_called_once_with(Path("/tmp"))
+
+        m_initramfs_tools_config.assert_not_called()
+        m_config_stas.assert_not_called()
+
+    @patch('curtin.nvmet.get_nvme_stas_controller_directives', Mock())
+    @patch('curtin.nvmet.requires_firmware_support', Mock(return_value=False))
+    @patch('curtin.nvmet.dracut_add_systemd_network_cmdline')
+    @patch('curtin.nvmet.initramfs_tools_configure')
+    @patch('curtin.nvmet.need_network_in_initramfs', Mock(return_value=True))
+    @patch('curtin.nvmet.configure_nvme_stas')
+    @patch('curtin.distro.install_packages')
+    def test_configure_nvme_over_tcp__initramfs_tools(
+            self, m_install_pkgs, m_config_stas, m_initramfs_tools_config,
+            m_dracut_add_module):
+
+        curthooks.configure_nvme_over_tcp({}, Path('/tmp'))
+
+        m_initramfs_tools_config.assert_called_once_with({}, Path('/tmp'))
+
+        m_dracut_add_module.assert_not_called()
+        m_config_stas.assert_not_called()
+        m_install_pkgs.assert_not_called()
+
+    @patch('curtin.nvmet.get_nvme_stas_controller_directives', Mock())
+    @patch('curtin.nvmet.requires_firmware_support', Mock(return_value=False))
+    @patch('curtin.nvmet.dracut_add_systemd_network_cmdline')
+    @patch('curtin.nvmet.initramfs_tools_configure')
+    @patch('curtin.nvmet.need_network_in_initramfs', Mock(return_value=False))
+    @patch('curtin.nvmet.configure_nvme_stas')
+    @patch('curtin.distro.install_packages')
+    def test_configure_nvme_over_tcp__nvme_stas(
+            self, m_install_pkgs, m_config_stas, m_initramfs_tools_config,
+            m_dracut_add_module):
+
+        curthooks.configure_nvme_over_tcp({}, Path('/tmp'))
+
+        m_install_pkgs.assert_called_once_with('nvme-stas', target='/tmp')
+        m_config_stas.assert_called_once_with({}, Path('/tmp'))
+
+        m_initramfs_tools_config.assert_not_called()
+        m_dracut_add_module.assert_not_called()
 
 
 class TestUefiFindGrubDeviceIds(CiTestCase):
diff --git a/tests/unittests/test_nvmet.py b/tests/unittests/test_nvmet.py
new file mode 100644
index 0000000..3fe3734
--- /dev/null
+++ b/tests/unittests/test_nvmet.py
@@ -0,0 +1,327 @@
+# This file is part of curtin. See LICENSE file for copyright and license info.
+
+from pathlib import Path
+from unittest.mock import patch
+
+
+from curtin import nvmet
+from .helpers import CiTestCase
+
+
+class TestNVMeTCP(CiTestCase):
+    def test_no_nvme_controller(self):
+        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
+                   return_value=[]):
+            self.assertFalse(
+                    nvmet.get_nvme_stas_controller_directives(None))
+            self.assertFalse(nvmet.get_nvme_commands(None))
+
+    def test_pcie_controller(self):
+        controllers = [{'type': 'nvme_controller', 'transport': 'pcie'}]
+        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
+                   return_value=controllers):
+            self.assertFalse(
+                    nvmet.get_nvme_stas_controller_directives(None))
+            self.assertFalse(nvmet.get_nvme_commands(None))
+
+    def test_tcp_controller(self):
+        stas_expected = {
+            'controller = transport=tcp;traddr=1.2.3.4;trsvcid=1111',
+        }
+        cmds_expected = [(
+            "nvme", "connect-all",
+            "--transport", "tcp",
+            "--traddr", "1.2.3.4",
+            "--trsvcid", "1111",
+            ),
+        ]
+        controllers = [{
+            "type": "nvme_controller",
+            "transport": "tcp",
+            "tcp_addr": "1.2.3.4",
+            "tcp_port": "1111",
+        }]
+
+        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
+                   return_value=controllers):
+            stas_result = nvmet.get_nvme_stas_controller_directives(None)
+            cmds_result = nvmet.get_nvme_commands(None)
+        self.assertEqual(stas_expected, stas_result)
+        self.assertEqual(cmds_expected, cmds_result)
+
+    def test_three_nvme_controllers(self):
+        stas_expected = {
+            "controller = transport=tcp;traddr=1.2.3.4;trsvcid=1111",
+            "controller = transport=tcp;traddr=4.5.6.7;trsvcid=1212",
+        }
+        cmds_expected = [(
+            "nvme", "connect-all",
+            "--transport", "tcp",
+            "--traddr", "1.2.3.4",
+            "--trsvcid", "1111",
+            ), (
+            "nvme", "connect-all",
+            "--transport", "tcp",
+            "--traddr", "4.5.6.7",
+            "--trsvcid", "1212",
+            ),
+        ]
+        controllers = [
+            {
+                "type": "nvme_controller",
+                "transport": "tcp",
+                "tcp_addr": "1.2.3.4",
+                "tcp_port": "1111",
+            }, {
+                "type": "nvme_controller",
+                "transport": "tcp",
+                "tcp_addr": "4.5.6.7",
+                "tcp_port": "1212",
+            }, {
+                "type": "nvme_controller",
+                "transport": "pcie",
+            },
+        ]
+
+        with patch('curtin.block.nvme.get_nvme_controllers_from_config',
+                   return_value=controllers):
+            stas_result = nvmet.get_nvme_stas_controller_directives(None)
+            cmds_result = nvmet.get_nvme_commands(None)
+        self.assertEqual(stas_expected, stas_result)
+        self.assertEqual(cmds_expected, cmds_result)
+
+    def test_get_ip_commands__ethernet_static(self):
+        netcfg = """\
+# This is the network config written by 'subiquity'
+network:
+  ethernets:
+    ens3:
+     addresses:
+     - 10.0.2.15/24
+     nameservers:
+       addresses:
+       - 8.8.8.8
+       - 8.4.8.4
+       search:
+       - foo
+       - bar
+     routes:
+     - to: default
+       via: 10.0.2.2
+  version: 2"""
+
+        cfg = {
+            "write_files": {
+                "etc_netplan_installer": {
+                    "content": netcfg,
+                    "path": "etc/netplan/00-installer-config.yaml",
+                    "permissions": "0600",
+                },
+            },
+        }
+        expected = [
+            ("ip", "address", "add", "10.0.2.15/24", "dev", "ens3"),
+            ("ip", "link", "set", "ens3", "up"),
+            ("ip", "route", "add", "default", "via", "10.0.2.2"),
+        ]
+        self.assertEqual(expected, nvmet.get_ip_commands(cfg))
+
+    def test_get_ip_commands__ethernet_dhcp4(self):
+        netcfg = """\
+# This is the network config written by 'subiquity'
+network:
+  ethernets:
+    ens3:
+     dhcp4: true
+  version: 2"""
+
+        cfg = {
+            "write_files": {
+                "etc_netplan_installer": {
+                    "content": netcfg,
+                    "path": "etc/netplan/00-installer-config.yaml",
+                    "permissions": "0600",
+                },
+            },
+        }
+        expected = [
+            ("dhcpcd", "-4", "ens3"),
+        ]
+        self.assertEqual(expected, nvmet.get_ip_commands(cfg))
+
+    def test_need_network_in_initramfs__usr_is_netdev(self):
+        self.assertTrue(nvmet.need_network_in_initramfs({
+            "storage": {
+                "config": [
+                    {
+                        "type": "mount",
+                        "path": "/usr",
+                        "options": "default,_netdev",
+                    }, {
+                        "type": "mount",
+                        "path": "/",
+                    }, {
+                        "type": "mount",
+                        "path": "/boot",
+                    },
+                ],
+            },
+        }))
+
+    def test_need_network_in_initramfs__rootfs_is_netdev(self):
+        self.assertTrue(nvmet.need_network_in_initramfs({
+            "storage": {
+                "config": [
+                    {
+                        "type": "mount",
+                        "path": "/",
+                        "options": "default,_netdev",
+                    }, {
+                        "type": "mount",
+                        "path": "/boot",
+                    },
+                ],
+            },
+        }))
+
+    def test_need_network_in_initramfs__only_home_is_netdev(self):
+        self.assertFalse(nvmet.need_network_in_initramfs({
+            "storage": {
+                "config": [
+                    {
+                        "type": "mount",
+                        "path": "/home",
+                        "options": "default,_netdev",
+                    }, {
+                        "type": "mount",
+                        "path": "/",
+                    },
+                ],
+            },
+        }))
+
+    def test_need_network_in_initramfs__empty_conf(self):
+        self.assertFalse(nvmet.need_network_in_initramfs({}))
+        self.assertFalse(nvmet.need_network_in_initramfs(
+            {"storage": False}))
+        self.assertFalse(nvmet.need_network_in_initramfs(
+            {"storage": {}}))
+        self.assertFalse(nvmet.need_network_in_initramfs({
+            "storage": {
+                "config": "disabled",
+            },
+        }))
+
+    def test_requires_firmware_support__root_on_remote(self):
+        self.assertTrue(nvmet.requires_firmware_support({
+            "storage": {
+                "config": [
+                    {
+                        "type": "mount",
+                        "path": "/",
+                        "options": "default,_netdev",
+                    },
+                ],
+            },
+        }))
+        self.assertFalse(nvmet.requires_firmware_support({
+            "storage": {
+                "config": [
+                    {
+                        "type": "mount",
+                        "path": "/boot",
+                    }, {
+                        "type": "mount",
+                        "path": "/",
+                        "options": "default,_netdev",
+                    },
+                ],
+            },
+        }))
+
+    def test_requires_firmware_support__empty_conf(self):
+        self.assertFalse(nvmet.requires_firmware_support({}))
+        self.assertFalse(nvmet.need_network_in_initramfs(
+            {"storage": False}))
+        self.assertFalse(nvmet.need_network_in_initramfs(
+            {"storage": {}}))
+        self.assertFalse(nvmet.need_network_in_initramfs({
+            "storage": {
+                "config": "disabled",
+            },
+        }))
+
+    def test_dracut_add_systemd_network_cmdline(self):
+        target = self.tmp_dir()
+        nvmet.dracut_add_systemd_network_cmdline(target=Path(target))
+        cmdline_sh = Path(
+                target + "/usr/lib/dracut/modules.d/35curtin-systemd-network-cmdline/networkd-cmdline.sh")
+        setup_sh = Path(
+                target + "/usr/lib/dracut/modules.d/35curtin-systemd-network-cmdline/module-setup.sh")
+        self.assertTrue(cmdline_sh.exists())
+        self.assertTrue(setup_sh.exists())
+
+    def test_initramfs_tools_configure(self):
+        target = self.tmp_dir()
+
+        nvme_cmds = [
+            ('nvme', 'connect-all', '--transport', 'tcp', '--traddr',
+            '172.16.82.77', '--trsvcid', '4420'),
+        ]
+
+        ip_cmds = [
+            ('dhcpcd', '-4', 'ens3'),
+        ]
+
+        with (patch("curtin.nvmet.get_nvme_commands", return_value=nvme_cmds),
+              patch("curtin.nvmet.get_ip_commands", return_value=ip_cmds)):
+            nvmet.initramfs_tools_configure({}, target=Path(target))
+
+        hook = Path(target + '/etc/initramfs-tools/hooks/curtin-nvme-over-tcp')
+        bootscript = Path(target + '/etc/initramfs-tools/scripts/init-premount/curtin-nvme-over-tcp')
+        netup_script = Path(target + '/etc/curtin-nvme-over-tcp/network-up')
+        connect_nvme_script = Path(target + '/etc/curtin-nvme-over-tcp/connect-nvme')
+
+        self.assertTrue(hook.exists())
+        self.assertTrue(bootscript.exists())
+
+        netup_expected_contents = '''\
+#!/bin/sh
+
+# This file was created by curtin.
+# If you make modifications to it, please remember to regenerate the initramfs
+# using the command `update-initramfs -u`.
+
+dhcpcd -4 ens3
+'''
+        self.assertEqual(netup_expected_contents, netup_script.read_text())
+        connect_nvme_expected_contents = '''\
+#!/bin/sh
+
+# This file was created by curtin.
+# If you make modifications to it, please remember to regenerate the initramfs
+# using the command `update-initramfs -u`.
+
+nvme connect-all --transport tcp --traddr 172.16.82.77 --trsvcid 4420
+'''
+        self.assertEqual(connect_nvme_expected_contents, connect_nvme_script.read_text())
+
+    def test_configure_nvme_stas(self):
+        target = self.tmp_dir()
+
+        directives = [
+            'controller = transport=tcp;traddr=172.16.82.77;trsvcid=4420',
+        ]
+
+        with patch('curtin.nvmet.get_nvme_stas_controller_directives', return_value=directives):
+            nvmet.configure_nvme_stas({}, target=Path(target))
+
+        stafd = Path(target + '/etc/stas/stafd.conf')
+
+        stafd_expected_contents = '''\
+# This file was created by curtin.
+
+[Controllers]
+controller = transport=tcp;traddr=172.16.82.77;trsvcid=4420
+'''
+        self.assertEqual(stafd_expected_contents, stafd.read_text())

References