aboutsummaryrefslogtreecommitdiff
path: root/scripts/testing
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 00:54:53 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 00:54:53 -0400
commit99a26d7de23bbfc757957c08e47606c3690df4cb (patch)
tree614b63abef7a5fdfb65d3e948788588632662f9e /scripts/testing
parent133d036aaa8cbe7523d217f2174fb9de191b61a9 (diff)
downloadarchsetup-99a26d7de23bbfc757957c08e47606c3690df4cb.tar.gz
archsetup-99a26d7de23bbfc757957c08e47606c3690df4cb.zip
test(archsetup): scaffold Testinfra post-install validation (P1)
Stand up the Testinfra/pytest harness alongside the existing shell sweep so the two can be compared for parity before pytest takes over. Adds scripts/testing/tests/ (conftest with failure attribution markers, a report hook, and a target_user fixture, plus three parity checks: user, ufw, dotfiles) and scripts/testing/lib/testinfra.sh, which injects a throwaway SSH key into the VM and runs pytest over SSH. The sweep is advisory here (RUN_TESTINFRA toggle, non-fatal) and does not yet affect pass/fail. Pulls python-pytest and python-pytest-testinfra into make deps. Verified on the host: py_compile clean, pytest --collect-only green, bash -n and shellcheck clean. The sweep running against a real VM is verified by the next make test run.
Diffstat (limited to 'scripts/testing')
-rw-r--r--scripts/testing/lib/testinfra.sh81
-rwxr-xr-xscripts/testing/run-test.sh6
-rw-r--r--scripts/testing/tests/conftest.py72
-rw-r--r--scripts/testing/tests/test_dotfiles.py19
-rw-r--r--scripts/testing/tests/test_services.py13
-rw-r--r--scripts/testing/tests/test_users.py18
6 files changed, 209 insertions, 0 deletions
diff --git a/scripts/testing/lib/testinfra.sh b/scripts/testing/lib/testinfra.sh
new file mode 100644
index 0000000..0db0ec9
--- /dev/null
+++ b/scripts/testing/lib/testinfra.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Testinfra post-install validation sweep (runs on the host, over SSH).
+#
+# P1 status: advisory. This runs alongside the shell sweep (run_all_validations)
+# so a real VM run can diff the two and prove parity before pytest becomes the
+# primary validator (P3 cutover). It never sets the run's pass/fail here.
+#
+# Auth: a throwaway ed25519 keypair is generated per run, its pubkey authorized
+# in the VM over the existing sshpass channel, and pytest/testinfra connects
+# key-only via a generated ssh-config. The keypair lives in the results dir and
+# is discarded with it.
+#
+# Uses globals from run-test.sh / vm-utils.sh: SCRIPT_DIR, VM_IP, SSH_PORT,
+# ROOT_PASSWORD, ARCHSETUP_VM_CONF. Toggle with RUN_TESTINFRA=false.
+
+# run_testinfra_validation <results_dir>
+run_testinfra_validation() {
+ local results_dir="$1"
+ local tests_dir="$SCRIPT_DIR/tests"
+ local key="$results_dir/testinfra_key"
+ local sshcfg="$results_dir/testinfra_ssh_config"
+
+ if [ "${RUN_TESTINFRA:-true}" != "true" ]; then
+ return 0
+ fi
+ if ! command -v pytest >/dev/null 2>&1 || ! python3 -c 'import testinfra' >/dev/null 2>&1; then
+ warn "Testinfra/pytest not installed on host - skipping pytest sweep (run: make deps)"
+ return 0
+ fi
+
+ step "Running Testinfra validation sweep (advisory)"
+
+ # Ephemeral keypair; authorize the pubkey in the VM over the existing channel.
+ rm -f "$key" "$key.pub"
+ if ! ssh-keygen -t ed25519 -N "" -q -f "$key"; then
+ warn "testinfra: ssh-keygen failed - skipping"
+ return 0
+ fi
+ if ! copy_to_vm "$key.pub" "/tmp/testinfra_key.pub" "$ROOT_PASSWORD"; then
+ warn "testinfra: pubkey copy failed - skipping"
+ return 0
+ fi
+ if ! vm_exec "$ROOT_PASSWORD" \
+ "mkdir -p /root/.ssh && chmod 700 /root/.ssh && cat /tmp/testinfra_key.pub >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys"; then
+ warn "testinfra: authorizing key in VM failed - skipping"
+ return 0
+ fi
+
+ # ssh-config so testinfra connects key-only, no host-key prompt.
+ cat > "$sshcfg" <<EOF
+Host testinfra-target
+ HostName ${VM_IP:-localhost}
+ Port ${SSH_PORT:-2222}
+ User root
+ IdentityFile $key
+ IdentitiesOnly yes
+ StrictHostKeyChecking no
+ UserKnownHostsFile /dev/null
+EOF
+
+ # The account archsetup created, for the tests that need it.
+ local test_user
+ test_user=$(sed -n 's/^USERNAME=//p' "$ARCHSETUP_VM_CONF" 2>/dev/null | head -n1)
+ : "${test_user:=cjennings}"
+
+ ARCHSETUP_TEST_USER="$test_user" pytest "$tests_dir" \
+ --hosts="ssh://testinfra-target" \
+ --ssh-config="$sshcfg" \
+ --attribution-file="$results_dir/testinfra-attribution.txt" \
+ -v >> "$results_dir/testinfra.log" 2>&1
+ local rc=$?
+
+ if [ "$rc" -eq 0 ]; then
+ success "Testinfra sweep passed (advisory; see testinfra.log)"
+ else
+ warn "Testinfra sweep reported failures (advisory; see testinfra.log + testinfra-attribution.txt)"
+ fi
+ return 0
+}
diff --git a/scripts/testing/run-test.sh b/scripts/testing/run-test.sh
index 9b47747..314097a 100755
--- a/scripts/testing/run-test.sh
+++ b/scripts/testing/run-test.sh
@@ -24,6 +24,7 @@ source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/vm-utils.sh"
source "$SCRIPT_DIR/lib/network-diagnostics.sh"
source "$SCRIPT_DIR/lib/validation.sh"
+source "$SCRIPT_DIR/lib/testinfra.sh"
# Parse arguments
KEEP_VM=false
@@ -321,6 +322,11 @@ set +e
run_all_validations
validate_all_services
+# Advisory Testinfra sweep alongside the shell sweep (P1). Compare the two on a
+# real run to confirm parity before pytest becomes primary. Does not affect
+# pass/fail yet.
+run_testinfra_validation "$TEST_RESULTS_DIR"
+
# Analyze log differences (pre vs post install)
analyze_log_diff "$TEST_RESULTS_DIR"
diff --git a/scripts/testing/tests/conftest.py b/scripts/testing/tests/conftest.py
new file mode 100644
index 0000000..00632b6
--- /dev/null
+++ b/scripts/testing/tests/conftest.py
@@ -0,0 +1,72 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Pytest + Testinfra config for archsetup post-install validation.
+
+These tests run on the *host* and connect to the freshly-installed VM over SSH
+(Testinfra provides the `host` fixture, parametrized from --hosts). This file
+adds two things the bespoke shell harness had that Testinfra does not:
+
+ - Failure attribution. Each check is marked with the layer that owns a
+ failure (archsetup | base_install | unknown), mirroring validation.sh's
+ attribute_issue. Failures are bucketed and written to --attribution-file
+ so run-test.sh can route base-install issues to the archzfs inbox as before.
+ - Tiering markers (smoke | integration) so `pytest -m smoke` is a fast gate.
+
+The `target_user` fixture supplies the account archsetup created; it reads
+ARCHSETUP_TEST_USER (set by run-test.sh from the VM conf) and defaults to the
+historical "cjennings".
+"""
+
+import os
+
+import pytest
+
+
+_ATTRIBUTION_BUCKETS = ("archsetup", "base_install", "unknown")
+_failures = {bucket: [] for bucket in _ATTRIBUTION_BUCKETS}
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--attribution-file",
+ action="store",
+ default=None,
+ help="write the failure attribution report (archsetup/base_install/unknown) here",
+ )
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "attribution(bucket): layer that owns a failure — archsetup, base_install, or unknown",
+ )
+ config.addinivalue_line("markers", "smoke: fast subset (user, key packages, dotfiles present)")
+ config.addinivalue_line("markers", "integration: full post-install checks")
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_makereport(item, call):
+ report = yield
+ if report.when == "call" and report.failed:
+ marker = item.get_closest_marker("attribution")
+ bucket = marker.args[0] if (marker and marker.args) else "archsetup"
+ if bucket not in _failures:
+ bucket = "unknown"
+ _failures[bucket].append(item.nodeid)
+ return report
+
+
+def pytest_sessionfinish(session, exitstatus):
+ path = session.config.getoption("--attribution-file")
+ if not path:
+ return
+ with open(path, "w") as fh:
+ for bucket in _ATTRIBUTION_BUCKETS:
+ fh.write("[%s]\n" % bucket)
+ for nodeid in _failures[bucket]:
+ fh.write(" %s\n" % nodeid)
+
+
+@pytest.fixture(scope="session")
+def target_user():
+ """The account archsetup created in the VM under test."""
+ return os.environ.get("ARCHSETUP_TEST_USER", "cjennings")
diff --git a/scripts/testing/tests/test_dotfiles.py b/scripts/testing/tests/test_dotfiles.py
new file mode 100644
index 0000000..cd6e474
--- /dev/null
+++ b/scripts/testing/tests/test_dotfiles.py
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: dotfiles stowed for the user.
+
+Parity port of validate_dotfiles from validation.sh: .zshrc must be a symlink
+into the ~/.dotfiles stow tree, not broken, and readable by the user (not just
+root).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_zshrc_stowed_and_readable(host, target_user):
+ zshrc = host.file("/home/%s/.zshrc" % target_user)
+ assert zshrc.is_symlink, ".zshrc should be a stow symlink"
+ assert ".dotfiles/" in zshrc.linked_to, "symlink should point into ~/.dotfiles"
+ assert zshrc.exists, "symlink target must exist (not broken)"
+ # Readable by the user, not only root.
+ assert host.run("sudo -u %s test -r %s" % (target_user, zshrc.path)).rc == 0
diff --git a/scripts/testing/tests/test_services.py b/scripts/testing/tests/test_services.py
new file mode 100644
index 0000000..dc89e74
--- /dev/null
+++ b/scripts/testing/tests/test_services.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: essential services archsetup enables.
+
+Parity port of validate_firewall from validation.sh (more to follow in P2).
+"""
+
+import pytest
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_ufw_firewall_enabled(host):
+ assert host.service("ufw").is_enabled
diff --git a/scripts/testing/tests/test_users.py b/scripts/testing/tests/test_users.py
new file mode 100644
index 0000000..92ce768
--- /dev/null
+++ b/scripts/testing/tests/test_users.py
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: the user account archsetup creates.
+
+Parity port of validate_user_created / validate_user_shell from validation.sh.
+"""
+
+import pytest
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_user_exists(host, target_user):
+ assert host.user(target_user).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_user_shell_is_zsh(host, target_user):
+ assert host.user(target_user).shell == "/usr/bin/zsh"