Skip to content
Merged
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
2 changes: 2 additions & 0 deletions tests/vm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<failures_dir>/<plan-id>.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
Expand Down
142 changes: 142 additions & 0 deletions tests/vm/vm-07-user-env-apply.sh
Original file line number Diff line number Diff line change
@@ -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" <<NIX
{ config, pkgs, ... }: {
home.username = "$TEST_USER";
home.homeDirectory = "/home/$TEST_USER";
home.stateVersion = "24.11";
home.packages = with pkgs; [ ];
programs.home-manager.enable = true;
}
NIX

cat > "$sandbox/repo/hosts/vm-test.ncl" <<NCL
{
meta = { hostname = "vm-test", timezone = "UTC", arch_level = "v3", locale = "en_US.UTF-8", keymap = "us" },
kernel = { package = "linux-cachyos" },
users = [
{
name = "$TEST_USER",
shell = "/bin/sh",
groups = [],
home_manager = {
enabled = true,
mode = "standalone",
config_path = "users/$TEST_USER",
channel = "release-24.11",
},
},
],
}
NCL

cat > "$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'
186 changes: 186 additions & 0 deletions tests/vm/vm-08-user-env-drift.sh
Original file line number Diff line number Diff line change
@@ -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" <<NIX
# version: $1
{ config, pkgs, ... }: {
home.username = "$TEST_USER";
home.homeDirectory = "/home/$TEST_USER";
home.stateVersion = "24.11";
home.packages = with pkgs; [ ];
programs.home-manager.enable = true;
}
NIX
}

cat > "$sandbox/repo/hosts/vm-test.ncl" <<NCL
{
meta = { hostname = "vm-test", timezone = "UTC", arch_level = "v3", locale = "en_US.UTF-8", keymap = "us" },
kernel = { package = "linux-cachyos" },
users = [
{
name = "$TEST_USER",
shell = "/bin/sh",
groups = [],
home_manager = {
enabled = true,
mode = "standalone",
config_path = "users/$TEST_USER",
channel = "release-24.11",
},
},
],
}
NCL

cat > "$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'
Loading