diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml new file mode 100644 index 0000000..9ba111e --- /dev/null +++ b/.github/workflows/install-test.yml @@ -0,0 +1,44 @@ +name: Test Patches - Post Build + +# Runs automatically after every "Test Release Build" run completes (that build +# is itself triggered by the 'make-test-build' label on a PR). Reuses the .deb +# that build produced — no rebuild — installs it on a runner seeded with the +# latest upstream Proxmox files, and verifies that every mod's patches (and +# post-apply hooks) apply and revert cleanly. Fails if any mod fails. +# +# Wired via workflow_run, so it fires for builds on ANY branch/PR. (GitHub always +# runs the default-branch copy of this file, so it activates once merged to main.) + +on: + workflow_run: + workflows: ["Test Release Build"] + types: [completed] + +permissions: + contents: read + actions: read # required to download the triggering run's artifact + +jobs: + patch-install-test: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Download deb from the build run + uses: actions/download-artifact@v4 + with: + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + pattern: pve-mods-test-build-* + merge-multiple: true + path: deb + + - name: Fetch upstream Proxmox files + run: bash src/Scripts/test/fetch-proxmox-files.sh + + - name: Install built package + run: sudo apt-get install -y ./deb/*.deb + + - name: Test all mods + run: sudo bash src/Scripts/test/test-mods.sh all diff --git a/.github/workflows/weekly-patch-test.yml b/.github/workflows/weekly-patch-test.yml new file mode 100644 index 0000000..2b239dd --- /dev/null +++ b/.github/workflows/weekly-patch-test.yml @@ -0,0 +1,110 @@ +name: Test Patches - Weekly + +# Weekly regression test of pve-mod against the latest upstream Proxmox VE files. +# Mods are auto-discovered from the [modules] section of src/pve-mod.conf and +# tested in parallel - adding a mod needs no change here. If a mod fails, an +# issue is opened (or updated) per failed mod explaining why. + +on: + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + mods: ${{ steps.list.outputs.mods }} + steps: + - uses: actions/checkout@v4 + + - name: Discover mods from [modules] + id: list + run: | + MODS=$(awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1 + }' src/pve-mod.conf | jq -R . | jq -cs .) + echo "Discovered mods: $MODS" + echo "mods=$MODS" >> "$GITHUB_OUTPUT" + + test: + needs: discover + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mod: ${{ fromJSON(needs.discover.outputs.mods) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── Build (mirrors test-release.yml) ─────────────────────────────────── + - name: Install build deps + run: sudo apt-get install -y devscripts debhelper build-essential + + - name: Inject version into PVEMod_Config.pm + run: | + VERSION=$(grep -m1 '(' debian/changelog | sed 's/.*(\(.*\)).*/\1/') + SHORT_SHA=$(git rev-parse --short HEAD) + export BUILD_VERSION="${VERSION}-test-${SHORT_SHA}" + perl -i -pe 'BEGIN { $v = $ENV{BUILD_VERSION} } + s/(our \$VERSION\s*=\s*'"'"')[^'"'"']*'"'"';/$1'"'"'$v'"'"';/ + ' src/PVENodeInfo/PVEMod_Config.pm + + - name: Build package + run: dpkg-buildpackage -us -uc -b + + # ── Test ─────────────────────────────────────────────────────────────── + - name: Fetch upstream Proxmox files + run: bash src/Scripts/test/fetch-proxmox-files.sh + + - name: Install built package + run: sudo apt-get install -y ../pve-mod_*.deb + + - name: Test mod + run: | + set -o pipefail + sudo bash src/Scripts/test/test-mods.sh "${{ matrix.mod }}" 2>&1 | tee test-output.log + + - name: Report failure as issue + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MOD: ${{ matrix.mod }} + run: | + TITLE="[patch-break] ${MOD}" + { + echo "The weekly patch test failed for mod \`${MOD}\` against the latest Proxmox VE files." + echo "" + echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "" + echo "
Test log" + echo "" + echo '```' + tail -n 200 test-output.log 2>/dev/null || echo "(no log captured)" + echo '```' + echo "
" + } > issue-body.md + + # Ensure the label exists (no-op if it already does). + gh label create patch-break --color B60205 \ + --description "pve-mod patch failed against upstream Proxmox" 2>/dev/null || true + + EXISTING=$(gh issue list --state open --label patch-break \ + --search "$TITLE in:title" --json number,title \ + --jq ".[] | select(.title==\"$TITLE\") | .number" | head -n1) + + if [[ -n "$EXISTING" ]]; then + gh issue comment "$EXISTING" --body-file issue-body.md + echo "Commented on existing issue #$EXISTING" + else + gh issue create --title "$TITLE" --label patch-break --body-file issue-body.md + echo "Opened new issue for mod $MOD" + fi diff --git a/src/Scripts/test/fetch-proxmox-files.sh b/src/Scripts/test/fetch-proxmox-files.sh new file mode 100644 index 0000000..282cfa7 --- /dev/null +++ b/src/Scripts/test/fetch-proxmox-files.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# src/Scripts/test/fetch-proxmox-files.sh +# +# Downloads the upstream Proxmox VE files that pve-mod patches and installs them +# at their real system paths, so the patch engine can be exercised on a plain +# (non-Proxmox) CI runner. +# +# Pulls pve-manager and proxmox-widget-toolkit from the Proxmox no-subscription +# repository (default: PVE 9 / Debian trixie) and extracts the target files. +# +# CI-only helper. The repo is added with [trusted=yes]: we are only fetching +# public, unmodified UI files to patch against, not installing Proxmox, so +# managing the signing key would add nothing. + +set -euo pipefail + +SUITE="${PVE_SUITE:-trixie}" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +# Files provided by the two packages, relative to the filesystem root. These are +# exactly the paths the .patch files (and the nag_screen post-apply hook) touch. +FILES=( + usr/share/perl5/PVE/API2/Nodes.pm + usr/share/pve-manager/js/pvemanagerlib.js + usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js + usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js +) + +echo "[fetch] Configuring Proxmox no-subscription repo ($SUITE)..." +echo "deb [trusted=yes] http://download.proxmox.com/debian/pve $SUITE pve-no-subscription" \ + | sudo tee /etc/apt/sources.list.d/pve-test.list >/dev/null +sudo apt-get update -qq + +echo "[fetch] Downloading pve-manager and proxmox-widget-toolkit..." +cd "$WORKDIR" +apt-get download pve-manager proxmox-widget-toolkit + +for deb in *.deb; do + echo "[fetch] Extracting $deb" + dpkg-deb -x "$deb" extract +done + +for rel in "${FILES[@]}"; do + src="extract/$rel" + dst="/$rel" + if [[ ! -f "$src" ]]; then + echo "[fetch] ERROR: expected file not found in packages: $rel" >&2 + exit 1 + fi + sudo install -Dm644 "$src" "$dst" + echo "[fetch] Installed $dst" +done + +echo "[fetch] Done." diff --git a/src/Scripts/test/test-mods.sh b/src/Scripts/test/test-mods.sh new file mode 100644 index 0000000..86d3bf1 --- /dev/null +++ b/src/Scripts/test/test-mods.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# src/Scripts/test/test-mods.sh +# +# Exercises pve-mod's patch engine against the installed Proxmox files. +# +# Usage: test-mods.sh | all +# +# Mods are auto-discovered from the [modules] section of the main config, so +# adding a new mod (a new [modules] key plus a patches// directory) is +# picked up automatically - no edits to this script are required. +# +# For each target mod it: +# 1. enables only that mod in the main config +# 2. turns on every conditional flag referenced by the mod's patches.list, +# so all of the mod's patches are exercised (e.g. node_info's gpu_history) +# 3. runs apply-patches.sh and asserts it exits 0 +# 4. runs revert-patches.sh and asserts it reports no unclean reversions +# 5. restores the pristine Proxmox files before moving to the next mod +# +# Failure detection relies on the apply exit code: the patch engine performs an +# atomic preflight dry-run and exits non-zero if any mod cannot apply cleanly. +# +# Must run as root (it edits /etc/pve-mod and the patched system files). + +set -u + +MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +APPLY="${PVE_MOD_APPLY:-/usr/lib/pve-mod/apply-patches.sh}" +REVERT="${PVE_MOD_REVERT:-/usr/lib/pve-mod/revert-patches.sh}" + +# Proxmox files touched by the patches; snapshotted before and restored after +# each mod so every mod is tested from a pristine baseline. +PROXMOX_FILES=( + /usr/share/perl5/PVE/API2/Nodes.pm + /usr/share/pve-manager/js/pvemanagerlib.js + /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js + /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js +) + +SNAP_DIR="$(mktemp -d)" +trap 'rm -rf "$SNAP_DIR"' EXIT + +info() { echo "[test] $*"; } +warn() { echo "[test] WARNING: $*" >&2; } + +# List the keys of the [modules] section in the main config, one per line. +list_modules() { + [[ -f "$MAIN_CONF" ]] || return 0 + awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + print $1 + } + ' "$MAIN_CONF" +} + +# set_conf
: set an existing key within a section. +set_conf() { + local file="$1" section="$2" key="$3" value="$4" + [[ -f "$file" ]] || { warn "config not found: $file"; return 1; } + awk -v sec="[$section]" -v k="$key" -v v="$value" ' + /^\[/ { in_sec = ($0 == sec) } + { + if (in_sec && $0 ~ "^[[:space:]]*"k"[[:space:]]*=") { + print k"="v + } else { + print + } + } + ' "$file" > "$file.tmp" && mv "$file.tmp" "$file" +} + +# Disable every discovered mod in the main config. +disable_all_modules() { + local m + for m in $(list_modules); do + set_conf "$MAIN_CONF" modules "$m" 0 + done +} + +# Turn on every conditional flag a mod's patches.list references, so all of its +# patches become active. Conditions look like: section.key=value +enable_conditions() { + local mod="$1" + local manifest="$PATCHES_DIR/$mod/patches.list" + local mod_conf="$CONFD_DIR/$mod.conf" + [[ -f "$manifest" ]] || return 0 + local line cond local_key want sect ckey + while IFS= read -r line; do + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + # Skip lines without a condition (no whitespace after the patch name). + [[ "$line" == *[[:space:]]* ]] || continue + cond="$(echo "${line#*[[:space:]]}" | sed -E 's/^[[:space:]]+//')" + [[ "$cond" == *=* ]] || continue + local_key="${cond%%=*}" + want="${cond#*=}" + sect="${local_key%%.*}" + ckey="${local_key#*.}" + set_conf "$mod_conf" "$sect" "$ckey" "$want" \ + && info " enabled condition $local_key=$want in $(basename "$mod_conf")" + done < "$manifest" +} + +snapshot_files() { + rm -rf "$SNAP_DIR"/*; mkdir -p "$SNAP_DIR" + local i=0 f + for f in "${PROXMOX_FILES[@]}"; do + if [[ -e "$f" || -L "$f" ]]; then + cp -a "$f" "$SNAP_DIR/$i" + fi + i=$((i+1)) + done +} + +restore_files() { + local i=0 f + for f in "${PROXMOX_FILES[@]}"; do + if [[ -e "$SNAP_DIR/$i" || -L "$SNAP_DIR/$i" ]]; then + rm -f "$f" + cp -a "$SNAP_DIR/$i" "$f" + fi + i=$((i+1)) + done +} + +# Test a single mod end to end. Returns 0 on success, 1 on failure. +test_one_mod() { + local mod="$1" + echo "::group::Testing mod: $mod" + local ok=0 + + if [[ ! -f "$PATCHES_DIR/$mod/patches.list" ]]; then + warn "no patch manifest for mod '$mod' ($PATCHES_DIR/$mod/patches.list)" + echo "::endgroup::" + return 1 + fi + + disable_all_modules + set_conf "$MAIN_CONF" modules "$mod" 1 + enable_conditions "$mod" + snapshot_files + + info "Applying patches for '$mod'..." + local apply_log apply_rc + apply_log="$("$APPLY" 2>&1)"; apply_rc=$? + echo "$apply_log" + if [[ $apply_rc -ne 0 ]]; then + warn "apply-patches.sh failed for mod '$mod' (exit $apply_rc)" + ok=1 + fi + + if [[ $ok -eq 0 ]]; then + info "Reverting patches for '$mod'..." + local revert_log + revert_log="$("$REVERT" 2>&1)" + echo "$revert_log" + if echo "$revert_log" | grep -qE "could not be reverted cleanly|reported an error"; then + warn "revert-patches.sh reported an unclean revert for mod '$mod'" + ok=1 + fi + fi + + restore_files + + if [[ $ok -eq 0 ]]; then + info "PASS: $mod" + else + warn "FAIL: $mod" + fi + echo "::endgroup::" + return $ok +} + +# ── main ────────────────────────────────────────────────────────────────────── +[[ $# -eq 1 ]] || { echo "Usage: $0 |all" >&2; exit 2; } + +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; install the 'patch' package." + exit 1 +fi + +targets=() +if [[ "$1" == "all" ]]; then + mapfile -t targets < <(list_modules) + [[ ${#targets[@]} -gt 0 ]] || { warn "no mods found in [modules] of $MAIN_CONF"; exit 1; } +else + targets=("$1") +fi + +failed=() +for mod in "${targets[@]}"; do + test_one_mod "$mod" || failed+=("$mod") +done + +echo "" +if [[ ${#failed[@]} -gt 0 ]]; then + warn "Failed mods: ${failed[*]}" + exit 1 +fi +info "All tested mods passed: ${targets[*]}" +exit 0