From 6aab8ffd0c76e98fb1f996331d1d8f355bf9a7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Mon, 9 Jun 2025 14:02:14 +0200 Subject: [PATCH 01/15] change install test to run clan outside of the VM --- checks/installation/flake-module.nix | 326 +++++++++++++++++++++------ 1 file changed, 259 insertions(+), 67 deletions(-) diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index 527890f53..d9313aae8 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -1,23 +1,12 @@ { self, lib, + ... }: let - installer = + target = { modulesPath, pkgs, ... }: - let - dependencies = [ - self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel - self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript - pkgs.stdenv.drvPath - pkgs.bash.drvPath - pkgs.nixos-anywhere - pkgs.bubblewrap - pkgs.buildPackages.xorg.lndir - ] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); - closureInfo = pkgs.closureInfo { rootPaths = dependencies; }; - in { imports = [ (modulesPath + "/../tests/common/auto-format-root-device.nix") @@ -28,33 +17,30 @@ let services.openssh.settings.PasswordAuthentication = false; system.nixos.variant_id = "installer"; environment.systemPackages = [ - self.packages.${pkgs.system}.clan-cli-full pkgs.nixos-facter ]; - environment.etc."install-closure".source = "${closureInfo}/store-paths"; + # Disable cache.nixos.org to speed up tests + nix.settings.substituters = [ ]; + nix.settings.trusted-public-keys = [ ]; virtualisation.emptyDiskImages = [ 512 ]; virtualisation.diskSize = 8 * 1024; virtualisation.rootDevice = "/dev/vdb"; # both installer and target need to use the same diskImage virtualisation.diskImage = "./target.qcow2"; virtualisation.memorySize = 3048; - nix.settings = { - substituters = lib.mkForce [ ]; - hashed-mirrors = null; - connect-timeout = lib.mkForce 3; - flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}''; - experimental-features = [ - "nix-command" - "flakes" - ]; - }; users.users.nonrootuser = { isNormalUser = true; - openssh.authorizedKeys.keyFiles = [ ../assets/ssh/pubkey ]; + openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; extraGroups = [ "wheel" ]; }; + users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + # Allow users to manage their own SSH keys + services.openssh.authorizedKeysFiles = [ + "/root/.ssh/authorized_keys" + "/home/%u/.ssh/authorized_keys" + "/etc/ssh/authorized_keys.d/%u" + ]; security.sudo.wheelNeedsPassword = false; - system.extraDependencies = dependencies; }; in { @@ -105,6 +91,25 @@ in environment.etc."install-successful".text = "ok"; + # Enable SSH and add authorized key for testing + services.openssh.enable = true; + services.openssh.settings.PasswordAuthentication = false; + users.users.nonrootuser = { + isNormalUser = true; + openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + extraGroups = [ "wheel" ]; + home = "/home/nonrootuser"; + createHome = true; + }; + users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + # Allow users to manage their own SSH keys + services.openssh.authorizedKeysFiles = [ + "/root/.ssh/authorized_keys" + "/home/%u/.ssh/authorized_keys" + "/etc/ssh/authorized_keys.d/%u" + ]; + security.sudo.wheelNeedsPassword = false; + boot.consoleLogLevel = lib.mkForce 100; boot.kernelParams = [ "boot.shell_on_fail" ]; @@ -181,55 +186,242 @@ in # vm-test-run-test-installation-> target: waiting for the VM to finish booting # vm-test-run-test-installation-> target: Guest root shell did not produce any data yet... # vm-test-run-test-installation-> target: To debug, enter the VM and run 'systemctl status backdoor.service'. - checks = pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) { - nixos-test-installation = self.clanLib.test.baseTest { - name = "installation"; - nodes.target = { - services.openssh.enable = true; - virtualisation.diskImage = "./target.qcow2"; - virtualisation.useBootLoader = true; + checks = + let + # Custom Python package for port management utilities + portUtils = pkgs.python3Packages.buildPythonPackage { + pname = "port-utils"; + version = "1.0.0"; + format = "other"; + src = lib.fileset.toSource { + root = ./.; + fileset = ./port_utils.py; + }; + dontUnpack = true; + installPhase = '' + install -D $src/port_utils.py $out/${pkgs.python3.sitePackages}/port_utils.py + touch $out/${pkgs.python3.sitePackages}/py.typed + ''; + doCheck = false; }; - nodes.installer = installer; + closureInfo = pkgs.closureInfo { + rootPaths = [ + self.checks.x86_64-linux.clan-core-for-checks + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.initialRamdisk + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript + pkgs.stdenv.drvPath + pkgs.bash.drvPath + pkgs.buildPackages.xorg.lndir + ] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); + }; + in + pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) { + nixos-test-installation = self.clanLib.test.baseTest { + name = "installation"; + nodes.target = target; + extraPythonPackages = _p: [ + portUtils + self.legacyPackages.${pkgs.system}.setupNixInNixPythonPackage + ]; - testScript = '' - installer.start() + testScript = '' + import tempfile + import os + import subprocess + from port_utils import find_free_port, setup_port_forwarding # type: ignore[import-untyped] + from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] - installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519") + def create_test_machine(oldmachine=None, **kwargs): + """Create a new test machine from an installed disk image""" + start_command = [ + "${pkgs.qemu_test}/bin/qemu-kvm", + "-cpu", "max", + "-m", "3048", + "-virtfs", "local,path=/nix/store,security_model=none,mount_tag=nix-store", + "-drive", f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", + "-device", "virtio-blk-pci,drive=drive1", + "-netdev", "user,id=net0", + "-device", "virtio-net-pci,netdev=net0", + ] + machine = create_machine(start_command=" ".join(start_command), **kwargs) + driver.machines.append(machine) + return machine - installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname") - installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake") + if "NIX_REMOTE" in os.environ: + del os.environ["NIX_REMOTE"] # we don't have any nix daemon running + target.start() - installer.succeed("clan machines install --no-reboot --debug --flake test-flake --yes test-install-machine-without-system --target-host nonrootuser@localhost --update-hardware-config nixos-facter >&2") - installer.shutdown() + # Set up SSH key on host (test driver environment) + with tempfile.TemporaryDirectory() as temp_dir: - # We are missing the test instrumentation somehow. Test this later. - target.state_dir = installer.state_dir - target.start() - target.wait_for_unit("multi-user.target") - ''; - } { inherit pkgs self; }; + # Set up nix chroot store environment + os.environ["closureInfo"] = "${closureInfo}" + os.environ["TMPDIR"] = temp_dir + + # Run setup function + setup_nix_in_nix() - nixos-test-update-hardware-configuration = self.clanLib.test.baseTest { - name = "update-hardware-configuration"; - nodes.installer = installer; - testScript = '' - installer.start() - installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519") - installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname") - installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake") - installer.fail("test -f test-flake/machines/test-install-machine/hardware-configuration.nix") - installer.fail("test -f test-flake/machines/test-install-machine/facter.json") + host_port = find_free_port() + target.wait_for_unit("sshd.service") + target.wait_for_open_port(22) - installer.succeed("clan machines update-hardware-config --debug --flake test-flake test-install-machine-without-system nonrootuser@localhost >&2") - installer.succeed("test -f test-flake/machines/test-install-machine-without-system/facter.json") - installer.succeed("rm test-flake/machines/test-install-machine-without-system/facter.json") + setup_port_forwarding(target, host_port) - installer.succeed("clan machines update-hardware-config --debug --backend nixos-generate-config --flake test-flake test-install-machine-without-system nonrootuser@localhost >&2") - installer.succeed("test -f test-flake/machines/test-install-machine-without-system/hardware-configuration.nix") - installer.succeed("rm test-flake/machines/test-install-machine-without-system/hardware-configuration.nix") - ''; - } { inherit pkgs self; }; - }; + ssh_key = os.path.join(temp_dir, "id_ed25519") + with open(ssh_key, "w") as f: + with open("${../assets/ssh/privkey}", "r") as src: + f.write(src.read()) + os.chmod(ssh_key, 0o600) + + # Copy test flake to temp directory + flake_dir = os.path.join(temp_dir, "test-flake") + subprocess.run(["cp", "-r", "${self.checks.x86_64-linux.clan-core-for-checks}", flake_dir], check=True) + subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) + + # Run clan install from host using port forwarding + clan_cmd = [ + "${self.packages.${pkgs.system}.clan-cli-full}/bin/clan", + "machines", + "install", + "--phases", "disko,install", + "--debug", + "--flake", env.flake_dir, + "--yes", "test-install-machine-without-system", + "--target-host", f"nonrootuser@localhost:{host_port}", + "-i", ssh_key, + "--option", "store", os.environ['CLAN_TEST_STORE'], + "--update-hardware-config", "nixos-facter", + ] + + # Set NIX_CONFIG to disable cache.nixos.org for speed + env = os.environ.copy() + env["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " + subprocess.run(clan_cmd, check=True, env=env) + + # Shutdown the installer machine gracefully + try: + target.shutdown() + except BrokenPipeError: + # qemu has already exited + pass + + # Create a new machine instance that boots from the installed system + installed_machine = create_test_machine(oldmachine=target, name="after_install") + installed_machine.start() + installed_machine.wait_for_unit("multi-user.target") + installed_machine.succeed("test -f /etc/install-successful") + ''; + } { inherit pkgs self; }; + + nixos-test-update-hardware-configuration = self.clanLib.test.baseTest { + name = "update-hardware-configuration"; + nodes.target = target; + extraPythonPackages = _p: [ + portUtils + self.legacyPackages.${pkgs.system}.setupNixInNixPythonPackage + ]; + + testScript = '' + import tempfile + import os + import subprocess + from port_utils import find_free_port, setup_port_forwarding # type: ignore[import-untyped] + from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] + + # Keep reference to closureInfo: ${closureInfo} + + # Set up nix chroot store environment + os.environ["closureInfo"] = "${closureInfo}" + + # Run setup function + setup_nix_in_nix() + + host_port = find_free_port() + + target.start() + + setup_port_forwarding(target, host_port) + + # Set up SSH key and flake on host + with tempfile.TemporaryDirectory() as temp_dir: + ssh_key = os.path.join(temp_dir, "id_ed25519") + with open(ssh_key, "w") as f: + with open("${../assets/ssh/privkey}", "r") as src: + f.write(src.read()) + os.chmod(ssh_key, 0o600) + + flake_dir = os.path.join(temp_dir, "test-flake") + subprocess.run(["cp", "-r", "${self.checks.x86_64-linux.clan-core-for-checks}", flake_dir], check=True) + subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) + + # Verify files don't exist initially + hw_config_file = os.path.join(flake_dir, "machines/test-install-machine/hardware-configuration.nix") + facter_file = os.path.join(flake_dir, "machines/test-install-machine/facter.json") + + assert not os.path.exists(hw_config_file), "hardware-configuration.nix should not exist initially" + assert not os.path.exists(facter_file), "facter.json should not exist initially" + + target.wait_for_unit("sshd.service") + target.wait_for_open_port(22) + + # Test facter backend + clan_cmd = [ + "${self.packages.${pkgs.system}.clan-cli-full}/bin/clan", + "machines", + "update-hardware-config", + "--debug", + "--flake", ".", + "--host-key-check", "none", + "test-install-machine-without-system", + "-i", ssh_key, + "--option", "store", os.environ['CLAN_TEST_STORE'], + f"nonrootuser@localhost:{env.host_port}" + ] + + env = os.environ.copy() + env["CLAN_FLAKE"] = flake_dir + # Set NIX_CONFIG to disable cache.nixos.org for speed + env["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " + result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir, env=env) + if result.returncode != 0: + print(f"Clan update-hardware-config failed: {result.stderr.decode()}") + raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}") + + facter_without_system_file = os.path.join(env.flake_dir, "machines/test-install-machine-without-system/facter.json") + assert os.path.exists(facter_without_system_file), "facter.json should exist after update" + os.remove(facter_without_system_file) + + # Test nixos-generate-config backend + clan_cmd = [ + "${self.packages.${pkgs.system}.clan-cli-full}/bin/clan", + "machines", + "update-hardware-config", + "--debug", + "--backend", "nixos-generate-config", + "--host-key-check", "none", + "--flake", ".", + "test-install-machine-without-system", + "--option", "ssh-option", f"-i {ssh_key} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null", + "--option", "store", os.environ['CLAN_TEST_STORE'], + f"nonrootuser@localhost:{env.host_port}" + ] + + env = os.environ.copy() + env["CLAN_FLAKE"] = flake_dir + # Set NIX_CONFIG to disable cache.nixos.org for speed + env["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " + result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir, env=env) + if result.returncode != 0: + print(f"Clan update-hardware-config (nixos-generate-config) failed: {result.stderr.decode()}") + raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}") + + hw_config_without_system_file = os.path.join(env.flake_dir, "machines/test-install-machine-without-system/hardware-configuration.nix") + assert os.path.exists(hw_config_without_system_file), "hardware-configuration.nix should exist after update" + ''; + } { inherit pkgs self; }; + + }; }; } From 1558a366de4edbf30cdd88c829723836dd8d86aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Tue, 17 Jun 2025 12:30:14 +0200 Subject: [PATCH 02/15] bump clan-core-for-checks --- checks/clan-core-for-checks.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checks/clan-core-for-checks.nix b/checks/clan-core-for-checks.nix index cdff657fa..12aa373aa 100644 --- a/checks/clan-core-for-checks.nix +++ b/checks/clan-core-for-checks.nix @@ -1,6 +1,6 @@ { fetchgit }: fetchgit { url = "https://git.clan.lol/clan/clan-core.git"; - rev = "28131afbbcd379a8ff04c79c66c670ef655ed889"; - sha256 = "1294cwjlnc341fl6zbggn4rgq8z33gqkcyggjfvk9cf7zdgygrf6"; + rev = "eea93ea22c9818da67e148ba586277bab9e73cea"; + sha256 = "sha256-PV0Z+97QuxQbkYSVuNIJwUNXMbHZG/vhsA9M4cDTCOE="; } From 541732462bdb4343a297197cb89fc1b5904a1974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 14:26:43 +0200 Subject: [PATCH 03/15] add port_utils module for installation testions --- checks/installation/port_utils.py | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 checks/installation/port_utils.py diff --git a/checks/installation/port_utils.py b/checks/installation/port_utils.py new file mode 100644 index 000000000..a836785c4 --- /dev/null +++ b/checks/installation/port_utils.py @@ -0,0 +1,51 @@ +"""Port management utilities for NixOS installation tests.""" + +import socket +import time +from typing import Any + + +class PortUtilsError(Exception): + """Port utils related errors.""" + + +def find_free_port() -> int: + """Find a free port on the host.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def check_host_port_open(port: int) -> bool: + """Verify port forwarding is working by checking if the host port is listening.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + result = s.connect_ex(("localhost", port)) + return result == 0 + except Exception as e: + print(f"Port check failed: {e}") + return False + + +def setup_port_forwarding(target: Any, host_port: int) -> None: + """Set up port forwarding and wait for it to be ready.""" + print(f"Setting up port forwarding from host port {host_port} to guest port 22") + target.forward_port(host_port=host_port, guest_port=22) + + # Give the port forwarding time to establish + time.sleep(2) + + # Wait up to 30 seconds for the port to become available + port_ready = False + for i in range(30): + if check_host_port_open(host_port): + port_ready = True + print(f"Host port {host_port} is now listening") + break + print(f"Waiting for host port {host_port} to be ready... attempt {i + 1}/30") + time.sleep(1) + + if not port_ready: + msg = f"Host port {host_port} never became available for forwarding" + raise PortUtilsError(msg) From 76b0a9bf13f447514b3e9c73ef54f43d6df92750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 15:24:44 +0200 Subject: [PATCH 04/15] add -i option to update-hardware-config --- pkgs/clan-cli/clan_cli/machines/hardware.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index 67f0ac923..b65cf7831 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -1,5 +1,6 @@ import argparse import logging +from pathlib import Path from clan_lib.machines.hardware import ( HardwareConfig, @@ -30,9 +31,11 @@ def update_hardware_config_command(args: argparse.Namespace) -> None: if args.target_host: target_host = Remote.from_ssh_uri( machine_name=machine.name, address=args.target_host - ).override(host_key_check=host_key_check) + ).override(host_key_check=host_key_check, private_key=args.identity_file) else: - target_host = machine.target_host().override(host_key_check=host_key_check) + target_host = machine.target_host().override( + host_key_check=host_key_check, private_key=args.identity_file + ) generate_machine_hardware_info(opts, target_host) @@ -69,3 +72,9 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None: choices=["nixos-generate-config", "nixos-facter"], default="nixos-facter", ) + parser.add_argument( + "-i", + dest="identity_file", + type=Path, + help="specify which SSH private key file to use", + ) From b5262427446ae00bd501312f664c3781538089c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 15:25:57 +0200 Subject: [PATCH 05/15] share more code between installation and update test --- checks/installation/flake-module.nix | 186 ++++-------------- .../installation/nixos_test_lib/__init__.py | 3 + .../nixos_test_lib/environment.py | 13 ++ checks/installation/nixos_test_lib/machine.py | 25 +++ .../{port_utils.py => nixos_test_lib/port.py} | 0 checks/installation/nixos_test_lib/py.typed | 0 checks/installation/nixos_test_lib/ssh.py | 53 +++++ checks/installation/pyproject.toml | 44 +++++ checks/installation/test-helpers.nix | 173 ++++++++++++++++ 9 files changed, 346 insertions(+), 151 deletions(-) create mode 100644 checks/installation/nixos_test_lib/__init__.py create mode 100644 checks/installation/nixos_test_lib/environment.py create mode 100644 checks/installation/nixos_test_lib/machine.py rename checks/installation/{port_utils.py => nixos_test_lib/port.py} (100%) create mode 100644 checks/installation/nixos_test_lib/py.typed create mode 100644 checks/installation/nixos_test_lib/ssh.py create mode 100644 checks/installation/pyproject.toml create mode 100644 checks/installation/test-helpers.nix diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index d9313aae8..4d19bed2b 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -4,45 +4,6 @@ ... }: -let - target = - { modulesPath, pkgs, ... }: - { - imports = [ - (modulesPath + "/../tests/common/auto-format-root-device.nix") - ]; - networking.useNetworkd = true; - services.openssh.enable = true; - services.openssh.settings.UseDns = false; - services.openssh.settings.PasswordAuthentication = false; - system.nixos.variant_id = "installer"; - environment.systemPackages = [ - pkgs.nixos-facter - ]; - # Disable cache.nixos.org to speed up tests - nix.settings.substituters = [ ]; - nix.settings.trusted-public-keys = [ ]; - virtualisation.emptyDiskImages = [ 512 ]; - virtualisation.diskSize = 8 * 1024; - virtualisation.rootDevice = "/dev/vdb"; - # both installer and target need to use the same diskImage - virtualisation.diskImage = "./target.qcow2"; - virtualisation.memorySize = 3048; - users.users.nonrootuser = { - isNormalUser = true; - openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; - extraGroups = [ "wheel" ]; - }; - users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; - # Allow users to manage their own SSH keys - services.openssh.authorizedKeysFiles = [ - "/root/.ssh/authorized_keys" - "/home/%u/.ssh/authorized_keys" - "/etc/ssh/authorized_keys.d/%u" - ]; - security.sudo.wheelNeedsPassword = false; - }; -in { # The purpose of this test is to ensure `clan machines install` works @@ -189,21 +150,6 @@ in checks = let # Custom Python package for port management utilities - portUtils = pkgs.python3Packages.buildPythonPackage { - pname = "port-utils"; - version = "1.0.0"; - format = "other"; - src = lib.fileset.toSource { - root = ./.; - fileset = ./port_utils.py; - }; - dontUnpack = true; - installPhase = '' - install -D $src/port_utils.py $out/${pkgs.python3.sitePackages}/port_utils.py - touch $out/${pkgs.python3.sitePackages}/py.typed - ''; - doCheck = false; - }; closureInfo = pkgs.closureInfo { rootPaths = [ self.checks.x86_64-linux.clan-core-for-checks @@ -219,9 +165,9 @@ in pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) { nixos-test-installation = self.clanLib.test.baseTest { name = "installation"; - nodes.target = target; + nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target; extraPythonPackages = _p: [ - portUtils + (import ./test-helpers.nix { inherit lib pkgs self; }).nixosTestLib self.legacyPackages.${pkgs.system}.setupNixInNixPythonPackage ]; @@ -229,56 +175,20 @@ in import tempfile import os import subprocess - from port_utils import find_free_port, setup_port_forwarding # type: ignore[import-untyped] - from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] + from nixos_test_lib.ssh import setup_test_environment # type: ignore[import-untyped] + from nixos_test_lib.machine import create_test_machine # type: ignore[import-untyped] - def create_test_machine(oldmachine=None, **kwargs): - """Create a new test machine from an installed disk image""" - start_command = [ - "${pkgs.qemu_test}/bin/qemu-kvm", - "-cpu", "max", - "-m", "3048", - "-virtfs", "local,path=/nix/store,security_model=none,mount_tag=nix-store", - "-drive", f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", - "-device", "virtio-blk-pci,drive=drive1", - "-netdev", "user,id=net0", - "-device", "virtio-net-pci,netdev=net0", - ] - machine = create_machine(start_command=" ".join(start_command), **kwargs) - driver.machines.append(machine) - return machine - - if "NIX_REMOTE" in os.environ: - del os.environ["NIX_REMOTE"] # we don't have any nix daemon running target.start() - # Set up SSH key on host (test driver environment) + # Set up test environment using common helper with tempfile.TemporaryDirectory() as temp_dir: - - # Set up nix chroot store environment - os.environ["closureInfo"] = "${closureInfo}" - os.environ["TMPDIR"] = temp_dir - - # Run setup function - setup_nix_in_nix() - - - host_port = find_free_port() - target.wait_for_unit("sshd.service") - target.wait_for_open_port(22) - - setup_port_forwarding(target, host_port) - - ssh_key = os.path.join(temp_dir, "id_ed25519") - with open(ssh_key, "w") as f: - with open("${../assets/ssh/privkey}", "r") as src: - f.write(src.read()) - os.chmod(ssh_key, 0o600) - - # Copy test flake to temp directory - flake_dir = os.path.join(temp_dir, "test-flake") - subprocess.run(["cp", "-r", "${self.checks.x86_64-linux.clan-core-for-checks}", flake_dir], check=True) - subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) + env = setup_test_environment( + target, + temp_dir, + "${closureInfo}", + "${../assets/ssh/privkey}", + "${self.checks.x86_64-linux.clan-core-for-checks}" + ) # Run clan install from host using port forwarding clan_cmd = [ @@ -289,16 +199,13 @@ in "--debug", "--flake", env.flake_dir, "--yes", "test-install-machine-without-system", - "--target-host", f"nonrootuser@localhost:{host_port}", - "-i", ssh_key, + "--target-host", f"nonrootuser@localhost:{env.host_port}", + "-i", env.ssh_key, "--option", "store", os.environ['CLAN_TEST_STORE'], "--update-hardware-config", "nixos-facter", ] - # Set NIX_CONFIG to disable cache.nixos.org for speed - env = os.environ.copy() - env["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " - subprocess.run(clan_cmd, check=True, env=env) + subprocess.run(clan_cmd, check=True) # Shutdown the installer machine gracefully try: @@ -308,7 +215,7 @@ in pass # Create a new machine instance that boots from the installed system - installed_machine = create_test_machine(oldmachine=target, name="after_install") + installed_machine = create_test_machine(target, "${pkgs.qemu_test}", name="after_install") installed_machine.start() installed_machine.wait_for_unit("multi-user.target") installed_machine.succeed("test -f /etc/install-successful") @@ -317,9 +224,9 @@ in nixos-test-update-hardware-configuration = self.clanLib.test.baseTest { name = "update-hardware-configuration"; - nodes.target = target; + nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target; extraPythonPackages = _p: [ - portUtils + (import ./test-helpers.nix { inherit lib pkgs self; }).nixosTestLib self.legacyPackages.${pkgs.system}.setupNixInNixPythonPackage ]; @@ -327,44 +234,29 @@ in import tempfile import os import subprocess - from port_utils import find_free_port, setup_port_forwarding # type: ignore[import-untyped] - from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] - - # Keep reference to closureInfo: ${closureInfo} - - # Set up nix chroot store environment - os.environ["closureInfo"] = "${closureInfo}" - - # Run setup function - setup_nix_in_nix() - - host_port = find_free_port() + from nixos_test_lib.ssh import setup_test_environment # type: ignore[import-untyped] target.start() - setup_port_forwarding(target, host_port) - - # Set up SSH key and flake on host + # Set up test environment using common helper with tempfile.TemporaryDirectory() as temp_dir: - ssh_key = os.path.join(temp_dir, "id_ed25519") - with open(ssh_key, "w") as f: - with open("${../assets/ssh/privkey}", "r") as src: - f.write(src.read()) - os.chmod(ssh_key, 0o600) - - flake_dir = os.path.join(temp_dir, "test-flake") - subprocess.run(["cp", "-r", "${self.checks.x86_64-linux.clan-core-for-checks}", flake_dir], check=True) - subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) + env = setup_test_environment( + target, + temp_dir, + "${closureInfo}", + "${../assets/ssh/privkey}", + "${self.checks.x86_64-linux.clan-core-for-checks}" + ) # Verify files don't exist initially - hw_config_file = os.path.join(flake_dir, "machines/test-install-machine/hardware-configuration.nix") - facter_file = os.path.join(flake_dir, "machines/test-install-machine/facter.json") + hw_config_file = os.path.join(env.flake_dir, "machines/test-install-machine/hardware-configuration.nix") + facter_file = os.path.join(env.flake_dir, "machines/test-install-machine/facter.json") assert not os.path.exists(hw_config_file), "hardware-configuration.nix should not exist initially" assert not os.path.exists(facter_file), "facter.json should not exist initially" - target.wait_for_unit("sshd.service") - target.wait_for_open_port(22) + # Set CLAN_FLAKE for the commands + os.environ["CLAN_FLAKE"] = env.flake_dir # Test facter backend clan_cmd = [ @@ -375,16 +267,12 @@ in "--flake", ".", "--host-key-check", "none", "test-install-machine-without-system", - "-i", ssh_key, + "-i", env.ssh_key, "--option", "store", os.environ['CLAN_TEST_STORE'], f"nonrootuser@localhost:{env.host_port}" ] - env = os.environ.copy() - env["CLAN_FLAKE"] = flake_dir - # Set NIX_CONFIG to disable cache.nixos.org for speed - env["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " - result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir, env=env) + result = subprocess.run(clan_cmd, capture_output=True, cwd=env.flake_dir) if result.returncode != 0: print(f"Clan update-hardware-config failed: {result.stderr.decode()}") raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}") @@ -403,16 +291,12 @@ in "--host-key-check", "none", "--flake", ".", "test-install-machine-without-system", - "--option", "ssh-option", f"-i {ssh_key} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null", + "-i", env.ssh_key, "--option", "store", os.environ['CLAN_TEST_STORE'], f"nonrootuser@localhost:{env.host_port}" ] - env = os.environ.copy() - env["CLAN_FLAKE"] = flake_dir - # Set NIX_CONFIG to disable cache.nixos.org for speed - env["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " - result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir, env=env) + result = subprocess.run(clan_cmd, capture_output=True, cwd=env.flake_dir) if result.returncode != 0: print(f"Clan update-hardware-config (nixos-generate-config) failed: {result.stderr.decode()}") raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}") diff --git a/checks/installation/nixos_test_lib/__init__.py b/checks/installation/nixos_test_lib/__init__.py new file mode 100644 index 000000000..ded39654b --- /dev/null +++ b/checks/installation/nixos_test_lib/__init__.py @@ -0,0 +1,3 @@ +"""NixOS test library for clan VM testing""" + +__version__ = "1.0.0" diff --git a/checks/installation/nixos_test_lib/environment.py b/checks/installation/nixos_test_lib/environment.py new file mode 100644 index 000000000..c66be466e --- /dev/null +++ b/checks/installation/nixos_test_lib/environment.py @@ -0,0 +1,13 @@ +"""Environment setup utilities for VM tests""" + +import os + + +def setup_nix_environment(temp_dir: str, closure_info: str) -> None: + """Set up nix chroot store environment""" + if "NIX_REMOTE" in os.environ: + del os.environ["NIX_REMOTE"] # we don't have any nix daemon running + + os.environ["TMPDIR"] = temp_dir + # Set NIX_CONFIG globally to disable substituters for speed + os.environ["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " diff --git a/checks/installation/nixos_test_lib/machine.py b/checks/installation/nixos_test_lib/machine.py new file mode 100644 index 000000000..c20bad723 --- /dev/null +++ b/checks/installation/nixos_test_lib/machine.py @@ -0,0 +1,25 @@ +"""VM machine management utilities""" + + +def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs): + """Create a new test machine from an installed disk image""" + start_command = [ + f"{qemu_test_bin}/bin/qemu-kvm", + "-cpu", + "max", + "-m", + "3048", + "-virtfs", + "local,path=/nix/store,security_model=none,mount_tag=nix-store", + "-drive", + f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", + "-device", + "virtio-blk-pci,drive=drive1", + "-netdev", + "user,id=net0", + "-device", + "virtio-net-pci,netdev=net0", + ] + machine = create_machine(start_command=" ".join(start_command), **kwargs) + driver.machines.append(machine) + return machine diff --git a/checks/installation/port_utils.py b/checks/installation/nixos_test_lib/port.py similarity index 100% rename from checks/installation/port_utils.py rename to checks/installation/nixos_test_lib/port.py diff --git a/checks/installation/nixos_test_lib/py.typed b/checks/installation/nixos_test_lib/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/checks/installation/nixos_test_lib/ssh.py b/checks/installation/nixos_test_lib/ssh.py new file mode 100644 index 000000000..367fd4bb0 --- /dev/null +++ b/checks/installation/nixos_test_lib/ssh.py @@ -0,0 +1,53 @@ +"""SSH and test setup utilities""" + +import os +import subprocess +from typing import NamedTuple + +from .environment import setup_nix_environment +from .port import find_free_port, setup_port_forwarding + + +class TestEnvironment(NamedTuple): + host_port: int + ssh_key: str + flake_dir: str + + +def setup_test_environment( + target, + temp_dir: str, + closure_info: str, + assets_ssh_privkey: str, + clan_core_for_checks: str, +) -> TestEnvironment: + """Set up common test environment including SSH, port forwarding, and flake setup + + Returns: + TestEnvironment with host_port, ssh_key, and flake_dir + """ + from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] + + setup_nix_environment(temp_dir, closure_info) + + # Run setup function + setup_nix_in_nix() + + host_port = find_free_port() + target.wait_for_unit("sshd.service") + target.wait_for_open_port(22) + + setup_port_forwarding(target, host_port) + + ssh_key = os.path.join(temp_dir, "id_ed25519") + with open(ssh_key, "w") as f: + with open(assets_ssh_privkey) as src: + f.write(src.read()) + os.chmod(ssh_key, 0o600) + + # Copy test flake to temp directory + flake_dir = os.path.join(temp_dir, "test-flake") + subprocess.run(["cp", "-r", clan_core_for_checks, flake_dir], check=True) + subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) + + return TestEnvironment(host_port, ssh_key, flake_dir) diff --git a/checks/installation/pyproject.toml b/checks/installation/pyproject.toml new file mode 100644 index 000000000..b82e1bc08 --- /dev/null +++ b/checks/installation/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "nixos-test-lib" +version = "1.0.0" +description = "NixOS test utilities for clan VM testing" +authors = [ + {name = "Clan Core Team"} +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "mypy", + "ruff" +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["nixos_test_lib*"] + +[tool.setuptools.package-data] +"nixos_test_lib" = ["py.typed"] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_configs = true + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D", # docstrings + "ANN", # type annotations + "COM812", # trailing comma + "ISC001", # string concatenation +] \ No newline at end of file diff --git a/checks/installation/test-helpers.nix b/checks/installation/test-helpers.nix new file mode 100644 index 000000000..dddf81d4f --- /dev/null +++ b/checks/installation/test-helpers.nix @@ -0,0 +1,173 @@ +{ + lib, + pkgs, + self, + ... +}: +let + # Common target VM configuration used by both installation and update tests + target = + { modulesPath, pkgs, ... }: + { + imports = [ + (modulesPath + "/../tests/common/auto-format-root-device.nix") + ]; + networking.useNetworkd = true; + services.openssh.enable = true; + services.openssh.settings.UseDns = false; + services.openssh.settings.PasswordAuthentication = false; + system.nixos.variant_id = "installer"; + environment.systemPackages = [ + pkgs.nixos-facter + ]; + # Disable cache.nixos.org to speed up tests + nix.settings.substituters = [ ]; + nix.settings.trusted-public-keys = [ ]; + virtualisation.emptyDiskImages = [ 512 ]; + virtualisation.diskSize = 8 * 1024; + virtualisation.rootDevice = "/dev/vdb"; + # both installer and target need to use the same diskImage + virtualisation.diskImage = "./target.qcow2"; + virtualisation.memorySize = 3048; + users.users.nonrootuser = { + isNormalUser = true; + openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + extraGroups = [ "wheel" ]; + }; + users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + # Allow users to manage their own SSH keys + services.openssh.authorizedKeysFiles = [ + "/root/.ssh/authorized_keys" + "/home/%u/.ssh/authorized_keys" + "/etc/ssh/authorized_keys.d/%u" + ]; + security.sudo.wheelNeedsPassword = false; + }; + + # Common base test machine configuration + baseTestMachine = + { lib, modulesPath, ... }: + { + imports = [ + (modulesPath + "/testing/test-instrumentation.nix") + (modulesPath + "/profiles/qemu-guest.nix") + self.clanLib.test.minifyModule + ]; + + # Enable SSH and add authorized key for testing + services.openssh.enable = true; + services.openssh.settings.PasswordAuthentication = false; + users.users.nonrootuser = { + isNormalUser = true; + openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + extraGroups = [ "wheel" ]; + home = "/home/nonrootuser"; + createHome = true; + }; + users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ]; + # Allow users to manage their own SSH keys + services.openssh.authorizedKeysFiles = [ + "/root/.ssh/authorized_keys" + "/home/%u/.ssh/authorized_keys" + "/etc/ssh/authorized_keys.d/%u" + ]; + security.sudo.wheelNeedsPassword = false; + + boot.consoleLogLevel = lib.mkForce 100; + boot.kernelParams = [ "boot.shell_on_fail" ]; + + # disko config + boot.loader.grub.efiSupport = lib.mkDefault true; + boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true; + clan.core.vars.settings.secretStore = "vm"; + clan.core.vars.generators.test = { + files.test.neededFor = "partitioning"; + script = '' + echo "notok" > "$out"/test + ''; + }; + disko.devices = { + disk = { + main = { + type = "disk"; + device = "/dev/vda"; + + preCreateHook = '' + test -e /run/partitioning-secrets/test/test + ''; + + content = { + type = "gpt"; + partitions = { + boot = { + size = "1M"; + type = "EF02"; # for grub MBR + priority = 1; + }; + ESP = { + size = "512M"; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = [ "umask=0077" ]; + }; + }; + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; + }; + }; + + # NixOS test library combining port utils and clan VM test utilities + nixosTestLib = pkgs.python3Packages.buildPythonPackage { + pname = "nixos-test-lib"; + version = "1.0.0"; + format = "pyproject"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./pyproject.toml + ./nixos_test_lib + ]; + }; + nativeBuildInputs = with pkgs.python3Packages; [ + setuptools + wheel + ]; + doCheck = false; + }; + + # Common closure info + closureInfo = pkgs.closureInfo { + rootPaths = [ + self.checks.x86_64-linux.clan-core-for-checks + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.initialRamdisk + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript + self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.clan.deployment.file + pkgs.stdenv.drvPath + pkgs.bash.drvPath + pkgs.buildPackages.xorg.lndir + ] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); + }; + +in +{ + inherit + target + baseTestMachine + nixosTestLib + closureInfo + ; +} From c148ece02e8467ea47f8745b80dbe2a98f2d2956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:01:28 +0200 Subject: [PATCH 06/15] move setup_nix_in_nix into nixos_test_lib --- checks/installation/flake-module.nix | 2 - .../installation/nixos_test_lib/nix_setup.py | 70 +++++++++++++++++++ checks/installation/nixos_test_lib/ssh.py | 8 +-- .../service-dummy-test-from-flake/default.nix | 4 +- pkgs/testing/flake-module.nix | 51 -------------- 5 files changed, 74 insertions(+), 61 deletions(-) create mode 100644 checks/installation/nixos_test_lib/nix_setup.py diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index 4d19bed2b..e297d88ad 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -168,7 +168,6 @@ nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target; extraPythonPackages = _p: [ (import ./test-helpers.nix { inherit lib pkgs self; }).nixosTestLib - self.legacyPackages.${pkgs.system}.setupNixInNixPythonPackage ]; testScript = '' @@ -227,7 +226,6 @@ nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target; extraPythonPackages = _p: [ (import ./test-helpers.nix { inherit lib pkgs self; }).nixosTestLib - self.legacyPackages.${pkgs.system}.setupNixInNixPythonPackage ]; testScript = '' diff --git a/checks/installation/nixos_test_lib/nix_setup.py b/checks/installation/nixos_test_lib/nix_setup.py new file mode 100644 index 000000000..5e518bcfd --- /dev/null +++ b/checks/installation/nixos_test_lib/nix_setup.py @@ -0,0 +1,70 @@ +"""Nix store setup utilities for VM tests""" + +import os +import shutil +import subprocess +from pathlib import Path + + +def setup_nix_in_nix(closure_info: str) -> None: + """Set up Nix store inside test environment + + Args: + closure_info: Path to closure info directory containing store-paths file + """ + tmpdir = os.environ.get("TMPDIR", "/tmp") + + # Remove NIX_REMOTE if present (we don't have any nix daemon running) + if "NIX_REMOTE" in os.environ: + del os.environ["NIX_REMOTE"] + + # Set NIX_CONFIG globally to disable substituters for speed + os.environ["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " + + # Set up environment variables for test environment + os.environ["HOME"] = tmpdir + os.environ["NIX_STATE_DIR"] = f"{tmpdir}/nix" + os.environ["NIX_CONF_DIR"] = f"{tmpdir}/etc" + os.environ["IN_NIX_SANDBOX"] = "1" + os.environ["CLAN_TEST_STORE"] = f"{tmpdir}/store" + os.environ["LOCK_NIX"] = f"{tmpdir}/nix_lock" + + # Create necessary directories + Path(f"{tmpdir}/nix").mkdir(parents=True, exist_ok=True) + Path(f"{tmpdir}/etc").mkdir(parents=True, exist_ok=True) + Path(f"{tmpdir}/store").mkdir(parents=True, exist_ok=True) + + # Set up Nix store if closure info is provided + if closure_info and os.path.exists(closure_info): + store_paths_file = os.path.join(closure_info, "store-paths") + if os.path.exists(store_paths_file): + with open(store_paths_file) as f: + store_paths = f.read().strip().split("\n") + + # Copy store paths to test store + for store_path in store_paths: + if store_path.strip(): + dest_path = f"{tmpdir}/store{store_path}" + if not os.path.exists(dest_path): + # Create parent directories + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + # Copy the store path + if os.path.isdir(store_path): + shutil.copytree(store_path, dest_path, dirs_exist_ok=True) + else: + shutil.copy2(store_path, dest_path) + + # Load Nix database + registration_file = os.path.join(closure_info, "registration") + if os.path.exists(registration_file): + env = os.environ.copy() + env["NIX_REMOTE"] = f"local?store={tmpdir}/store" + + with open(registration_file) as f: + subprocess.run( + ["nix-store", "--load-db"], + input=f.read(), + text=True, + env=env, + check=True, + ) diff --git a/checks/installation/nixos_test_lib/ssh.py b/checks/installation/nixos_test_lib/ssh.py index 367fd4bb0..645a0769a 100644 --- a/checks/installation/nixos_test_lib/ssh.py +++ b/checks/installation/nixos_test_lib/ssh.py @@ -4,7 +4,7 @@ import os import subprocess from typing import NamedTuple -from .environment import setup_nix_environment +from .nix_setup import setup_nix_in_nix from .port import find_free_port, setup_port_forwarding @@ -26,12 +26,8 @@ def setup_test_environment( Returns: TestEnvironment with host_port, ssh_key, and flake_dir """ - from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] - - setup_nix_environment(temp_dir, closure_info) - # Run setup function - setup_nix_in_nix() + setup_nix_in_nix(closure_info) host_port = find_free_port() target.wait_for_unit("sshd.service") diff --git a/checks/service-dummy-test-from-flake/default.nix b/checks/service-dummy-test-from-flake/default.nix index 0faa8f9b1..aaf2444b6 100644 --- a/checks/service-dummy-test-from-flake/default.nix +++ b/checks/service-dummy-test-from-flake/default.nix @@ -29,8 +29,8 @@ nixosLib.runTest ( testScript = { nodes, ... }: '' - from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped] - setup_nix_in_nix() + from nixos_test_lib.nix_setup import setup_nix_in_nix # type: ignore[import-untyped] + setup_nix_in_nix(None) # No closure info for this test def run_clan(cmd: list[str], **kwargs) -> str: import subprocess diff --git a/pkgs/testing/flake-module.nix b/pkgs/testing/flake-module.nix index 51c39729a..11c81bfd9 100644 --- a/pkgs/testing/flake-module.nix +++ b/pkgs/testing/flake-module.nix @@ -21,57 +21,6 @@ fi ''; - setupNixInNixPythonPackage = pkgs.python3Packages.buildPythonPackage { - pname = "setup-nix-in-nix"; - version = "1.0.0"; - format = "other"; - - dontUnpack = true; - - installPhase = '' - mkdir -p $out/${pkgs.python3.sitePackages} - cat > $out/${pkgs.python3.sitePackages}/setup_nix_in_nix.py << 'EOF' - from os import environ - import subprocess - from pathlib import Path - - def setup_nix_in_nix(): - """Set up a Nix store inside the test environment.""" - environ['HOME'] = environ['TMPDIR'] - environ['NIX_STATE_DIR'] = environ['TMPDIR'] + '/nix' - environ['NIX_CONF_DIR'] = environ['TMPDIR'] + '/etc' - environ['IN_NIX_SANDBOX'] = '1' - environ['CLAN_TEST_STORE'] = environ['TMPDIR'] + '/store' - environ['LOCK_NIX'] = environ['TMPDIR'] + '/nix_lock' - - Path(environ['CLAN_TEST_STORE'] + '/nix/store').mkdir(parents=True, exist_ok=True) - Path(environ['CLAN_TEST_STORE'] + '/nix/var/nix/gcroots').mkdir(parents=True, exist_ok=True) - - if 'closureInfo' in environ: - # Read store paths from the closure info file - with open(environ['closureInfo'] + '/store-paths', 'r') as f: - store_paths = f.read().strip().split('\n') - - # Copy store paths using absolute path to cp - subprocess.run( - ['${pkgs.coreutils}/bin/cp', '--recursive', '--target', environ['CLAN_TEST_STORE'] + '/nix/store'] + store_paths, - check=True - ) - - # Load the nix database using absolute path to nix-store - with open(environ['closureInfo'] + '/registration', 'r') as f: - subprocess.run( - ['${pkgs.nix}/bin/nix-store', '--load-db', '--store', environ['CLAN_TEST_STORE']], - input=f.read(), - text=True, - check=True - ) - EOF - touch $out/${pkgs.python3.sitePackages}/py.typed - ''; - - doCheck = false; - }; }; }; } From 1e7453ab04673244a12e64ae1880e52c9f6d6547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:03:01 +0200 Subject: [PATCH 07/15] move nixosTestLib to pkgs/testing --- checks/installation/flake-module.nix | 4 +- .../nixos_test_lib/environment.py | 13 ------ .../service-dummy-test-from-flake/default.nix | 2 +- pkgs/testing/flake-module.nix | 21 ++++++++- .../testing}/nixos_test_lib/__init__.py | 0 pkgs/testing/nixos_test_lib/machine.py | 25 +++++++++++ .../testing}/nixos_test_lib/nix_setup.py | 16 +++---- .../testing}/nixos_test_lib/port.py | 0 .../testing}/nixos_test_lib/py.typed | 0 .../testing}/nixos_test_lib/ssh.py | 1 - pkgs/testing/pyproject.toml | 44 +++++++++++++++++++ 11 files changed, 100 insertions(+), 26 deletions(-) delete mode 100644 checks/installation/nixos_test_lib/environment.py rename {checks/installation => pkgs/testing}/nixos_test_lib/__init__.py (100%) create mode 100644 pkgs/testing/nixos_test_lib/machine.py rename {checks/installation => pkgs/testing}/nixos_test_lib/nix_setup.py (84%) rename {checks/installation => pkgs/testing}/nixos_test_lib/port.py (100%) rename {checks/installation => pkgs/testing}/nixos_test_lib/py.typed (100%) rename {checks/installation => pkgs/testing}/nixos_test_lib/ssh.py (99%) create mode 100644 pkgs/testing/pyproject.toml diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index e297d88ad..6041d082e 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -167,7 +167,7 @@ name = "installation"; nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target; extraPythonPackages = _p: [ - (import ./test-helpers.nix { inherit lib pkgs self; }).nixosTestLib + self.legacyPackages.${pkgs.system}.nixosTestLib ]; testScript = '' @@ -225,7 +225,7 @@ name = "update-hardware-configuration"; nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target; extraPythonPackages = _p: [ - (import ./test-helpers.nix { inherit lib pkgs self; }).nixosTestLib + self.legacyPackages.${pkgs.system}.nixosTestLib ]; testScript = '' diff --git a/checks/installation/nixos_test_lib/environment.py b/checks/installation/nixos_test_lib/environment.py deleted file mode 100644 index c66be466e..000000000 --- a/checks/installation/nixos_test_lib/environment.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Environment setup utilities for VM tests""" - -import os - - -def setup_nix_environment(temp_dir: str, closure_info: str) -> None: - """Set up nix chroot store environment""" - if "NIX_REMOTE" in os.environ: - del os.environ["NIX_REMOTE"] # we don't have any nix daemon running - - os.environ["TMPDIR"] = temp_dir - # Set NIX_CONFIG globally to disable substituters for speed - os.environ["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " diff --git a/checks/service-dummy-test-from-flake/default.nix b/checks/service-dummy-test-from-flake/default.nix index aaf2444b6..324e2f087 100644 --- a/checks/service-dummy-test-from-flake/default.nix +++ b/checks/service-dummy-test-from-flake/default.nix @@ -23,7 +23,7 @@ nixosLib.runTest ( clan.test.fromFlake = ./.; extraPythonPackages = _p: [ - clan-core.legacyPackages.${hostPkgs.system}.setupNixInNixPythonPackage + clan-core.legacyPackages.${hostPkgs.system}.nixosTestLib ]; testScript = diff --git a/pkgs/testing/flake-module.nix b/pkgs/testing/flake-module.nix index 11c81bfd9..c30aaf383 100644 --- a/pkgs/testing/flake-module.nix +++ b/pkgs/testing/flake-module.nix @@ -1,6 +1,6 @@ { perSystem = - { pkgs, ... }: + { pkgs, lib, ... }: { legacyPackages = { setupNixInNix = '' @@ -21,6 +21,25 @@ fi ''; + # NixOS test library combining port utils and clan VM test utilities + nixosTestLib = pkgs.python3Packages.buildPythonPackage { + pname = "nixos-test-lib"; + version = "1.0.0"; + format = "pyproject"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./pyproject.toml + ./nixos_test_lib + ]; + }; + nativeBuildInputs = with pkgs.python3Packages; [ + setuptools + wheel + ]; + doCheck = false; + }; + }; }; } diff --git a/checks/installation/nixos_test_lib/__init__.py b/pkgs/testing/nixos_test_lib/__init__.py similarity index 100% rename from checks/installation/nixos_test_lib/__init__.py rename to pkgs/testing/nixos_test_lib/__init__.py diff --git a/pkgs/testing/nixos_test_lib/machine.py b/pkgs/testing/nixos_test_lib/machine.py new file mode 100644 index 000000000..c20bad723 --- /dev/null +++ b/pkgs/testing/nixos_test_lib/machine.py @@ -0,0 +1,25 @@ +"""VM machine management utilities""" + + +def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs): + """Create a new test machine from an installed disk image""" + start_command = [ + f"{qemu_test_bin}/bin/qemu-kvm", + "-cpu", + "max", + "-m", + "3048", + "-virtfs", + "local,path=/nix/store,security_model=none,mount_tag=nix-store", + "-drive", + f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", + "-device", + "virtio-blk-pci,drive=drive1", + "-netdev", + "user,id=net0", + "-device", + "virtio-net-pci,netdev=net0", + ] + machine = create_machine(start_command=" ".join(start_command), **kwargs) + driver.machines.append(machine) + return machine diff --git a/checks/installation/nixos_test_lib/nix_setup.py b/pkgs/testing/nixos_test_lib/nix_setup.py similarity index 84% rename from checks/installation/nixos_test_lib/nix_setup.py rename to pkgs/testing/nixos_test_lib/nix_setup.py index 5e518bcfd..5fc9d1d69 100644 --- a/checks/installation/nixos_test_lib/nix_setup.py +++ b/pkgs/testing/nixos_test_lib/nix_setup.py @@ -12,7 +12,7 @@ def setup_nix_in_nix(closure_info: str) -> None: Args: closure_info: Path to closure info directory containing store-paths file """ - tmpdir = os.environ.get("TMPDIR", "/tmp") + tmpdir = Path(os.environ.get("TMPDIR", "/tmp")) # Remove NIX_REMOTE if present (we don't have any nix daemon running) if "NIX_REMOTE" in os.environ: @@ -35,10 +35,10 @@ def setup_nix_in_nix(closure_info: str) -> None: Path(f"{tmpdir}/store").mkdir(parents=True, exist_ok=True) # Set up Nix store if closure info is provided - if closure_info and os.path.exists(closure_info): - store_paths_file = os.path.join(closure_info, "store-paths") - if os.path.exists(store_paths_file): - with open(store_paths_file) as f: + if closure_info and Path(closure_info).exists(): + store_paths_file = Path(closure_info) / "store-paths" + if store_paths_file.exists(): + with store_paths_file.open() as f: store_paths = f.read().strip().split("\n") # Copy store paths to test store @@ -55,12 +55,12 @@ def setup_nix_in_nix(closure_info: str) -> None: shutil.copy2(store_path, dest_path) # Load Nix database - registration_file = os.path.join(closure_info, "registration") - if os.path.exists(registration_file): + registration_file = Path(closure_info) / "registration" + if registration_file.exists(): env = os.environ.copy() env["NIX_REMOTE"] = f"local?store={tmpdir}/store" - with open(registration_file) as f: + with registration_file.open() as f: subprocess.run( ["nix-store", "--load-db"], input=f.read(), diff --git a/checks/installation/nixos_test_lib/port.py b/pkgs/testing/nixos_test_lib/port.py similarity index 100% rename from checks/installation/nixos_test_lib/port.py rename to pkgs/testing/nixos_test_lib/port.py diff --git a/checks/installation/nixos_test_lib/py.typed b/pkgs/testing/nixos_test_lib/py.typed similarity index 100% rename from checks/installation/nixos_test_lib/py.typed rename to pkgs/testing/nixos_test_lib/py.typed diff --git a/checks/installation/nixos_test_lib/ssh.py b/pkgs/testing/nixos_test_lib/ssh.py similarity index 99% rename from checks/installation/nixos_test_lib/ssh.py rename to pkgs/testing/nixos_test_lib/ssh.py index 645a0769a..ae6a84f89 100644 --- a/checks/installation/nixos_test_lib/ssh.py +++ b/pkgs/testing/nixos_test_lib/ssh.py @@ -1,6 +1,5 @@ """SSH and test setup utilities""" -import os import subprocess from typing import NamedTuple diff --git a/pkgs/testing/pyproject.toml b/pkgs/testing/pyproject.toml new file mode 100644 index 000000000..b82e1bc08 --- /dev/null +++ b/pkgs/testing/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "nixos-test-lib" +version = "1.0.0" +description = "NixOS test utilities for clan VM testing" +authors = [ + {name = "Clan Core Team"} +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "mypy", + "ruff" +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["nixos_test_lib*"] + +[tool.setuptools.package-data] +"nixos_test_lib" = ["py.typed"] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_configs = true + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D", # docstrings + "ANN", # type annotations + "COM812", # trailing comma + "ISC001", # string concatenation +] \ No newline at end of file From 68b2aaea8951bf08f397891a80de11a27931964c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:10:24 +0200 Subject: [PATCH 08/15] setup_nix_in_nix: use cp intead of shutil it's faster and handles symlinks --- pkgs/testing/nixos_test_lib/nix_setup.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/pkgs/testing/nixos_test_lib/nix_setup.py b/pkgs/testing/nixos_test_lib/nix_setup.py index 5fc9d1d69..2bd3b0f1f 100644 --- a/pkgs/testing/nixos_test_lib/nix_setup.py +++ b/pkgs/testing/nixos_test_lib/nix_setup.py @@ -6,11 +6,11 @@ import subprocess from pathlib import Path -def setup_nix_in_nix(closure_info: str) -> None: +def setup_nix_in_nix(closure_info: str | None) -> None: """Set up Nix store inside test environment Args: - closure_info: Path to closure info directory containing store-paths file + closure_info: Path to closure info directory containing store-paths file, or None if no closure info """ tmpdir = Path(os.environ.get("TMPDIR", "/tmp")) @@ -41,18 +41,12 @@ def setup_nix_in_nix(closure_info: str) -> None: with store_paths_file.open() as f: store_paths = f.read().strip().split("\n") - # Copy store paths to test store - for store_path in store_paths: - if store_path.strip(): - dest_path = f"{tmpdir}/store{store_path}" - if not os.path.exists(dest_path): - # Create parent directories - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - # Copy the store path - if os.path.isdir(store_path): - shutil.copytree(store_path, dest_path, dirs_exist_ok=True) - else: - shutil.copy2(store_path, dest_path) + # Copy store paths to test store using external cp command (handles symlinks better) + subprocess.run( + ["cp", "--recursive", "--target", f"{tmpdir}/store"] + + [path.strip() for path in store_paths if path.strip()], + check=True + ) # Load Nix database registration_file = Path(closure_info) / "registration" From ea93d8fec7a3876b33144f3a1d6ed44f76d24c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:14:45 +0200 Subject: [PATCH 09/15] inline create_test_machine again --- checks/installation/flake-module.nix | 24 +++++++++++++++++- checks/installation/nixos_test_lib/machine.py | 25 ------------------- pkgs/testing/nixos_test_lib/machine.py | 25 ------------------- 3 files changed, 23 insertions(+), 51 deletions(-) delete mode 100644 checks/installation/nixos_test_lib/machine.py delete mode 100644 pkgs/testing/nixos_test_lib/machine.py diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index 6041d082e..163e6e653 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -175,7 +175,29 @@ import os import subprocess from nixos_test_lib.ssh import setup_test_environment # type: ignore[import-untyped] - from nixos_test_lib.machine import create_test_machine # type: ignore[import-untyped] + + def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs): + """Create a new test machine from an installed disk image""" + start_command = [ + f"{qemu_test_bin}/bin/qemu-kvm", + "-cpu", + "max", + "-m", + "3048", + "-virtfs", + "local,path=/nix/store,security_model=none,mount_tag=nix-store", + "-drive", + f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", + "-device", + "virtio-blk-pci,drive=drive1", + "-netdev", + "user,id=net0", + "-device", + "virtio-net-pci,netdev=net0", + ] + machine = create_machine(start_command=" ".join(start_command), **kwargs) + driver.machines.append(machine) + return machine target.start() diff --git a/checks/installation/nixos_test_lib/machine.py b/checks/installation/nixos_test_lib/machine.py deleted file mode 100644 index c20bad723..000000000 --- a/checks/installation/nixos_test_lib/machine.py +++ /dev/null @@ -1,25 +0,0 @@ -"""VM machine management utilities""" - - -def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs): - """Create a new test machine from an installed disk image""" - start_command = [ - f"{qemu_test_bin}/bin/qemu-kvm", - "-cpu", - "max", - "-m", - "3048", - "-virtfs", - "local,path=/nix/store,security_model=none,mount_tag=nix-store", - "-drive", - f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", - "-device", - "virtio-blk-pci,drive=drive1", - "-netdev", - "user,id=net0", - "-device", - "virtio-net-pci,netdev=net0", - ] - machine = create_machine(start_command=" ".join(start_command), **kwargs) - driver.machines.append(machine) - return machine diff --git a/pkgs/testing/nixos_test_lib/machine.py b/pkgs/testing/nixos_test_lib/machine.py deleted file mode 100644 index c20bad723..000000000 --- a/pkgs/testing/nixos_test_lib/machine.py +++ /dev/null @@ -1,25 +0,0 @@ -"""VM machine management utilities""" - - -def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs): - """Create a new test machine from an installed disk image""" - start_command = [ - f"{qemu_test_bin}/bin/qemu-kvm", - "-cpu", - "max", - "-m", - "3048", - "-virtfs", - "local,path=/nix/store,security_model=none,mount_tag=nix-store", - "-drive", - f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report", - "-device", - "virtio-blk-pci,drive=drive1", - "-netdev", - "user,id=net0", - "-device", - "virtio-net-pci,netdev=net0", - ] - machine = create_machine(start_command=" ".join(start_command), **kwargs) - driver.machines.append(machine) - return machine From c509f333e4fd9e038e6fcd27e72b03dafd38626e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:22:58 +0200 Subject: [PATCH 10/15] nixosTestLib: fix various linting issues --- pkgs/testing/nixos_test_lib/nix_setup.py | 15 ++++++++------- pkgs/testing/nixos_test_lib/port.py | 2 +- pkgs/testing/nixos_test_lib/ssh.py | 18 +++++++++--------- pkgs/testing/pyproject.toml | 1 + 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/pkgs/testing/nixos_test_lib/nix_setup.py b/pkgs/testing/nixos_test_lib/nix_setup.py index 2bd3b0f1f..b42126b44 100644 --- a/pkgs/testing/nixos_test_lib/nix_setup.py +++ b/pkgs/testing/nixos_test_lib/nix_setup.py @@ -1,7 +1,6 @@ """Nix store setup utilities for VM tests""" import os -import shutil import subprocess from pathlib import Path @@ -10,7 +9,8 @@ def setup_nix_in_nix(closure_info: str | None) -> None: """Set up Nix store inside test environment Args: - closure_info: Path to closure info directory containing store-paths file, or None if no closure info + closure_info: Path to closure info directory containing store-paths file, + or None if no closure info """ tmpdir = Path(os.environ.get("TMPDIR", "/tmp")) @@ -22,7 +22,7 @@ def setup_nix_in_nix(closure_info: str | None) -> None: os.environ["NIX_CONFIG"] = "substituters = \ntrusted-public-keys = " # Set up environment variables for test environment - os.environ["HOME"] = tmpdir + os.environ["HOME"] = str(tmpdir) os.environ["NIX_STATE_DIR"] = f"{tmpdir}/nix" os.environ["NIX_CONF_DIR"] = f"{tmpdir}/etc" os.environ["IN_NIX_SANDBOX"] = "1" @@ -41,11 +41,12 @@ def setup_nix_in_nix(closure_info: str | None) -> None: with store_paths_file.open() as f: store_paths = f.read().strip().split("\n") - # Copy store paths to test store using external cp command (handles symlinks better) + # Copy store paths to test store using external cp command + # (handles symlinks better) subprocess.run( - ["cp", "--recursive", "--target", f"{tmpdir}/store"] + - [path.strip() for path in store_paths if path.strip()], - check=True + ["cp", "--recursive", "--target", f"{tmpdir}/store"] + + [path.strip() for path in store_paths if path.strip()], + check=True, ) # Load Nix database diff --git a/pkgs/testing/nixos_test_lib/port.py b/pkgs/testing/nixos_test_lib/port.py index a836785c4..b81353fa8 100644 --- a/pkgs/testing/nixos_test_lib/port.py +++ b/pkgs/testing/nixos_test_lib/port.py @@ -23,7 +23,7 @@ def check_host_port_open(port: int) -> bool: s.settimeout(1) result = s.connect_ex(("localhost", port)) return result == 0 - except Exception as e: + except OSError as e: print(f"Port check failed: {e}") return False diff --git a/pkgs/testing/nixos_test_lib/ssh.py b/pkgs/testing/nixos_test_lib/ssh.py index ae6a84f89..97a52766c 100644 --- a/pkgs/testing/nixos_test_lib/ssh.py +++ b/pkgs/testing/nixos_test_lib/ssh.py @@ -1,6 +1,7 @@ """SSH and test setup utilities""" import subprocess +from pathlib import Path from typing import NamedTuple from .nix_setup import setup_nix_in_nix @@ -34,15 +35,14 @@ def setup_test_environment( setup_port_forwarding(target, host_port) - ssh_key = os.path.join(temp_dir, "id_ed25519") - with open(ssh_key, "w") as f: - with open(assets_ssh_privkey) as src: - f.write(src.read()) - os.chmod(ssh_key, 0o600) + ssh_key = Path(temp_dir) / "id_ed25519" + with ssh_key.open("w") as f, Path(assets_ssh_privkey).open() as src: + f.write(src.read()) + ssh_key.chmod(0o600) # Copy test flake to temp directory - flake_dir = os.path.join(temp_dir, "test-flake") - subprocess.run(["cp", "-r", clan_core_for_checks, flake_dir], check=True) - subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) + flake_dir = Path(temp_dir) / "test-flake" + subprocess.run(["cp", "-r", clan_core_for_checks, flake_dir], check=True) # noqa: S603, S607 + subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) # noqa: S603, S607 - return TestEnvironment(host_port, ssh_key, flake_dir) + return TestEnvironment(host_port, str(ssh_key), str(flake_dir)) diff --git a/pkgs/testing/pyproject.toml b/pkgs/testing/pyproject.toml index b82e1bc08..c29fd67f5 100644 --- a/pkgs/testing/pyproject.toml +++ b/pkgs/testing/pyproject.toml @@ -41,4 +41,5 @@ ignore = [ "ANN", # type annotations "COM812", # trailing comma "ISC001", # string concatenation + "T201", # print statements ] \ No newline at end of file From a53efb9386346e6a23f6b64c49222431918b51d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:26:23 +0200 Subject: [PATCH 11/15] nixosTestLib: substitute dependencies on tools in --- pkgs/testing/flake-module.nix | 5 +++++ pkgs/testing/nixos_test_lib/nix_setup.py | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkgs/testing/flake-module.nix b/pkgs/testing/flake-module.nix index c30aaf383..05f4c53cb 100644 --- a/pkgs/testing/flake-module.nix +++ b/pkgs/testing/flake-module.nix @@ -37,6 +37,11 @@ setuptools wheel ]; + postPatch = '' + substituteInPlace nixos_test_lib/nix_setup.py \ + --replace '@cp@' '${pkgs.coreutils}/bin/cp' \ + --replace '@nix-store@' '${pkgs.nix}/bin/nix-store' + ''; doCheck = false; }; diff --git a/pkgs/testing/nixos_test_lib/nix_setup.py b/pkgs/testing/nixos_test_lib/nix_setup.py index b42126b44..a8ed4d72d 100644 --- a/pkgs/testing/nixos_test_lib/nix_setup.py +++ b/pkgs/testing/nixos_test_lib/nix_setup.py @@ -4,6 +4,10 @@ import os import subprocess from pathlib import Path +# These paths will be substituted during package build +CP_BIN = "@cp@" +NIX_STORE_BIN = "@nix-store@" + def setup_nix_in_nix(closure_info: str | None) -> None: """Set up Nix store inside test environment @@ -44,7 +48,7 @@ def setup_nix_in_nix(closure_info: str | None) -> None: # Copy store paths to test store using external cp command # (handles symlinks better) subprocess.run( - ["cp", "--recursive", "--target", f"{tmpdir}/store"] + [CP_BIN, "--recursive", "--target", f"{tmpdir}/store"] + [path.strip() for path in store_paths if path.strip()], check=True, ) @@ -57,7 +61,7 @@ def setup_nix_in_nix(closure_info: str | None) -> None: with registration_file.open() as f: subprocess.run( - ["nix-store", "--load-db"], + [NIX_STORE_BIN, "--load-db"], input=f.read(), text=True, env=env, From 7f4f11751e2727e5586e43b1d95d56540d66d9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 16:38:20 +0200 Subject: [PATCH 12/15] nixosTestLib: use xargs for copying store inputs --- pkgs/testing/flake-module.nix | 3 +- pkgs/testing/nixos_test_lib/nix_setup.py | 36 +++++++++++++----------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/pkgs/testing/flake-module.nix b/pkgs/testing/flake-module.nix index 05f4c53cb..235514393 100644 --- a/pkgs/testing/flake-module.nix +++ b/pkgs/testing/flake-module.nix @@ -40,7 +40,8 @@ postPatch = '' substituteInPlace nixos_test_lib/nix_setup.py \ --replace '@cp@' '${pkgs.coreutils}/bin/cp' \ - --replace '@nix-store@' '${pkgs.nix}/bin/nix-store' + --replace '@nix-store@' '${pkgs.nix}/bin/nix-store' \ + --replace '@xargs@' '${pkgs.findutils}/bin/xargs' ''; doCheck = false; }; diff --git a/pkgs/testing/nixos_test_lib/nix_setup.py b/pkgs/testing/nixos_test_lib/nix_setup.py index a8ed4d72d..8ec2b76cc 100644 --- a/pkgs/testing/nixos_test_lib/nix_setup.py +++ b/pkgs/testing/nixos_test_lib/nix_setup.py @@ -7,6 +7,7 @@ from pathlib import Path # These paths will be substituted during package build CP_BIN = "@cp@" NIX_STORE_BIN = "@nix-store@" +XARGS_BIN = "@xargs@" def setup_nix_in_nix(closure_info: str | None) -> None: @@ -16,7 +17,7 @@ def setup_nix_in_nix(closure_info: str | None) -> None: closure_info: Path to closure info directory containing store-paths file, or None if no closure info """ - tmpdir = Path(os.environ.get("TMPDIR", "/tmp")) + tmpdir = Path(os.environ.get("TMPDIR", "/tmp")) # noqa: S108 # Remove NIX_REMOTE if present (we don't have any nix daemon running) if "NIX_REMOTE" in os.environ: @@ -37,33 +38,36 @@ def setup_nix_in_nix(closure_info: str | None) -> None: Path(f"{tmpdir}/nix").mkdir(parents=True, exist_ok=True) Path(f"{tmpdir}/etc").mkdir(parents=True, exist_ok=True) Path(f"{tmpdir}/store").mkdir(parents=True, exist_ok=True) + Path(f"{tmpdir}/store/nix/store").mkdir(parents=True, exist_ok=True) + Path(f"{tmpdir}/store/nix/var/nix/gcroots").mkdir(parents=True, exist_ok=True) # Set up Nix store if closure info is provided if closure_info and Path(closure_info).exists(): store_paths_file = Path(closure_info) / "store-paths" if store_paths_file.exists(): + # Use xargs to handle potentially long lists of store paths + # Equivalent to: xargs cp --recursive --target-directory + # "$CLAN_TEST_STORE/nix/store" < "$closureInfo/store-paths" with store_paths_file.open() as f: - store_paths = f.read().strip().split("\n") - - # Copy store paths to test store using external cp command - # (handles symlinks better) - subprocess.run( - [CP_BIN, "--recursive", "--target", f"{tmpdir}/store"] - + [path.strip() for path in store_paths if path.strip()], - check=True, - ) + subprocess.run( # noqa: S603 + [ + XARGS_BIN, + CP_BIN, + "--recursive", + "--target-directory", + f"{tmpdir}/store/nix/store", + ], + stdin=f, + check=True, + ) # Load Nix database registration_file = Path(closure_info) / "registration" if registration_file.exists(): - env = os.environ.copy() - env["NIX_REMOTE"] = f"local?store={tmpdir}/store" - with registration_file.open() as f: - subprocess.run( - [NIX_STORE_BIN, "--load-db"], + subprocess.run( # noqa: S603 + [NIX_STORE_BIN, "--load-db", "--store", f"{tmpdir}/store"], input=f.read(), text=True, - env=env, check=True, ) From 543c518ed0573a23883aa1f0f0be0163bb098add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 17:33:32 +0200 Subject: [PATCH 13/15] make host key check an enum instead of an literal type this is more typesafe at runtime. --- pkgs/clan-cli/clan_cli/host_key_check.py | 28 +++++++++++++++++++ pkgs/clan-cli/clan_cli/machines/hardware.py | 8 ++---- pkgs/clan-cli/clan_cli/machines/install.py | 8 ++---- pkgs/clan-cli/clan_cli/machines/update.py | 8 ++---- pkgs/clan-cli/clan_cli/ssh/deploy_info.py | 11 +++----- .../clan-cli/clan_cli/ssh/test_deploy_info.py | 9 ++++-- pkgs/clan-cli/clan_cli/tests/hosts.py | 3 +- pkgs/clan-cli/clan_lib/ssh/host_key.py | 22 +++++++-------- pkgs/clan-cli/clan_lib/ssh/remote.py | 2 +- pkgs/clan-cli/clan_lib/ssh/remote_test.py | 5 ++-- pkgs/clan-cli/clan_lib/tests/test_create.py | 3 +- 11 files changed, 63 insertions(+), 44 deletions(-) create mode 100644 pkgs/clan-cli/clan_cli/host_key_check.py diff --git a/pkgs/clan-cli/clan_cli/host_key_check.py b/pkgs/clan-cli/clan_cli/host_key_check.py new file mode 100644 index 000000000..df1331574 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/host_key_check.py @@ -0,0 +1,28 @@ +"""Common argument types and utilities for host key checking in clan CLI commands.""" + +import argparse + +from clan_lib.ssh.host_key import HostKeyCheck + + +def host_key_check_type(value: str) -> HostKeyCheck: + """ + Argparse type converter for HostKeyCheck enum. + """ + try: + return HostKeyCheck(value) + except ValueError: + valid_values = [e.value for e in HostKeyCheck] + msg = f"Invalid host key check mode: {value}. Valid options: {', '.join(valid_values)}" + raise argparse.ArgumentTypeError(msg) from None + + +def add_host_key_check_arg( + parser: argparse.ArgumentParser, default: HostKeyCheck = HostKeyCheck.ASK +) -> None: + parser.add_argument( + "--host-key-check", + type=host_key_check_type, + default=default, + help=f"Host key (.ssh/known_hosts) check mode. Options: {', '.join([e.value for e in HostKeyCheck])}", + ) diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index b65cf7831..2db751fd3 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -12,6 +12,7 @@ from clan_lib.machines.suggestions import validate_machine_names from clan_lib.ssh.remote import Remote from clan_cli.completions import add_dynamic_completer, complete_machines +from clan_cli.host_key_check import add_host_key_check_arg from .types import machine_name_type @@ -54,12 +55,7 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None: nargs="?", help="ssh address to install to in the form of user@host:2222", ) - parser.add_argument( - "--host-key-check", - choices=["strict", "ask", "tofu", "none"], - default="ask", - help="Host key (.ssh/known_hosts) check mode.", - ) + add_host_key_check_arg(parser) parser.add_argument( "--password", help="Pre-provided password the cli will prompt otherwise if needed.", diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index b93a7331d..a6222e88f 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -13,6 +13,7 @@ from clan_cli.completions import ( complete_machines, complete_target_host, ) +from clan_cli.host_key_check import add_host_key_check_arg from clan_cli.machines.hardware import HardwareConfig from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host, ssh_command_parse @@ -97,12 +98,7 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None: help="do not reboot after installation (deprecated)", default=False, ) - parser.add_argument( - "--host-key-check", - choices=["strict", "ask", "tofu", "none"], - default="ask", - help="Host key (.ssh/known_hosts) check mode.", - ) + add_host_key_check_arg(parser) parser.add_argument( "--build-on", choices=[x.value for x in BuildOn], diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index 19ed3412b..76f5afb38 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -16,6 +16,7 @@ from clan_cli.completions import ( complete_machines, complete_tags, ) +from clan_cli.host_key_check import add_host_key_check_arg log = logging.getLogger(__name__) @@ -163,12 +164,7 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None: ) add_dynamic_completer(tag_parser, complete_tags) - parser.add_argument( - "--host-key-check", - choices=["strict", "ask", "tofu", "none"], - default="ask", - help="Host key (.ssh/known_hosts) check mode.", - ) + add_host_key_check_arg(parser) parser.add_argument( "--target-host", type=str, diff --git a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py index 9ba609c95..3734b47a7 100644 --- a/pkgs/clan-cli/clan_cli/ssh/deploy_info.py +++ b/pkgs/clan-cli/clan_cli/ssh/deploy_info.py @@ -8,12 +8,14 @@ from typing import Any from clan_lib.cmd import run from clan_lib.errors import ClanError from clan_lib.nix import nix_shell -from clan_lib.ssh.remote import HostKeyCheck, Remote +from clan_lib.ssh.host_key import HostKeyCheck +from clan_lib.ssh.remote import Remote from clan_cli.completions import ( add_dynamic_completer, complete_machines, ) +from clan_cli.host_key_check import add_host_key_check_arg from clan_cli.ssh.tor import TorTarget, spawn_tor, ssh_tor_reachable log = logging.getLogger(__name__) @@ -181,10 +183,5 @@ def register_parser(parser: argparse.ArgumentParser) -> None: "--png", help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)", ) - parser.add_argument( - "--host-key-check", - choices=["strict", "ask", "tofu", "none"], - default="tofu", - help="Host key (.ssh/known_hosts) check mode.", - ) + add_host_key_check_arg(parser, default=HostKeyCheck.TOFU) parser.set_defaults(func=ssh_command) diff --git a/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py b/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py index 3a4996f7d..5efa7cbfb 100644 --- a/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py +++ b/pkgs/clan-cli/clan_cli/ssh/test_deploy_info.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from clan_lib.cmd import RunOpts, run from clan_lib.nix import nix_shell +from clan_lib.ssh.host_key import HostKeyCheck from clan_lib.ssh.remote import Remote from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host @@ -23,7 +24,7 @@ def test_qrcode_scan(temp_dir: Path) -> None: run(cmd, RunOpts(input=data.encode())) # Call the qrcode_scan function - deploy_info = DeployInfo.from_qr_code(img_path, "none") + deploy_info = DeployInfo.from_qr_code(img_path, HostKeyCheck.NONE) host = deploy_info.addrs[0] assert host.address == "192.168.122.86" @@ -46,7 +47,7 @@ def test_qrcode_scan(temp_dir: Path) -> None: def test_from_json() -> None: data = '{"pass":"scabbed-defender-headlock","tor":"qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion","addrs":["192.168.122.86"]}' - deploy_info = DeployInfo.from_json(json.loads(data), "none") + deploy_info = DeployInfo.from_json(json.loads(data), HostKeyCheck.NONE) host = deploy_info.addrs[0] assert host.password == "scabbed-defender-headlock" @@ -69,7 +70,9 @@ def test_from_json() -> None: @pytest.mark.with_core def test_find_reachable_host(hosts: list[Remote]) -> None: host = hosts[0] - deploy_info = DeployInfo.from_hostnames(["172.19.1.2", host.ssh_url()], "none") + deploy_info = DeployInfo.from_hostnames( + ["172.19.1.2", host.ssh_url()], HostKeyCheck.NONE + ) assert deploy_info.addrs[0].address == "172.19.1.2" diff --git a/pkgs/clan-cli/clan_cli/tests/hosts.py b/pkgs/clan-cli/clan_cli/tests/hosts.py index 84c32e720..f40c79e63 100644 --- a/pkgs/clan-cli/clan_cli/tests/hosts.py +++ b/pkgs/clan-cli/clan_cli/tests/hosts.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from clan_cli.tests.sshd import Sshd +from clan_lib.ssh.host_key import HostKeyCheck from clan_lib.ssh.remote import Remote @@ -16,7 +17,7 @@ def hosts(sshd: Sshd) -> list[Remote]: port=sshd.port, user=login, private_key=Path(sshd.key), - host_key_check="none", + host_key_check=HostKeyCheck.NONE, command_prefix="local_test", ) ] diff --git a/pkgs/clan-cli/clan_lib/ssh/host_key.py b/pkgs/clan-cli/clan_lib/ssh/host_key.py index 3f2e6968c..e97932ef3 100644 --- a/pkgs/clan-cli/clan_lib/ssh/host_key.py +++ b/pkgs/clan-cli/clan_lib/ssh/host_key.py @@ -1,15 +1,15 @@ # Adapted from https://github.com/numtide/deploykit -from typing import Literal +from enum import Enum from clan_lib.errors import ClanError -HostKeyCheck = Literal[ - "strict", # Strictly check ssh host keys, prompt for unknown ones - "ask", # Ask for confirmation on first use - "tofu", # Trust on ssh keys on first use - "none", # Do not check ssh host keys -] + +class HostKeyCheck(Enum): + STRICT = "strict" # Strictly check ssh host keys, prompt for unknown ones + ASK = "ask" # Ask for confirmation on first use + TOFU = "tofu" # Trust on ssh keys on first use + NONE = "none" # Do not check ssh host keys def hostkey_to_ssh_opts(host_key_check: HostKeyCheck) -> list[str]: @@ -17,13 +17,13 @@ def hostkey_to_ssh_opts(host_key_check: HostKeyCheck) -> list[str]: Convert a HostKeyCheck value to SSH options. """ match host_key_check: - case "strict": + case HostKeyCheck.STRICT: return ["-o", "StrictHostKeyChecking=yes"] - case "ask": + case HostKeyCheck.ASK: return [] - case "tofu": + case HostKeyCheck.TOFU: return ["-o", "StrictHostKeyChecking=accept-new"] - case "none": + case HostKeyCheck.NONE: return [ "-o", "StrictHostKeyChecking=no", diff --git a/pkgs/clan-cli/clan_lib/ssh/remote.py b/pkgs/clan-cli/clan_lib/ssh/remote.py index de486d80f..41db26ad5 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote.py @@ -39,7 +39,7 @@ class Remote: private_key: Path | None = None password: str | None = None forward_agent: bool = True - host_key_check: HostKeyCheck = "ask" + host_key_check: HostKeyCheck = HostKeyCheck.ASK verbose_ssh: bool = False ssh_options: dict[str, str] = field(default_factory=dict) tor_socks: bool = False diff --git a/pkgs/clan-cli/clan_lib/ssh/remote_test.py b/pkgs/clan-cli/clan_lib/ssh/remote_test.py index 6d8a094b7..00b6ca53c 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote_test.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote_test.py @@ -9,6 +9,7 @@ from clan_lib.async_run import AsyncRuntime from clan_lib.cmd import ClanCmdTimeoutError, Log, RunOpts from clan_lib.errors import ClanError, CmdOut from clan_lib.ssh.remote import Remote +from clan_lib.ssh.host_key import HostKeyCheck from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy if sys.platform == "darwin": @@ -113,7 +114,7 @@ def test_parse_deployment_address( result = Remote.from_ssh_uri( machine_name=machine_name, address=test_addr, - ).override(host_key_check="strict") + ).override(host_key_check=HostKeyCheck.STRICT) if expected_exception: return @@ -131,7 +132,7 @@ def test_parse_deployment_address( def test_parse_ssh_options() -> None: addr = "root@example.com:2222?IdentityFile=/path/to/private/key&StrictRemoteKeyChecking=yes" host = Remote.from_ssh_uri(machine_name="foo", address=addr).override( - host_key_check="strict" + host_key_check=HostKeyCheck.STRICT ) assert host.address == "example.com" assert host.port == 2222 diff --git a/pkgs/clan-cli/clan_lib/tests/test_create.py b/pkgs/clan-cli/clan_lib/tests/test_create.py index 7090e8b85..ccbdce0ec 100644 --- a/pkgs/clan-cli/clan_lib/tests/test_create.py +++ b/pkgs/clan-cli/clan_lib/tests/test_create.py @@ -33,6 +33,7 @@ from clan_lib.nix_models.clan import ( ) from clan_lib.nix_models.clan import InventoryMachineDeploy as MachineDeploy from clan_lib.persist.util import set_value_by_path +from clan_lib.ssh.host_key import HostKeyCheck from clan_lib.ssh.remote import Remote, can_ssh_login log = logging.getLogger(__name__) @@ -198,7 +199,7 @@ def test_clan_create_api( clan_dir_flake.invalidate_cache() target_host = machine.target_host().override( - private_key=private_key, host_key_check="none" + private_key=private_key, host_key_check=HostKeyCheck.NONE ) result = can_ssh_login(target_host) assert result == "Online", f"Machine {machine.name} is not online" From 247151e93f892eb962809899a6aea01a01df562b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 2 Jul 2025 17:43:04 +0200 Subject: [PATCH 14/15] only override identify/host_key_check in a single place --- pkgs/clan-cli/clan_cli/machines/hardware.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index 2db751fd3..cc36cdf0d 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -21,7 +21,6 @@ log = logging.getLogger(__name__) def update_hardware_config_command(args: argparse.Namespace) -> None: validate_machine_names([args.machine], args.flake) - host_key_check = args.host_key_check machine = Machine(flake=args.flake, name=args.machine) opts = HardwareGenerateOptions( machine=machine, @@ -32,11 +31,13 @@ def update_hardware_config_command(args: argparse.Namespace) -> None: if args.target_host: target_host = Remote.from_ssh_uri( machine_name=machine.name, address=args.target_host - ).override(host_key_check=host_key_check, private_key=args.identity_file) - else: - target_host = machine.target_host().override( - host_key_check=host_key_check, private_key=args.identity_file ) + else: + target_host = machine.target_host() + + target_host = target_host.override( + host_key_check=args.host_key_check, private_key=args.identity_file + ) generate_machine_hardware_info(opts, target_host) From 76e653f37f49e2a3ad1f3580f798786962d5755b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 4 Jul 2025 16:56:42 +0200 Subject: [PATCH 15/15] nixoTestLib: split setting up function for port-forwarding and setuping up flake --- checks/installation/flake-module.nix | 74 ++++++++++++++--------- pkgs/clan-cli/clan_lib/ssh/remote_test.py | 2 +- pkgs/testing/nixos_test_lib/nix_setup.py | 23 +++++++ pkgs/testing/nixos_test_lib/ssh.py | 30 ++++----- 4 files changed, 79 insertions(+), 50 deletions(-) diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index 163e6e653..473dde6c1 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -174,7 +174,8 @@ import tempfile import os import subprocess - from nixos_test_lib.ssh import setup_test_environment # type: ignore[import-untyped] + from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped] + from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped] def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs): """Create a new test machine from an installed disk image""" @@ -201,14 +202,20 @@ target.start() - # Set up test environment using common helper + # Set up test environment with tempfile.TemporaryDirectory() as temp_dir: - env = setup_test_environment( - target, - temp_dir, - "${closureInfo}", - "${../assets/ssh/privkey}", - "${self.checks.x86_64-linux.clan-core-for-checks}" + # Prepare test flake and Nix store + flake_dir = prepare_test_flake( + temp_dir, + "${self.checks.x86_64-linux.clan-core-for-checks}", + "${closureInfo}" + ) + + # Set up SSH connection + ssh_conn = setup_ssh_connection( + target, + temp_dir, + "${../assets/ssh/privkey}" ) # Run clan install from host using port forwarding @@ -218,10 +225,10 @@ "install", "--phases", "disko,install", "--debug", - "--flake", env.flake_dir, + "--flake", flake_dir, "--yes", "test-install-machine-without-system", - "--target-host", f"nonrootuser@localhost:{env.host_port}", - "-i", env.ssh_key, + "--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}", + "-i", ssh_conn.ssh_key, "--option", "store", os.environ['CLAN_TEST_STORE'], "--update-hardware-config", "nixos-facter", ] @@ -254,29 +261,36 @@ import tempfile import os import subprocess - from nixos_test_lib.ssh import setup_test_environment # type: ignore[import-untyped] + from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped] + from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped] target.start() - # Set up test environment using common helper + # Set up test environment with tempfile.TemporaryDirectory() as temp_dir: - env = setup_test_environment( - target, - temp_dir, - "${closureInfo}", - "${../assets/ssh/privkey}", - "${self.checks.x86_64-linux.clan-core-for-checks}" + # Prepare test flake and Nix store + flake_dir = prepare_test_flake( + temp_dir, + "${self.checks.x86_64-linux.clan-core-for-checks}", + "${closureInfo}" + ) + + # Set up SSH connection + ssh_conn = setup_ssh_connection( + target, + temp_dir, + "${../assets/ssh/privkey}" ) # Verify files don't exist initially - hw_config_file = os.path.join(env.flake_dir, "machines/test-install-machine/hardware-configuration.nix") - facter_file = os.path.join(env.flake_dir, "machines/test-install-machine/facter.json") + hw_config_file = os.path.join(flake_dir, "machines/test-install-machine/hardware-configuration.nix") + facter_file = os.path.join(flake_dir, "machines/test-install-machine/facter.json") assert not os.path.exists(hw_config_file), "hardware-configuration.nix should not exist initially" assert not os.path.exists(facter_file), "facter.json should not exist initially" # Set CLAN_FLAKE for the commands - os.environ["CLAN_FLAKE"] = env.flake_dir + os.environ["CLAN_FLAKE"] = flake_dir # Test facter backend clan_cmd = [ @@ -287,17 +301,17 @@ "--flake", ".", "--host-key-check", "none", "test-install-machine-without-system", - "-i", env.ssh_key, + "-i", ssh_conn.ssh_key, "--option", "store", os.environ['CLAN_TEST_STORE'], - f"nonrootuser@localhost:{env.host_port}" + f"nonrootuser@localhost:{ssh_conn.host_port}" ] - result = subprocess.run(clan_cmd, capture_output=True, cwd=env.flake_dir) + result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir) if result.returncode != 0: print(f"Clan update-hardware-config failed: {result.stderr.decode()}") raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}") - facter_without_system_file = os.path.join(env.flake_dir, "machines/test-install-machine-without-system/facter.json") + facter_without_system_file = os.path.join(flake_dir, "machines/test-install-machine-without-system/facter.json") assert os.path.exists(facter_without_system_file), "facter.json should exist after update" os.remove(facter_without_system_file) @@ -311,17 +325,17 @@ "--host-key-check", "none", "--flake", ".", "test-install-machine-without-system", - "-i", env.ssh_key, + "-i", ssh_conn.ssh_key, "--option", "store", os.environ['CLAN_TEST_STORE'], - f"nonrootuser@localhost:{env.host_port}" + f"nonrootuser@localhost:{ssh_conn.host_port}" ] - result = subprocess.run(clan_cmd, capture_output=True, cwd=env.flake_dir) + result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir) if result.returncode != 0: print(f"Clan update-hardware-config (nixos-generate-config) failed: {result.stderr.decode()}") raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}") - hw_config_without_system_file = os.path.join(env.flake_dir, "machines/test-install-machine-without-system/hardware-configuration.nix") + hw_config_without_system_file = os.path.join(flake_dir, "machines/test-install-machine-without-system/hardware-configuration.nix") assert os.path.exists(hw_config_without_system_file), "hardware-configuration.nix should exist after update" ''; } { inherit pkgs self; }; diff --git a/pkgs/clan-cli/clan_lib/ssh/remote_test.py b/pkgs/clan-cli/clan_lib/ssh/remote_test.py index 00b6ca53c..7eeb5feac 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote_test.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote_test.py @@ -8,8 +8,8 @@ import pytest from clan_lib.async_run import AsyncRuntime from clan_lib.cmd import ClanCmdTimeoutError, Log, RunOpts from clan_lib.errors import ClanError, CmdOut -from clan_lib.ssh.remote import Remote from clan_lib.ssh.host_key import HostKeyCheck +from clan_lib.ssh.remote import Remote from clan_lib.ssh.sudo_askpass_proxy import SudoAskpassProxy if sys.platform == "darwin": diff --git a/pkgs/testing/nixos_test_lib/nix_setup.py b/pkgs/testing/nixos_test_lib/nix_setup.py index 8ec2b76cc..aeb4e35b7 100644 --- a/pkgs/testing/nixos_test_lib/nix_setup.py +++ b/pkgs/testing/nixos_test_lib/nix_setup.py @@ -71,3 +71,26 @@ def setup_nix_in_nix(closure_info: str | None) -> None: text=True, check=True, ) + + +def prepare_test_flake( + temp_dir: str, clan_core_for_checks: str, closure_info: str +) -> str: + """Set up Nix store and copy test flake to temporary directory + + Args: + temp_dir: Temporary directory + clan_core_for_checks: Path to clan-core-for-checks + closure_info: Path to closure info for Nix store setup + + Returns: + Path to the test flake directory + """ + # Set up Nix store + setup_nix_in_nix(closure_info) + + # Copy test flake + flake_dir = Path(temp_dir) / "test-flake" + subprocess.run(["cp", "-r", clan_core_for_checks, flake_dir], check=True) # noqa: S603, S607 + subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) # noqa: S603, S607 + return str(flake_dir) diff --git a/pkgs/testing/nixos_test_lib/ssh.py b/pkgs/testing/nixos_test_lib/ssh.py index 97a52766c..dc51213ac 100644 --- a/pkgs/testing/nixos_test_lib/ssh.py +++ b/pkgs/testing/nixos_test_lib/ssh.py @@ -1,34 +1,31 @@ """SSH and test setup utilities""" -import subprocess from pathlib import Path from typing import NamedTuple -from .nix_setup import setup_nix_in_nix from .port import find_free_port, setup_port_forwarding -class TestEnvironment(NamedTuple): +class SSHConnection(NamedTuple): host_port: int ssh_key: str - flake_dir: str -def setup_test_environment( +def setup_ssh_connection( target, temp_dir: str, - closure_info: str, assets_ssh_privkey: str, - clan_core_for_checks: str, -) -> TestEnvironment: - """Set up common test environment including SSH, port forwarding, and flake setup +) -> SSHConnection: + """Set up SSH connection with port forwarding to test VM + + Args: + target: Test VM target + temp_dir: Temporary directory for SSH key + assets_ssh_privkey: Path to SSH private key asset Returns: - TestEnvironment with host_port, ssh_key, and flake_dir + SSHConnection with host_port and ssh_key path """ - # Run setup function - setup_nix_in_nix(closure_info) - host_port = find_free_port() target.wait_for_unit("sshd.service") target.wait_for_open_port(22) @@ -40,9 +37,4 @@ def setup_test_environment( f.write(src.read()) ssh_key.chmod(0o600) - # Copy test flake to temp directory - flake_dir = Path(temp_dir) / "test-flake" - subprocess.run(["cp", "-r", clan_core_for_checks, flake_dir], check=True) # noqa: S603, S607 - subprocess.run(["chmod", "-R", "+w", flake_dir], check=True) # noqa: S603, S607 - - return TestEnvironment(host_port, str(ssh_key), str(flake_dir)) + return SSHConnection(host_port, str(ssh_key))