Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ on:

jobs:
test-linux:
name: "Test NH on Linux"
name: "Test NH on Linux (x86_64-linux)"
runs-on: ubuntu-latest
steps:
- uses: cachix/install-nix-action@master
Expand All @@ -46,8 +46,12 @@ jobs:
run: |
nix run .#nh -- os switch --diff never --dry --no-nom --verbose --file ./test/nixos.nix

- name: Test NH features in NixOS VM
run: |
nix build .#checks.x86_64-linux.nh-remote-test

build-darwin:
name: "Test NH on Darwin"
name: "Test NH on Darwin (aarch64-darwin)"
runs-on: macos-latest

steps:
Expand Down
10 changes: 9 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@
default = self.packages.${pkgs.stdenv.hostPlatform.system}.nh;
});

checks = self.packages // self.devShells;
checks =
self.packages
// self.devShells
// nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: {
nh-remote-test = nixpkgs.legacyPackages.${system}.callPackage ./test/vm/test-remote.nix {
inherit (self.packages.${system}) nh;
inherit nixpkgs;
};
});
Comment on lines +29 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incorrect attribute set merge overwrites checks for Linux systems.

The // operator performs shallow merging at the top level (system keys), not a deep merge of the inner attributes. This means:

  • self.packages.x86_64-linux (containing nh, default) gets overwritten by the genAttrs result { nh-remote-test = ...; }
  • Linux systems lose their package checks entirely
  • Darwin systems won't have nh-remote-test (correct) but the merge still has issues

To properly merge nested attrsets, use lib.recursiveUpdate or restructure:

Proposed fix using recursiveUpdate
       checks =
-        self.packages
-        // self.devShells
-        // nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: {
-          nh-remote-test = nixpkgs.legacyPackages.${system}.callPackage ./test/vm/test-remote.nix {
-            inherit (self.packages.${system}) nh;
-            inherit nixpkgs;
-          };
-        });
+        nixpkgs.lib.recursiveUpdate
+          (nixpkgs.lib.recursiveUpdate self.packages self.devShells)
+          (nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: {
+            nh-remote-test = nixpkgs.legacyPackages.${system}.callPackage ./test/vm/test-remote.nix {
+              inherit (self.packages.${system}) nh;
+              inherit nixpkgs;
+            };
+          }));

