From ae39de940a35530323cef0dbef82fc2c2811318a Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:50:02 +0300 Subject: [PATCH] test(vm): vm-07 user-env apply + vm-08 user-env drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.4 W2 — closes the M3 W2 VM-tier line items "vm-07 user-env apply, vm-08 user-env drift". vm-07 declares one HM-enabled user with an inline home.nix fixture, applies, and verifies: - apply exit 0, actions_executed >= 1 - state.toml grew a [[managed.user_env]] entry for $PEARLITE_VM_USER with a non-empty 64-hex config_hash vm-08 exercises the drift loop end-to-end: 1. Apply with home.nix v1 → state.toml records hash_v1. 2. Re-plan (no edits) → no user_env_switch action emitted (idempotency). 3. Mutate home.nix to v2 → re-plan emits a user_env_switch action. 4. Apply v2 → state.toml's config_hash flips to hash_v2 AND managed.user_env still contains exactly one row for the user (upsert per PR #50, not append). Both scripts: - POSIX sh; shellcheck-clean. - Mutating, gated behind PEARLITE_VM_TEST=1 (the runner enforces). - Honour $PEARLITE_VM_USER (default `pearlite-vm`) for the test account; fail fast with a clear error if that user doesn't exist rather than calling useradd from inside the script (cleaner: the VM image owns user provisioning). - Use grep over the toml/json output rather than jaq so they run on minimal CachyOS images, matching the convention from vm-02..06. vm-09 (Nix bootstrap via DeterminateNixInstaller) defers — the LiveNixInstaller adapter exists but no caller wires it yet; the bootstrap-via-curl-pipe behaviour belongs in M3 follow-up. README updated with the new rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/vm/README.md | 2 + tests/vm/vm-07-user-env-apply.sh | 142 +++++++++++++++++++++++ tests/vm/vm-08-user-env-drift.sh | 186 +++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100755 tests/vm/vm-07-user-env-apply.sh create mode 100755 tests/vm/vm-08-user-env-drift.sh diff --git a/tests/vm/README.md b/tests/vm/README.md index 32adda3..39f6883 100644 --- a/tests/vm/README.md +++ b/tests/vm/README.md @@ -19,6 +19,8 @@ runner is gated by ADR-0009 (CachyOS-fidelity CI runner choice). | `vm-04-config-write.sh` | mutating | Config-file write — declares `/etc/pearlite-vm-test-04.conf`, applies, asserts target SHA-256 matches source. | | `vm-05-rollback.sh` | mutating | Apply → rollback round-trip — asserts `pacman -Qe tree` is gone after the snapper revert restores the pre-apply subvolume. | | `vm-06-failure-record.sh` | mutating | Induced `APPLY_SHA_MISMATCH` failure — asserts exit 4, forensic JSON at `/.json`, and that `gen show` surfaces the SHA-256 message. | +| `vm-07-user-env-apply.sh` | mutating | Per-user `home-manager switch` via `pearlite apply` (PRD §8.2 phase 7). Asserts `state.toml`'s `[[managed.user_env]]` records the user's `config_hash` after the switch. Requires `home-manager` on `PATH` and the `$PEARLITE_VM_USER` (default `pearlite-vm`) login present on the VM. | +| `vm-08-user-env-drift.sh` | mutating | Drift detection — apply v1, idempotent re-plan emits no action, mutate `home.nix` to v2, re-plan emits `user_env_switch`, apply v2 updates `state.toml` `config_hash` and keeps `managed.user_env` at one row per user (upsert). | Mutating scripts refuse to run unless `PEARLITE_VM_TEST=1` is set in the environment — they install/remove packages, write to `/etc`, and diff --git a/tests/vm/vm-07-user-env-apply.sh b/tests/vm/vm-07-user-env-apply.sh new file mode 100755 index 0000000..ef02785 --- /dev/null +++ b/tests/vm/vm-07-user-env-apply.sh @@ -0,0 +1,142 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Mohamed Hammad +# +# vm-07: per-user `home-manager switch` via `pearlite apply` (PRD §8.2 +# phase 7). +# +# Declares one HM-enabled user pointing at a tiny inline home.nix +# fixture, applies, and verifies: +# +# - apply exit 0 +# - data.actions_executed >= 1 +# - state.toml's [[managed.user_env]] grew an entry for the user +# with a non-empty config_hash +# +# Prereqs (in addition to the vm-01 prereqs): +# - home-manager installed and on PATH (paru -S home-manager-flake) +# - the test user owns $HOME and can write a profile symlink +# +# Mutating: requires PEARLITE_VM_TEST=1. Runs `home-manager switch` +# under the configured test user — disposable VM only. +# +# POSIX sh; no Bash-isms. + +set -eu + +if [ "${PEARLITE_VM_TEST:-}" != "1" ]; then + printf 'vm-07: refusing to run without PEARLITE_VM_TEST=1\n' >&2 + exit 2 +fi + +PEARLITE_BIN="${PEARLITE_BIN:-}" +TEST_USER="${PEARLITE_VM_USER:-pearlite-vm}" +sandbox=$(mktemp -d) +trap 'rm -rf "$sandbox"' EXIT INT TERM + +if [ -z "$PEARLITE_BIN" ]; then + cargo build --quiet --release -p pearlite-cli + PEARLITE_BIN="$(cargo metadata --format-version=1 --no-deps \ + | grep -o '"target_directory":"[^"]*"' \ + | head -1 \ + | cut -d'"' -f4)/release/pearlite" +fi + +# Verify the test user exists on this host. We don't create one; that's +# the VM image's responsibility (cleaner than scripting useradd here). +if ! id -u "$TEST_USER" >/dev/null 2>&1; then + printf 'vm-07: PEARLITE_VM_USER=%s does not exist on this host\n' "$TEST_USER" >&2 + exit 2 +fi + +mkdir -p "$sandbox/repo/hosts" "$sandbox/repo/users/$TEST_USER" +cat > "$sandbox/repo/users/$TEST_USER/home.nix" < "$sandbox/repo/hosts/vm-test.ncl" < "$sandbox/state.toml" <<'TOML' +schema_version = 1 +host = "vm-test" +tool_version = "0.1.0" +config_dir = "/tmp/repo" + +[managed] +pacman = [] +cargo = [] + +[adopted] +pacman = [] +cargo = [] +TOML + +stdout="$sandbox/apply.json" +"$PEARLITE_BIN" \ + --format=json \ + --config-dir="$sandbox/repo" \ + --state-file="$sandbox/state.toml" \ + apply \ + --host-file="$sandbox/repo/hosts/vm-test.ncl" \ + --snapper-config=root \ + > "$stdout" || { + printf 'vm-07: apply exit %s\n' "$?" >&2 + cat "$stdout" >&2 + exit 1 + } + +# Substring assertions — keep vm-07 jaq-free for portability. +fail=0 +if ! grep -q '"actions_executed":1' "$stdout"; then + if ! grep -q '"actions_executed":2' "$stdout"; then + printf 'vm-07: expected at least one action executed\n' >&2 + fail=1 + fi +fi + +# state.toml grew a [[managed.user_env]] entry for $TEST_USER with a +# non-empty config_hash. Use grep over the toml; avoids a jaq dep. +if ! grep -q "\\[\\[managed.user_env\\]\\]" "$sandbox/state.toml"; then + printf 'vm-07: state.toml has no [[managed.user_env]] section\n' >&2 + fail=1 +elif ! grep -q "user = \"$TEST_USER\"" "$sandbox/state.toml"; then + printf 'vm-07: no managed.user_env entry for %s\n' "$TEST_USER" >&2 + fail=1 +elif ! grep -E -q 'config_hash = "[a-f0-9]{64}"' "$sandbox/state.toml"; then + printf 'vm-07: managed.user_env.config_hash is empty or malformed\n' >&2 + fail=1 +fi + +if [ "$fail" -ne 0 ]; then + printf 'vm-07: assertions failed; full apply output:\n' >&2 + cat "$stdout" >&2 + printf '\nstate.toml:\n' >&2 + cat "$sandbox/state.toml" >&2 + exit 1 +fi + +printf 'vm-07: user-env apply PASS\n' diff --git a/tests/vm/vm-08-user-env-drift.sh b/tests/vm/vm-08-user-env-drift.sh new file mode 100755 index 0000000..d502e53 --- /dev/null +++ b/tests/vm/vm-08-user-env-drift.sh @@ -0,0 +1,186 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Mohamed Hammad +# +# vm-08: user-env config-hash drift detection. +# +# Three steps: +# +# 1. Apply with home.nix v1. state.toml records the v1 config_hash. +# 2. Re-plan without changing anything → no UserEnvSwitch action +# (idempotent). +# 3. Mutate home.nix to v2. Re-plan → expect ONE UserEnvSwitch in +# the actions list (drift detected via sha256_dir mismatch). +# Apply v2 → state.toml's config_hash changes to the new value +# and managed.user_env still has exactly one row for the user +# (upsert, not append). +# +# Mutating: requires PEARLITE_VM_TEST=1. +# +# POSIX sh; no Bash-isms. + +set -eu + +if [ "${PEARLITE_VM_TEST:-}" != "1" ]; then + printf 'vm-08: refusing to run without PEARLITE_VM_TEST=1\n' >&2 + exit 2 +fi + +PEARLITE_BIN="${PEARLITE_BIN:-}" +TEST_USER="${PEARLITE_VM_USER:-pearlite-vm}" +sandbox=$(mktemp -d) +trap 'rm -rf "$sandbox"' EXIT INT TERM + +if [ -z "$PEARLITE_BIN" ]; then + cargo build --quiet --release -p pearlite-cli + PEARLITE_BIN="$(cargo metadata --format-version=1 --no-deps \ + | grep -o '"target_directory":"[^"]*"' \ + | head -1 \ + | cut -d'"' -f4)/release/pearlite" +fi + +if ! id -u "$TEST_USER" >/dev/null 2>&1; then + printf 'vm-08: PEARLITE_VM_USER=%s does not exist on this host\n' "$TEST_USER" >&2 + exit 2 +fi + +mkdir -p "$sandbox/repo/hosts" "$sandbox/repo/users/$TEST_USER" + +write_home_nix() { + # $1 is a sentinel comment so the file's bytes (and therefore the + # sha256_dir hash) change between v1 and v2. + cat > "$sandbox/repo/users/$TEST_USER/home.nix" < "$sandbox/repo/hosts/vm-test.ncl" < "$sandbox/state.toml" <<'TOML' +schema_version = 1 +host = "vm-test" +tool_version = "0.1.0" +config_dir = "/tmp/repo" + +[managed] +pacman = [] +cargo = [] + +[adopted] +pacman = [] +cargo = [] +TOML + +write_home_nix v1 + +# Step 1: first apply. +"$PEARLITE_BIN" \ + --format=json \ + --config-dir="$sandbox/repo" \ + --state-file="$sandbox/state.toml" \ + apply \ + --host-file="$sandbox/repo/hosts/vm-test.ncl" \ + --snapper-config=root \ + > "$sandbox/apply-v1.json" || { + printf 'vm-08: step 1 apply failed:\n' >&2 + cat "$sandbox/apply-v1.json" >&2 + exit 1 + } + +hash_v1=$(grep -E -o 'config_hash = "[a-f0-9]{64}"' "$sandbox/state.toml" | head -1) + +# Step 2: idempotent re-plan with v1 unchanged should produce no actions. +"$PEARLITE_BIN" \ + --format=json \ + --config-dir="$sandbox/repo" \ + --state-file="$sandbox/state.toml" \ + plan \ + --host-file="$sandbox/repo/hosts/vm-test.ncl" \ + > "$sandbox/plan-idempotent.json" || { + printf 'vm-08: idempotent plan failed:\n' >&2 + cat "$sandbox/plan-idempotent.json" >&2 + exit 1 + } +if grep -q '"user_env_switch"' "$sandbox/plan-idempotent.json"; then + printf 'vm-08: idempotent re-plan emitted a user_env_switch (drift false-positive)\n' >&2 + cat "$sandbox/plan-idempotent.json" >&2 + exit 1 +fi + +# Step 3: mutate home.nix → re-plan should see drift. +write_home_nix v2 + +"$PEARLITE_BIN" \ + --format=json \ + --config-dir="$sandbox/repo" \ + --state-file="$sandbox/state.toml" \ + plan \ + --host-file="$sandbox/repo/hosts/vm-test.ncl" \ + > "$sandbox/plan-drifted.json" || { + printf 'vm-08: drift plan failed:\n' >&2 + cat "$sandbox/plan-drifted.json" >&2 + exit 1 + } +if ! grep -q '"user_env_switch"' "$sandbox/plan-drifted.json"; then + printf 'vm-08: drift plan did NOT emit a user_env_switch action:\n' >&2 + cat "$sandbox/plan-drifted.json" >&2 + exit 1 +fi + +# Apply v2. +"$PEARLITE_BIN" \ + --format=json \ + --config-dir="$sandbox/repo" \ + --state-file="$sandbox/state.toml" \ + apply \ + --host-file="$sandbox/repo/hosts/vm-test.ncl" \ + --snapper-config=root \ + > "$sandbox/apply-v2.json" || { + printf 'vm-08: step 3 apply failed:\n' >&2 + cat "$sandbox/apply-v2.json" >&2 + exit 1 + } + +hash_v2=$(grep -E -o 'config_hash = "[a-f0-9]{64}"' "$sandbox/state.toml" | head -1) +if [ "$hash_v1" = "$hash_v2" ]; then + printf 'vm-08: state.toml config_hash unchanged after drift apply\n' >&2 + printf ' v1: %s\n v2: %s\n' "$hash_v1" "$hash_v2" >&2 + exit 1 +fi + +# managed.user_env must still contain exactly one row for $TEST_USER +# (upsert, not append). +count=$(grep -c "user = \"$TEST_USER\"" "$sandbox/state.toml" || true) +if [ "$count" -ne 1 ]; then + printf 'vm-08: managed.user_env has %s rows for %s, expected 1 (upsert violated)\n' \ + "$count" "$TEST_USER" >&2 + cat "$sandbox/state.toml" >&2 + exit 1 +fi + +printf 'vm-08: user-env drift detection PASS\n'