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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ jobs:
nix build .#checks.x86_64-linux.shellcheck-scripts --print-build-logs
nix build .#checks.x86_64-linux.tesmart-ctl-build --print-build-logs

# TIN-539: NixOS VM tests (pkgs.testers.runNixOSTest) for ser2net, NUT,
# and tailscale module logic, no physical hardware required. These need
# /dev/kvm inside the runner. `tinyland-nix` is a self-hosted ARC
# runner, not GitHub's own `ubuntu-latest` (which has had KVM since
# 2023 per DeterminateSystems' installer setting
# `system-features = nixos-test kvm` automatically) -- this repo's CI
# was not previously proven to have KVM passthrough at all. If this
# step fails with a `/dev/kvm` or `qemu-kvm` permission/availability
# error rather than an actual test assertion failure, that is the
# runner's nested-virtualization posture, not a defect in the tests
# themselves; route it to whoever owns tinyland-nix's ARC pod spec
# rather than re-authoring the tests.
- name: NixOS VM tests (ser2net, NUT, tailscale)
run: |
nix build .#checks.x86_64-linux.vm-ser2net --print-build-logs
nix build .#checks.x86_64-linux.vm-nut --print-build-logs
nix build .#checks.x86_64-linux.vm-tailscale --print-build-logs
nix build .#checks.x86_64-linux.vm-integration --print-build-logs

