From ebddd792b241fcd0697982f8cbb7f6c4fef481b6 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 16:41:16 -0500 Subject: [PATCH] update security --- README.md | 24 ++++ defaults.inc | 4 +- inc/cluster-update.inc | 128 +++++++++++++++++++++ inc/fleet.inc | 210 +++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 3 +- run.sh | 11 +- tests/test-cluster-update.sh | 65 +++++++++++ tests/test-fleet.sh | 89 +++++++++++++++ 8 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 inc/cluster-update.inc create mode 100644 inc/fleet.inc create mode 100644 tests/test-cluster-update.sh create mode 100644 tests/test-fleet.sh diff --git a/README.md b/README.md index bd33e26..e9f7180 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ interactive menu. Update availability is checked in the background and cached; updates are installed only when explicitly selected or requested with `tapm update`. +On a clustered Proxmox host, both `tapm update` and the management-menu update +update every online cluster node over Proxmox's root SSH trust, then update the +initiating node. Each node independently performs the same clean-worktree, +branch, and fast-forward safety checks. Offline, unreachable, dirty, ahead, or +diverged nodes are never overwritten and are reported as failures. Standalone +hosts retain the local-only update behavior. + When testing this branch, use `tapm main` to switch the installed copy back to the published main branch. The command refuses to switch when local changes, local-only commits, or diverged branch history would be at risk. @@ -159,3 +166,20 @@ both values. Inputs are validated and written atomically with mode `0600` before update checks or menu actions continue. A non-interactive launch without valid configuration stops with an explicit setup message instead of selecting a default company URL. + +## Installation registry + +V2 creates a random installation UUID and 256-bit credential in +`/var/lib/ta-proxmenu/identity.env`. The directory is mode `0700` and the file +is mode `0600`. On each interactive launch, TA-ProxMenu makes best-effort HTTPS +calls to the configured broker to record installation/upgrade state, start and +completion status, duration, TA-ProxMenu and Git versions, PVE/OS/kernel +versions, architecture, and whether the host is clustered. No background +service is installed, and broker availability never prevents the menu from +running. + +The registry does not send hostnames, machine IDs, MAC addresses, usernames, +customer names, VM/container inventory, deployment codes, authorization +tokens, or command output. The broker stores only a digest of the random host +credential. A successful deployment-code exchange links the already-random +installation ID to a verified registry entry. diff --git a/defaults.inc b/defaults.inc index b81e636..074964a 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-5' +VERS='2026.7.26-7' noupdate=' ' @@ -21,6 +21,8 @@ PULSE_PORT='7655' source "${FOLDER}/inc/runtime-config.inc" TAPM_ENSURE_RUNTIME_CONFIG || return 1 2>/dev/null || exit 1 +source "${FOLDER}/inc/fleet.inc" +TAPM_FLEET_ENSURE_IDENTITY || true S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' diff --git a/inc/cluster-update.inc b/inc/cluster-update.inc new file mode 100644 index 0000000..f4133d0 --- /dev/null +++ b/inc/cluster-update.inc @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Cluster discovery and remote update helpers for TA-ProxMenu. + +TAPM_COROSYNC_CONFIG="${TAPM_COROSYNC_CONFIG:-/etc/pve/corosync.conf}" + +TAPM_CLUSTER_NODES_FROM_JSON() { + python3 -c ' +import json, re, sys +try: + payload = json.load(sys.stdin) +except (json.JSONDecodeError, OSError): + raise SystemExit(1) +if isinstance(payload, dict): + payload = payload.get("data", []) +if not isinstance(payload, list): + raise SystemExit(1) +valid_name = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$") +rows = [] +for item in payload: + if not isinstance(item, dict): + continue + name = str(item.get("node", "")).strip() + status = str(item.get("status", "unknown")).strip().lower() + if valid_name.fullmatch(name): + rows.append((name, status)) +for name, status in sorted(rows): + print(f"{name}\t{status}") +' +} + +TAPM_CLUSTER_NODE_ROWS() { + local node_json + + node_json="$(timeout 10 pvesh get /nodes --output-format json 2>/dev/null)" || + return 1 + printf '%s' "$node_json" | TAPM_CLUSTER_NODES_FROM_JSON +} + +TAPM_LOCAL_CLUSTER_NODE() { + local local_link='' + + local_link="$(readlink /etc/pve/local 2>/dev/null || true)" + if [[ -n "$local_link" ]]; then + basename "$local_link" + else + hostname -s + fi +} + +TAPM_UPDATE_REMOTE_NODE() { + local node="$1" + + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || return 1 + timeout 240 ssh \ + -o BatchMode=yes \ + -o ConnectTimeout=10 \ + -o ServerAliveInterval=15 \ + -o ServerAliveCountMax=2 \ + "root@${node}" \ + '/opt/idssys/ta-proxmenu/run.sh update --local-only' +} + +INSTALL_CLUSTER_UPDATES() { + local index + local local_node + local node + local required_command + local status + local node_rows='' + local cluster_failed=0 + local -a cluster_nodes=() + local -a cluster_statuses=() + + if [[ ! -s "$TAPM_COROSYNC_CONFIG" ]]; then + INSTALL_LOCAL_UPDATES + return $? + fi + for required_command in pvesh python3 ssh timeout; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Cluster update requires ${required_command}; no nodes were updated.${idsCL[Default]}" + return 1 + fi + done + node_rows="$(TAPM_CLUSTER_NODE_ROWS)" || { + echo -e "${idsCL[Red]}Could not retrieve the Proxmox cluster node list; no nodes were updated.${idsCL[Default]}" + return 1 + } + while IFS=$'\t' read -r node status; do + [[ -n "$node" ]] || continue + cluster_nodes+=("$node") + cluster_statuses+=("$status") + done <<<"$node_rows" + if (( ${#cluster_nodes[@]} == 0 )); then + echo -e "${idsCL[Red]}The Proxmox API returned no cluster nodes; no nodes were updated.${idsCL[Default]}" + return 1 + fi + + local_node="$(TAPM_LOCAL_CLUSTER_NODE)" + echo -e "${idsCL[LightCyan]}Updating TA-ProxMenu across ${#cluster_nodes[@]} cluster node(s)...${idsCL[Default]}" + for index in "${!cluster_nodes[@]}"; do + node="${cluster_nodes[$index]}" + status="${cluster_statuses[$index]}" + if [[ "$node" == "$local_node" ]]; then + continue + fi + if [[ "$status" != "online" ]]; then + echo -e "${idsCL[LightYellow]}Skipping ${node}: node status is ${status}.${idsCL[Default]}" + cluster_failed=1 + continue + fi + echo -e "${idsCL[LightCyan]}Updating remote node ${node}...${idsCL[Default]}" + if TAPM_UPDATE_REMOTE_NODE "$node"; then + echo -e "${idsCL[Green]}Remote node ${node} is updated.${idsCL[Default]}" + else + echo -e "${idsCL[Red]}Remote node ${node} failed to update.${idsCL[Default]}" + cluster_failed=1 + fi + done + + echo -e "${idsCL[LightCyan]}Updating local node ${local_node}...${idsCL[Default]}" + INSTALL_LOCAL_UPDATES || cluster_failed=1 + if (( cluster_failed == 0 )); then + echo -e "${idsCL[Green]}TA-ProxMenu is updated on all ${#cluster_nodes[@]} cluster node(s).${idsCL[Default]}" + return 0 + fi + echo -e "${idsCL[LightYellow]}Cluster update completed with failures; review the node messages above.${idsCL[Default]}" + return 1 +} diff --git a/inc/fleet.inc b/inc/fleet.inc new file mode 100644 index 0000000..801bb06 --- /dev/null +++ b/inc/fleet.inc @@ -0,0 +1,210 @@ +#!/usr/bin/env bash + +TAPM_FLEET_STATE_DIR="${TAPM_FLEET_STATE_DIR:-/var/lib/ta-proxmenu}" +TAPM_FLEET_IDENTITY_FILE="${TAPM_FLEET_IDENTITY_FILE:-${TAPM_FLEET_STATE_DIR}/identity.env}" +TAPM_FLEET_INSTALLATION_ID='' +TAPM_FLEET_CREDENTIAL='' +TAPM_FLEET_LAST_VERSION='' +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_STARTED_AT=0 +TAPM_FLEET_VERSION='' + +TAPM_FLEET_READ_VALUE() { + local key="$1" + local value='' + if [[ -r "$TAPM_FLEET_IDENTITY_FILE" ]]; then + value="$( + sed -n "s/^${key}=//p" "$TAPM_FLEET_IDENTITY_FILE" | + tail -n 1 + )" + fi + printf '%s' "$value" +} + +TAPM_FLEET_LOAD_IDENTITY() { + TAPM_FLEET_INSTALLATION_ID="$(TAPM_FLEET_READ_VALUE INSTALLATION_ID)" + TAPM_FLEET_CREDENTIAL="$(TAPM_FLEET_READ_VALUE CREDENTIAL)" + TAPM_FLEET_LAST_VERSION="$(TAPM_FLEET_READ_VALUE LAST_VERSION)" +} + +TAPM_FLEET_VALID_IDENTITY() { + [[ "$TAPM_FLEET_INSTALLATION_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ && + "$TAPM_FLEET_CREDENTIAL" =~ ^[0-9a-f]{64}$ ]] +} + +TAPM_FLEET_WRITE_IDENTITY() { + local temporary_file + + mkdir -p "$TAPM_FLEET_STATE_DIR" 2>/dev/null || return 1 + chmod 0700 "$TAPM_FLEET_STATE_DIR" 2>/dev/null || return 1 + temporary_file="$(mktemp "${TAPM_FLEET_IDENTITY_FILE}.tmp.XXXXXX")" || return 1 + if ! { + printf 'INSTALLATION_ID=%s\n' "$TAPM_FLEET_INSTALLATION_ID" + printf 'CREDENTIAL=%s\n' "$TAPM_FLEET_CREDENTIAL" + printf 'LAST_VERSION=%s\n' "$TAPM_FLEET_LAST_VERSION" + } >"$temporary_file" || + ! chmod 0600 "$temporary_file" || + ! mv -f "$temporary_file" "$TAPM_FLEET_IDENTITY_FILE"; then + rm -f "$temporary_file" + return 1 + fi +} + +TAPM_FLEET_ENSURE_IDENTITY() { + TAPM_FLEET_LOAD_IDENTITY + TAPM_FLEET_VALID_IDENTITY && return 0 + command -v openssl >/dev/null 2>&1 || return 1 + if [[ -r /proc/sys/kernel/random/uuid ]]; then + IFS= read -r TAPM_FLEET_INSTALLATION_ID /dev/null 2>&1; then + TAPM_FLEET_INSTALLATION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" + else + return 1 + fi + TAPM_FLEET_CREDENTIAL="$(openssl rand -hex 32)" || return 1 + TAPM_FLEET_LAST_VERSION='' + TAPM_FLEET_WRITE_IDENTITY +} + +TAPM_FLEET_COLLECT_METADATA() { + TAPM_FLEET_GIT_COMMIT="$(git -C "${FOLDER:-/opt/idssys/ta-proxmenu}" rev-parse HEAD 2>/dev/null || true)" + TAPM_FLEET_PVE_VERSION="$(pveversion 2>/dev/null | head -n 1 || true)" + TAPM_FLEET_OS_VERSION="$( + if [[ -r /etc/os-release ]]; then + ( + # shellcheck disable=SC1091 + source /etc/os-release + printf '%s' "${PRETTY_NAME:-}" + ) + fi + )" + TAPM_FLEET_KERNEL_VERSION="$(uname -r 2>/dev/null || true)" + TAPM_FLEET_ARCHITECTURE="$( + dpkg --print-architecture 2>/dev/null || + uname -m 2>/dev/null || + true + )" + if [[ -s /etc/pve/corosync.conf ]]; then + TAPM_FLEET_CLUSTERED=true + else + TAPM_FLEET_CLUSTERED=false + fi +} + +TAPM_FLEET_JSON() { + local event="$1" + local result="$2" + local error_code="${3:-}" + local duration="${4:-0}" + + TAPM_FLEET_EVENT="$event" \ + TAPM_FLEET_RESULT="$result" \ + TAPM_FLEET_ERROR_CODE="$error_code" \ + TAPM_FLEET_DURATION="$duration" \ + TAPM_FLEET_INSTALLATION_ID="$TAPM_FLEET_INSTALLATION_ID" \ + TAPM_FLEET_CREDENTIAL="$TAPM_FLEET_CREDENTIAL" \ + TAPM_FLEET_VERSION="$TAPM_FLEET_VERSION" \ + TAPM_FLEET_GIT_COMMIT="$TAPM_FLEET_GIT_COMMIT" \ + TAPM_FLEET_PVE_VERSION="$TAPM_FLEET_PVE_VERSION" \ + TAPM_FLEET_OS_VERSION="$TAPM_FLEET_OS_VERSION" \ + TAPM_FLEET_KERNEL_VERSION="$TAPM_FLEET_KERNEL_VERSION" \ + TAPM_FLEET_ARCHITECTURE="$TAPM_FLEET_ARCHITECTURE" \ + TAPM_FLEET_CLUSTERED="$TAPM_FLEET_CLUSTERED" \ + TAPM_FLEET_INCLUDE_CREDENTIAL="${TAPM_FLEET_INCLUDE_CREDENTIAL:-0}" \ + python3 -c ' +import json, os, sys +payload = { + "schema_version": 1, + "installation_id": os.environ["TAPM_FLEET_INSTALLATION_ID"], + "event": os.environ["TAPM_FLEET_EVENT"], + "result": os.environ["TAPM_FLEET_RESULT"], + "proxmenu_version": os.environ["TAPM_FLEET_VERSION"], + "git_commit": os.environ["TAPM_FLEET_GIT_COMMIT"], + "pve_version": os.environ["TAPM_FLEET_PVE_VERSION"], + "os_version": os.environ["TAPM_FLEET_OS_VERSION"], + "kernel_version": os.environ["TAPM_FLEET_KERNEL_VERSION"], + "architecture": os.environ["TAPM_FLEET_ARCHITECTURE"], + "clustered": os.environ["TAPM_FLEET_CLUSTERED"] == "true", + "error_code": os.environ["TAPM_FLEET_ERROR_CODE"], + "duration_seconds": int(os.environ["TAPM_FLEET_DURATION"]), +} +if os.environ.get("TAPM_FLEET_INCLUDE_CREDENTIAL") == "1": + payload["credential"] = os.environ["TAPM_FLEET_CREDENTIAL"] +json.dump(payload, sys.stdout, separators=(",", ":")) +' +} + +TAPM_FLEET_REGISTER() { + TAPM_FLEET_INCLUDE_CREDENTIAL=1 TAPM_FLEET_JSON installed success | + curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 10 \ + --header 'Content-Type: application/json' \ + --data-binary @- \ + "${TAPM_BROKER_URL}/api/v1/hosts/register" \ + >/dev/null 2>&1 +} + +TAPM_FLEET_EVENT_SEND() { + local event="$1" + local result="$2" + local error_code="${3:-}" + local duration="${4:-0}" + local event_payload='' + + (( TAPM_FLEET_REGISTERED == 1 )) || return 0 + event_payload="$(mktemp "${TMPDIR:-/tmp}/tapm-fleet-event.XXXXXX")" || return 0 + chmod 0600 "$event_payload" 2>/dev/null || { + rm -f "$event_payload" + return 0 + } + if ! TAPM_FLEET_JSON "$event" "$result" "$error_code" "$duration" >"$event_payload"; then + rm -f "$event_payload" + return 0 + fi + printf 'header = "Content-Type: application/json"\nheader = "Authorization: Bearer %s"\nurl = "%s/api/v1/hosts/events"\n' \ + "$TAPM_FLEET_CREDENTIAL" "$TAPM_BROKER_URL" | + curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 10 \ + --config - --data-binary "@${event_payload}" \ + >/dev/null 2>&1 || true + rm -f "$event_payload" +} + +TAPM_FLEET_START() { + TAPM_FLEET_VERSION="$1" + TAPM_FLEET_STARTED_AT="$(date +%s)" + TAPM_FLEET_ENSURE_IDENTITY || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + command -v curl >/dev/null 2>&1 || return 0 + TAPM_FLEET_COLLECT_METADATA + if TAPM_FLEET_REGISTER; then + TAPM_FLEET_REGISTERED=1 + else + return 0 + fi + if [[ -n "$TAPM_FLEET_LAST_VERSION" && + "$TAPM_FLEET_LAST_VERSION" != "$TAPM_FLEET_VERSION" ]]; then + TAPM_FLEET_EVENT_SEND upgraded success + fi + TAPM_FLEET_EVENT_SEND run_started started +} + +TAPM_FLEET_FINISH() { + local exit_status="${1:-0}" + local duration=0 + + if [[ "$TAPM_FLEET_STARTED_AT" =~ ^[0-9]+$ ]] && + (( TAPM_FLEET_STARTED_AT > 0 )); then + duration="$(( $(date +%s) - TAPM_FLEET_STARTED_AT ))" + fi + if (( exit_status == 0 )); then + TAPM_FLEET_EVENT_SEND run_completed success '' "$duration" + else + TAPM_FLEET_EVENT_SEND run_failed failure exit_nonzero "$duration" + fi + if (( TAPM_FLEET_REGISTERED == 1 )) && TAPM_FLEET_VALID_IDENTITY; then + TAPM_FLEET_LAST_VERSION="$TAPM_FLEET_VERSION" + TAPM_FLEET_WRITE_IDENTITY || true + fi + return 0 +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index cd8694a..15cd0fc 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -185,8 +185,9 @@ TAPM_AUTHORIZE() { exchange_response="$( TAPM_CODE="$deploycode" TAPM_FINGERPRINT="$host_fingerprint" TAPM_HOSTNAME="$(hostname)" \ TAPM_LAN_IP="${RNIP:-}" \ + TAPM_INSTALLATION_ID="${TAPM_FLEET_INSTALLATION_ID:-}" \ TAPM_REQUESTED_ACTION="$required_action" TAPM_REQUESTED_PACKAGE="$required_package" \ - python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "lan_ip": os.environ["TAPM_LAN_IP"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | + python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "lan_ip": os.environ["TAPM_LAN_IP"], "installation_id": os.environ["TAPM_INSTALLATION_ID"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | curl --fail --silent --show-error \ --header 'Content-Type: application/json' \ --data-binary @- "${TAPM_BROKER_URL}/api/v1/exchange" diff --git a/run.sh b/run.sh index 657c3b6..04378bc 100755 --- a/run.sh +++ b/run.sh @@ -10,6 +10,7 @@ TAPM_ENSURE_RUNTIME_CONFIG || exit 1 DEFAULTS_REPOSITORY_URL="${GITEA_URL}/voltron/iDS-Defaults.git" source /opt/idssys/ta-proxmenu/inc/git-update.inc +source /opt/idssys/ta-proxmenu/inc/cluster-update.inc AUTO_UPDATE_DEFAULTS() { local checked_at=0 @@ -100,6 +101,8 @@ AUTO_UPDATE_DEFAULTS [ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc +TAPM_FLEET_START "$VERS" +trap 'TAPM_FLEET_FINISH "$?"' EXIT UPDATE_REPOSITORY() { local repository="$1" @@ -235,7 +238,7 @@ SWITCH_TAPM_BRANCH() { exec /opt/idssys/ta-proxmenu/run.sh } -INSTALL_UPDATES() { +INSTALL_LOCAL_UPDATES() { local current_branch local defaults_warning=0 local update_failed=0 @@ -279,7 +282,11 @@ INSTALL_UPDATES() { case "${1:-}" in update|u) - INSTALL_UPDATES + if [[ "${2:-}" == "--local-only" ]]; then + INSTALL_LOCAL_UPDATES + else + INSTALL_CLUSTER_UPDATES + fi exit $? ;; main) diff --git a/tests/test-cluster-update.sh b/tests/test-cluster-update.sh new file mode 100644 index 0000000..b742f66 --- /dev/null +++ b/tests/test-cluster-update.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/cluster-update.inc" + +nodes_json='[ + {"node":"pve3","status":"offline"}, + {"node":"pve1","status":"online"}, + {"node":"pve2","status":"online"}, + {"node":"bad node","status":"online"} +]' +expected=$'pve1\tonline\npve2\tonline\npve3\toffline' +assert_equal "$expected" \ + "$(printf '%s' "$nodes_json" | TAPM_CLUSTER_NODES_FROM_JSON)" \ + "cluster node discovery filters and sorts API output" + +wrapped_json='{"data":[{"node":"pve2.example","status":"ONLINE"}]}' +assert_equal $'pve2.example\tonline' \ + "$(printf '%s' "$wrapped_json" | TAPM_CLUSTER_NODES_FROM_JSON)" \ + "wrapped API response and normalized status" + +if printf '%s' 'not-json' | TAPM_CLUSTER_NODES_FROM_JSON 2>/dev/null; then + printf 'FAIL: malformed cluster JSON was accepted\n' >&2 + exit 1 +fi +assert_failure "remote node rejects shell characters" \ + TAPM_UPDATE_REMOTE_NODE 'pve1;reboot' + +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT +TAPM_COROSYNC_CONFIG="${test_dir}/corosync.conf" +printf 'totem {}\n' >"$TAPM_COROSYNC_CONFIG" +update_log="${test_dir}/updates" +LightCyan=0 +LightYellow=0 +Green=0 +Red=0 +Default=0 +idsCL[0]='' +pvesh() { + return 0 +} +TAPM_CLUSTER_NODE_ROWS() { + printf 'pve1\tonline\npve2\tonline\npve3\toffline\n' +} +TAPM_LOCAL_CLUSTER_NODE() { + printf 'pve1\n' +} +TAPM_UPDATE_REMOTE_NODE() { + printf 'remote:%s\n' "$1" >>"$update_log" +} +INSTALL_LOCAL_UPDATES() { + printf 'local:pve1\n' >>"$update_log" +} +if INSTALL_CLUSTER_UPDATES >/dev/null; then + printf 'FAIL: cluster update ignored an offline node\n' >&2 + exit 1 +fi +assert_equal $'remote:pve2\nlocal:pve1' \ + "$(cat "$update_log")" \ + "online remotes and initiating node update once" + +finish_tests diff --git a/tests/test-fleet.sh b/tests/test-fleet.sh new file mode 100644 index 0000000..6ad4134 --- /dev/null +++ b/tests/test-fleet.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +TAPM_FLEET_STATE_DIR="${test_dir}/state" +TAPM_FLEET_IDENTITY_FILE="${TAPM_FLEET_STATE_DIR}/identity.env" +TAPM_BROKER_URL='https://tapm.example.com' +source "${TEST_ROOT}/inc/fleet.inc" + +TAPM_FLEET_ENSURE_IDENTITY +if ! TAPM_FLEET_VALID_IDENTITY; then + printf 'FAIL: generated fleet identity is invalid\n' >&2 + exit 1 +fi +if stat -c '%a' "$TAPM_FLEET_IDENTITY_FILE" >/dev/null 2>&1; then + identity_mode="$(stat -c '%a' "$TAPM_FLEET_IDENTITY_FILE")" +else + identity_mode="$(stat -f '%Lp' "$TAPM_FLEET_IDENTITY_FILE")" +fi +if [[ "$identity_mode" != 600 ]]; then + printf 'FAIL: fleet identity mode is not 600\n' >&2 + exit 1 +fi + +TAPM_FLEET_VERSION='2026.7.26-7' +TAPM_FLEET_GIT_COMMIT='0123456789abcdef0123456789abcdef01234567' +TAPM_FLEET_PVE_VERSION='pve-manager/9.0.3/abc~1' +TAPM_FLEET_OS_VERSION='Debian GNU/Linux 13 (trixie)' +TAPM_FLEET_KERNEL_VERSION='6.14.11-2-pve' +TAPM_FLEET_ARCHITECTURE='amd64' +TAPM_FLEET_CLUSTERED=true + +registration_json="$( + TAPM_FLEET_INCLUDE_CREDENTIAL=1 TAPM_FLEET_JSON installed success +)" +REGISTRATION_JSON="$registration_json" python3 -c ' +import json, os +payload = json.loads(os.environ["REGISTRATION_JSON"]) +assert payload["installation_id"] +assert len(payload["credential"]) == 64 +assert payload["proxmenu_version"] == "2026.7.26-7" +assert payload["clustered"] is True +' + +printf '0\n' >"${test_dir}/curl-count" +curl() { + local count data_path='' previous='' argument='' + count="$(cat "${test_dir}/curl-count")" + count="$((count + 1))" + printf '%s\n' "$count" >"${test_dir}/curl-count" + for argument in "$@"; do + if [[ "$previous" == '--data-binary' ]]; then + data_path="${argument#@}" + fi + previous="$argument" + done + if [[ -n "$data_path" ]]; then + cp "$data_path" "${test_dir}/curl-body-${count}.json" + fi + cat >"${test_dir}/curl-config-${count}" + return 0 +} + +TAPM_FLEET_REGISTERED=1 +TAPM_FLEET_EVENT_SEND run_completed success '' 12 +EVENT_BODY="$(cat "${test_dir}/curl-body-1.json")" \ +EVENT_CONFIG="$(cat "${test_dir}/curl-config-1")" \ +EXPECTED_CREDENTIAL="$TAPM_FLEET_CREDENTIAL" \ +python3 -c ' +import json, os +payload = json.loads(os.environ["EVENT_BODY"]) +assert payload["event"] == "run_completed" +assert payload["result"] == "success" +assert payload["duration_seconds"] == 12 +assert "credential" not in payload +config = os.environ["EVENT_CONFIG"] +assert "Authorization: Bearer " + os.environ["EXPECTED_CREDENTIAL"] in config +assert "https://tapm.example.com/api/v1/hosts/events" in config +' + +if compgen -G "${TMPDIR:-/tmp}/tapm-fleet-event.*" >/dev/null; then + printf 'FAIL: fleet event left a temporary payload file\n' >&2 + exit 1 +fi + +printf 'PASS: fleet identity and event client\n'