diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-25 00:54:53 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-25 00:54:53 -0400 |
| commit | 99a26d7de23bbfc757957c08e47606c3690df4cb (patch) | |
| tree | 614b63abef7a5fdfb65d3e948788588632662f9e /scripts/testing | |
| parent | 133d036aaa8cbe7523d217f2174fb9de191b61a9 (diff) | |
| download | archsetup-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.sh | 81 | ||||
| -rwxr-xr-x | scripts/testing/run-test.sh | 6 | ||||
| -rw-r--r-- | scripts/testing/tests/conftest.py | 72 | ||||
| -rw-r--r-- | scripts/testing/tests/test_dotfiles.py | 19 | ||||
| -rw-r--r-- | scripts/testing/tests/test_services.py | 13 | ||||
| -rw-r--r-- | scripts/testing/tests/test_users.py | 18 |
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" |