build-packages:
name: Build Packages
runs-on: tinyland-nix
Expand Down
15 changes: 15 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@
tesmart-ctl-build =
self.packages.${system}.tesmart-ctl
or (pkgs.runCommand "tesmart-ctl-skip" { } "echo 'skipped on ${system}'; touch $out");
}
# NixOS VM tests (TIN-539) need KVM, which is only sanctioned
# here on x86_64-linux CI runners (GitHub-hosted `ubuntu-latest`
# has had KVM since 2023; module logic under test is
# arch-independent, so one arch's VM proves the Nix code path
# aarch64 hosts also run). Do not add these under
# aarch64-linux/darwin -- there is no KVM there in CI, and a
# `nix flake check` that silently skips VM tests on the arches
# that lack it is the gate-theater failure mode TIN-3457 warns
# about, so this is an explicit restriction, not an omission.
// pkgs.lib.optionalAttrs (system == "x86_64-linux") {
vm-ser2net = import ./tests/vm/ser2net.nix { inherit pkgs; };
vm-nut = import ./tests/vm/nut.nix { inherit pkgs; };
vm-tailscale = import ./tests/vm/tailscale.nix { inherit pkgs; };
vm-integration = import ./tests/vm/integration.nix { inherit pkgs; };
};
in
{
Expand Down
139 changes: 139 additions & 0 deletions tests/vm/integration.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# tests/vm/integration.nix — TIN-539
#
# Combined VM test: ser2net + NUT (dummy-ups) + tailscale on one machine,
# validating service ordering and that none of their fixed ports collide
# (ser2net 3001, NUT upsd 3493, tailscale UDP port -- disjoint by
# construction, asserted here rather than merely assumed).
{ pkgs, ... }:

let
ser2netModule = ../../modules/ser2net;
testDevice = "/tmp/lab-test-tty";
testDevicePeer = "/tmp/lab-test-tty-peer";
ser2netPort = 3001;
nutPort = 3493;
testPasswordFile = pkgs.writeText "nut-vm-test-password" "vm-test-only-not-a-real-secret";
in
pkgs.testers.runNixOSTest {
name = "lab-services-integration";

nodes.machine =
{ pkgs, config, ... }:
{
imports = [ ser2netModule ];

environment.systemPackages = [
pkgs.socat
pkgs.nut
];

services.lab-ser2net = {
enable = true;
connections.test-port = {
port = ser2netPort;
device = testDevice;
speed = "9600n81";
description = "integration test serial connection";
};
};

power.ups = {
enable = true;
mode = "netserver";
ups."dummy-ups" = {
description = "VM Test Dummy UPS";
driver = "dummy-ups";
port = "dummy-ups.dev";
};
upsd.listen = [
{
address = "0.0.0.0";
port = nutPort;
}
];
users.upsmon = {
passwordFile = testPasswordFile;
upsmon = "primary";
};
upsmon = {
enable = true;
monitor."dummy-ups@localhost" = {
powerValue = 1;
type = "primary";
user = "upsmon";
passwordFile = testPasswordFile;
};
settings = {
MINSUPPLIES = 1;
SHUTDOWNCMD = "${pkgs.coreutils}/bin/true";
POLLFREQ = 5;
POLLFREQALERT = 2;
FINALDELAY = 5;
};
};
openFirewall = true;
};

services.tailscale = {
enable = true;
useRoutingFeatures = "server";
};

networking.firewall = {
trustedInterfaces = [ "tailscale0" ];
allowedUDPPorts = [ config.services.tailscale.port ];
checkReversePath = "loose";
};
};

testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")

with subtest("configured TCP/UDP ports are disjoint"):
ser2net_port = ${builtins.toString ser2netPort}
nut_port = ${builtins.toString nutPort}
assert ser2net_port != nut_port, "ser2net and NUT ports collide by construction"

with subtest("serial device pair exists for ser2net"):
machine.succeed(
"socat -d -d "
"pty,raw,echo=0,link=${testDevice} "
"pty,raw,echo=0,link=${testDevicePeer} "
">/tmp/socat.log 2>&1 </dev/null &"
)
machine.wait_for_file("${testDevice}")

with subtest("ser2net starts and listens"):
machine.succeed("systemctl restart ser2net.service")
machine.wait_for_unit("ser2net.service")
machine.wait_for_open_port(ser2net_port)

with subtest("NUT dummy driver is wired and upsd listens"):
machine.succeed(
"cat > /etc/nut/dummy-ups.dev <<'DUMMYEOF'\n"
"battery.charge: 100\n"
"ups.status: OL\n"
"DUMMYEOF\n"
)
machine.succeed("systemctl restart upsd.service")
machine.wait_for_unit("upsd.service")
machine.succeed("systemctl restart upsdrv.service")
machine.wait_for_unit("upsdrv.service")
machine.wait_for_open_port(nut_port)

with subtest("tailscaled starts alongside the other two services"):
machine.wait_for_unit("tailscaled.service")

with subtest("no port conflict: both services are independently reachable"):
machine.wait_for_open_port(ser2net_port)
machine.wait_for_open_port(nut_port)
status = machine.succeed("upsc dummy-ups@localhost ups.status").strip()
assert "OL" in status, f"expected OL status, got: {status!r}"

with subtest("firewall trusts tailscale0 without blocking the other two services' ports"):
fw = machine.succeed("iptables -L INPUT -n")
assert str(ser2net_port) in fw
assert str(nut_port) in fw
'';
}
119 changes: 119 additions & 0 deletions tests/vm/nut.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# tests/vm/nut.nix — TIN-539
#
# NixOS VM test for NUT (Network UPS Tools) in netserver mode, using the
# `dummy-ups` driver so no physical UPS is required. This deliberately does
# NOT import modules/nut-server directly: that module wires
# `config.sops.secrets.nut-password.path`, which needs live sops-nix
# secrets infrastructure this hermetic VM does not have. Instead it
# reconstructs the same `power.ups` shape (netserver mode, upsd listening,
# upsmon primary monitor) using a `pkgs.writeText` password file in place
# of a sops secret, which is the documented safe substitution for a VM
# test of module *logic*.
#
# Verified against the live module source
# (nixos/modules/services/monitoring/ups.nix, nixos-24.11) rather than
# assumed:
# * NUT_CONFPATH is hardcoded to /etc/nut -- not host/version-dependent.
# * the systemd units are upsd.service, upsdrv.service (a oneshot that
# runs `upsdrvctl start` for every configured UPS, NOT one unit per
# UPS) and upsmon.service.
# * `users.<name>` and `upsmon.monitor.<name>` only accept
# `passwordFile`, never a plaintext `password`.
{ pkgs, ... }:

let
testPasswordFile = pkgs.writeText "nut-vm-test-password" "vm-test-only-not-a-real-secret";
in
pkgs.testers.runNixOSTest {
name = "nut";

nodes.machine =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.nut ];

power.ups = {
enable = true;
mode = "netserver";

ups."dummy-ups" = {
description = "VM Test Dummy UPS";
driver = "dummy-ups";
port = "dummy-ups.dev";
};

upsd.listen = [
{
address = "0.0.0.0";
port = 3493;
}
];

users.upsmon = {
passwordFile = testPasswordFile;
upsmon = "primary";
};

upsmon = {
enable = true;
monitor."dummy-ups@localhost" = {
powerValue = 1;
type = "primary";
user = "upsmon";
passwordFile = testPasswordFile;
};
settings = {
MINSUPPLIES = 1;
SHUTDOWNCMD = "${pkgs.coreutils}/bin/true";
POLLFREQ = 5;
POLLFREQALERT = 2;
FINALDELAY = 5;
};
};

openFirewall = true;
};
};

testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")

with subtest("write the dummy-ups simulated reading file"):
# NUT_CONFPATH is fixed at /etc/nut by the module; dummy-ups reads
# its simulated readings from <confpath>/<port>.
machine.succeed(
"cat > /etc/nut/dummy-ups.dev <<'DUMMYEOF'\n"
"battery.charge: 100\n"
"battery.runtime: 3600\n"
"ups.status: OL\n"
"ups.mfr: Dummy\n"
"ups.model: VM Test UPS\n"
"DUMMYEOF\n"
)