Alternatively, use forAllSystems with conditional logic for a cleaner approach.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
checks =
self.packages
// self.devShells
// nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: {
nh-remote-test = nixpkgs.legacyPackages.${system}.callPackage ./test/vm/test-remote.nix {
inherit (self.packages.${system}) nh;
inherit nixpkgs;
};
});
checks =
nixpkgs.lib.recursiveUpdate
(nixpkgs.lib.recursiveUpdate self.packages self.devShells)
(nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: {
nh-remote-test = nixpkgs.legacyPackages.${system}.callPackage ./test/vm/test-remote.nix {
inherit (self.packages.${system}) nh;
inherit nixpkgs;
};
}));
🤖 Prompt for AI Agents
In `@flake.nix` around lines 29 - 37, The current checks assignment uses the
shallow merge operator (//) which causes self.packages.${system} to be
overwritten by the genAttrs result and drops Linux package checks; update the
merge to perform a deep/recursive merge so nested keys are preserved — for
example use nixpkgs.lib.recursiveUpdate (or nixpkgs.lib.recursiveUpdateAttrs) to
combine self.packages and the genAttrs result that defines nh-remote-test, or
refactor to forAllSystems with conditional logic that adds nh-remote-test only
for non-Darwin systems; locate the checks assignment and replace the top-level
// merge of self.packages and the genAttrs block (the nh-remote-test definition)
with a recursiveUpdate-based merge or a forAllSystems conditional so nh and
default entries in self.packages.${system} are not lost.


devShells = forAllSystems (pkgs: {
default = import ./shell.nix { inherit pkgs; };
Expand Down
19 changes: 19 additions & 0 deletions test/ssh-keys.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
pkgs: {
# This key is used in integration tests
# This is NOT a security issue
# It uses the test key defined in RFC 9500
# https://datatracker.ietf.org/doc/rfc9500/
snakeOilPrivateKey = pkgs.writeText "privkey.snakeoil" ''
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIObLW92AqkWunJXowVR2Z5/+yVPBaFHnEedDk5WJxk/BoAoGCCqGSM49
AwEHoUQDQgAEQiVI+I+3gv+17KN0RFLHKh5Vj71vc75eSOkyMsxFxbFsTNEMTLjV
uKFxOelIgsiZJXKZNCX0FBmrfpCkKklCcg==
-----END EC PRIVATE KEY-----
'';

snakeOilPublicKey = pkgs.lib.concatStrings [
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHA"
"yNTYAAABBBEIlSPiPt4L/teyjdERSxyoeVY+9b3O+XkjpMjLMRcWxbEzRDEy41b"
"ihcTnpSILImSVymTQl9BQZq36QpCpJQnI= snakeoil"
];
}
254 changes: 254 additions & 0 deletions test/vm/test-remote.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
{
lib,
pkgs,
# Inherited from flake.nix
nixpkgs,
nh,
...
}:
let
modulesPath = "${nixpkgs}/nixos/modules";
inherit (import ../ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;

sshConfig = builtins.toFile "ssh.conf" ''
UserKnownHostsFile=/dev/null
StrictHostKeyChecking=no
'';

# Base configuration for target
targetBaseConfig = {
documentation.enable = false;
services.openssh.enable = true;
system.switch.enable = true;
};

# Configuration file generator
mkConfigFile =
hostname:
pkgs.writeText "configuration-${hostname}.nix" ''
import <nixpkgs/nixos> {
configuration = {
imports = [
./hardware-configuration.nix
"${modulesPath}/profiles/installation-device.nix"
];

boot.loader.grub = {
enable = true;
device = "/dev/vda";
forceInstall = true;
};

documentation.enable = false;
services.openssh.enable = true;
system.switch.enable = true;

networking.hostName = "${hostname}";

environment.systemPackages = with pkgs; [ hello ];
};
}
'';
in
pkgs.testers.nixosTest {
name = "nh-remote-test";
meta.maintainers = with lib.maintainers; [ NotAShelf ];

nodes = {
deployer =
{
lib,
pkgs,
...
}:
{
imports = [ "${modulesPath}/profiles/installation-device.nix" ];

nix.settings = {
substituters = lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = 1;
experimental-features = [
"nix-command"
"flakes"
];
};

virtualisation = {
cores = 2;
memorySize = 3072;
};

system = {
includeBuildDependencies = true;
switch.enable = true;
build.privateKey = snakeOilPrivateKey;
build.publicKey = snakeOilPublicKey;
};

services.openssh.enable = true;
environment.systemPackages = [ nh ];
users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
};

target =
{
nodes,
lib,
...
}:
{
virtualisation = {
cores = 2;
memorySize = 2048;
vlans = [ 1 ];
};

nix.settings = {
substituters = lib.mkForce [ ];
experimental-features = [
"nix-command"
"flakes"
];
};

system.switch.enable = true;

users.users.root.openssh.authorizedKeys.keys = [ nodes.deployer.system.build.publicKey ];

services.openssh.enable = true;
environment.systemPackages = [ nh ];
networking.hostName = "target";
};

buildHost =
{
nodes,
lib,
...
}:
{
virtualisation = {
cores = 2;
memorySize = 2048;
vlans = [ 1 ];
};

nix.settings = {
substituters = lib.mkForce [ ];
experimental-features = [
"nix-command"
"flakes"
];
};

system.switch.enable = true;
users.users.root.openssh.authorizedKeys.keys = [ nodes.deployer.system.build.publicKey ];

services.openssh.enable = true;
environment.systemPackages = [ nh ];
networking.hostName = "buildHost";
};
};

testScript = ''
start_all()

# Wait for all nodes to be ready
deployer.wait_for_unit("multi-user.target")
target.wait_for_unit("sshd.service")
buildHost.wait_for_unit("sshd.service")

# Setup SSH keys on deployer
deployer.succeed("mkdir -p /root/.ssh")
deployer.succeed("install -m 600 ${snakeOilPrivateKey} /root/.ssh/id_ecdsa")
deployer.succeed("install ${sshConfig} /root/.ssh/config")

# Get IP addresses from VLAN interface (eth1)
# Yeesh.
target_ip = target.succeed("ip -4 addr show eth1 | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'").strip()
build_host_ip = buildHost.succeed("ip -4 addr show eth1 | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'").strip()

print(f"Target IP: {target_ip}")
print(f"Build host IP: {build_host_ip}")

# Setup known_hosts
deployer.succeed(f"ssh-keyscan {target_ip} >> /root/.ssh/known_hosts")
deployer.succeed(f"ssh-keyscan {build_host_ip} >> /root/.ssh/known_hosts")

# Test SSH connectivity
deployer.succeed(f"ssh root@{target_ip} 'echo SSH to target works'")
deployer.succeed(f"ssh root@{build_host_ip} 'echo SSH to buildHost works'")

# Generate hardware configuration on target and verify it exists
target.succeed("nixos-generate-config --dir /root")
target.succeed("ls -la /root/hardware-configuration.nix") # Debug: verify file exists
deployer.succeed(f"scp root@{target_ip}:/root/hardware-configuration.nix /root/hardware-configuration.nix")

# Copy test configurations to deployer
deployer.copy_from_host("${mkConfigFile "config-1-deployed"}", "/root/configuration-1.nix")
deployer.copy_from_host("${mkConfigFile "config-2-deployed"}", "/root/configuration-2.nix")
deployer.copy_from_host("${mkConfigFile "config-3-deployed"}", "/root/configuration-3.nix")

with subtest("Local build and switch on target"):
# Copy config to target for local build
deployer.succeed(f"scp /root/configuration-1.nix root@{target_ip}:/root/configuration.nix")
deployer.succeed(f"scp /root/hardware-configuration.nix root@{target_ip}:/root/hardware-configuration.nix")

# Build locally on target using non-flake syntax
target.succeed("nh os switch --bypass-root-check -f '<nixpkgs/nixos>'")

# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-1-deployed", f"Expected 'config-1-deployed', got '{target_hostname}'"

# Verify hello package is available
target.succeed("hello --version")

# Build on deployer, activate on target
with subtest("Remote build on deployer, deploy to target with --target-host"):
deployer.succeed(f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --target-host root@{target_ip}")

# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-2-deployed", f"Expected 'config-2-deployed', got '{target_hostname}'"

# Build on buildHost, activate on target (both different from deployer)
with subtest("Remote build on buildHost with --build-host, deploy to target with --target-host"):
deployer.succeed(
f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --build-host root@{build_host_ip} --target-host root@{target_ip}"
)

# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-3-deployed", f"Expected 'config-3-deployed', got '{target_hostname}'"
Comment on lines +209 to +224
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing configuration file setup for remote subtests.

The remote build subtests (lines 209-248) run nh os switch with -f '<nixpkgs/nixos>' on the deployer, which expects /root/configuration.nix to exist. However, only /root/configuration-{1,2,3}.nix files are copied to the deployer (lines 189-191), and /root/configuration.nix is never created on the deployer.

Each remote subtest should copy the appropriate configuration file to /root/configuration.nix on the deployer before running the switch command:

Proposed fix for the second subtest
     # Build on deployer, activate on target
     with subtest("Remote build on deployer, deploy to target with --target-host"):
+        deployer.succeed("cp /root/configuration-2.nix /root/configuration.nix")
         deployer.succeed(f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --target-host root@{target_ip}")

Similar changes needed for the other remote subtests with the appropriate configuration file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
with subtest("Remote build on deployer, deploy to target with --target-host"):
deployer.succeed(f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --target-host root@{target_ip}")
# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-2-deployed", f"Expected 'config-2-deployed', got '{target_hostname}'"
# Build on buildHost, activate on target (both different from deployer)
with subtest("Remote build on buildHost with --build-host, deploy to target with --target-host"):
deployer.succeed(
f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --build-host root@{build_host_ip} --target-host root@{target_ip}"
)
# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-3-deployed", f"Expected 'config-3-deployed', got '{target_hostname}'"
with subtest("Remote build on deployer, deploy to target with --target-host"):
deployer.succeed("cp /root/configuration-2.nix /root/configuration.nix")
deployer.succeed(f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --target-host root@{target_ip}")
# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-2-deployed", f"Expected 'config-2-deployed', got '{target_hostname}'"
# Build on buildHost, activate on target (both different from deployer)
with subtest("Remote build on buildHost with --build-host, deploy to target with --target-host"):
deployer.succeed(
f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --build-host root@{build_host_ip} --target-host root@{target_ip}"
)
# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-3-deployed", f"Expected 'config-3-deployed', got '{target_hostname}'"
🤖 Prompt for AI Agents
In `@test/vm/test-remote.nix` around lines 209 - 224, The remote subtests call
deployer.succeed("nh os switch ... -f '<nixpkgs/nixos>'") but never create
/root/configuration.nix on the deployer; copy the appropriate prepared file to
/root/configuration.nix on the deployer before each remote deployer.succeed
invocation (e.g. copy configuration-2.nix before the subtest that expects
config-2-deployed and configuration-3.nix before the subtest that expects
config-3-deployed). Use deployer.succeed to perform the copy on the deployer
host immediately prior to the deployer.succeed("nh os switch ...") calls so the
switch command finds /root/configuration.nix.


with subtest("Remote build and deploy to same host (build-host == target-host)"):
# Reset target to config-1 first
deployer.succeed(f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --target-host root@{target_ip}")

# Build and deploy on target itself via deployer
deployer.succeed(
f"nh os switch --bypass-root-check -f '<nixpkgs/nixos>' --build-host root@{target_ip} --target-host root@{target_ip}"
)

# Verify hostname changed
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-2-deployed", f"Expected 'config-2-deployed', got '{target_hostname}'"

with subtest("Build-only operation with --build-host (no activation)"):
# Just build, don't activate
deployer.succeed(f"nh os build --bypass-root-check -f '<nixpkgs/nixos>' --build-host root@{build_host_ip}")

# Verify build succeeded by checking result link exists
deployer.succeed("test -L result")

# Verify target hostname didn't change (still config-2)
target_hostname = target.succeed("cat /etc/hostname").strip()
assert target_hostname == "config-2-deployed", f"Hostname should not have changed, got '{target_hostname}'"

with subtest("Fail when running as root without --bypass-root-check"):
# Attempt to run as root without the bypass flag - should fail
target.fail("nh os switch -f '<nixpkgs/nixos>'")
'';
}