with subtest("upsd starts and serves on the configured port"):
machine.succeed("systemctl restart upsd.service")
machine.wait_for_unit("upsd.service")
machine.wait_for_open_port(3493)

with subtest("upsdrv registers the dummy driver against the reading file"):
# upsdrv is a oneshot (RemainAfterExit); restarting re-runs
# `upsdrvctl start` now that both upsd and the reading file exist.
machine.succeed("systemctl restart upsdrv.service")
machine.wait_for_unit("upsdrv.service")

with subtest("upsc query returns the dummy reading"):
status = machine.succeed("upsc dummy-ups@localhost ups.status").strip()
assert "OL" in status, f"expected OL status, got: {status!r}"
charge = machine.succeed("upsc dummy-ups@localhost battery.charge").strip()
assert charge == "100", f"expected battery.charge 100, got: {charge!r}"

with subtest("upsmon comes up and can reach upsd"):
machine.wait_for_unit("upsmon.service")

with subtest("firewall opens the configured upsd port"):
fw = machine.succeed("iptables -L INPUT -n")
assert "3493" in fw, "configured NUT port is not in the firewall accept rules"
'';
}
90 changes: 90 additions & 0 deletions tests/vm/ser2net.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# tests/vm/ser2net.nix — TIN-539
#
# NixOS VM test for modules/ser2net: validates the module's generated
# systemd unit, config, and firewall rule without any physical serial
# hardware. A `socat` PTY pair stands in for the serial device the module
# expects at `conn.device`; ser2net's own `Restart = "on-failure"` policy
# means start order between socat and ser2net does not matter.
#
# Run via `nix flake check` (CI only — this repo's local dev machine does
# not build/eval; see justfile/CI for the sanctioned entry point).
{ pkgs, ... }:

let
ser2netModule = ../../modules/ser2net;
testDevice = "/tmp/lab-test-tty";
testDevicePeer = "/tmp/lab-test-tty-peer";
testPort = 3001;
testDescription = "VM test serial connection";
in
pkgs.testers.runNixOSTest {
name = "ser2net";

nodes.machine =
{ pkgs, ... }:
{
imports = [ ser2netModule ];

environment.systemPackages = [ pkgs.socat ];

services.lab-ser2net = {
enable = true;
connections.test-port = {
port = testPort;
device = testDevice;
speed = "9600n81";
description = testDescription;
};
};
};

testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")

with subtest("generated config carries the module's own values"):
config_text = machine.succeed("cat /etc/ser2net/ser2net.yaml")
assert "${builtins.toString testPort}" in config_text, "port missing from generated config"
assert "${testDevice}" in config_text, "device path missing from generated config"
assert "${testDescription}" in config_text, "banner description missing from generated config"

with subtest("firewall opens the configured port"):
fw = machine.succeed("iptables -L INPUT -n")
assert "${builtins.toString testPort}" in fw, "configured port is not in the firewall accept rules"

with subtest("serial device pair exists before ser2net needs it"):
# Trailing `&` backgrounds socat so `succeed()` (which waits for the
# shell it runs to exit) returns immediately instead of blocking on
# a long-running process; redirecting all three fds detaches it from
# the test driver's connection.
machine.succeed(
"socat -d -d "
"pty,raw,echo=0,link=${testDevice} "
"pty,raw,echo=0,link=${testDevicePeer} "
">/tmp/socat.log 2>&1 </dev/null &"
)
machine.wait_for_file("${testDevice}")
machine.wait_for_file("${testDevicePeer}")

with subtest("ser2net starts once its serial device is available"):
# The module sets Restart=on-failure/RestartSec=5, so a restart here
# (rather than relying on the original boot-time attempt) proves the
# service can recover once the device shows up, which is the real
# startup order on physical hardware too (USB serial adapters attach
# after boot).
machine.succeed("systemctl restart ser2net.service")
machine.wait_for_unit("ser2net.service")
machine.wait_for_open_port(${builtins.toString testPort})

with subtest("connecting over TCP reaches the configured serial device"):
# ser2net sends its configured banner over the TCP side immediately
# on accept, before any serial-side bytes flow, so receiving it here
# proves the full accepter -> connector -> serial-device chain is
# live, not just that something is listening on the port.
banner = machine.succeed(
"timeout 5 bash -c "
"'exec 3<>/dev/tcp/127.0.0.1/${builtins.toString testPort}; head -c 200 <&3'"
)
assert "${testDescription}" in banner, f"banner missing expected description, got: {banner!r}"
'';
}
Loading
Loading