diff --git a/README.md b/README.md index 45d0310..46358d7 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,199 @@ -# TA-Proxmenu - Proxmox Scripts for TA Use +# TA-ProxMenu - bash <(curl -sL https://go.scity.us/install-tapm) +Interactive installation and configuration helpers used when deploying Proxmox +VE environments. -To update an installed main-branch copy, switch it to V2, and immediately load -the V2 menu: +## Install - tapm V2 +Run as `root` on a Proxmox VE host: -To change the TA-ProxMenu `origin` URL on every installed node in the current -Proxmox cluster, either select `G` in the legacy menu or run: +```bash +bash <(curl -fsSL https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2/install-ta_proxmenu.sh) +``` - tapm git-url +The installed launcher loads TA-ProxMenu's bundled defaults and colors, then +opens the interactive menu. Updates are installed only when explicitly +selected or requested with `tapm update`. -The command prompts for the new repository URL, discovers cluster nodes with -`pvecm`, verifies that each node can read the new repository's `main` branch, -and only then changes that node's `origin`. A URL can also be supplied: +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. - tapm git-url https://git.example.com/organization/TA-ProxMenu.git +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. -Add `--yes` as the third argument for a non-interactive confirmation: +The TA-ProxMenu Management menu can safely switch TA-ProxMenu between branches published on +its Git origin. Branch switching is refused when the installed repository has +local changes or when the destination branch has commits that would be +discarded. - tapm git-url https://git.example.com/organization/TA-ProxMenu.git --yes +## Direct actions -Private repositories require read credentials to be configured on every node -before migration. Repository credentials must not be embedded in the URL. +The menu script also supports these direct actions: + +```text +pulse Install Pulse monitoring +rmm Install the ConnectWise RMM agent +omsa Install legacy Dell OMSA on supported PowerEdge x30/x40 hosts +glances Install Glances +acronis Install the Acronis agent +post-install Open the native TAPM host configuration menu +proxmenux Open TAPM host configuration for migration compatibility +virtio Download current VirtIO drivers +sentinelone Install the SentinelOne agent +screenconnect Install the ScreenConnect agent +restart Restart core local Proxmox management services +cpu Detect and optionally apply a migration-safe CPU model +maintenance Toggle local HA maintenance mode +keepalived Deploy Keepalived across the cluster +iso-nfs Create an LXC NFS server and cluster-wide shared ISO storage +``` + +## Companion files + +Large installer artifacts used by this project are stored in the private +`TAI/files` package registry. SentinelOne, RMM, Acronis, and ScreenConnect +installation require a temporary deployment code from TAPM Deployment Access. +The private Git credentials are never stored on or returned to a Proxmox +host. SentinelOne package versions can be updated through the deployment +portal without changing ProxMenu. + +## Runtime requirements + +- Proxmox VE and root privileges +- Bash, Git, curl, wget, Python 3, and standard Debian package tools +- The bundled `defaults.inc` and `colors.inc` files + +The Keepalived deployment additionally requires a healthy, quorate Proxmox +cluster and passwordless root SSH between cluster nodes. + +The shared ISO storage wizard creates a privileged Debian LXC with a dedicated +secondary volume, restricts its NFS export to a supplied client CIDR, and adds +the export to Proxmox's cluster-wide storage configuration. The container host +and network path to the container must remain available for nodes to use the +ISO repository. + +The legacy Dell OMSA installer is limited to supported PowerEdge x30/x40 +systems running Proxmox VE 9 on Debian 13 (Trixie), amd64. + +CPU compatibility analysis compares every cluster host's physical processor, +processor generation/family, and supported generic VM CPU baseline. It then previews +QEMU VM and template changes before applying the highest baseline shared by +all nodes through the Proxmox CLI. Running VMs are not restarted automatically. + +The native TAPM host configuration workflow replaces the former ProxMenux +post-install dependency. It can audit a host, apply the recommended PVE 9 +profile, customize individual items, migrate recognized ProxMenux +configurations, repair ProxMenux's system gzip replacement, and remove +ProxMenux after validation. Every mutating run first creates a timestamped +backup under `/var/backups/ta-proxmenu/post-install`. + +The recommended profile installs only missing diagnostic utilities, preserves +the configured timezone and NTP servers, enables conservative kernel and +network safeguards, retains up to 1 GiB or 30 days of persistent journal +history, verifies logrotate, and uses Proxmox's native pigz support without +replacing `/bin/gzip`. APT remains dual-stack by default; its conditional IPv4 +compatibility check is optional. Global vzdump bandwidth and I/O-priority +changes are available through a separate explicit menu. + +Pulse can also be deployed without installing TA-ProxMenu. Run the standalone +bootstrap as root on a Proxmox VE host: + +```bash +bash <(curl -fsSL https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2/install-pulse.sh) +``` + +The bootstrap downloads the Pulse deployment module and its LXC storage helper +from the same branch into a protected temporary directory, validates their Bash +syntax and expected entry points, runs the normal interactive Pulse workflow, +and removes the temporary files afterward. It does not clone or install +TA-ProxMenu. Set `TAPM_PULSE_SOURCE_BRANCH` before running it to use another +branch. + +VirtIO downloads are managed from a dedicated submenu. The stable release is +checked only when that submenu is opened, and curated compatibility ISOs are +available for Windows Server 2008, 2008 R2, 2012/R2, and 2016. Downloads are +validated before atomically replacing a file with the same name. + +Maintenance evacuation leaves HA-managed guests under Proxmox HA control. +Remaining shared-storage guests are routed to online, non-maintenance nodes +with the required storage, while local-storage guests are gracefully shut +down. HA node-affinity preferences are honored when an eligible node exists. + +## Development checks + +Run the local validation suite with Bash 4.3 or newer: + +```bash +./tests/run.sh +``` + +The suite checks Bash syntax and Git whitespace, runs ShellCheck when it is +installed, and exercises Git update states, LXC input/storage selection, +maintenance evacuation routing, HA affinity parsing, host-profile migration +signatures and backups, and VirtIO filename validation. Tests use temporary +files and mocked Proxmox output; they do not download installers or change a +Proxmox host. +## Deployment broker URL + +TA-ProxMenu exchanges deployment codes with the configured TAPM broker. The +URL is selected in this order: + +1. Existing `TAPM_BROKER_URL` environment variable +2. `TAPM_BROKER_URL` in `/etc/ta-proxmenu/config.env` + +For a persistent per-system setting: + +```sh +install -d -m 0755 /etc/ta-proxmenu +install -m 0644 config.env.example /etc/ta-proxmenu/config.env +``` + +The tracked example contains only: + +```dotenv +TAPM_BROKER_URL=https://tapm.example.com +GITEA_DOMAIN=git.example.com +``` + +Replace it with the deployed HTTPS origin, without an API path. There is no +hard-coded operational broker URL; authorization fails with a configuration +message when the variable is missing or invalid. `GITEA_DOMAIN` is a hostname, +without `https://` or a path. Repository updates use the installed +TA-ProxMenu checkout's configured Git `origin` and do not overwrite the system +configuration file. + +On the first V2 launch after installation or upgrade, TA-ProxMenu detects a +missing or incomplete `/etc/ta-proxmenu/config.env` and interactively requests +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`. The first successful launch enrolls the host with the broker. +Later launches make one best-effort HTTPS call on completion or failure to +record the hostname, duration, result, TA-ProxMenu and Git versions, +PVE/OS/kernel versions, architecture, and whether the host is clustered. A +failed event schedules re-enrollment on the next invocation instead of adding +another retry to the current run. No background service is installed, and +broker availability never prevents the menu from running. + +Automatic Git update checks and the stable VirtIO release lookup are cached for +24 hours. Their explicit **Check again** and **Refresh** actions bypass the +cache. Installer, package-repository, and vendor download traffic occurs only +after a technician selects the corresponding action. + +The hostname is treated as a limited operational identifier. The registry does +not send machine IDs, MAC addresses, usernames, VM/container inventory, +deployment codes, authorization tokens, credentials, or command output. The +broker stores only a digest of the random host credential. A successful +deployment-code exchange carrying the same random installation ID marks that +registry entry as verified. diff --git a/colors.inc b/colors.inc new file mode 100755 index 0000000..13d461f --- /dev/null +++ b/colors.inc @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# + +declare -A idsCL +idsCL[Default]="\e[39m" +idsCL[White]="\e[97m" +idsCL[LightGray]="\e[37m" +idsCL[DarkGray]="\e[90m" +idsCL[Black]="\e[30m" +idsCL[Red]="\e[31m" +idsCL[RedBold]="\e[31;1m" +idsCL[LightRed]="\e[91m" +idsCL[Magenta]="\e[35m" +idsCL[LightMagenta]="\e[95m" +idsCL[Blue]="\e[34m" +idsCL[LightBlue]="\e[94m" +idsCL[Cyan]="\e[36m" +idsCL[LightCyan]="\e[96m" +idsCL[Green]="\e[32m" +idsCL[LightGreen]="\e[92m" +idsCL[Yellow]="\e[33m" +idsCL[LightYellow]="\e[93m" + +declare -A idsBG +idsBG[Default]="\e[49m" +idsBG[Black]="\e[40m" +idsBG[Red]="\e[41m" +idsBG[Green]="\e[42m" +idsBG[Yellow]="\e[43m" +idsBG[Blue]="\e[44m" +idsBG[Magenta]="\e[45m" +idsBG[Cyan]="\e[46m" +idsBG[LightGray]="\e[47m" +idsBG[DarkGray]="\e[100m" +idsBG[LightRed]="\e[101m" +idsBG[LightGreen]="\e[102m" +idsBG[LightYellow]="\e[103m" +idsBG[LightBlue]="\e[104m" +idsBG[LightMagenta]="\e[105m" +idsBG[LightCyan]="\e[106m" +idsBG[White]="\e[107m" + +declare -A idsST +idsST[Reset]="\e[0m" +idsST[Bold]="\e[1m" +idsST[Dim]="\e[2m" +idsST[UnderLine]="\e[4m" +idsST[Blink]="\e[5m" +idsST[Invert]="\e[7m" +idsST[Hidden]="\e[8m" diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..1248320 --- /dev/null +++ b/config.env.example @@ -0,0 +1,3 @@ +# Copy to /etc/ta-proxmenu/config.env and replace with the deployed broker. +TAPM_BROKER_URL=https://tapm.example.com +GITEA_DOMAIN=git.example.com diff --git a/defaults.inc b/defaults.inc index 7d0580c..fe593dd 100755 --- a/defaults.inc +++ b/defaults.inc @@ -1,29 +1,43 @@ #!/usr/bin/env bash # TA-Proxmenu - Proxmox Setup Scripts for TA Use -action="$1" +action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-1' +VERS='2026.7.29-1' noupdate=' ' - -RNIP=$(ip addr show $(ip route | awk '/default/ { print $5 }') | grep "inet" | head -n 1 | awk '/inet/ {print $2}' | cut -d'/' -f1) - - -download_url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" -if curl -m 3 -s --head --request GET ${download_url} | grep "HTTP/2 200" > /dev/null; then - while redirect_url=$( curl -I -s -S -f -w "%{redirect_url}\\n" -o /dev/null "${download_url}" ); do - VIRTIO_DOWNLOAD_URL=${download_url}; url=${redirect_url}; [[ -z "$url" ]] && break - done +default_interface="$(ip route 2>/dev/null | awk '/^default/ { print $5; exit }')" +if [[ -n "$default_interface" ]]; then + RNIP="$(ip -4 -o addr show dev "$default_interface" scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4; exit }')" else - VIRTIO_DOWNLOAD_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.285-1/virtio-win-0.1.285.iso" + RNIP="" fi -VIRTIO_FILE=${VIRTIO_DOWNLOAD_URL##*/} -[ -d /mnt/pve/PVE-Shared-Storage/template/iso ] && DLDIR=/mnt/pve/PVE-Shared-Storage/template/iso || DLDIR=/var/lib/vz/template/iso +VIRTIO_STABLE_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" +PULSE_RELEASE='v6.1.1' +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' + +# Imported from iDS-Defaults/default.inc. These are the only shared-default +# functions TA-ProxMenu calls. +EXIT1() { + clear + exit 0 +} + +ENTER2CONTINUE() { + echo + read -r -s -p "[Press ENTER to continue]" + echo -e "\e[1A\n\e[0K\r\n" +} # if [ -f /etc/apt/sources.list.d/gyptazy.list ]; then # rm -f /etc/apt/sources.list.d/gyptazy.list /etc/apt/keyrings/gyptazy.asc @@ -31,4 +45,3 @@ VIRTIO_FILE=${VIRTIO_DOWNLOAD_URL##*/} # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg # apt-get update # fi - diff --git a/inc/cluster-update.inc b/inc/cluster-update.inc new file mode 100644 index 0000000..b94a5c1 --- /dev/null +++ b/inc/cluster-update.inc @@ -0,0 +1,138 @@ +#!/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 + printf '\n' + echo -e "${idsCL[LightYellow]}Skipping ${node}: node status is ${status}.${idsCL[Default]}" + cluster_failed=1 + continue + fi + printf '\n' + 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 + + printf '\n' + echo -e "${idsCL[LightCyan]}Updating local node ${local_node}...${idsCL[Default]}" + if INSTALL_LOCAL_UPDATES; then + echo -e "${idsCL[Green]}Local node ${local_node} is updated.${idsCL[Default]}" + else + echo -e "${idsCL[Red]}Local node ${local_node} failed to update.${idsCL[Default]}" + cluster_failed=1 + fi + + printf '\n' + 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/cpu-compat.inc b/inc/cpu-compat.inc new file mode 100644 index 0000000..2f60edb --- /dev/null +++ b/inc/cpu-compat.inc @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +# Parse the per-node rows printed by ProxCLMC. Fields are separated with an +# ASCII unit separator so processor descriptions can safely contain spaces. +TAPM_PROXCLMC_HOST_ROWS() { + awk -F '|' ' +function trim(value) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + return value +} + +{ + node = trim($1) + address = trim($2) + level = trim($3) + + if (node ~ /^[[:alnum:]_.-]+$/ && level ~ /^x86-64-v(1|2-AES|3|4)$/) { + printf "%s\034%s\034%s\n", node, address, level + } +} +' +} + +# Produce a conservative generation label from the processor name exposed by +# Proxmox. Ambiguous products are described as a family instead of being given +# a potentially incorrect codename. +TAPM_CPU_GENERATION_FROM_NAME() { + local cpu_name="${1:-}" + local normalized + local generation="" + local model_number="" + + normalized="$(printf '%s' "$cpu_name" | tr '[:upper:]' '[:lower:]')" + + if [[ "$normalized" =~ xeon.*e5-[[:digit:]]+[[:space:]]+v([1-4]) ]]; then + generation="${BASH_REMATCH[1]}" + case "$generation" in + 1) printf '%s\n' "Sandy Bridge-EP" ;; + 2) printf '%s\n' "Ivy Bridge-EP" ;; + 3) printf '%s\n' "Haswell-EP" ;; + 4) printf '%s\n' "Broadwell-EP" ;; + esac + return 0 + fi + + if [[ "$normalized" =~ xeon.*(bronze|silver|gold|platinum)[[:space:]]+([[:digit:]]{4}) ]]; then + model_number="${BASH_REMATCH[2]}" + generation="${model_number:1:1}" + case "$generation" in + 1) printf '%s\n' "1st Gen Xeon Scalable (Skylake-SP)" ;; + 2) printf '%s\n' "2nd Gen Xeon Scalable (Cascade Lake)" ;; + 3) printf '%s\n' "3rd Gen Xeon Scalable (Ice Lake)" ;; + 4) printf '%s\n' "4th Gen Xeon Scalable (Sapphire Rapids)" ;; + 5) printf '%s\n' "5th Gen Xeon Scalable (Emerald Rapids)" ;; + *) printf '%s\n' "Intel Xeon Scalable family" ;; + esac + return 0 + fi + + if [[ "$normalized" =~ epyc[[:space:]]+([[:digit:]]{4}) ]]; then + model_number="${BASH_REMATCH[1]}" + generation="${model_number:3:1}" + case "$generation" in + 1) printf '%s\n' "1st Gen EPYC (Naples / Zen)" ;; + 2) printf '%s\n' "2nd Gen EPYC (Rome / Zen 2)" ;; + 3) printf '%s\n' "3rd Gen EPYC (Milan / Zen 3)" ;; + 4) printf '%s\n' "4th Gen EPYC (Zen 4 family)" ;; + 5) printf '%s\n' "5th Gen EPYC (Zen 5 family)" ;; + *) printf '%s\n' "AMD EPYC family" ;; + esac + return 0 + fi + + if [[ "$normalized" =~ core.*i[3579]-([[:digit:]]{4,5}) ]]; then + model_number="${BASH_REMATCH[1]}" + if (( ${#model_number} == 4 )); then + generation="${model_number:0:1}" + else + generation="${model_number:0:2}" + fi + printf '%s\n' "Intel Core Gen ${generation}" + return 0 + fi + + if [[ "$normalized" =~ ryzen.*[[:space:]]([[:digit:]]{4,5}) ]]; then + model_number="${BASH_REMATCH[1]}" + printf '%s\n' "AMD Ryzen ${model_number:0:1}000 family" + return 0 + fi + + case "$normalized" in + *intel*) printf '%s\n' "Intel generation unknown" ;; + *amd*) printf '%s\n' "AMD generation unknown" ;; + *) printf '%s\n' "Generation unknown" ;; + esac +} diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh new file mode 100644 index 0000000..7634d3e --- /dev/null +++ b/inc/deploy-iso-nfs-lxc.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# Deploy a dedicated LXC NFS server and register it as cluster ISO storage. + +TAPM_ISO_NFS_VALID_ID() { + [[ "${1:-}" =~ ^[A-Za-z][A-Za-z0-9_-]{0,31}$ ]] +} + +TAPM_ISO_NFS_VALID_CTID() { + [[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]] +} + +TAPM_ISO_NFS_VALID_HOSTNAME() { + [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] +} + +TAPM_ISO_NFS_VALID_IPV4_CIDR() { + local value="${1:-}" + local address prefix octet + local -a octets + + [[ "$value" == */* ]] || return 1 + address="${value%/*}" + prefix="${value#*/}" + [[ "$prefix" =~ ^[0-9]+$ ]] && (( prefix <= 32 )) || return 1 + IFS=. read -r -a octets <<<"$address" + (( ${#octets[@]} == 4 )) || return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] && (( 10#$octet <= 255 )) || return 1 + done +} + +TAPM_ISO_NFS_PROMPT() { + local variable="$1" + local label="$2" + local default_value="${3:-}" + local value + + if [[ -n "$default_value" ]]; then + read -r -p " ${label} [${default_value}]: " value + printf -v "$variable" '%s' "${value:-$default_value}" + else + read -r -p " ${label}: " value + printf -v "$variable" '%s' "$value" + fi +} + +TAPM_ISO_NFS_FAIL() { + echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" + return 1 +} + +TAPM_ISO_NFS_LOCAL_TEMPLATE() { + local storage="$1" + + pveam list "$storage" 2>/dev/null | + awk 'NR > 1 && $1 ~ /:vztmpl\/debian-(13|12)-standard_/ { print $1 }' | + sort -V | + tail -1 +} + +TAPM_ISO_NFS_SELECT_STORAGE() { + local variable="$1" + local label="$2" + local default_value="$3" + local storage type status index + local default_found=0 + local -a storage_ids=() + local -a storage_types=() + local -a labels=() + local -a values=() + + while read -r storage type status _; do + [[ "$storage" != 'Name' && "$status" == 'active' ]] || continue + storage_ids+=("$storage") + storage_types+=("$type") + [[ "$storage" == "$default_value" ]] && default_found=1 + done < <(pvesm status --content rootdir --enabled 1 2>/dev/null) + + if (( ${#storage_ids[@]} == 0 )); then + TAPM_ISO_NFS_FAIL "No active, enabled storage supports LXC volumes." + return 1 + fi + + # Put the suggested storage first so pressing ENTER retains the default. + if (( default_found == 1 )); then + for index in "${!storage_ids[@]}"; do + [[ "${storage_ids[$index]}" == "$default_value" ]] || continue + labels+=("${storage_ids[$index]} (${storage_types[$index]}) — default") + values+=("storage:${storage_ids[$index]}") + break + done + fi + for index in "${!storage_ids[@]}"; do + [[ $default_found == 1 && "${storage_ids[$index]}" == "$default_value" ]] && + continue + labels+=("${storage_ids[$index]} (${storage_types[$index]})") + values+=("storage:${storage_ids[$index]}") + done + + SELECT_MENU "$label" labels values + case "$MENU_SELECTION" in + storage:*) printf -v "$variable" '%s' "${MENU_SELECTION#storage:}";; + quit) EXIT1; exit 0;; + *) return 1;; + esac +} + +TAPM_DEPLOY_ISO_NFS_LXC() { + local ctid default_ctid hostname address_cidr server_ip gateway bridge + local client_cidr root_storage data_storage root_size data_size + local pve_storage_id template_storage template_name template_path + local default_root_storage default_template_storage choice test_file + local container_config + + echo + echo -e "${idsCL[LightCyan]}Shared ISO storage using an LXC NFS server${idsCL[Default]}" + echo + echo " This creates a privileged Debian LXC with an unconfined AppArmor profile," + echo " allocates a dedicated mp0 data volume, exports it to the cluster, and" + echo " registers it in storage.cfg. This reduced isolation is required for the" + echo " kernel NFS service; do not run unrelated or untrusted software in this LXC." + echo " The container's host must be online for ISO storage to remain available." + echo + + [[ $EUID -eq 0 ]] || + { TAPM_ISO_NFS_FAIL "Run this action as root on a Proxmox VE host."; return 1; } + for command in pct pvesm pveam pvesh ha-manager; do + command -v "$command" >/dev/null 2>&1 || + { TAPM_ISO_NFS_FAIL "Required Proxmox command '${command}' was not found."; return 1; } + done + + default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + TAPM_ISO_NFS_PROMPT ctid "Container ID" "$default_ctid" + TAPM_ISO_NFS_VALID_CTID "$ctid" || + { TAPM_ISO_NFS_FAIL "The container ID is invalid."; return 1; } + if pct status "$ctid" >/dev/null 2>&1; then + TAPM_ISO_NFS_FAIL "Container ${ctid} already exists; no changes were made." + return 1 + fi + + TAPM_ISO_NFS_PROMPT hostname "Container hostname" "PVE-Shared-Storage" + TAPM_ISO_NFS_VALID_HOSTNAME "$hostname" || + { TAPM_ISO_NFS_FAIL "The hostname is invalid."; return 1; } + TAPM_ISO_NFS_PROMPT address_cidr "Static IPv4 address with prefix (example: 10.20.30.10/24)" + TAPM_ISO_NFS_VALID_IPV4_CIDR "$address_cidr" || + { TAPM_ISO_NFS_FAIL "A valid static IPv4 address and prefix are required."; return 1; } + server_ip="${address_cidr%/*}" + TAPM_ISO_NFS_PROMPT gateway "IPv4 gateway" + TAPM_ISO_NFS_VALID_IPV4_CIDR "${gateway}/32" || + { TAPM_ISO_NFS_FAIL "A valid IPv4 gateway is required."; return 1; } + TAPM_ISO_NFS_PROMPT bridge "Proxmox bridge" "vmbr0" + ip link show "$bridge" >/dev/null 2>&1 || + { TAPM_ISO_NFS_FAIL "Bridge '${bridge}' does not exist on this host."; return 1; } + client_cidr="$( + python3 -c 'import ipaddress,sys; print(ipaddress.ip_interface(sys.argv[1]).network)' \ + "$address_cidr" 2>/dev/null + )" || client_cidr='' + TAPM_ISO_NFS_PROMPT client_cidr "CIDR allowed to mount the export" "$client_cidr" + TAPM_ISO_NFS_VALID_IPV4_CIDR "$client_cidr" || + { TAPM_ISO_NFS_FAIL "The allowed client CIDR is invalid."; return 1; } + + default_root_storage="$( + pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_root_storage" ]] || + { TAPM_ISO_NFS_FAIL "No active storage supports LXC volumes."; return 1; } + TAPM_ISO_NFS_SELECT_STORAGE root_storage "Root filesystem storage" "$default_root_storage" || return 1 + TAPM_ISO_NFS_SELECT_STORAGE data_storage "Dedicated ISO volume storage" "$root_storage" || return 1 + TAPM_ISO_NFS_PROMPT root_size "Root filesystem size in GiB" "8" + [[ "$root_size" =~ ^[1-9][0-9]*$ ]] || + { TAPM_ISO_NFS_FAIL "The root filesystem size must be a positive integer."; return 1; } + TAPM_ISO_NFS_PROMPT data_size "ISO volume size in GiB" "250" + [[ "$data_size" =~ ^[1-9][0-9]*$ ]] || + { TAPM_ISO_NFS_FAIL "The ISO volume size must be a positive integer."; return 1; } + TAPM_ISO_NFS_PROMPT pve_storage_id "Proxmox cluster storage ID" "$hostname" + TAPM_ISO_NFS_VALID_ID "$pve_storage_id" || + { TAPM_ISO_NFS_FAIL "The Proxmox storage ID is invalid."; return 1; } + if pvesm status 2>/dev/null | awk 'NR > 1 { print $1 }' | grep -Fxq -- "$pve_storage_id"; then + TAPM_ISO_NFS_FAIL "Storage ID '${pve_storage_id}' already exists; no changes were made." + return 1 + fi + + default_template_storage="$( + pvesm status --content vztmpl 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_template_storage" ]] || + { TAPM_ISO_NFS_FAIL "No active storage supports container templates."; return 1; } + template_storage="$default_template_storage" + + echo + echo " Deployment summary" + echo " LXC: ${ctid} (${hostname}), privileged" + echo " Network: ${address_cidr} via ${gateway} on ${bridge}" + echo " Resources: 2 vCPU, 2048 MiB RAM, 512 MiB swap, 100 CPU units" + echo " Root volume: ${root_storage}:${root_size} GiB" + echo " ISO volume mp0: ${data_storage}:${data_size} GiB -> /srv/iso" + echo " NFS clients: ${client_cidr}" + echo " Cluster storage: ${pve_storage_id}" + echo " HA resource: ct:${ctid} (started)" + echo + read -r -p " Create this container and storage (type yes to continue)? " choice + [[ "$choice" =~ ^[Yy][Ee][Ss]$ ]] || { + echo " Cancelled; no changes were made." + return 0 + } + + echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}" + template_path="$(TAPM_ISO_NFS_LOCAL_TEMPLATE "$template_storage")" + if [[ -z "$template_path" ]]; then + pveam update || + { TAPM_ISO_NFS_FAIL "Could not refresh the template catalog."; return 1; } + template_name="$( + pveam available --section system | + awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + sort -V | + tail -1 + )" + [[ -n "$template_name" ]] || + { TAPM_ISO_NFS_FAIL "No supported Debian 12/13 standard template was found."; return 1; } + template_path="${template_storage}:vztmpl/${template_name}" + pveam download "$template_storage" "$template_name" || + { TAPM_ISO_NFS_FAIL "The Debian template download failed."; return 1; } + fi + + echo -e "\n${idsCL[LightCyan]}Creating LXC ${ctid}...${idsCL[Default]}" + if ! pct create "$ctid" "$template_path" \ + --hostname "$hostname" \ + --ostype debian \ + --unprivileged 0 \ + --features nesting=1 \ + --cores 2 \ + --cpuunits 100 \ + --memory 2048 \ + --swap 512 \ + --rootfs "${root_storage}:${root_size}" \ + --mp0 "${data_storage}:${data_size},mp=/srv/iso,backup=1" \ + --net0 "name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},type=veth" \ + --onboot 1 \ + --startup order=1; then + TAPM_ISO_NFS_FAIL "Container creation failed." + return 1 + fi + container_config="/etc/pve/lxc/${ctid}.conf" + if ! grep -q '^lxc\.apparmor\.profile:' "$container_config"; then + printf 'lxc.apparmor.profile: unconfined\n' >>"$container_config" || + { TAPM_ISO_NFS_FAIL "Container ${ctid} was created, but its NFS AppArmor setting could not be applied."; return 1; } + fi + + pct start "$ctid" || + { TAPM_ISO_NFS_FAIL "Container ${ctid} was created but could not be started."; return 1; } + if ! timeout 60 bash -c \ + "until pct exec '$ctid' -- test -d /run/systemd/system >/dev/null 2>&1; do sleep 2; done"; then + TAPM_ISO_NFS_FAIL "Container ${ctid} did not become ready within 60 seconds." + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Installing and configuring NFS...${idsCL[Default]}" + pct exec "$ctid" -- apt-get update || + { TAPM_ISO_NFS_FAIL "Package index refresh failed inside container ${ctid}."; return 1; } + pct exec "$ctid" -- env DEBIAN_FRONTEND=noninteractive \ + apt-get install -y nfs-kernel-server || + { TAPM_ISO_NFS_FAIL "NFS package installation failed inside container ${ctid}."; return 1; } + pct exec "$ctid" -- install -d -m 0775 /srv/iso/template/iso || + { TAPM_ISO_NFS_FAIL "Could not initialize the ISO directory."; return 1; } + pct exec "$ctid" -- install -d -m 0755 /etc/exports.d || + { TAPM_ISO_NFS_FAIL "Could not initialize the NFS exports directory."; return 1; } + printf '/srv/iso %s(rw,sync,no_subtree_check,no_root_squash)\n' "$client_cidr" | + pct exec "$ctid" -- tee /etc/exports.d/proxmox-isos.exports >/dev/null || + { TAPM_ISO_NFS_FAIL "Could not write the NFS export configuration."; return 1; } + pct exec "$ctid" -- exportfs -ra || + { TAPM_ISO_NFS_FAIL "The NFS export configuration was rejected."; return 1; } + pct exec "$ctid" -- systemctl enable --now nfs-server || + { TAPM_ISO_NFS_FAIL "The NFS server could not be started."; return 1; } + pct exec "$ctid" -- exportfs -v | grep -Fq "/srv/iso" || + { TAPM_ISO_NFS_FAIL "The expected NFS export is not active."; return 1; } + + echo -e "\n${idsCL[LightCyan]}Registering cluster storage...${idsCL[Default]}" + if ! pvesm add nfs "$pve_storage_id" \ + --server "$server_ip" \ + --export /srv/iso \ + --content iso \ + --options vers=3; then + TAPM_ISO_NFS_FAIL "The LXC is running, but Proxmox could not add the NFS storage." + return 1 + fi + if ! timeout 30 pvesm status --storage "$pve_storage_id" | + awk -v id="$pve_storage_id" 'NR > 1 && $1 == id && $3 == "active" { found=1 } END { exit !found }'; then + pvesm remove "$pve_storage_id" >/dev/null 2>&1 || true + TAPM_ISO_NFS_FAIL "The NFS storage did not become active; its cluster entry was removed." + return 1 + fi + + test_file="/mnt/pve/${pve_storage_id}/template/iso/.tapm-write-test" + if ! touch "$test_file" || ! rm -f -- "$test_file"; then + pvesm remove "$pve_storage_id" >/dev/null 2>&1 || true + TAPM_ISO_NFS_FAIL "The NFS mount was not writable; its cluster entry was removed." + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Adding LXC ${ctid} to Proxmox HA...${idsCL[Default]}" + if ! ha-manager add "ct:${ctid}" --state started; then + TAPM_ISO_NFS_FAIL \ + "Shared storage '${pve_storage_id}' is active, but LXC ${ctid} could not be added to HA." + return 1 + fi + + echo + echo -e "${idsCL[Green]}Shared ISO storage '${pve_storage_id}' is active.${idsCL[Default]}" + echo " LXC ${ctid} serves ${data_storage}:${data_size} GiB from ${server_ip}:/srv/iso." + echo " HA now manages ct:${ctid} with requested state 'started'." + echo " Because Proxmox storage configuration is cluster-wide, every cluster node" + echo " can use it when that node can reach ${server_ip} and is allowed by ${client_cidr}." + return 0 +} diff --git a/inc/deploy-proxmox-keepalived.sh b/inc/deploy-proxmox-keepalived.sh index 389d0be..65d427f 100644 --- a/inc/deploy-proxmox-keepalived.sh +++ b/inc/deploy-proxmox-keepalived.sh @@ -345,6 +345,7 @@ remote_exec() { if [[ "$node" == "$LOCAL_NODE" ]]; then bash -lc "$cmd" else + # shellcheck disable=SC2029 # cmd is intentionally expanded into the remote command. ssh "${SSH_OPTS[@]}" "root@${node}" "$cmd" fi } diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh new file mode 100644 index 0000000..b1547d5 --- /dev/null +++ b/inc/deploy-pulse-lxc.sh @@ -0,0 +1,1449 @@ +#!/usr/bin/env bash +# TA-managed Pulse LXC deployment for Proxmox VE. +# +# TA-ProxMenu owns the LXC provisioning and pins an exact Pulse release. The +# matching upstream installer and archive are both downloaded from that release +# and verified with Pulse's published SSH signing key before use. There is no +# "latest" URL lookup or HEAD request in this workflow. + +TAPM_PULSE_SIGNING_IDENTITY='pulse-installer' +TAPM_PULSE_SIGNING_NAMESPACE='pulse-install' +TAPM_PULSE_SIGNING_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm' + +TAPM_PULSE_VALID_RELEASE() { + [[ "${1:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] +} + +TAPM_PULSE_VALID_CTID() { + [[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]] +} + +TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES() { + local resources_json="${1:-[]}" + local starting_id="${2:-200}" + + RESOURCES_JSON="$resources_json" STARTING_ID="$starting_id" python3 -c ' +import json, os +try: + resources = json.loads(os.environ["RESOURCES_JSON"]) + candidate = int(os.environ["STARTING_ID"]) +except (TypeError, ValueError): + raise SystemExit(1) +if not isinstance(resources, list) or candidate < 100 or candidate > 999999999: + raise SystemExit(1) +used = set() +for resource in resources: + if not isinstance(resource, dict): + continue + try: + vmid = int(resource.get("vmid")) + except (TypeError, ValueError): + continue + used.add(vmid) +while candidate in used and candidate <= 999999999: + candidate += 1 +if candidate > 999999999: + raise SystemExit(1) +print(candidate) +' 2>/dev/null +} + +TAPM_PULSE_VALID_HOSTNAME() { + [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] +} + +TAPM_PULSE_VALID_POSITIVE_INTEGER() { + [[ "${1:-}" =~ ^[1-9][0-9]*$ ]] +} + +TAPM_PULSE_VALID_NONNEGATIVE_INTEGER() { + [[ "${1:-}" =~ ^[0-9]+$ ]] +} + +TAPM_PULSE_BACKOFF_NEXT() { + local current="${1:-1}" + local maximum="${2:-8}" + local next + + [[ "$current" =~ ^[1-9][0-9]*$ && + "$maximum" =~ ^[1-9][0-9]*$ ]] || return 1 + next=$((current * 2)) + (( next > maximum )) && next="$maximum" + printf '%s\n' "$next" +} + +TAPM_PULSE_BACKOFF_SLEEP() { + local deadline="$1" + local delay="$2" + local remaining=$((deadline - SECONDS)) + + (( remaining > 0 )) || return 0 + (( delay > remaining )) && delay="$remaining" + sleep "$delay" +} + +TAPM_PULSE_VALID_PORT() { + [[ "${1:-}" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) +} + +TAPM_PULSE_VALID_IPV4_CIDR() { + local value="${1:-}" + local address prefix octet + local -a octets + + [[ "$value" == */* ]] || return 1 + address="${value%/*}" + prefix="${value#*/}" + [[ "$prefix" =~ ^[0-9]+$ ]] && (( prefix <= 32 )) || return 1 + IFS=. read -r -a octets <<<"$address" + (( ${#octets[@]} == 4 )) || return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] && (( 10#$octet <= 255 )) || return 1 + done +} + +TAPM_PULSE_VALID_IPV4() { + local value="${1:-}" + + [[ "$value" != */* ]] && TAPM_PULSE_VALID_IPV4_CIDR "${value}/32" +} + +TAPM_PULSE_VALID_OPTIONAL_IPV4_CIDR() { + [[ -z "${1:-}" ]] || TAPM_PULSE_VALID_IPV4_CIDR "$1" +} + +TAPM_PULSE_VALID_OPTIONAL_VLAN() { + local value="${1:-}" + + [[ -z "$value" ]] || + { [[ "$value" =~ ^[0-9]+$ ]] && (( 10#$value >= 1 && 10#$value <= 4094 )); } +} + +TAPM_PULSE_ARCH() { + case "${1:-}" in + x86_64|amd64) printf 'amd64\n';; + aarch64|arm64) printf 'arm64\n';; + *) return 1;; + esac +} + +TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT() { + local output="${1:-}" + + BOOTSTRAP_OUTPUT="$output" python3 -c ' +import os, re +match = re.search(r"Token:\s*([0-9a-fA-F]{48})(?:\s|$)", os.environ["BOOTSTRAP_OUTPUT"]) +if not match: + raise SystemExit(1) +print(match.group(1).lower()) +' 2>/dev/null +} + +TAPM_PULSE_TOKEN_FROM_RESPONSE() { + local response="${1:-}" + + TOKEN_RESPONSE="$response" python3 -c ' +import json, os, re +try: + token = json.loads(os.environ["TOKEN_RESPONSE"]).get("token", "") +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +if not isinstance(token, str) or not re.fullmatch(r"[0-9a-fA-F]+", token): + raise SystemExit(1) +print(token.lower()) +' 2>/dev/null +} + +TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE() { + local response="${1:-}" + + AGENT_RESPONSE="$response" python3 -c ' +import json, os +try: + agent = json.loads(os.environ["AGENT_RESPONSE"]).get("agent", {}) +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +if not isinstance(agent, dict) or not str(agent.get("id", "")).strip(): + raise SystemExit(1) +' 2>/dev/null +} + +TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON() { + printf '%s\n' '{"type":"pve","enableCommands":true}' +} + +TAPM_PULSE_ONLINE_NODES_FROM_JSON() { + local nodes_json="${1:-[]}" + + NODES_JSON="$nodes_json" python3 -c ' +import json, os +try: + nodes = json.loads(os.environ["NODES_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +for item in sorted(nodes, key=lambda value: str(value.get("node", ""))): + node = str(item.get("node", "")).strip() + if item.get("status") == "online" and node: + print(node) +' 2>/dev/null +} + +TAPM_PULSE_OFFLINE_NODES_FROM_JSON() { + local nodes_json="${1:-[]}" + + NODES_JSON="$nodes_json" python3 -c ' +import json, os +try: + nodes = json.loads(os.environ["NODES_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +for item in sorted(nodes, key=lambda value: str(value.get("node", ""))): + node = str(item.get("node", "")).strip() + if item.get("status") != "online" and node: + print(node) +' 2>/dev/null +} + +TAPM_PULSE_RESOURCE_INSTALLED() { + local resources_json="${1:-[]}" + + RESOURCES_JSON="$resources_json" python3 -c ' +import json, os +try: + resources = json.loads(os.environ["RESOURCES_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +for item in resources: + tags = str(item.get("tags", "")).split(";") + if item.get("type") == "lxc" and ( + "pulse" in tags or + str(item.get("name", "")).lower() in {"pulse", "pulse-monitor"} + ): + raise SystemExit(0) +raise SystemExit(1) +' 2>/dev/null +} + +TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS() { + local nodes_directory="${1:-/etc/pve/nodes}" + local config hostname tags + local -a configs + + [[ -d "$nodes_directory" ]] || return 2 + configs=("$nodes_directory"/*/lxc/*.conf) + for config in "${configs[@]}"; do + [[ -f "$config" ]] || continue + hostname="$( + awk -F':[[:space:]]*' '$1 == "hostname" { print $2; exit }' "$config" + )" + tags="$( + awk -F':[[:space:]]*' '$1 == "tags" { print $2; exit }' "$config" + )" + case "$hostname" in + [Pp][Uu][Ll][Ss][Ee]|[Pp][Uu][Ll][Ss][Ee]-[Mm][Oo][Nn][Ii][Tt][Oo][Rr]) + return 0 + ;; + esac + [[ ";${tags};" == *";pulse;"* ]] && return 0 + done + return 1 +} + +TAPM_PULSE_PROMPT() { + local variable="$1" + local label="$2" + local default_value="${3:-}" + local value + + if [[ -n "$default_value" ]]; then + read -r -p " ${label} [${default_value}]: " value + printf -v "$variable" '%s' "${value:-$default_value}" + else + read -r -p " ${label}: " value + printf -v "$variable" '%s' "$value" + fi +} + +TAPM_PULSE_PROMPT_UNTIL_VALID() { + local variable="$1" + local label="$2" + local default_value="$3" + local validator="$4" + local error_message="$5" + local candidate + + while true; do + TAPM_PULSE_PROMPT candidate "$label" "$default_value" + if "$validator" "$candidate"; then + printf -v "$variable" '%s' "$candidate" + return 0 + fi + TAPM_PULSE_FAIL "$error_message" + done +} + +TAPM_PULSE_VALID_ADMIN_PASSWORD() { + local password="${1:-}" + + TAPM_PULSE_PASSWORD="$password" python3 -c ' +import os +password = os.environ["TAPM_PULSE_PASSWORD"] +raise SystemExit( + 0 if len(password) >= 12 and len(password.encode("utf-8")) <= 72 else 1 +) +' 2>/dev/null +} + +TAPM_PULSE_SELECT_ADMIN_PASSWORD() { + local output_variable="$1" + local mode_variable="$2" + local selection candidate confirmation + local -a labels=( + "Generate a strong random password (recommended)" + "Enter a custom password" + ) + local -a values=("generated" "custom") + + SELECT_MENU "Pulse administrator password" labels values 0 + selection="$MENU_SELECTION" + if [[ "$selection" == 'generated' ]]; then + printf -v "$output_variable" '%s' '' + printf -v "$mode_variable" '%s' 'Generated' + return 0 + fi + + while true; do + echo + read -r -s -p " Enter custom Pulse admin password (12+ characters): " candidate + echo + if ! TAPM_PULSE_VALID_ADMIN_PASSWORD "$candidate"; then + TAPM_PULSE_FAIL \ + "The password must be at least 12 characters and no more than 72 bytes." + continue + fi + read -r -s -p " Confirm custom Pulse admin password: " confirmation + echo + if [[ "$candidate" != "$confirmation" ]]; then + TAPM_PULSE_FAIL "The passwords did not match. Please try again." + continue + fi + printf -v "$output_variable" '%s' "$candidate" + printf -v "$mode_variable" '%s' 'Custom' + unset candidate confirmation + return 0 + done +} + +TAPM_PULSE_FAIL() { + echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" + return 1 +} + +TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { + local pulse_url="$1" + local admin_username="$2" + local admin_password="$3" + local primary_api_token="$4" + local acknowledgement + + while true; do + echo + echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" + echo -e "${idsCL[LightYellow]} IMPORTANT — SAVE THESE PULSE CREDENTIALS NOW${idsCL[Default]}" + echo -e "${idsCL[LightYellow]} They will not be displayed again by this installer.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" + echo -e " Pulse URL: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}" + echo -e " Username: ${idsCL[White]}${admin_username}${idsCL[Default]}" + echo -e " Password: ${idsCL[LightGreen]}${admin_password}${idsCL[Default]}" + echo -e " API token: ${idsCL[LightGreen]}${primary_api_token}${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" + echo + read -r -p " Type saved after recording the password and API token: " acknowledgement + [[ "$acknowledgement" =~ ^[Ss][Aa][Vv][Ee][Dd]$ ]] && return 0 + echo -e "\n${idsCL[LightYellow]}The credentials remain above. Save them before continuing.${idsCL[Default]}" + done +} + +TAPM_PULSE_SET_BRIDGE_FROM_SELECTION() { + local variable="$1" + local selection="${2:-}" + local selected_bridge + + [[ "$selection" == bridge:* ]] || return 1 + selected_bridge="${selection#bridge:}" + [[ -n "$selected_bridge" ]] || return 1 + printf -v "$variable" '%s' "$selected_bridge" +} + +TAPM_PULSE_SELECT_BRIDGE() { + local variable="$1" + local bridge_name default_bridge="${2:-vmbr0}" + local default_found=0 + local -a bridges=() + local -a labels=() + local -a values=() + + while IFS= read -r bridge_name; do + [[ -n "$bridge_name" ]] || continue + bridges+=("$bridge_name") + [[ "$bridge_name" == "$default_bridge" ]] && default_found=1 + done < <( + { + for bridge_path in /sys/class/net/*/bridge; do + [[ -d "$bridge_path" ]] && basename "$(dirname "$bridge_path")" + done + if command -v ovs-vsctl >/dev/null 2>&1; then + ovs-vsctl list-br 2>/dev/null || true + fi + } | sort -Vu + ) + + (( ${#bridges[@]} > 0 )) || + { TAPM_PULSE_FAIL "No Linux bridges were found on this host."; return 1; } + + if (( default_found == 1 )); then + labels+=("${default_bridge} — default") + values+=("bridge:${default_bridge}") + fi + for bridge_name in "${bridges[@]}"; do + [[ $default_found == 1 && "$bridge_name" == "$default_bridge" ]] && continue + labels+=("$bridge_name") + values+=("bridge:${bridge_name}") + done + + SELECT_MENU "Pulse network bridge" labels values + case "$MENU_SELECTION" in + bridge:*) TAPM_PULSE_SET_BRIDGE_FROM_SELECTION "$variable" "$MENU_SELECTION";; + quit) EXIT1; exit 0;; + *) return 1;; + esac +} + +TAPM_PULSE_STORAGE_IS_SHARED() { + local storage="$1" + + pvesh get "/storage/${storage}" --output-format json 2>/dev/null | + python3 -c ' +import json, sys +try: + value = json.load(sys.stdin).get("shared", 0) +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +raise SystemExit(0 if str(value).lower() in {"1", "true", "yes"} else 1) +' +} + +TAPM_PULSE_HA_STATUS_ENABLED() { + local status="${1:-}" + + grep -q '^quorum OK' <<<"$status" && + grep -Eq '^master[[:space:]].*\(active,' <<<"$status" +} + +TAPM_PULSE_CLUSTER_HA_ENABLED() { + local status + + command -v ha-manager >/dev/null 2>&1 || return 1 + status="$(ha-manager status 2>/dev/null)" || return 1 + TAPM_PULSE_HA_STATUS_ENABLED "$status" +} + +TAPM_PULSE_VERIFY_SIGNATURE() { + local target_path="$1" + local signature_path="$2" + local label="${3:-Pulse release asset}" + local allowed_signers + + command -v ssh-keygen >/dev/null 2>&1 || + { TAPM_PULSE_FAIL "OpenSSH is required to verify ${label}."; return 1; } + [[ -s "$target_path" && -s "$signature_path" ]] || + { TAPM_PULSE_FAIL "${label} or its signature is missing."; return 1; } + + allowed_signers="$(mktemp /tmp/tapm-pulse-signers.XXXXXX)" || + { TAPM_PULSE_FAIL "Could not create the Pulse signature verifier file."; return 1; } + printf '%s %s\n' "$TAPM_PULSE_SIGNING_IDENTITY" \ + "$TAPM_PULSE_SIGNING_KEY" >"$allowed_signers" + + if ! ssh-keygen -Y verify \ + -f "$allowed_signers" \ + -I "$TAPM_PULSE_SIGNING_IDENTITY" \ + -n "$TAPM_PULSE_SIGNING_NAMESPACE" \ + -s "$signature_path" <"$target_path" >/dev/null 2>&1; then + rm -f -- "$allowed_signers" + TAPM_PULSE_FAIL "Signature verification failed for ${label}; nothing was installed." + return 1 + fi + rm -f -- "$allowed_signers" +} + +TAPM_PULSE_REMOVE_PARTIAL_LXC() { + local ctid="$1" + + echo -e "${idsCL[LightYellow]}Removing incomplete LXC ${ctid} created by this deployment...${idsCL[Default]}" + if command -v ha-manager >/dev/null 2>&1; then + ha-manager remove "ct:${ctid}" >/dev/null 2>&1 || true + fi + pct stop "$ctid" --skiplock 1 >/dev/null 2>&1 || true + pct destroy "$ctid" --purge 1 >/dev/null 2>&1 || true +} + +TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL() { + local ctid="$1" + local choice + + while true; do + echo + read -r -p " Remove the incomplete Pulse LXC ${ctid} now? [Y/n] " choice + case "$choice" in + ''|[Yy]) + TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" + echo -e "${idsCL[Green]}Incomplete Pulse LXC ${ctid} was removed.${idsCL[Default]}" + return 0 + ;; + [Nn]) + echo -e "${idsCL[LightYellow]}Pulse LXC ${ctid} was kept for troubleshooting.${idsCL[Default]}" + return 1 + ;; + *) + TAPM_PULSE_FAIL "Enter y or n." + ;; + esac + done +} + +TAPM_PULSE_CONFIGURE_SECURITY() { + local ctid="$1" + local pulse_url="$2" + local temp_dir="$3" + local username_variable="$4" + local password_variable="$5" + local token_variable="$6" + local requested_password="${7:-}" + local bootstrap_output bootstrap_token generated_username generated_password generated_api_token + local request_file curl_config response security_ready='no' + local deadline delay + + generated_username='admin' + if [[ -n "$requested_password" ]]; then + generated_password="$requested_password" + else + generated_password="Ta!9-$(openssl rand -hex 18)" || return 1 + fi + generated_api_token="$(openssl rand -hex 32)" || return 1 + request_file="${temp_dir}/pulse-quick-setup.json" + curl_config="${temp_dir}/pulse-quick-setup.curl" + + bootstrap_output="$( + pct exec "$ctid" -- env PULSE_DATA_DIR=/etc/pulse \ + /usr/local/bin/pulse bootstrap-token 2>/dev/null + )" || { + TAPM_PULSE_FAIL "Pulse did not provide its first-run bootstrap token." + return 1 + } + bootstrap_token="$(TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT "$bootstrap_output")" || { + unset bootstrap_output + TAPM_PULSE_FAIL "The Pulse bootstrap-token output could not be validated." + return 1 + } + unset bootstrap_output + + TAPM_PULSE_ADMIN_USER="$generated_username" \ + TAPM_PULSE_ADMIN_PASSWORD="$generated_password" \ + TAPM_PULSE_PRIMARY_TOKEN="$generated_api_token" \ + python3 -c ' +import json, os, sys +json.dump({ + "username": os.environ["TAPM_PULSE_ADMIN_USER"], + "password": os.environ["TAPM_PULSE_ADMIN_PASSWORD"], + "apiToken": os.environ["TAPM_PULSE_PRIMARY_TOKEN"], + "enableNotifications": False, + "darkMode": False, + "force": False, +}, sys.stdout) +' >"$request_file" || { + unset bootstrap_token generated_password generated_api_token + TAPM_PULSE_FAIL "Could not prepare the Pulse security configuration." + return 1 + } + chmod 0600 "$request_file" || return 1 + { + printf 'header = "Content-Type: application/json"\n' + printf 'header = "X-Setup-Token: %s"\n' "$bootstrap_token" + printf 'data-binary = "@%s"\n' "$request_file" + } >"$curl_config" || return 1 + chmod 0600 "$curl_config" || return 1 + + response="$( + curl --fail --silent --show-error \ + --request POST \ + --config "$curl_config" \ + "${pulse_url}/api/security/quick-setup" + )" || { + unset bootstrap_token generated_password generated_api_token + TAPM_PULSE_FAIL "Pulse rejected the automated first-time security setup." + return 1 + } + if ! SETUP_RESPONSE="$response" python3 -c ' +import json, os +try: + success = json.loads(os.environ["SETUP_RESPONSE"]).get("success") +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +raise SystemExit(0 if success is True else 1) +' 2>/dev/null; then + unset bootstrap_token generated_password generated_api_token response + TAPM_PULSE_FAIL "Pulse did not confirm that first-time setup completed." + return 1 + fi + unset bootstrap_token response + + { + printf 'header = "X-API-Token: %s"\n' "$generated_api_token" + } >"$curl_config" || return 1 + deadline=$((SECONDS + 30)) + delay=1 + while (( SECONDS < deadline )); do + if curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 5 \ + --config "$curl_config" \ + "${pulse_url}/api/security/status" >/dev/null 2>&1; then + security_ready='yes' + break + fi + TAPM_PULSE_BACKOFF_SLEEP "$deadline" "$delay" + delay="$(TAPM_PULSE_BACKOFF_NEXT "$delay" 4)" + done + if [[ "$security_ready" != 'yes' ]]; then + unset generated_password generated_api_token + TAPM_PULSE_FAIL "The generated Pulse administrator token could not be verified." + return 1 + fi + + printf -v "$username_variable" '%s' "$generated_username" + printf -v "$password_variable" '%s' "$generated_password" + printf -v "$token_variable" '%s' "$generated_api_token" + unset generated_password generated_api_token +} + +TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT() { + local response="$1" + local pulse_url="$2" + local expected_host="${3:-}" + + PULSE_SETUP_RESPONSE="$response" \ + PULSE_SETUP_URL="$pulse_url" \ + PULSE_SETUP_HOST="$expected_host" \ + python3 -c ' +import json +import os +import re +import sys +import time +from urllib.parse import quote + +try: + data = json.loads(os.environ["PULSE_SETUP_RESPONSE"]) +except (TypeError, ValueError): + raise SystemExit("Pulse returned invalid setup-token JSON") + +token = str(data.get("setupToken", "")).strip() +returned_host = str(data.get("host", "")).strip() +host = os.environ["PULSE_SETUP_HOST"].strip() or returned_host +expires = data.get("expires", 0) +if not re.fullmatch(r"[0-9a-fA-F]{32,128}", token): + raise SystemExit("Pulse returned no valid setup token") +if data.get("type") != "pve" or not returned_host or not host: + raise SystemExit("Pulse returned an invalid PVE setup artifact") +try: + if int(expires) <= int(time.time()): + raise SystemExit("Pulse returned an expired setup token") +except (TypeError, ValueError): + raise SystemExit("Pulse returned an invalid setup-token expiry") + +pulse_url = os.environ["PULSE_SETUP_URL"].rstrip("/") +empty = "" +expected_url = ( + f"{pulse_url}/api/setup-script?" + f"host={quote(host, safe=empty)}&" + f"pulse_url={quote(pulse_url, safe=empty)}&type=pve" +) +expected_download_url = ( + f"{pulse_url}/api/setup-script?" + f"host={quote(host, safe=empty)}&" + f"pulse_url={quote(pulse_url, safe=empty)}&" + f"setup_token={quote(token, safe=empty)}&type=pve" +) + +# Pulse v6.1.1 rejects its own valid artifact if the server selected a +# different public base URL or included an optional query parameter. These +# presentation fields are not executed by auto_register_pve_node; align them +# with the helper contract while preserving the server-issued token. +old_url = str(data.get("url", "")) +if not old_url: + raise SystemExit("Pulse returned no setup-script URL") +for field in ("command", "commandWithEnv", "commandWithoutEnv"): + value = str(data.get(field, "")) + if not value or old_url not in value: + raise SystemExit(f"Pulse returned an invalid {field} field") + data[field] = value.replace(old_url, expected_url) + +data["url"] = expected_url +data["downloadURL"] = expected_download_url +data["host"] = host +data["scriptFileName"] = "pulse-setup-pve.sh" +json.dump(data, sys.stdout, separators=(",", ":")) +' +} + +TAPM_PULSE_CREATE_PVE_AUTO_REGISTER_TOKEN() { + local token_name="$1" + local output_variable="$2" + local status_variable="$3" + local command_output='' + local command_status=0 + + if command_output="$( + pveum user token add pulse-monitor@pve "$token_name" \ + --privsep 1 --output-format json 2>&1 + )"; then + command_status=0 + else + command_status=$? + fi + + # Older pveum versions reject --output-format and require table parsing. + if (( command_status != 0 )) && + grep -Eqi \ + 'unknown option|unknown command|no such option|unable to parse option|output-format' \ + <<<"$command_output"; then + if command_output="$( + pveum user token add pulse-monitor@pve "$token_name" \ + --privsep 1 2>&1 + )"; then + command_status=0 + else + command_status=$? + fi + fi + + printf -v "$output_variable" '%s' "$command_output" + printf -v "$status_variable" '%s' "$command_status" +} + +TAPM_PULSE_REGISTER_CLUSTER() { + local ctid="$1" + local container_ip="$2" + local pulse_port="$3" + local installer="$4" + local primary_token="$5" + local temp_dir="$6" + local pulse_url="http://${container_ip}:${pulse_port}" + local curl_config="${temp_dir}/pulse-cluster-registration.curl" + + printf 'header = "X-API-Token: %s"\n' "$primary_token" >"$curl_config" || + return 1 + chmod 0600 "$curl_config" || return 1 + + ( + trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT + # Reuse the verified release's registration implementation so its + # Proxmox roles, token contract, and compatibility checks stay in sync. + # shellcheck disable=SC1090 + source "$installer" + IN_CONTAINER=false + # Pulse v6.1.1's helper shadows the caller's token_output and + # token_status variables, discarding a successful pveum result. + # Override only that helper so auto_register_pve_node receives them. + create_pve_auto_register_token() { + TAPM_PULSE_CREATE_PVE_AUTO_REGISTER_TOKEN "$@" + } + # Pulse v6.1.1's installer omits backup_perms from its expected + # artifact URLs even when it requests backupPerms=true. That causes it + # to reject Pulse's otherwise valid response as "missing setup token." + # Use the internally consistent least-privilege request until a newer + # pinned Pulse release fixes that upstream contract mismatch. + PULSE_AUTO_BACKUP_PERMS=false + + # Pulse v6.1.1 requires authentication for setup-token creation. Add + # the primary token only to that request; never send it to Proxmox. + curl() { + local curl_argument + local setup_response + + for curl_argument in "$@"; do + if [[ "$curl_argument" == "${pulse_url}/api/setup-script-url" ]]; then + setup_response="$(command curl --config "$curl_config" "$@")" || + return 1 + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + "$setup_response" "$pulse_url" \ + "${normalized_host_url:-}" + return + fi + done + command curl "$@" + } + + wait_for_pulse_ready "$pulse_url" 120 1 || return 1 + auto_register_pve_node "$ctid" "$container_ip" "$pulse_port" + [[ "${AUTO_NODE_REGISTERED:-false}" == true ]] + ) +} + +TAPM_PULSE_ISSUE_AGENT_TOKEN() { + local pulse_url="$1" + local primary_token="$2" + local node="$3" + local temp_dir="$4" + local request_file="${temp_dir}/agent-${node}.json" + local curl_config="${temp_dir}/agent-${node}.curl" + local response + + TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON >"$request_file" || return 1 + chmod 0600 "$request_file" || return 1 + { + printf 'header = "Content-Type: application/json"\n' + printf 'header = "X-API-Token: %s"\n' "$primary_token" + printf 'data-binary = "@%s"\n' "$request_file" + } >"$curl_config" || return 1 + chmod 0600 "$curl_config" || return 1 + + response="$( + curl --fail --silent --show-error \ + --request POST \ + --config "$curl_config" \ + "${pulse_url}/api/agent-install-command" + )" || return 1 + TAPM_PULSE_TOKEN_FROM_RESPONSE "$response" +} + +TAPM_PULSE_WAIT_AGENT_REGISTERED() { + local pulse_url="$1" + local token="$2" + local node="$3" + local temp_dir="$4" + local curl_config="${temp_dir}/agent-${node}-verify.curl" + local encoded_node response + local deadline delay + + printf 'header = "X-API-Token: %s"\n' "$token" >"$curl_config" || return 1 + chmod 0600 "$curl_config" || return 1 + encoded_node="$( + TAPM_PULSE_NODE_NAME="$node" python3 -c ' +import os, urllib.parse +print(urllib.parse.quote(os.environ["TAPM_PULSE_NODE_NAME"], safe="")) +' + )" || return 1 + + # Pulse v6.1.1 completes its Proxmox setup before sending the first host + # report. Its built-in registration retries can span at least 135 seconds, + # so allow up to four minutes while backing off repeated local lookups. + deadline=$((SECONDS + 240)) + delay=1 + while (( SECONDS < deadline )); do + response="$( + curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 5 \ + --config "$curl_config" \ + "${pulse_url}/api/agents/agent/lookup?hostname=${encoded_node}" \ + 2>/dev/null + )" || response='' + if TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE "$response"; then + return 0 + fi + TAPM_PULSE_BACKOFF_SLEEP "$deadline" "$delay" + delay="$(TAPM_PULSE_BACKOFF_NEXT "$delay" 8)" + done + return 1 +} + +TAPM_PULSE_INSTALL_AGENT_LOCAL() { + local node="$1" + local pulse_url="$2" + local token="$3" + local token_file installer_file cleanup_state_dir artifact + local status=0 + local -a old_artifacts=( + /usr/local/bin/pulse-agent + /var/lib/pulse-agent + /var/log/pulse-agent.log + /etc/systemd/system/pulse-agent.service + /etc/systemd/system/multi-user.target.wants/pulse-agent.service + ) + + token_file="$(mktemp /tmp/tapm-pulse-agent-token.XXXXXX)" || return 1 + installer_file="$(mktemp /tmp/tapm-pulse-agent-installer.XXXXXX)" || { + rm -f -- "$token_file" + return 1 + } + cleanup_state_dir="$(mktemp -d /tmp/tapm-pulse-agent-cleanup.XXXXXX)" || { + rm -f -- "$token_file" "$installer_file" + return 1 + } + chmod 0600 "$token_file" "$installer_file" || { + rm -f -- "$token_file" "$installer_file" + rm -rf -- "$cleanup_state_dir" + return 1 + } + printf '%s' "$token" >"$token_file" || { + rm -f -- "$token_file" "$installer_file" + rm -rf -- "$cleanup_state_dir" + return 1 + } + curl --fail --silent --show-error --location \ + --output "$installer_file" "${pulse_url}/install.sh" || status=$? + if (( status == 0 )); then + echo " Removing any previous Pulse Unified Agent installation..." + # Remove its connection sources before invoking the supported cleanup + # routine. This is a replacement install, so contacting the retired + # Pulse server to unregister is unnecessary. + systemctl stop pulse-agent >/dev/null 2>&1 || true + systemctl disable pulse-agent >/dev/null 2>&1 || true + pkill -x pulse-agent >/dev/null 2>&1 || true + rm -f -- \ + /etc/systemd/system/pulse-agent.service \ + /etc/systemd/system/multi-user.target.wants/pulse-agent.service + systemctl daemon-reload >/dev/null 2>&1 || true + rm -rf -- /var/lib/pulse-agent + bash "$installer_file" \ + --uninstall \ + --non-interactive \ + --state-dir "$cleanup_state_dir" || status=$? + fi + if (( status == 0 )) && systemctl is-active --quiet pulse-agent; then + echo " The previous pulse-agent service is still active." >&2 + status=1 + fi + if (( status == 0 )) && command -v pgrep >/dev/null 2>&1 && + pgrep -x pulse-agent >/dev/null 2>&1; then + echo " A previous pulse-agent process is still running." >&2 + status=1 + fi + if (( status == 0 )); then + for artifact in "${old_artifacts[@]}"; do + if [[ -e "$artifact" || -L "$artifact" ]]; then + echo " Previous Pulse agent artifact remains: ${artifact}" >&2 + status=1 + fi + done + fi + if (( status == 0 )); then + if ! command -v sensors >/dev/null 2>&1; then + echo " Installing lm-sensors for Pulse host temperature telemetry..." + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends lm-sensors; then + echo " lm-sensors could not be installed; Pulse will continue without host temperature telemetry." >&2 + fi + fi + bash "$installer_file" \ + --url "$pulse_url" \ + --token-file "$token_file" \ + --hostname "$node" \ + --enable-host \ + --enable-proxmox \ + --proxmox-type pve \ + --enable-commands \ + --non-interactive \ + --insecure || status=$? + fi + rm -f -- "$token_file" "$installer_file" + rm -rf -- "$cleanup_state_dir" + (( status == 0 )) || return "$status" + systemctl is-active --quiet pulse-agent +} + +TAPM_PULSE_INSTALL_AGENT_REMOTE() { + local node="$1" + local pulse_url="$2" + local token="$3" + local remote_token_file + local -a ssh_args=( + ssh + -o BatchMode=yes + -o ConnectTimeout=10 + "root@${node}" + ) + + remote_token_file="$( + printf '%s' "$token" | + "${ssh_args[@]}" \ + 'umask 077; token_file=$(mktemp /tmp/tapm-pulse-agent-token.XXXXXX) || exit 1; cat >"$token_file" || exit 1; printf "%s\n" "$token_file"' | + tail -1 + )" || return 1 + [[ "$remote_token_file" =~ ^/tmp/tapm-pulse-agent-token\.[A-Za-z0-9]+$ ]] || + return 1 + + "${ssh_args[@]}" bash -s -- \ + "$pulse_url" "$remote_token_file" "$node" <<'TAPM_PULSE_REMOTE_AGENT' +set -eu -o pipefail +pulse_url="$1" +token_file="$2" +node="$3" +installer_file="$(mktemp /tmp/tapm-pulse-agent-installer.XXXXXX)" +cleanup_state_dir="$(mktemp -d /tmp/tapm-pulse-agent-cleanup.XXXXXX)" +trap 'rm -f -- "$token_file" "$installer_file"; rm -rf -- "$cleanup_state_dir"' EXIT +chmod 0600 "$token_file" "$installer_file" +curl --fail --silent --show-error --location \ + --output "$installer_file" "${pulse_url}/install.sh" + +echo " Removing any previous Pulse Unified Agent installation..." +# This is a replacement install. Remove the old connection sources first so +# the supported cleanup routine cannot contact the retired Pulse server. +systemctl stop pulse-agent >/dev/null 2>&1 || true +systemctl disable pulse-agent >/dev/null 2>&1 || true +pkill -x pulse-agent >/dev/null 2>&1 || true +rm -f -- \ + /etc/systemd/system/pulse-agent.service \ + /etc/systemd/system/multi-user.target.wants/pulse-agent.service +systemctl daemon-reload >/dev/null 2>&1 || true +rm -rf -- /var/lib/pulse-agent +bash "$installer_file" \ + --uninstall \ + --non-interactive \ + --state-dir "$cleanup_state_dir" +if systemctl is-active --quiet pulse-agent; then + echo " The previous pulse-agent service is still active." >&2 + exit 1 +fi +if command -v pgrep >/dev/null 2>&1 && + pgrep -x pulse-agent >/dev/null 2>&1; then + echo " A previous pulse-agent process is still running." >&2 + exit 1 +fi +for artifact in \ + /usr/local/bin/pulse-agent \ + /var/lib/pulse-agent \ + /var/log/pulse-agent.log \ + /etc/systemd/system/pulse-agent.service \ + /etc/systemd/system/multi-user.target.wants/pulse-agent.service; do + if [[ -e "$artifact" || -L "$artifact" ]]; then + echo " Previous Pulse agent artifact remains: ${artifact}" >&2 + exit 1 + fi +done + +if ! command -v sensors >/dev/null 2>&1; then + echo " Installing lm-sensors for Pulse host temperature telemetry..." + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends lm-sensors; then + echo " lm-sensors could not be installed; Pulse will continue without host temperature telemetry." >&2 + fi +fi + +bash "$installer_file" \ + --url "$pulse_url" \ + --token-file "$token_file" \ + --hostname "$node" \ + --enable-host \ + --enable-proxmox \ + --proxmox-type pve \ + --enable-commands \ + --non-interactive \ + --insecure +systemctl is-active --quiet pulse-agent +TAPM_PULSE_REMOTE_AGENT +} + +TAPM_PULSE_DEPLOY_CLUSTER_AGENTS() { + local pulse_url="$1" + local primary_token="$2" + local temp_dir="$3" + local nodes_json node agent_token current_node + local installed=0 failed=0 + + nodes_json="$(pvesh get /nodes --output-format json 2>/dev/null)" || return 1 + current_node="$(hostname -s)" + while IFS= read -r node; do + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || { + echo -e "${idsCL[LightYellow]}Skipping invalid cluster node name '${node}'.${idsCL[Default]}" + ((failed += 1)) + continue + } + echo -e "${idsCL[LightCyan]}Resetting and installing the Pulse Unified Agent on ${node}...${idsCL[Default]}" + agent_token="$( + TAPM_PULSE_ISSUE_AGENT_TOKEN \ + "$pulse_url" "$primary_token" "$node" "$temp_dir" + )" || { + echo -e "${idsCL[LightRed]}Could not create an enrollment token for ${node}.${idsCL[Default]}" + ((failed += 1)) + continue + } + + if [[ "$node" == "$current_node" || "$node" == "$(hostname)" ]]; then + TAPM_PULSE_INSTALL_AGENT_LOCAL "$node" "$pulse_url" "$agent_token" + else + TAPM_PULSE_INSTALL_AGENT_REMOTE "$node" "$pulse_url" "$agent_token" + fi + if (( $? == 0 )); then + echo " Waiting for Pulse to confirm registration from ${node}..." + if TAPM_PULSE_WAIT_AGENT_REGISTERED \ + "$pulse_url" "$agent_token" "$node" "$temp_dir"; then + echo -e "${idsCL[Green]}Pulse confirmed Unified Agent registration for ${node}.${idsCL[Default]}" + ((installed += 1)) + else + echo -e "${idsCL[LightRed]}pulse-agent is active on ${node}, but Pulse did not confirm its registration.${idsCL[Default]}" + ((failed += 1)) + fi + else + echo -e "${idsCL[LightRed]}Pulse Unified Agent installation failed on ${node}.${idsCL[Default]}" + ((failed += 1)) + fi + unset agent_token + done < <(TAPM_PULSE_ONLINE_NODES_FROM_JSON "$nodes_json") + while IFS= read -r node; do + [[ -n "$node" ]] || continue + echo -e "${idsCL[LightYellow]}Pulse agent installation skipped on offline node ${node}.${idsCL[Default]}" + ((failed += 1)) + done < <(TAPM_PULSE_OFFLINE_NODES_FROM_JSON "$nodes_json") + + TAPM_PULSE_AGENTS_INSTALLED="$installed" + TAPM_PULSE_AGENTS_FAILED="$failed" + (( installed > 0 || failed == 0 )) +} + +TAPM_DEPLOY_PULSE_LXC() { + local release="${PULSE_RELEASE:-v6.1.1}" + local pulse_port="${PULSE_PORT:-7655}" auto_update_flag='--disable-auto-updates' + local ctid default_ctid cluster_resources hostname bridge address_cidr gateway vlan_id + local root_storage default_root_storage template_storage template_name template_path + local arch archive_name base_url installer archive signature installer_signature + local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup + local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir + local default_bridge pulse_url admin_username admin_password admin_password_mode + local primary_api_token + local container_created=0 + local -a create_args=() + + memory=2048 + disk=4 + cores=2 + cpulimit=2 + swap=256 + onboot=1 + firewall=1 + unprivileged=1 + startup=99 + auto_update_flag='--enable-auto-updates' + + echo + echo -e "${idsCL[LightCyan]}Deploy Pulse monitoring in a dedicated LXC${idsCL[Default]}" + echo + echo " This TA-managed workflow creates the container and installs a pinned," + echo " cryptographically verified Pulse release. It does not query a latest" + echo " release URL before installation." + echo + + [[ $EUID -eq 0 ]] || + { TAPM_PULSE_FAIL "Run this action as root on a Proxmox VE host."; return 1; } + for command in pct pvesm pveam pvesh ssh-keygen python3 curl openssl ssh; do + command -v "$command" >/dev/null 2>&1 || + { TAPM_PULSE_FAIL "Required command '${command}' was not found."; return 1; } + done + TAPM_PULSE_VALID_RELEASE "$release" || + { TAPM_PULSE_FAIL "Configured Pulse release '${release}' is invalid."; return 1; } + TAPM_PULSE_VALID_PORT "$pulse_port" || + { TAPM_PULSE_FAIL "Configured Pulse port '${pulse_port}' is invalid."; return 1; } + + default_ctid="$( + cluster_resources="$( + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null + )" && + TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES \ + "$cluster_resources" 200 + )" || default_ctid='' + if [[ -z "$default_ctid" ]]; then + default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + fi + while true; do + TAPM_PULSE_PROMPT_UNTIL_VALID ctid "Container ID" "$default_ctid" \ + TAPM_PULSE_VALID_CTID "The container ID must be a whole number of at least three digits." + if ! pct status "$ctid" >/dev/null 2>&1; then + break + fi + TAPM_PULSE_FAIL "Container ${ctid} already exists. Choose another container ID." + default_ctid="$((ctid + 1))" + done + + TAPM_PULSE_PROMPT_UNTIL_VALID hostname "Container hostname" "Pulse-Monitor" \ + TAPM_PULSE_VALID_HOSTNAME \ + "The hostname must contain only letters, numbers, periods, and hyphens." + + while true; do + read -r -p " Customize CPU, memory, disk, or swap? [y/N] " choice + [[ -z "$choice" || "$choice" =~ ^[YyNn]$ ]] && break + TAPM_PULSE_FAIL "Enter y or n." + done + if [[ "$choice" =~ ^[Yy]$ ]]; then + TAPM_PULSE_PROMPT_UNTIL_VALID memory "Memory in MiB" "$memory" \ + TAPM_PULSE_VALID_POSITIVE_INTEGER "Memory must be a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID disk "Root disk size in GiB" "$disk" \ + TAPM_PULSE_VALID_POSITIVE_INTEGER "Disk size must be a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID cores "CPU cores" "$cores" \ + TAPM_PULSE_VALID_POSITIVE_INTEGER "CPU cores must be a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID cpulimit "CPU limit (0 for unlimited)" "$cpulimit" \ + TAPM_PULSE_VALID_NONNEGATIVE_INTEGER \ + "CPU limit must be zero or a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID swap "Swap in MiB" "$swap" \ + TAPM_PULSE_VALID_NONNEGATIVE_INTEGER \ + "Swap must be zero or a positive whole number." + fi + + echo + default_bridge="$( + ip route 2>/dev/null | + awk '/^default/ { print $5; exit }' + )" + [[ -n "$default_bridge" ]] || default_bridge='vmbr0' + TAPM_PULSE_SELECT_BRIDGE bridge "$default_bridge" || return 1 + TAPM_PULSE_PROMPT_UNTIL_VALID address_cidr \ + "Static IPv4 address with prefix (leave blank for DHCP)" '' \ + TAPM_PULSE_VALID_OPTIONAL_IPV4_CIDR \ + "Enter a valid IPv4 CIDR such as 10.10.2.30/16, or leave it blank for DHCP." + if [[ -n "$address_cidr" ]]; then + TAPM_PULSE_PROMPT_UNTIL_VALID gateway "IPv4 gateway" '' \ + TAPM_PULSE_VALID_IPV4 "Enter a valid IPv4 gateway." + fi + TAPM_PULSE_PROMPT nameserver \ + "DNS servers, space-separated (leave blank to inherit host settings)" + TAPM_PULSE_PROMPT_UNTIL_VALID vlan_id \ + "VLAN ID (leave blank for untagged)" '' \ + TAPM_PULSE_VALID_OPTIONAL_VLAN \ + "The VLAN ID must be between 1 and 4094, or blank for untagged." + + TAPM_PULSE_CLUSTER_HA_ENABLED && add_ha='yes' + + default_root_storage="$( + pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_root_storage" ]] || + { TAPM_PULSE_FAIL "No active storage supports LXC volumes."; return 1; } + if [[ "$add_ha" == 'yes' ]]; then + while read -r choice; do + [[ -n "$choice" ]] || continue + if TAPM_PULSE_STORAGE_IS_SHARED "$choice"; then + default_root_storage="$choice" + break + fi + done < <( + pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1 }' + ) + fi + TAPM_ISO_NFS_SELECT_STORAGE root_storage \ + "Pulse root filesystem storage" "$default_root_storage" || return 1 + + admin_password='' + admin_password_mode='Generated' + TAPM_PULSE_SELECT_ADMIN_PASSWORD \ + admin_password admin_password_mode || return 1 + + default_root_storage="$( + pvesm status --content vztmpl 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_root_storage" ]] || + { TAPM_PULSE_FAIL "No active storage supports container templates."; return 1; } + template_storage="$default_root_storage" + + if [[ "$add_ha" == 'yes' ]] && + ! TAPM_PULSE_STORAGE_IS_SHARED "$root_storage"; then + echo -e "${idsCL[LightYellow]}Warning: HA is active, but '${root_storage}' is not marked shared.${idsCL[Default]}" + echo " The LXC will still be added to HA as requested, but automatic failover" + echo " requires shared storage or separately configured storage replication." + fi + + echo + echo " Deployment summary" + echo " Pulse release: ${release}" + echo " LXC: ${ctid} (${hostname}), unprivileged" + if [[ -n "$address_cidr" ]]; then + echo " Network: ${address_cidr} via ${gateway} on ${bridge}" + else + echo " Network: DHCP on ${bridge}" + fi + [[ -n "$vlan_id" ]] && echo " VLAN: ${vlan_id}" + [[ -n "$nameserver" ]] && echo " DNS servers: ${nameserver}" + echo " Resources: ${cores} vCPU (limit ${cpulimit}), ${memory} MiB RAM, ${swap} MiB swap" + echo " Root volume: ${root_storage}:${disk} GiB" + echo " Pulse port: ${pulse_port}" + echo " Start at boot: yes, order ${startup}" + echo " Firewall: enabled" + echo " Automatic update: ${auto_updates}" + echo " Proxmox HA: ${add_ha}" + echo " Admin password: ${admin_password_mode}" + echo + read -r -p " Create this Pulse container (type yes to continue)? " choice + [[ "$choice" =~ ^[Yy][Ee][Ss]$ ]] || { + echo " Cancelled; no changes were made." + return 0 + } + + arch="$(TAPM_PULSE_ARCH "$(uname -m)")" || + { TAPM_PULSE_FAIL "Pulse does not support this host architecture."; return 1; } + archive_name="pulse-${release}-linux-${arch}.tar.gz" + base_url="https://github.com/rcourtman/Pulse/releases/download/${release}" + + if ! TAPM_CREATE_TEMP_DIR pulse; then + return 1 + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/install.sh" + installer_signature="${installer}.sshsig" + archive="${temp_dir}/${archive_name}" + signature="${archive}.sshsig" + + echo -e "\n${idsCL[LightCyan]}Downloading and verifying Pulse ${release}...${idsCL[Default]}" + if ! TAPM_DOWNLOAD_HTTPS "${base_url}/install.sh" "$installer" "Pulse installer" || + ! TAPM_DOWNLOAD_HTTPS "${base_url}/install.sh.sshsig" \ + "$installer_signature" "Pulse installer signature" || + ! TAPM_PULSE_VERIFY_SIGNATURE "$installer" "$installer_signature" "Pulse installer" || + ! TAPM_DOWNLOAD_HTTPS "${base_url}/${archive_name}" "$archive" "Pulse archive" || + ! TAPM_DOWNLOAD_HTTPS "${base_url}/${archive_name}.sshsig" \ + "$signature" "Pulse archive signature" || + ! TAPM_PULSE_VERIFY_SIGNATURE "$archive" "$signature" "Pulse archive"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}" + template_path="$( + pveam list "$template_storage" 2>/dev/null | + awk 'NR > 1 && $1 ~ /:vztmpl\/debian-(13|12)-standard_/ { print $1 }' | + sort -V | + tail -1 + )" + if [[ -z "$template_path" ]]; then + if ! pveam update; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Could not refresh the container template catalog." + return 1 + fi + template_name="$( + pveam available --section system | + awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + sort -V | + tail -1 + )" + if [[ -z "$template_name" ]]; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "No supported Debian 12/13 standard template was found." + return 1 + fi + template_path="${template_storage}:vztmpl/${template_name}" + if ! pveam download "$template_storage" "$template_name"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "The Debian template download failed." + return 1 + fi + fi + + if [[ -n "$address_cidr" ]]; then + network_config="name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},firewall=${firewall},type=veth" + else + network_config="name=eth0,bridge=${bridge},ip=dhcp,firewall=${firewall},type=veth" + fi + [[ -n "$vlan_id" ]] && network_config+=",tag=${vlan_id}" + + echo -e "\n${idsCL[LightCyan]}Creating and starting LXC ${ctid}...${idsCL[Default]}" + create_args=( + pct create "$ctid" "$template_path" + --hostname "$hostname" + --ostype debian + --unprivileged "$unprivileged" + --features nesting=1 + --cores "$cores" + --memory "$memory" + --swap "$swap" + --rootfs "${root_storage}:${disk}" + --net0 "$network_config" + --onboot "$onboot" + --startup "order=${startup}" + ) + [[ "$cpulimit" != 0 ]] && create_args+=(--cpulimit "$cpulimit") + [[ -n "$nameserver" ]] && create_args+=(--nameserver "$nameserver") + if ! "${create_args[@]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Container creation failed." + return 1 + fi + container_created=1 + if ! pct start "$ctid" || + ! timeout 90 bash -c \ + "until pct exec '$ctid' -- test -d /run/systemd/system >/dev/null 2>&1; do sleep 2; done"; then + TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "The new Pulse container did not become ready." + return 1 + fi + + timezone="$(timedatectl show --property=Timezone --value 2>/dev/null || true)" + [[ -n "$timezone" ]] || timezone='America/Chicago' + pct exec "$ctid" -- ln -snf "/usr/share/zoneinfo/${timezone}" /etc/localtime || true + + echo -e "\n${idsCL[LightCyan]}Installing verified Pulse release inside LXC ${ctid}...${idsCL[Default]}" + if ! pct push "$ctid" "$installer" /tmp/install.sh || + ! pct push "$ctid" "$archive" "/tmp/${archive_name}" || + ! pct push "$ctid" "$signature" "/tmp/${archive_name}.sshsig" || + ! timeout 600 pct exec "$ctid" -- env "FRONTEND_PORT=${pulse_port}" \ + bash /tmp/install.sh \ + --in-container \ + --version "$release" \ + --archive "/tmp/${archive_name}" \ + "$auto_update_flag" || + ! pct exec "$ctid" -- systemctl is-active --quiet pulse; then + (( container_created == 1 )) && TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Pulse could not be installed or verified." + return 1 + fi + + container_ip="$( + pct exec "$ctid" -- hostname -I 2>/dev/null | + awk '{ print $1; exit }' + )" + + if [[ -n "$container_ip" ]]; then + pulse_url="http://${container_ip}:${pulse_port}" + fi + + if [[ "$add_ha" == 'yes' ]] && + ! ha-manager add "ct:${ctid}" --state started; then + echo -e "${idsCL[LightYellow]}Pulse is running, but it could not be added to HA.${idsCL[Default]}" + fi + + if [[ -z "$container_ip" ]]; then + pct exec "$ctid" -- rm -f \ + /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Pulse is running, but its LXC address could not be determined." + TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid" || true + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Configuring Pulse administrator security...${idsCL[Default]}" + if ! TAPM_PULSE_CONFIGURE_SECURITY \ + "$ctid" "$pulse_url" "$temp_dir" \ + admin_username admin_password primary_api_token \ + "$admin_password"; then + pct exec "$ctid" -- rm -f \ + /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true + TAPM_CLEAN_TEMP_DIR "$temp_dir" + if ! TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid"; then + echo " Setup can be recovered without reinstalling the retained LXC." + echo " Run this on the Proxmox host to request a fresh setup token:" + echo " pct exec ${ctid} -- env PULSE_DATA_DIR=/etc/pulse /usr/local/bin/pulse bootstrap-token" + fi + return 1 + fi + + pct exec "$ctid" -- rm -f \ + /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true + TAPM_CLEAN_TEMP_DIR "$temp_dir" + container_created=0 + + echo + echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" + echo -e "${idsCL[LightCyan]}Add Proxmox inventory and host telemetry from the Pulse web interface.${idsCL[Default]}" + TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED \ + "$pulse_url" "$admin_username" "$admin_password" "$primary_api_token" +} diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh new file mode 100644 index 0000000..50a43b2 --- /dev/null +++ b/inc/evacuate-proxmox-node.sh @@ -0,0 +1,964 @@ +#!/usr/bin/env bash +set -u -o pipefail + +# Evacuate non-HA guests after the local Proxmox node enters HA maintenance. +# HA-managed guests remain under Proxmox HA control. Non-HA guests using +# shared storage are migrated, while guests using local storage are shut down. + +HA_WAIT_SECONDS=300 +MAINTENANCE_WAIT_SECONDS=60 +MAX_PARALLEL_MIGRATIONS=3 +SHUTDOWN_TIMEOUT=180 +LOCAL_NODE="$(hostname -s)" +MIGRATION_LOG_DIR='' +PREFERRED_TARGET='' +HA_RULES_FILE="${HA_RULES_FILE:-/etc/pve/ha/rules.cfg}" + +declare -a MIGRATION_NODES=() +declare -A NODE_STORAGE_CACHE=() + +log() { + printf '\n[%s] %s\n' "$(date '+%F %T')" "$*" +} + +warn() { + printf '\nWARNING: %s\n' "$*" >&2 +} + +die() { + printf '\nERROR: %s\n' "$*" >&2 + exit 1 +} + +load_lines() { + local destination_name="$1" + local output + local -n destination_ref="$destination_name" + shift + + output="$("$@")" || return 1 + destination_ref=() + # shellcheck disable=SC2034 # mapfile writes through the nameref. + [[ -z "$output" ]] || mapfile -t destination_ref <<< "$output" +} + +cleanup() { + if [[ "$MIGRATION_LOG_DIR" == /tmp/ta-proxmenu-evacuation.* && + -d "$MIGRATION_LOG_DIR" ]]; then + rm -rf -- "$MIGRATION_LOG_DIR" + fi +} + +get_local_guests() { + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +node = sys.argv[1] +for guest in json.load(sys.stdin): + if guest.get("node") != node or guest.get("type") not in ("qemu", "lxc"): + continue + print( + guest.get("vmid", ""), + guest.get("type", ""), + guest.get("status", "unknown"), + str(guest.get("name", "")).replace("\x1f", " "), + int(guest.get("maxmem") or 0), + sep="\x1f", + ) +' "$LOCAL_NODE" +} + +get_ha_guest_ids() { + pvesh get /cluster/ha/resources --output-format json 2>/dev/null | + python3 -c ' +import json +import re +import sys + +for resource in json.load(sys.stdin): + sid = str(resource.get("sid") or resource.get("service") or "") + match = re.fullmatch(r"(?:vm|ct):(\d+)", sid) + if match: + print(match.group(1)) +' +} + +guest_is_ha_managed() { + local vmid="$1" + local ha_id + + for ha_id in "${CURRENT_HA_IDS[@]:-}"; do + [[ "$ha_id" == "$vmid" ]] && return 0 + done + return 1 +} + +get_online_nodes() { + pvesh get /nodes --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +local_node = sys.argv[1] +for node in json.load(sys.stdin): + name = str(node.get("node", "")) + if name and name != local_node and node.get("status") == "online": + print(name) +' "$LOCAL_NODE" +} + +node_in_maintenance() { + local node="$1" + + ha-manager status 2>/dev/null | + grep -F "lrm ${node} " | + grep -q "maintenance mode" +} + +get_shared_storages() { + pvesh get /storage --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for storage in json.load(sys.stdin): + if storage.get("shared"): + print(storage.get("storage", "")) +' +} + +get_node_active_storages() { + local node="$1" + + pvesh get "/nodes/${node}/storage" --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for storage in json.load(sys.stdin): + active = storage.get("active") + enabled = storage.get("enabled", 1) + if active in (1, True, "1") and enabled not in (0, False, "0"): + storage_id = str(storage.get("storage", "")) + if storage_id: + print(storage_id) +' +} + +refresh_migration_nodes() { + local ha_status + local node + local online_output + local storage_csv + local -a online_nodes=() + local -a usable_nodes=() + + online_output="$(get_online_nodes)" || { + warn "Could not refresh online cluster nodes." + return 1 + } + [[ -z "$online_output" ]] || + mapfile -t online_nodes <<< "$online_output" + + ha_status="$(ha-manager status 2>/dev/null)" || { + warn "Could not refresh HA node status." + return 1 + } + NODE_STORAGE_CACHE=() + + for node in "${online_nodes[@]}"; do + if grep -F "lrm ${node} " <<< "$ha_status" | + grep -q "maintenance mode"; then + continue + fi + storage_csv="$(get_node_active_storages "$node" | paste -sd, -)" || { + warn "Could not read storage status from ${node}; it will not be used." + continue + } + NODE_STORAGE_CACHE["$node"]="$storage_csv" + usable_nodes+=("$node") + done + MIGRATION_NODES=("${usable_nodes[@]}") +} + +node_has_required_storages() { + local node="$1" + local required_csv="$2" + local available_csv="${NODE_STORAGE_CACHE[$node]-}" + local storage + local -a required_storages=() + + [[ -n "$required_csv" ]] || return 0 + IFS=',' read -r -a required_storages <<< "$required_csv" + for storage in "${required_storages[@]}"; do + [[ ",${available_csv}," == *",${storage},"* ]] || return 1 + done +} + +guest_storage_scope() { + local guest_type="$1" + local vmid="$2" + local shared_csv="$3" + + pvesh get "/nodes/${LOCAL_NODE}/${guest_type}/${vmid}/config" \ + --output-format json 2>/dev/null | + python3 -c ' +import json +import re +import sys + +guest_type, shared_csv = sys.argv[1:3] +shared = {item for item in shared_csv.split(",") if item} +config = json.load(sys.stdin) + +if guest_type == "qemu": + disk_key = re.compile( + r"^(?:ide|sata|scsi|virtio|efidisk|tpmstate|unused)\d+$" + ) +else: + disk_key = re.compile(r"^(?:rootfs|mp\d+|unused\d+)$") + +local_reasons = [] +required_storages = set() +for key, raw_value in config.items(): + if not disk_key.match(key) or not isinstance(raw_value, str): + continue + + volume = raw_value.split(",", 1)[0] + if volume in ("none", "cdrom") or volume.startswith("none,"): + continue + if volume.startswith("/") or ":" not in volume: + local_reasons.append(f"{key}={volume}") + continue + + storage = volume.split(":", 1)[0] + required_storages.add(storage) + if storage not in shared: + local_reasons.append(f"{key}={storage}") + +scope = "local" if local_reasons else "shared" +print( + scope, + ", ".join(local_reasons), + ",".join(sorted(required_storages)), + sep="\x1f", +) +' "$guest_type" "$shared_csv" +} + +get_guest_node_affinity() { + local guest_type="$1" + local vmid="$2" + local sid_type='vm' + + [[ "$guest_type" == "lxc" ]] && sid_type='ct' + python3 -c ' +import os +import sys + +sid = sys.argv[1] +path = sys.argv[2] +if not os.path.exists(path): + print("none", "0", "", sep="\x1f") + raise SystemExit + +rules = [] +current = None +with open(path, encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.rstrip() + if not line or line.lstrip().startswith("#"): + continue + if not line[0].isspace() and ":" in line: + rule_type, rule_id = line.split(":", 1) + current = { + "type": rule_type.strip(), + "id": rule_id.strip(), + } + rules.append(current) + continue + if current is not None and line[0].isspace(): + key_value = line.strip().split(None, 1) + if len(key_value) == 2: + current[key_value[0]] = key_value[1].strip() + +for rule in rules: + if rule.get("type") != "node-affinity": + continue + if rule.get("disable", "0") in ("1", "yes", "true", "on"): + continue + resources = { + item.strip() + for item in rule.get("resources", "").replace(";", ",").split(",") + if item.strip() + } + if sid not in resources: + continue + + nodes = [] + for position, item in enumerate(rule.get("nodes", "").split(",")): + item = item.strip() + if not item: + continue + node = item + priority = 0 + if ":" in item: + possible_node, possible_priority = item.rsplit(":", 1) + try: + priority = int(possible_priority) + node = possible_node + except ValueError: + pass + nodes.append((node, priority, position)) + nodes.sort(key=lambda value: (-value[1], value[2])) + strict = "1" if rule.get("strict", "0") in ("1", "yes", "true", "on") else "0" + print(rule.get("id", "node-affinity"), strict, ",".join(n[0] for n in nodes), sep="\x1f") + raise SystemExit + +print("none", "0", "", sep="\x1f") +' "${sid_type}:${vmid}" "$HA_RULES_FILE" +} + +select_target_node() { + local choice + local index + + (( ${#MIGRATION_NODES[@]} > 0 )) || + die "No other online, non-maintenance cluster node is available." + + printf '\nAvailable preferred migration targets:\n\n' + for index in "${!MIGRATION_NODES[@]}"; do + printf ' %d) %s\n' "$((index + 1))" "${MIGRATION_NODES[$index]}" + done + + while true; do + printf '\n' + read -r -p "Select preferred migration target [1-${#MIGRATION_NODES[@]}]: " choice + if [[ "$choice" =~ ^[0-9]+$ ]] && + (( choice >= 1 && choice <= ${#MIGRATION_NODES[@]} )); then + PREFERRED_TARGET="${MIGRATION_NODES[$((choice - 1))]}" + return + fi + printf 'Invalid selection.\n' >&2 + done +} + +CHOSEN_DESTINATION='' +CHOSEN_NOTE='' + +choose_destination() { + local required_csv="$1" + local policy_nodes_csv="$2" + local policy_strict="$3" + local node + local policy_node + local -a storage_candidates=() + local -a policy_nodes=() + + CHOSEN_DESTINATION='' + CHOSEN_NOTE='' + + for node in "${MIGRATION_NODES[@]}"; do + if node_has_required_storages "$node" "$required_csv"; then + storage_candidates+=("$node") + fi + done + (( ${#storage_candidates[@]} > 0 )) || return 1 + + IFS=',' read -r -a policy_nodes <<< "$policy_nodes_csv" + if [[ -z "$policy_nodes_csv" ]]; then + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$PREFERRED_TARGET" ]]; then + CHOSEN_DESTINATION="$node" + return 0 + fi + done + CHOSEN_DESTINATION="${storage_candidates[0]}" + CHOSEN_NOTE="preferred target unavailable for required storage" + return 0 + fi + + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$PREFERRED_TARGET" && + ",${policy_nodes_csv}," == *",${node},"* ]]; then + CHOSEN_DESTINATION="$node" + return 0 + fi + done + + for policy_node in "${policy_nodes[@]}"; do + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$policy_node" ]]; then + CHOSEN_DESTINATION="$node" + CHOSEN_NOTE="routed to satisfy HA node-affinity preference" + return 0 + fi + done + done + + if (( ${#storage_candidates[@]} == 1 )); then + CHOSEN_DESTINATION="${storage_candidates[0]}" + CHOSEN_NOTE="only eligible node; HA node-affinity preference overridden" + return 0 + fi + + if [[ "$policy_strict" == "1" ]]; then + return 1 + fi + + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$PREFERRED_TARGET" ]]; then + CHOSEN_DESTINATION="$node" + CHOSEN_NOTE="no preferred HA node was eligible; non-strict fallback used" + return 0 + fi + done + CHOSEN_DESTINATION="${storage_candidates[0]}" + CHOSEN_NOTE="no preferred HA node was eligible; non-strict fallback used" +} + +get_node_available_memory() { + local node="$1" + + pvesh get /nodes --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +requested = sys.argv[1] +for node in json.load(sys.stdin): + if str(node.get("node", "")) != requested: + continue + total = int(node.get("maxmem") or 0) + used = int(node.get("mem") or 0) + print(max(0, total - used)) + raise SystemExit +raise SystemExit(1) +' "$node" +} + +format_bytes() { + local bytes="${1:-0}" + + if command -v numfmt >/dev/null 2>&1; then + numfmt --to=iec-i --suffix=B "$bytes" + else + printf '%d MiB' "$((bytes / 1024 / 1024))" + fi +} + +wait_for_ha_evacuation() { + local deadline=$((SECONDS + HA_WAIT_SECONDS)) + local -a local_guests + local -a ha_ids + local -a remaining + local guest + local guest_status + local guest_type + local guest_vmid + local ha_id + + log "Waiting for HA-managed guests to leave ${LOCAL_NODE}." + + while true; do + load_lines local_guests get_local_guests || { + warn "Could not refresh guests assigned to ${LOCAL_NODE}." + return 1 + } + load_lines ha_ids get_ha_guest_ids || { + warn "Could not refresh HA resources." + return 1 + } + remaining=() + + for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r guest_vmid guest_type guest_status _ <<< "$guest" + [[ "$guest_status" == "running" ]] || continue + for ha_id in "${ha_ids[@]}"; do + if [[ "$guest_vmid" == "$ha_id" ]]; then + remaining+=("$guest") + break + fi + done + done + + (( ${#remaining[@]} == 0 )) && return + + if (( SECONDS >= deadline )); then + warn "HA evacuation did not finish within ${HA_WAIT_SECONDS} seconds." + printf 'HA-managed guests still assigned to %s:\n' "$LOCAL_NODE" >&2 + printf ' %s\n' "${remaining[@]}" >&2 + return 1 + fi + + printf '\r Waiting: %d HA guest(s) remain... ' "${#remaining[@]}" + sleep 5 + done +} + +migrate_guest() { + local vmid="$1" + local guest_type="$2" + local status="$3" + local destination="$4" + + if [[ "$guest_type" == "qemu" ]]; then + if [[ "$status" == "running" ]]; then + qm migrate "$vmid" "$destination" --online + else + qm migrate "$vmid" "$destination" + fi + else + if [[ "$status" == "running" ]]; then + pct migrate "$vmid" "$destination" --restart 1 \ + --timeout "$SHUTDOWN_TIMEOUT" + else + pct migrate "$vmid" "$destination" + fi + fi +} + +get_guest_node() { + local vmid="$1" + + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +vmid = str(sys.argv[1]) +for guest in json.load(sys.stdin): + if str(guest.get("vmid", "")) == vmid: + print(guest.get("node", "")) + raise SystemExit +raise SystemExit(1) +' "$vmid" +} + +wait_for_guest_node() { + local vmid="$1" + local destination="$2" + local attempts="${3:-15}" + local attempt=0 + + while (( attempt < attempts )); do + [[ "$(get_guest_node "$vmid")" == "$destination" ]] && return 0 + sleep 1 + ((attempt++)) + done + return 1 +} + +wait_for_migration_batch() { + local index + local pid + local guest + local log_file + local vmid + local guest_type + local status + local name + local maxmem + local required_storages + local policy_nodes + local policy_strict + local policy_rule + local destination + local route_note + + for index in "${!batch_pids[@]}"; do + pid="${batch_pids[$index]}" + guest="${batch_guests[$index]}" + log_file="${batch_logs[$index]}" + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + + if wait "$pid" && wait_for_guest_node "$vmid" "$destination"; then + log "Migration completed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." + migration_successes+=("$guest") + else + warn "Migration failed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." + migration_failures+=("$guest") + fi + + if [[ -s "$log_file" ]]; then + sed 's/^/ /' "$log_file" + fi + done + + batch_pids=() + batch_guests=() + batch_logs=() +} + +shutdown_guest() { + local vmid="$1" + local guest_type="$2" + + if [[ "$guest_type" == "qemu" ]]; then + qm shutdown "$vmid" --timeout "$SHUTDOWN_TIMEOUT" + else + pct shutdown "$vmid" --timeout "$SHUTDOWN_TIMEOUT" + fi +} + +guest_status() { + local vmid="$1" + local guest_type="$2" + + if [[ "$guest_type" == "qemu" ]]; then + qm status "$vmid" 2>/dev/null | awk '{ print $2 }' + else + pct status "$vmid" 2>/dev/null | awk '{ print $2 }' + fi +} + +wait_for_guest_stopped() { + local vmid="$1" + local guest_type="$2" + local attempts="${3:-15}" + local attempt=0 + + while (( attempt < attempts )); do + [[ "$(guest_status "$vmid" "$guest_type")" == "stopped" ]] && return 0 + sleep 1 + ((attempt++)) + done + return 1 +} + +wait_for_maintenance_mode() { + local deadline=$((SECONDS + MAINTENANCE_WAIT_SECONDS)) + + log "Waiting for ${LOCAL_NODE} to enter HA maintenance mode." + + while true; do + if node_in_maintenance "$LOCAL_NODE"; then + printf '\n' + return + fi + + if (( SECONDS >= deadline )); then + die "${LOCAL_NODE} did not enter HA maintenance mode within ${MAINTENANCE_WAIT_SECONDS} seconds." + fi + + printf '\r Waiting for HA maintenance mode... ' + sleep 2 + done +} + +preflight() { + local command + + for command in pvesh ha-manager python3 qm pct; do + command -v "$command" >/dev/null 2>&1 || + die "${command} is required." + done + + pvesh get /cluster/resources --type vm --output-format json >/dev/null 2>&1 || + die "Could not read cluster guest resources." + pvesh get /cluster/ha/resources --output-format json >/dev/null 2>&1 || + die "Could not read HA resources." + pvesh get /nodes --output-format json >/dev/null 2>&1 || + die "Could not read cluster node status." + pvesh get /storage --output-format json >/dev/null 2>&1 || + die "Could not read cluster storage configuration." + ha-manager status >/dev/null 2>&1 || + die "Could not read HA node status." +} + +main() { + local answer + local available_memory + local destination + local guest + local guest_type + local log_file + local maxmem + local name + local policy_nodes + local policy_result + local policy_rule + local policy_strict + local reason + local required_memory + local required_storages + local route_note + local scope + local shared_csv + local status + local storage_result + local vmid + local -a guests=() + local -a shared_storages=() + local -a shared_guests=() + local -a local_guests=() + local -a unroutable_guests=() + local -a migration_failures=() + local -a migration_successes=() + local -a migration_skipped=() + local -a shutdown_failures=() + local -a shutdown_successes=() + local -a batch_pids=() + local -a batch_guests=() + local -a batch_logs=() + local -a final_guests=() + local -a running_final_guests=() + local -a CURRENT_HA_IDS=() + local -A DEST_REQUIRED_MEMORY=() + + MIGRATION_LOG_DIR="$(mktemp -d /tmp/ta-proxmenu-evacuation.XXXXXX)" || + die "Could not create the migration log directory." + chmod 0700 "$MIGRATION_LOG_DIR" + trap cleanup EXIT + + preflight + wait_for_maintenance_mode + wait_for_ha_evacuation || + die "Resolve the remaining HA guests before continuing the evacuation." + + load_lines shared_storages get_shared_storages || + die "Could not read shared-storage configuration." + shared_csv="$(IFS=,; echo "${shared_storages[*]}")" + load_lines guests get_local_guests || + die "Could not read guests assigned to ${LOCAL_NODE}." + + if (( ${#guests[@]} == 0 )); then + log "No guests remain on ${LOCAL_NODE}." + return 0 + fi + + for guest in "${guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest" + storage_result="$(guest_storage_scope "$guest_type" "$vmid" "$shared_csv")" || + die "Could not inspect storage for ${guest_type} ${vmid}." + IFS=$'\x1f' read -r scope reason required_storages <<< "$storage_result" + + if [[ "$scope" == "shared" ]]; then + shared_guests+=( + "${guest}"$'\x1f'"${required_storages}" + ) + else + local_guests+=( + "${guest}"$'\x1f'"${reason:-local storage}" + ) + fi + done + + if (( ${#shared_guests[@]} > 0 )); then + refresh_migration_nodes || + die "Could not build a safe migration-target list." + select_target_node + + guests=("${shared_guests[@]}") + shared_guests=() + for guest in "${guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages <<< "$guest" + policy_result="$(get_guest_node_affinity "$guest_type" "$vmid")" || + die "Could not inspect HA node-affinity policy for ${guest_type} ${vmid}." + IFS=$'\x1f' read -r policy_rule policy_strict policy_nodes <<< "$policy_result" + + if choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then + destination="$CHOSEN_DESTINATION" + route_note="$CHOSEN_NOTE" + shared_guests+=( + "${guest}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"$'\x1f'"${destination}"$'\x1f'"${route_note}" + ) + if [[ "$status" == "running" && "$maxmem" =~ ^[0-9]+$ ]]; then + DEST_REQUIRED_MEMORY["$destination"]="$(( ${DEST_REQUIRED_MEMORY[$destination]:-0} + maxmem ))" + fi + else + unroutable_guests+=( + "${guest}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}" + ) + fi + done + fi + + printf '\nEvacuation plan for %s:\n' "$LOCAL_NODE" + [[ -n "$PREFERRED_TARGET" ]] && + printf ' Preferred migration target: %s\n' "$PREFERRED_TARGET" + printf ' Shared-storage guests to migrate: %d\n' "${#shared_guests[@]}" + printf ' Local-storage guests to shut down: %d\n' "${#local_guests[@]}" + printf ' Shared guests without a valid route: %d\n' "${#unroutable_guests[@]}" + + if (( ${#shared_guests[@]} > 0 )); then + printf '\nShared-storage guests:\n' + for guest in "${shared_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + printf ' %-6s %-5s %-8s %-24s -> %s' \ + "$vmid" "$guest_type" "$status" "$name" "$destination" + [[ -n "$route_note" ]] && printf ' (%s)' "$route_note" + printf '\n' + done + fi + + if (( ${#local_guests[@]} > 0 )); then + printf '\nLocal-storage guests (will not migrate):\n' + for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest" + printf ' %-6s %-5s %-8s %-24s %s\n' \ + "$vmid" "$guest_type" "$status" "$name" "$reason" + done + fi + + if (( ${#unroutable_guests[@]} > 0 )); then + printf '\nShared guests with no eligible destination:\n' + for guest in "${unroutable_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule <<< "$guest" + printf ' %-6s %-5s %-24s storages=%s' \ + "$vmid" "$guest_type" "$name" "$required_storages" + [[ "$policy_rule" != "none" ]] && + printf ' policy=%s strict=%s nodes=%s' \ + "$policy_rule" "$policy_strict" "$policy_nodes" + printf '\n' + done + fi + + for destination in "${!DEST_REQUIRED_MEMORY[@]}"; do + required_memory="${DEST_REQUIRED_MEMORY[$destination]}" + available_memory="$(get_node_available_memory "$destination" 2>/dev/null || echo 0)" + printf '\nTarget %s memory: %s available; %s configured for incoming running guests.\n' \ + "$destination" "$(format_bytes "$available_memory")" \ + "$(format_bytes "$required_memory")" + if (( available_memory > 0 && required_memory > available_memory )); then + warn "${destination} has less currently available memory than the incoming guests' configured memory." + fi + done + + printf '\n' + read -r -p "Proceed with guest migrations and local-storage guest shutdown? [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] || { + echo "Evacuation cancelled; ${LOCAL_NODE} remains in maintenance mode." + return 1 + } + + load_lines CURRENT_HA_IDS get_ha_guest_ids || + die "Could not safely recheck HA-managed guests." + refresh_migration_nodes || + die "Could not safely recheck migration targets." + + for guest in "${shared_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + + if guest_is_ha_managed "$vmid"; then + status="$(guest_status "$vmid" "$guest_type")" + if [[ "$status" != "stopped" ]]; then + warn "${guest_type} ${vmid} is HA-managed and is not confirmed stopped; TAPM will not migrate it manually." + migration_skipped+=("$guest") + continue + fi + log "${guest_type} ${vmid} is HA-managed but stopped; continuing with offline migration." + fi + + if ! choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then + warn "No eligible destination remains for ${guest_type} ${vmid}; migration skipped." + migration_skipped+=("$guest") + continue + fi + if [[ "$destination" != "$CHOSEN_DESTINATION" ]]; then + warn "Destination for ${guest_type} ${vmid} changed from ${destination} to ${CHOSEN_DESTINATION} after recheck." + destination="$CHOSEN_DESTINATION" + route_note="$CHOSEN_NOTE" + guest="${vmid}"$'\x1f'"${guest_type}"$'\x1f'"${status}"$'\x1f'"${name}"$'\x1f'"${maxmem}"$'\x1f'"${required_storages}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"$'\x1f'"${destination}"$'\x1f'"${route_note}" + fi + + log "Starting migration for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." + log_file="${MIGRATION_LOG_DIR}/${guest_type}-${vmid}.log" + migrate_guest "$vmid" "$guest_type" "$status" "$destination" >"$log_file" 2>&1 & + batch_pids+=("$!") + batch_guests+=("$guest") + batch_logs+=("$log_file") + + if (( ${#batch_pids[@]} >= MAX_PARALLEL_MIGRATIONS )); then + wait_for_migration_batch + fi + done + (( ${#batch_pids[@]} == 0 )) || wait_for_migration_batch + + for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest" + status="$(guest_status "$vmid" "$guest_type")" + [[ "$status" == "running" ]] || continue + + log "Shutting down local-storage ${guest_type} ${vmid} (${name:-unnamed})." + if shutdown_guest "$vmid" "$guest_type" && + wait_for_guest_stopped "$vmid" "$guest_type"; then + shutdown_successes+=("$guest") + else + warn "Graceful shutdown failed for ${guest_type} ${vmid}; it was not force-stopped." + shutdown_failures+=("$guest") + fi + done + + load_lines final_guests get_local_guests || + die "Could not verify the final guest state on ${LOCAL_NODE}." + for guest in "${final_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest" + [[ "$status" == "running" ]] && running_final_guests+=("$guest") + done + + printf '\nEvacuation summary:\n' + printf ' Successful migrations: %d\n' "${#migration_successes[@]}" + printf ' Failed migrations: %d\n' "${#migration_failures[@]}" + printf ' Skipped migrations: %d\n' "${#migration_skipped[@]}" + printf ' Unroutable shared guests: %d\n' "${#unroutable_guests[@]}" + printf ' Local guest shutdowns: %d\n' "${#shutdown_successes[@]}" + printf ' Failed local shutdowns: %d\n' "${#shutdown_failures[@]}" + printf ' Guests still running locally: %d\n' "${#running_final_guests[@]}" + + if (( ${#migration_successes[@]} > 0 )); then + printf '\nMigrated guests:\n' + for guest in "${migration_successes[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + printf ' %-6s %-5s %-24s -> %s\n' \ + "$vmid" "$guest_type" "$name" "$destination" + done + fi + + if (( ${#migration_failures[@]} > 0 )); then + printf '\nFailed migrations (left unchanged and not force-stopped):\n' + for guest in "${migration_failures[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + printf ' %-6s %-5s %-24s target=%s\n' \ + "$vmid" "$guest_type" "$name" "$destination" + done + fi + + if (( ${#shutdown_successes[@]} > 0 )); then + printf '\nLocal-storage guests shut down:\n' + for guest in "${shutdown_successes[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest" + printf ' %-6s %-5s %s\n' "$vmid" "$guest_type" "$name" + done + fi + + if (( ${#final_guests[@]} > 0 )); then + printf '\nGuests still assigned to %s:\n' "$LOCAL_NODE" + for guest in "${final_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest" + printf ' %-6s %-5s %-8s %s\n' "$vmid" "$guest_type" "$status" "$name" + done + fi + + printf '\nNo guest was force-stopped.\n' + + if (( ${#migration_failures[@]} > 0 || + ${#migration_skipped[@]} > 0 || + ${#unroutable_guests[@]} > 0 || + ${#shutdown_failures[@]} > 0 || + ${#running_final_guests[@]} > 0 )); then + return 1 + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/inc/fleet.inc b/inc/fleet.inc new file mode 100644 index 0000000..9ed1876 --- /dev/null +++ b/inc/fleet.inc @@ -0,0 +1,227 @@ +#!/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_ENROLLED=0 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_STARTED_AT=0 +TAPM_FLEET_VERSION='' +TAPM_FLEET_HOSTNAME='' + +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_ENROLLED="$(TAPM_FLEET_READ_VALUE ENROLLED)" + [[ "$TAPM_FLEET_ENROLLED" == 1 ]] || TAPM_FLEET_ENROLLED=0 +} + +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" + printf 'ENROLLED=%s\n' "$TAPM_FLEET_ENROLLED" + } >"$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_ENROLLED=0 + TAPM_FLEET_WRITE_IDENTITY +} + +TAPM_FLEET_COLLECT_METADATA() { + TAPM_FLEET_HOSTNAME="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)" + 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_HOSTNAME="$TAPM_FLEET_HOSTNAME" \ + 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"], + "hostname": os.environ["TAPM_FLEET_HOSTNAME"], + "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 1 + event_payload="$(mktemp "${TMPDIR:-/tmp}/tapm-fleet-event.XXXXXX")" || return 1 + chmod 0600 "$event_payload" 2>/dev/null || { + rm -f "$event_payload" + return 1 + } + if ! TAPM_FLEET_JSON "$event" "$result" "$error_code" "$duration" >"$event_payload"; then + rm -f "$event_payload" + return 1 + fi + if 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; then + rm -f "$event_payload" + return 0 + fi + rm -f "$event_payload" + return 1 +} + +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_ENROLLED == 1 )); then + TAPM_FLEET_REGISTERED=1 + elif TAPM_FLEET_REGISTER; then + TAPM_FLEET_REGISTERED=1 + TAPM_FLEET_ENROLLED=1 + TAPM_FLEET_WRITE_IDENTITY || true + fi +} + +TAPM_FLEET_FINISH() { + local exit_status="${1:-0}" + local duration=0 + local event_sent=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" && + event_sent=1 + else + TAPM_FLEET_EVENT_SEND run_failed failure exit_nonzero "$duration" && + event_sent=1 + fi + if (( event_sent == 1 )) && TAPM_FLEET_VALID_IDENTITY; then + TAPM_FLEET_LAST_VERSION="$TAPM_FLEET_VERSION" + else + # A failed event may mean the broker no longer recognizes this local + # credential. Re-enroll on the next invocation, not during this one. + TAPM_FLEET_ENROLLED=0 + fi + TAPM_FLEET_VALID_IDENTITY && TAPM_FLEET_WRITE_IDENTITY || true + return 0 +} diff --git a/inc/git-update.inc b/inc/git-update.inc new file mode 100644 index 0000000..b8dc921 --- /dev/null +++ b/inc/git-update.inc @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Shared non-destructive Git update helpers for TA-ProxMenu. + +TAPM_GIT_WORKTREE_DIRTY() { + local repository="$1" + + [[ -n "$(git -C "$repository" status --porcelain --untracked-files=normal 2>/dev/null)" ]] +} + +TAPM_GIT_FETCH_BRANCH() { + local repository="$1" + local branch="$2" + local timeout_seconds="${3:-30}" + + timeout "$timeout_seconds" git -C "$repository" fetch --prune origin \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" +} + +TAPM_GIT_RELATION() { + local repository="$1" + local local_ref="$2" + local remote_ref="$3" + local local_commit + local remote_commit + + local_commit="$(git -C "$repository" rev-parse --verify "${local_ref}^{commit}" 2>/dev/null)" || + return 1 + remote_commit="$(git -C "$repository" rev-parse --verify "${remote_ref}^{commit}" 2>/dev/null)" || + return 1 + + if [[ "$local_commit" == "$remote_commit" ]]; then + printf 'current\n' + elif git -C "$repository" merge-base --is-ancestor "$local_commit" "$remote_commit"; then + printf 'behind\n' + elif git -C "$repository" merge-base --is-ancestor "$remote_commit" "$local_commit"; then + printf 'ahead\n' + else + printf 'diverged\n' + fi +} + +TAPM_GIT_BRANCH_STATE() { + local repository="$1" + local branch="$2" + local current_branch + + [[ -d "${repository}/.git" ]] || { + printf 'unavailable\n' + return 1 + } + + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + printf 'detached\n' + return 0 + fi + if [[ "$current_branch" != "$branch" ]]; then + printf 'wrong-branch\n' + return 0 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + printf 'dirty\n' + return 0 + fi + + TAPM_GIT_RELATION "$repository" HEAD "refs/remotes/origin/${branch}" || { + printf 'unavailable\n' + return 1 + } +} + +TAPM_GIT_FAST_FORWARD() { + local repository="$1" + local branch="$2" + + git -C "$repository" merge --ff-only "refs/remotes/origin/${branch}" +} diff --git a/inc/ha-status.inc b/inc/ha-status.inc new file mode 100644 index 0000000..db0dc6f --- /dev/null +++ b/inc/ha-status.inc @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +TAPM_HA_NODE_IN_MAINTENANCE() { + local node="${1:-}" + local status_file="${2:-/etc/pve/ha/manager_status}" + + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 2 + [[ -r "$status_file" ]] || return 2 + TAPM_HA_NODE="$node" python3 - "$status_file" <<'PY' +import json +import os +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as status_handle: + status = json.load(status_handle) +except (OSError, TypeError, ValueError): + raise SystemExit(2) + +node = os.environ["TAPM_HA_NODE"].strip().lower() + +def contains_maintenance(value): + if isinstance(value, str): + return "maintenance" in value.lower() + if isinstance(value, dict): + return any(contains_maintenance(item) for item in value.values()) + if isinstance(value, list): + return any(contains_maintenance(item) for item in value) + return False + +def node_is_in_maintenance(value): + if isinstance(value, dict): + for key, item in value.items(): + if str(key).strip().lower() == node and contains_maintenance(item): + return True + identity = next( + ( + value.get(key) + for key in ("node", "name", "id") + if isinstance(value.get(key), str) + ), + "", + ) + if identity.strip().lower() == node and contains_maintenance(value): + return True + return any(node_is_in_maintenance(item) for item in value.values()) + if isinstance(value, list): + return any(node_is_in_maintenance(item) for item in value) + return False + +raise SystemExit(0 if node_is_in_maintenance(status) else 1) +PY +} diff --git a/inc/header-info.inc b/inc/header-info.inc new file mode 100644 index 0000000..0138629 --- /dev/null +++ b/inc/header-info.inc @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Cached local host details displayed by the interactive menu header. + +TAPM_HEADER_INFO_LOADED=0 +TAPM_HEADER_PVE_VERSION='Unavailable' +TAPM_HEADER_CLUSTER='Standalone' + +TAPM_LOAD_HEADER_INFO() { + local pve_output='' + local first_line='' + local cluster_config="${1:-/etc/pve/corosync.conf}" + local cluster_name='' + local line='' + + (( TAPM_HEADER_INFO_LOADED == 0 )) || return 0 + TAPM_HEADER_INFO_LOADED=1 + + if command -v pveversion >/dev/null 2>&1; then + pve_output="$(pveversion 2>/dev/null || true)" + first_line="${pve_output%%$'\n'*}" + if [[ "$first_line" =~ ^pve-manager/([^/[:space:]]+) ]]; then + TAPM_HEADER_PVE_VERSION="${BASH_REMATCH[1]}" + elif [[ -n "$first_line" ]]; then + TAPM_HEADER_PVE_VERSION="${first_line%%[[:space:]]*}" + fi + fi + + if [[ -r "$cluster_config" ]]; then + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*cluster_name:[[:space:]]*([^[:space:]#]+) ]]; then + cluster_name="${BASH_REMATCH[1]}" + break + fi + done <"$cluster_config" + TAPM_HEADER_CLUSTER="${cluster_name:-Clustered}" + fi +} diff --git a/inc/post-install.inc b/inc/post-install.inc new file mode 100644 index 0000000..31ec1f5 --- /dev/null +++ b/inc/post-install.inc @@ -0,0 +1,1054 @@ +#!/usr/bin/env bash +# TA-ProxMenu native Proxmox VE 9 host configuration and ProxMenux migration. + +TAPM_POST_BACKUP_BASE='/var/backups/ta-proxmenu/post-install' +TAPM_POST_STATE_DIR='/var/lib/ta-proxmenu/post-install' +TAPM_POST_LAST_BACKUP='' +TAPM_POST_LAST_ERROR='' + +TAPM_POST_PATH() { + printf '%s%s' "${TAPM_HOST_ROOT:-}" "$1" +} + +TAPM_POST_LIVE_ROOT() { + [[ -z "${TAPM_HOST_ROOT:-}" ]] +} + +TAPM_POST_WRITE_FILE() { + local target + local mode="${2:-0644}" + local temporary + + target="$(TAPM_POST_PATH "$1")" + mkdir -p -- "$(dirname "$target")" || return 1 + temporary="$(mktemp "${target}.tapm.XXXXXX")" || return 1 + if ! cat >"$temporary" || ! chmod "$mode" "$temporary" || + ! mv -f -- "$temporary" "$target"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_FILE_NORMALIZED() { + local file="$1" + + [[ -f "$file" ]] || return 1 + tr -d '[:space:]' <"$file" +} + +TAPM_POST_EXACT_APT_LANGUAGES() { + local file + file="$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-disable-translations')" + [[ "$(TAPM_POST_FILE_NORMALIZED "$file" 2>/dev/null)" == \ + 'Acquire::Languages"none";' ]] +} + +TAPM_POST_EXACT_APT_IPV4() { + local file + file="$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-force-ipv4')" + [[ "$(TAPM_POST_FILE_NORMALIZED "$file" 2>/dev/null)" == \ + 'Acquire::ForceIPv4"true";' ]] +} + +TAPM_POST_PROXMENUX_JOURNALD() { + local file + file="$(TAPM_POST_PATH '/etc/systemd/journald.conf')" + [[ -f "$file" ]] && + grep -q '^SystemMaxUse=64M$' "$file" && + grep -q '^RuntimeMaxUse=60M$' "$file" && + grep -q '^Seal=no$' "$file" && + grep -q '^MaxLevelStore=info$' "$file" +} + +TAPM_POST_PROXMENUX_LOGROTATE() { + local file + file="$(TAPM_POST_PATH '/etc/logrotate.conf')" + [[ -f "$file" ]] && + grep -q '^# ProxMenux optimized configuration' "$file" && + grep -q '^size 10M$' "$file" && + grep -q '^copytruncate$' "$file" +} + +TAPM_POST_PROXMENUX_GZIP_WRAPPER() { + local file + file="$(TAPM_POST_PATH '/bin/gzip')" + [[ -f "$file" ]] && + grep -q 'GZIP="-1"' "$file" && + grep -q 'exec /usr/bin/pigz' "$file" +} + +TAPM_POST_PROXMENUX_NETWORK() { + local file + file="$(TAPM_POST_PATH '/etc/sysctl.d/99-network.conf')" + [[ -f "$file" ]] && + grep -q '^# ProxMenux - Network tuning' "$file" +} + +TAPM_POST_PROXMENUX_MENU_LAUNCHER() { + local file + local target + + file="$(TAPM_POST_PATH '/usr/local/bin/menu')" + if [[ -L "$file" ]]; then + target="$(readlink "$file" 2>/dev/null)" + [[ "$target" == *proxmenux* ]] + return + fi + [[ -f "$file" ]] && grep -qi 'proxmenux' "$file" +} + +TAPM_POST_PROXMENUX_DETECTED() { + [[ -d "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" || + -e "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-monitor.service')" || + -e "$(TAPM_POST_PATH '/opt/.PROXMENUX_POST_INSTALL')" ]] || + TAPM_POST_PROXMENUX_MENU_LAUNCHER || + TAPM_POST_PROXMENUX_GZIP_WRAPPER || + TAPM_POST_PROXMENUX_NETWORK || + TAPM_POST_PROXMENUX_JOURNALD || + TAPM_POST_PROXMENUX_LOGROTATE || + TAPM_POST_EXACT_APT_LANGUAGES || + TAPM_POST_EXACT_APT_IPV4 +} + +TAPM_POST_MANAGED_FILES() { + cat <<'EOF' +/etc/sysctl.d/99-ta-proxmenu-kernel-panic.conf +/etc/sysctl.d/99-ta-proxmenu-limits.conf +/etc/systemd/journald.conf.d/99-ta-proxmenu.conf +/etc/sysctl.d/99-ta-proxmenu-memory.conf +/etc/sysctl.d/99-ta-proxmenu-network.conf +/etc/sysctl.d/99-ta-proxmenu-bbr.conf +/etc/modules-load.d/ta-proxmenu-bbr.conf +/etc/apt/apt.conf.d/99-ta-proxmenu-force-ipv4 +EOF +} + +TAPM_POST_MIGRATION_FILES() { + cat <<'EOF' +/etc/systemd/journald.conf +/etc/systemd/journald.conf.bak +/etc/logrotate.conf +/etc/logrotate.conf.bak +/etc/vzdump.conf +/etc/apt/sources.list +/etc/sysctl.d/99-kernelpanic.conf +/etc/sysctl.d/99-maxwatches.conf +/etc/sysctl.d/99-maxkeys.conf +/etc/sysctl.d/99-fs.conf +/etc/sysctl.d/99-swap.conf +/etc/sysctl.d/99-memory.conf +/etc/sysctl.d/99-network.conf +/etc/sysctl.d/99-kernel-bbr.conf +/etc/sysctl.d/99-tcp-fastopen.conf +/etc/security/limits.d/99-limits.conf +/etc/systemd/system.conf +/etc/systemd/user.conf +/etc/pam.d/common-session +/etc/pam.d/runuser-l +/etc/network/interfaces +/root/.profile +/root/.bashrc +/root/.bashrc.bak +/etc/motd +/etc/motd.bak +/etc/apt/apt.conf.d/99-disable-translations +/etc/apt/apt.conf.d/99-force-ipv4 +/usr/local/sbin/proxmenux-fwbr-tune +/etc/systemd/system/proxmenux-fwbr-tune.service +/etc/udev/rules.d/99-proxmenux-fwbr-tune.rules +/etc/systemd/system/proxmenux-monitor.service +/usr/local/bin/menu +/usr/local/share/proxmenux +/root/.config/proxmenux-monitor +/opt/googletrans-env +/var/lib/proxmenux +/opt/.PROXMENUX_POST_INSTALL +/bin/gzip +/bin/gzip.original +/bin/pigzwrapper +EOF +} + +TAPM_POST_ALL_BACKUP_PATHS() { + { + TAPM_POST_MANAGED_FILES + TAPM_POST_MIGRATION_FILES + } | awk '!seen[$0]++' +} + +TAPM_POST_BACKUP() { + local backup_root + local source + local relative_path + local manifest + local checksum + local timestamp + local service + local enabled + local active + + timestamp="$(date +%Y%m%d-%H%M%S)" + backup_root="$(TAPM_POST_PATH "$TAPM_POST_BACKUP_BASE")" + mkdir -p -m 0700 -- "$backup_root" || return 1 + TAPM_POST_LAST_BACKUP="$(mktemp -d "${backup_root}/${timestamp}.XXXXXX")" || + return 1 + chmod 0700 "$TAPM_POST_LAST_BACKUP" || return 1 + manifest="${TAPM_POST_LAST_BACKUP}/manifest.tsv" + printf 'TA-ProxMenu host configuration backup\nCreated: %s\nHost: %s\n' \ + "$(date --iso-8601=seconds 2>/dev/null || date)" \ + "$(hostname 2>/dev/null || printf unknown)" \ + >"${TAPM_POST_LAST_BACKUP}/README.txt" + + while IFS= read -r relative_path; do + [[ -n "$relative_path" ]] || continue + source="$(TAPM_POST_PATH "$relative_path")" + if [[ -e "$source" || -L "$source" ]]; then + mkdir -p -- "${TAPM_POST_LAST_BACKUP}/files$(dirname "$relative_path")" || + return 1 + cp -a -- "$source" \ + "${TAPM_POST_LAST_BACKUP}/files${relative_path}" || return 1 + if [[ -f "$source" && ! -L "$source" ]]; then + checksum="$(sha256sum "$source" | awk '{print $1}')" + else + checksum='-' + fi + printf 'PRESENT\t%s\t%s\n' "$relative_path" "$checksum" >>"$manifest" + else + printf 'ABSENT\t%s\t-\n' "$relative_path" >>"$manifest" + fi + done < <(TAPM_POST_ALL_BACKUP_PATHS) + + if TAPM_POST_LIVE_ROOT; then + for service in \ + proxmenux-monitor.service proxmenux-fwbr-tune.service \ + chrony.service systemd-timesyncd.service logrotate.timer; do + systemctl is-enabled --quiet "$service" 2>/dev/null && + enabled=enabled || enabled=disabled + systemctl is-active --quiet "$service" 2>/dev/null && + active=active || active=inactive + printf '%s\t%s\t%s\n' "$service" "$enabled" "$active" \ + >>"${TAPM_POST_LAST_BACKUP}/services.tsv" + done + fi + + printf '%s\n' "$TAPM_POST_LAST_BACKUP" +} + +TAPM_POST_RESTORE_BACKUP() { + local backup_dir="$1" + local manifest="${backup_dir}/manifest.tsv" + local status + local relative_path + local checksum + local target + local stored + + [[ -d "$backup_dir" && -f "$manifest" ]] || return 1 + while IFS=$'\t' read -r status relative_path checksum; do + [[ "$relative_path" == /* && "$relative_path" != *'..'* ]] || return 1 + target="$(TAPM_POST_PATH "$relative_path")" + stored="${backup_dir}/files${relative_path}" + case "$status" in + PRESENT) + [[ -e "$stored" || -L "$stored" ]] || return 1 + if [[ "$checksum" != '-' ]]; then + [[ -f "$stored" && ! -L "$stored" ]] || return 1 + [[ "$(sha256sum "$stored" | awk '{print $1}')" == "$checksum" ]] || + return 1 + fi + mkdir -p -- "$(dirname "$target")" || return 1 + rm -rf -- "$target" || return 1 + cp -a -- "$stored" "$target" || return 1 + ;; + ABSENT) + rm -rf -- "$target" || return 1 + ;; + *) + return 1 + ;; + esac + done <"$manifest" + + if TAPM_POST_LIVE_ROOT; then + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ -f "${backup_dir}/services.tsv" ]]; then + while IFS=$'\t' read -r relative_path status checksum; do + if [[ "$status" == enabled ]]; then + systemctl enable "$relative_path" >/dev/null 2>&1 || true + else + systemctl disable "$relative_path" >/dev/null 2>&1 || true + fi + if [[ "$checksum" == active ]]; then + systemctl start "$relative_path" >/dev/null 2>&1 || true + else + systemctl stop "$relative_path" >/dev/null 2>&1 || true + fi + done <"${backup_dir}/services.tsv" + fi + sysctl --system >/dev/null 2>&1 || true + systemctl restart systemd-journald.service >/dev/null 2>&1 || true + fi +} + +TAPM_POST_PRECHECK() { + local pve_major + + if TAPM_POST_LIVE_ROOT; then + if (( EUID != 0 )); then + TAPM_POST_LAST_ERROR='This workflow must be run as root.' + return 1 + fi + pve_major="$( + pveversion 2>/dev/null | + sed -n 's/.*pve-manager\/\([0-9][0-9]*\).*/\1/p' | + head -1 + )" + if [[ "$pve_major" != '9' ]]; then + TAPM_POST_LAST_ERROR='The native TAPM profile currently supports Proxmox VE 9 only.' + return 1 + fi + if ps -eo comm= 2>/dev/null | + grep -Eq '^(vzdump|pigz|gzip|zstd)$'; then + TAPM_POST_LAST_ERROR='A backup or compression process is active; retry after it finishes.' + return 1 + fi + if command -v flock >/dev/null 2>&1 && + ! flock -n /var/lib/dpkg/lock-frontend true 2>/dev/null; then + TAPM_POST_LAST_ERROR='The package manager is currently busy.' + return 1 + fi + fi +} + +TAPM_POST_DEFAULT_SELECTIONS() { + TAPM_POST_DO_UTILITIES=1 + TAPM_POST_DO_TIME=1 + TAPM_POST_DO_PANIC=1 + TAPM_POST_DO_LIMITS=1 + TAPM_POST_DO_JOURNALD=1 + TAPM_POST_DO_LOGROTATE=1 + TAPM_POST_DO_MEMORY=1 + TAPM_POST_DO_NETWORK=1 + TAPM_POST_DO_BBR=1 + TAPM_POST_DO_PIGZ=1 + TAPM_POST_DO_APT_NETWORK=0 +} + +TAPM_POST_PACKAGE_INSTALLED() { + dpkg-query -W -f='${Status}' "$1" 2>/dev/null | + grep -q '^install ok installed$' +} + +TAPM_POST_INSTALL_PACKAGES() { + local -a packages=( + htop btop iftop iotop iperf3 net-tools unzip zip + tmux mtr-tiny dnsutils lsof jq rsync sysstat ethtool + smartmontools nvme-cli + ) + local -a missing=() + local package + local microcode='' + local vendor + + TAPM_POST_LIVE_ROOT || return 0 + for package in "${packages[@]}"; do + TAPM_POST_PACKAGE_INSTALLED "$package" || missing+=("$package") + done + if (( ${#missing[@]} > 0 )); then + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends "${missing[@]}"; then + apt-get update || return 1 + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends "${missing[@]}" || return 1 + fi + fi + + vendor="$(awk -F: '/vendor_id/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' \ + /proc/cpuinfo 2>/dev/null)" + case "$vendor" in + GenuineIntel) microcode='intel-microcode' ;; + AuthenticAMD) microcode='amd64-microcode' ;; + esac + if [[ -n "$microcode" ]] && ! TAPM_POST_PACKAGE_INSTALLED "$microcode"; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + "$microcode" || + printf 'Warning: %s was unavailable; continuing.\n' "$microcode" >&2 + fi +} + +TAPM_POST_ENSURE_APT_LAYOUT() { + local sources_list + + sources_list="$(TAPM_POST_PATH '/etc/apt/sources.list')" + if [[ -e "$sources_list" && ! -f "$sources_list" ]]; then + return 1 + fi + mkdir -p -- "$(dirname "$sources_list")" || return 1 + : >"$sources_list" || return 1 + chmod 0644 "$sources_list" || return 1 + if TAPM_POST_LIVE_ROOT; then + apt-get update + fi +} + +TAPM_POST_CONFIGURE_TIME() { + TAPM_POST_LIVE_ROOT || return 0 + + if ! TAPM_POST_PACKAGE_INSTALLED chrony; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + chrony || return 1 + fi + systemctl enable --now chrony.service || return 1 + if systemctl is-active --quiet systemd-timesyncd.service; then + systemctl disable --now systemd-timesyncd.service >/dev/null 2>&1 || true + fi + chronyc tracking >/dev/null 2>&1 || { + printf 'Warning: chrony is active but has not synchronized yet.\n' >&2 + return 0 + } +} + +TAPM_POST_CONFIGURE_PANIC() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-kernel-panic.conf' <<'EOF' +# Managed by TA-ProxMenu +kernel.panic = 30 +kernel.panic_on_oops = 1 +kernel.hardlockup_panic = 1 +kernel.softlockup_panic = 0 +EOF +} + +TAPM_POST_CONFIGURE_LIMITS() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-limits.conf' <<'EOF' +# Managed by TA-ProxMenu +fs.inotify.max_user_watches = 524288 +fs.inotify.max_user_instances = 1024 +fs.inotify.max_queued_events = 32768 +EOF +} + +TAPM_POST_CONFIGURE_JOURNALD() { + if TAPM_POST_PROXMENUX_JOURNALD; then + printf '[Journal]\n' | + TAPM_POST_WRITE_FILE '/etc/systemd/journald.conf' + fi + TAPM_POST_WRITE_FILE '/etc/systemd/journald.conf.d/99-ta-proxmenu.conf' <<'EOF' +# Managed by TA-ProxMenu +[Journal] +Storage=persistent +Compress=yes +SystemMaxUse=1G +SystemKeepFree=1G +SystemMaxFileSize=64M +MaxRetentionSec=30day +EOF + if TAPM_POST_LIVE_ROOT; then + systemctl restart systemd-journald.service || return 1 + systemd-analyze cat-config systemd/journald.conf 2>/dev/null | + grep -q '^MaxRetentionSec=30day$' || return 1 + fi +} + +TAPM_POST_LOGROTATE_BACKUP_VALID() { + local file="$1" + + [[ -f "$file" ]] || return 1 + ! grep -q '^# ProxMenux optimized configuration' "$file" && + grep -Eq '^[[:space:]]*include[[:space:]]+/etc/logrotate.d' "$file" +} + +TAPM_POST_CONFIGURE_LOGROTATE() { + local backup + + if TAPM_POST_PROXMENUX_LOGROTATE; then + backup="$(TAPM_POST_PATH '/etc/logrotate.conf.bak')" + if TAPM_POST_LOGROTATE_BACKUP_VALID "$backup"; then + cp -a -- "$backup" "$(TAPM_POST_PATH '/etc/logrotate.conf')" || + return 1 + else + TAPM_POST_WRITE_FILE '/etc/logrotate.conf' <<'EOF' +# Debian-compatible baseline restored by TA-ProxMenu +weekly +rotate 4 +create +include /etc/logrotate.d +EOF + fi + fi + if TAPM_POST_LIVE_ROOT; then + systemctl enable --now logrotate.timer >/dev/null 2>&1 || return 1 + logrotate --debug /etc/logrotate.conf >/dev/null 2>&1 || return 1 + fi +} + +TAPM_POST_CONFIGURE_MEMORY() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-memory.conf' <<'EOF' +# Managed by TA-ProxMenu +vm.swappiness = 10 +EOF +} + +TAPM_POST_CONFIGURE_NETWORK() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-network.conf' <<'EOF' +# Managed by TA-ProxMenu +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.default.accept_redirects = 0 +net.ipv4.conf.all.secure_redirects = 0 +net.ipv4.conf.default.secure_redirects = 0 +net.ipv4.conf.all.accept_source_route = 0 +net.ipv4.conf.default.accept_source_route = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.default.send_redirects = 0 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +net.ipv4.icmp_ignore_bogus_error_responses = 1 +net.ipv4.tcp_rfc1337 = 1 +net.ipv4.tcp_mtu_probing = 1 +EOF +} + +TAPM_POST_CONFIGURE_BBR() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-bbr.conf' <<'EOF' +# Managed by TA-ProxMenu +net.core.default_qdisc = fq +net.ipv4.tcp_congestion_control = bbr +net.ipv4.tcp_fastopen = 3 +EOF + if TAPM_POST_LIVE_ROOT; then + modprobe tcp_bbr >/dev/null 2>&1 || true + if ! sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null | + grep -qw bbr; then + rm -f -- \ + "$(TAPM_POST_PATH '/etc/sysctl.d/99-ta-proxmenu-bbr.conf')" \ + "$(TAPM_POST_PATH '/etc/modules-load.d/ta-proxmenu-bbr.conf')" + printf 'Warning: BBR is unavailable in the running kernel; continuing.\n' >&2 + return 0 + fi + if modinfo tcp_bbr >/dev/null 2>&1; then + printf 'tcp_bbr\n' | + TAPM_POST_WRITE_FILE '/etc/modules-load.d/ta-proxmenu-bbr.conf' + fi + fi +} + +TAPM_POST_REMOVE_COLON_KEY() { + local relative_path="$1" + local key="$2" + local file + local temporary + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] || return 0 + temporary="$(mktemp "${file}.tapm.XXXXXX")" || return 1 + if ! awk -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*:" { next } + { print } + ' "$file" >"$temporary" || + ! { chmod --reference="$file" "$temporary" 2>/dev/null || + chmod 0644 "$temporary"; } || + ! mv -f -- "$temporary" "$file"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_SET_COLON_KEY() { + local relative_path="$1" + local key="$2" + local value="$3" + local file + local temporary + + file="$(TAPM_POST_PATH "$relative_path")" + mkdir -p -- "$(dirname "$file")" || return 1 + touch "$file" || return 1 + temporary="$(mktemp "${file}.tapm.XXXXXX")" || return 1 + if ! awk -v key="$key" -v value="$value" ' + BEGIN { written=0 } + $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*:" { + if (!written) { + print key ": " value + written=1 + } + next + } + { print } + END { + if (!written) print key ": " value + } + ' "$file" >"$temporary" || + ! chmod 0644 "$temporary" || + ! mv -f -- "$temporary" "$file"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_REPAIR_GZIP() { + local gzip_path + local original + local wrapper + + TAPM_POST_PROXMENUX_GZIP_WRAPPER || return 0 + gzip_path="$(TAPM_POST_PATH '/bin/gzip')" + original="$(TAPM_POST_PATH '/bin/gzip.original')" + wrapper="$(TAPM_POST_PATH '/bin/pigzwrapper')" + + if [[ -x "$original" ]] && + "$original" --version 2>/dev/null | head -1 | grep -qi 'gzip'; then + cp -a -- "$original" "$gzip_path" || return 1 + elif TAPM_POST_LIVE_ROOT; then + DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y gzip || + return 1 + else + return 1 + fi + + "$gzip_path" --version 2>/dev/null | head -1 | grep -qi 'gzip' || return 1 + if ! TAPM_POST_LIVE_ROOT || + printf 'TA-ProxMenu gzip verification\n' | + "$gzip_path" -c | + "$gzip_path" -dc | + grep -q '^TA-ProxMenu gzip verification$'; then + rm -f -- "$wrapper" "$original" + else + return 1 + fi +} + +TAPM_POST_CONFIGURE_PIGZ() { + if TAPM_POST_LIVE_ROOT && ! TAPM_POST_PACKAGE_INSTALLED pigz; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + pigz || return 1 + fi + TAPM_POST_SET_COLON_KEY '/etc/vzdump.conf' pigz 1 +} + +TAPM_POST_APT_NETWORK_CHECK() { + local tapm_ipv4_file='/etc/apt/apt.conf.d/99-ta-proxmenu-force-ipv4' + + TAPM_POST_LIVE_ROOT || return 0 + rm -f -- "$(TAPM_POST_PATH "$tapm_ipv4_file")" + if apt-get update; then + return 0 + fi + if apt-get -o Acquire::ForceIPv4=true update; then + printf 'Acquire::ForceIPv4 "true";\n' | + TAPM_POST_WRITE_FILE "$tapm_ipv4_file" + return + fi + return 1 +} + +TAPM_POST_REMOVE_FILE_IF_MATCHES() { + local relative_path="$1" + local pattern="$2" + local file + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] || return 0 + grep -q -- "$pattern" "$file" || return 0 + rm -f -- "$file" +} + +TAPM_POST_PROXMENUX_TOOL_REGISTERED() { + local tool="$1" + local registry + + registry="$(TAPM_POST_PATH '/usr/local/share/proxmenux/installed_tools.json')" + [[ -f "$registry" ]] && grep -q "\"${tool}\"" "$registry" +} + +TAPM_POST_COLON_VALUE_IS() { + local relative_path="$1" + local key="$2" + local expected="$3" + local file + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] && + awk -F: -v key="$key" -v expected="$expected" ' + $1 ~ "^[[:space:]]*" key "[[:space:]]*$" { + gsub(/[[:space:]]/, "", $2) + found=($2 == expected) + } + END { exit !found } + ' "$file" +} + +TAPM_POST_REMOVE_EXACT_LINE() { + local relative_path="$1" + local line="$2" + local file + local temporary + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] || return 0 + temporary="$(mktemp "${file}.tapm.XXXXXX")" || return 1 + if ! awk -v line="$line" '$0 != line { print }' "$file" >"$temporary" || + ! { chmod --reference="$file" "$temporary" 2>/dev/null || + chmod 0644 "$temporary"; } || + ! mv -f -- "$temporary" "$file"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_RESTORE_FWBR_RUNTIME_DEFAULTS() { + local interface_path + local default_value + local interface_name + + TAPM_POST_LIVE_ROOT || return 0 + default_value="$(cat /proc/sys/net/ipv4/conf/default/rp_filter 2>/dev/null)" || + return 0 + for interface_path in /proc/sys/net/ipv4/conf/*; do + [[ -d "$interface_path" ]] || continue + interface_name="${interface_path##*/}" + case "$interface_name" in + fwbr*|fwln*|fwpr*|tap*) + [[ -w "${interface_path}/rp_filter" ]] && + printf '%s\n' "$default_value" >"${interface_path}/rp_filter" + ;; + esac + done +} + +TAPM_POST_CLEAN_PROXMENUX_SETTINGS() { + local cleanup_status=0 + local file + local limits_were_proxmenux=0 + local vzdump_was_proxmenux=0 + + file="$(TAPM_POST_PATH '/etc/security/limits.d/99-limits.conf')" + [[ -f "$file" ]] && grep -q '# ProxMenux configuration' "$file" && + limits_were_proxmenux=1 + if TAPM_POST_PROXMENUX_TOOL_REGISTERED vzdump_speed || + { [[ -e "$(TAPM_POST_PATH '/opt/.PROXMENUX_POST_INSTALL')" ]] && + TAPM_POST_COLON_VALUE_IS /etc/vzdump.conf bwlimit 0 && + TAPM_POST_COLON_VALUE_IS /etc/vzdump.conf ionice 5; }; then + vzdump_was_proxmenux=1 + fi + + if TAPM_POST_EXACT_APT_LANGUAGES; then + rm -f -- "$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-disable-translations')" || + cleanup_status=1 + fi + if TAPM_POST_EXACT_APT_IPV4; then + rm -f -- "$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-force-ipv4')" || + cleanup_status=1 + fi + + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-kernelpanic.conf' \ + '^kernel.core_pattern = /var/crash/' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-maxwatches.conf' \ + 'fs.inotify.max_user_instances = 1048576' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-maxkeys.conf' \ + 'kernel.keys.maxkeys=1000000' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-fs.conf' \ + 'fs.aio-max-nr = 1048576' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-swap.conf' \ + '# ProxMenux configuration' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-memory.conf' \ + '^# Balanced Memory Optimization' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-network.conf' \ + '^# ProxMenux - Network tuning' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-kernel-bbr.conf' \ + 'net.ipv4.tcp_congestion_control = bbr' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-tcp-fastopen.conf' \ + 'net.ipv4.tcp_fastopen = 3' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/security/limits.d/99-limits.conf' \ + '# ProxMenux configuration' || cleanup_status=1 + + if (( limits_were_proxmenux == 1 )); then + TAPM_POST_REMOVE_EXACT_LINE '/etc/systemd/system.conf' \ + 'DefaultLimitNOFILE=1048576' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/etc/systemd/user.conf' \ + 'DefaultLimitNOFILE=1048576' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/etc/pam.d/common-session' \ + 'session required pam_limits.so' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/etc/pam.d/runuser-l' \ + 'session required pam_limits.so' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/root/.profile' 'ulimit -n 1048576' || + cleanup_status=1 + fi + if (( vzdump_was_proxmenux == 1 )); then + TAPM_POST_REMOVE_COLON_KEY '/etc/vzdump.conf' bwlimit || cleanup_status=1 + TAPM_POST_REMOVE_COLON_KEY '/etc/vzdump.conf' ionice || cleanup_status=1 + fi + + if TAPM_POST_LIVE_ROOT; then + systemctl disable --now proxmenux-fwbr-tune.service >/dev/null 2>&1 || true + fi + rm -f -- \ + "$(TAPM_POST_PATH '/usr/local/sbin/proxmenux-fwbr-tune')" \ + "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-fwbr-tune.service')" \ + "$(TAPM_POST_PATH '/etc/udev/rules.d/99-proxmenux-fwbr-tune.rules')" || + cleanup_status=1 + TAPM_POST_RESTORE_FWBR_RUNTIME_DEFAULTS || cleanup_status=1 + + file="$(TAPM_POST_PATH '/etc/network/interfaces')" + if [[ -f "$file" ]]; then + awk ' + $0 == "source /etc/network/interfaces.d/*" { + if (seen++) next + } + { print } + ' "$file" >"${file}.tapm" && + { chmod --reference="$file" "${file}.tapm" 2>/dev/null || + chmod 0644 "${file}.tapm"; } && + mv -f -- "${file}.tapm" "$file" || cleanup_status=1 + fi + return "$cleanup_status" +} + +TAPM_POST_REMOVE_PROXMENUX_APP() { + local bashrc_backup + local cleanup_status=0 + local had_app=0 + local motd_backup + local menu_launcher + + [[ -d "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" ]] && had_app=1 + + if TAPM_POST_LIVE_ROOT; then + systemctl disable --now proxmenux-monitor.service >/dev/null 2>&1 || true + fi + rm -f -- \ + "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-monitor.service')" \ + "$(TAPM_POST_PATH '/opt/.PROXMENUX_POST_INSTALL')" || cleanup_status=1 + menu_launcher="$(TAPM_POST_PATH '/usr/local/bin/menu')" + if TAPM_POST_PROXMENUX_MENU_LAUNCHER; then + rm -f -- "$menu_launcher" || cleanup_status=1 + fi + rm -rf -- \ + "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" \ + "$(TAPM_POST_PATH '/root/.config/proxmenux-monitor')" \ + "$(TAPM_POST_PATH '/var/lib/proxmenux')" || cleanup_status=1 + if (( had_app == 1 )); then + rm -rf -- "$(TAPM_POST_PATH '/opt/googletrans-env')" || cleanup_status=1 + fi + + bashrc_backup="$(TAPM_POST_PATH '/root/.bashrc.bak')" + if [[ -f "$bashrc_backup" ]] && + grep -qi 'proxmenux' "$(TAPM_POST_PATH '/root/.bashrc')" 2>/dev/null; then + mv -f -- "$bashrc_backup" "$(TAPM_POST_PATH '/root/.bashrc')" || + cleanup_status=1 + fi + motd_backup="$(TAPM_POST_PATH '/etc/motd.bak')" + if [[ -f "$motd_backup" ]] && + grep -qi 'proxmenux' "$(TAPM_POST_PATH '/etc/motd')" 2>/dev/null; then + mv -f -- "$motd_backup" "$(TAPM_POST_PATH '/etc/motd')" || + cleanup_status=1 + else + TAPM_POST_REMOVE_EXACT_LINE '/etc/motd' \ + 'This system is optimised by: ProxMenux' || cleanup_status=1 + fi + if TAPM_POST_LIVE_ROOT; then + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl reset-failed >/dev/null 2>&1 || true + fi + return "$cleanup_status" +} + +TAPM_POST_APPLY_SYSCTL() { + TAPM_POST_LIVE_ROOT || return 0 + sysctl --system >/dev/null +} + +TAPM_POST_STATE() { + local status="$1" + local step="$2" + local state_dir + + state_dir="$(TAPM_POST_PATH "$TAPM_POST_STATE_DIR")" + mkdir -p -m 0750 -- "$state_dir" || return 1 + { + printf 'status=%s\n' "$status" + printf 'step=%s\n' "$step" + printf 'updated=%s\n' "$(date --iso-8601=seconds 2>/dev/null || date)" + printf 'backup=%s\n' "$TAPM_POST_LAST_BACKUP" + } >"${state_dir}/state" +} + +TAPM_POST_RUN_STEP() { + local label="$1" + shift + + printf ' - %s...\n' "$label" + TAPM_POST_STATE in_progress "$label" || return 1 + if "$@"; then + printf ' OK\n' + return 0 + fi + TAPM_POST_LAST_ERROR="$label failed." + TAPM_POST_STATE failed "$label" || true + return 1 +} + +TAPM_POST_SAVE_REPORT() { + local mode="$1" + local state_dir + + state_dir="$(TAPM_POST_PATH "$TAPM_POST_STATE_DIR")" + mkdir -p -m 0750 -- "$state_dir" || return 1 + { + printf 'TA-ProxMenu host configuration report\n' + printf 'Completed: %s\n' "$(date --iso-8601=seconds 2>/dev/null || date)" + printf 'Mode: %s\n' "$mode" + printf 'Host: %s\n' "$(hostname 2>/dev/null || printf unknown)" + printf 'Backup: %s\n' "$TAPM_POST_LAST_BACKUP" + printf 'ProxMenux detected after completion: ' + TAPM_POST_PROXMENUX_DETECTED && printf 'yes\n' || printf 'no\n' + printf '\nSelected profile:\n' + printf ' Utilities: %s\n' "$TAPM_POST_DO_UTILITIES" + printf ' Time synchronization: %s\n' "$TAPM_POST_DO_TIME" + printf ' Kernel panic recovery: %s\n' "$TAPM_POST_DO_PANIC" + printf ' Inotify limits: %s\n' "$TAPM_POST_DO_LIMITS" + printf ' Journald: %s\n' "$TAPM_POST_DO_JOURNALD" + printf ' Log rotation: %s\n' "$TAPM_POST_DO_LOGROTATE" + printf ' Memory behavior: %s\n' "$TAPM_POST_DO_MEMORY" + printf ' Network safeguards: %s\n' "$TAPM_POST_DO_NETWORK" + printf ' BBR/TCP Fast Open: %s\n' "$TAPM_POST_DO_BBR" + printf ' Native pigz: %s\n' "$TAPM_POST_DO_PIGZ" + printf ' APT network check: %s\n' "$TAPM_POST_DO_APT_NETWORK" + } >"${state_dir}/last-report.txt" + TAPM_POST_STATE complete complete +} + +TAPM_POST_APPLY_PROFILE() { + local mode="${1:-apply}" + local migrate=0 + + [[ "$mode" == migrate ]] && migrate=1 + TAPM_POST_LAST_ERROR='' + TAPM_POST_PRECHECK || return 1 + if (( migrate == 1 )) && ! TAPM_POST_PROXMENUX_DETECTED; then + TAPM_POST_LAST_ERROR='No ProxMenux installation or recognized artifacts were detected.' + return 1 + fi + TAPM_POST_BACKUP >/dev/null || { + TAPM_POST_LAST_ERROR='The configuration backup could not be created.' + return 1 + } + + TAPM_POST_RUN_STEP 'Validating the TAPM-managed APT source layout' \ + TAPM_POST_ENSURE_APT_LAYOUT || return 1 + if (( migrate == 1 )); then + TAPM_POST_RUN_STEP 'Repairing the ProxMenux gzip replacement' \ + TAPM_POST_REPAIR_GZIP || return 1 + fi + (( TAPM_POST_DO_UTILITIES == 0 )) || + TAPM_POST_RUN_STEP 'Installing missing system utilities' \ + TAPM_POST_INSTALL_PACKAGES || return 1 + (( TAPM_POST_DO_TIME == 0 )) || + TAPM_POST_RUN_STEP 'Configuring time synchronization' \ + TAPM_POST_CONFIGURE_TIME || return 1 + (( TAPM_POST_DO_PANIC == 0 )) || + TAPM_POST_RUN_STEP 'Configuring kernel panic recovery' \ + TAPM_POST_CONFIGURE_PANIC || return 1 + (( TAPM_POST_DO_LIMITS == 0 )) || + TAPM_POST_RUN_STEP 'Configuring conservative inotify limits' \ + TAPM_POST_CONFIGURE_LIMITS || return 1 + (( TAPM_POST_DO_JOURNALD == 0 )) || + TAPM_POST_RUN_STEP 'Configuring journald retention' \ + TAPM_POST_CONFIGURE_JOURNALD || return 1 + (( TAPM_POST_DO_LOGROTATE == 0 )) || + TAPM_POST_RUN_STEP 'Verifying and repairing log rotation' \ + TAPM_POST_CONFIGURE_LOGROTATE || return 1 + (( TAPM_POST_DO_MEMORY == 0 )) || + TAPM_POST_RUN_STEP 'Configuring host memory behavior' \ + TAPM_POST_CONFIGURE_MEMORY || return 1 + (( TAPM_POST_DO_NETWORK == 0 )) || + TAPM_POST_RUN_STEP 'Applying network safeguards' \ + TAPM_POST_CONFIGURE_NETWORK || return 1 + (( TAPM_POST_DO_BBR == 0 )) || + TAPM_POST_RUN_STEP 'Enabling BBR and TCP Fast Open' \ + TAPM_POST_CONFIGURE_BBR || return 1 + (( TAPM_POST_DO_PIGZ == 0 )) || + TAPM_POST_RUN_STEP 'Enabling native parallel gzip for vzdump' \ + TAPM_POST_CONFIGURE_PIGZ || return 1 + (( TAPM_POST_DO_APT_NETWORK == 0 )) || + TAPM_POST_RUN_STEP 'Checking APT network compatibility' \ + TAPM_POST_APT_NETWORK_CHECK || return 1 + + if (( migrate == 1 )); then + TAPM_POST_RUN_STEP 'Removing recognized ProxMenux settings' \ + TAPM_POST_CLEAN_PROXMENUX_SETTINGS || return 1 + # Reassert TAPM files after removing the older ProxMenux equivalents. + (( TAPM_POST_DO_PANIC == 0 )) || TAPM_POST_CONFIGURE_PANIC || return 1 + (( TAPM_POST_DO_LIMITS == 0 )) || TAPM_POST_CONFIGURE_LIMITS || return 1 + (( TAPM_POST_DO_MEMORY == 0 )) || TAPM_POST_CONFIGURE_MEMORY || return 1 + (( TAPM_POST_DO_NETWORK == 0 )) || TAPM_POST_CONFIGURE_NETWORK || return 1 + (( TAPM_POST_DO_BBR == 0 )) || TAPM_POST_CONFIGURE_BBR || return 1 + fi + + TAPM_POST_RUN_STEP 'Applying and validating kernel settings' \ + TAPM_POST_APPLY_SYSCTL || return 1 + if (( migrate == 1 )); then + TAPM_POST_RUN_STEP 'Removing the ProxMenux application' \ + TAPM_POST_REMOVE_PROXMENUX_APP || return 1 + fi + TAPM_POST_SAVE_REPORT "$mode" || return 1 +} + +TAPM_POST_AUDIT() { + local state_file + local report_file + + printf 'TA-ProxMenu host configuration audit\n\n' + printf 'ProxMenux installation/artifacts: ' + TAPM_POST_PROXMENUX_DETECTED && printf 'detected\n' || printf 'not detected\n' + printf 'ProxMenux gzip replacement: ' + TAPM_POST_PROXMENUX_GZIP_WRAPPER && printf 'detected - migration required\n' || + printf 'not detected\n' + printf 'ProxMenux journald replacement: ' + TAPM_POST_PROXMENUX_JOURNALD && printf 'detected\n' || printf 'not detected\n' + printf 'ProxMenux logrotate replacement: ' + TAPM_POST_PROXMENUX_LOGROTATE && printf 'detected\n' || printf 'not detected\n' + printf 'APT language suppression: ' + TAPM_POST_EXACT_APT_LANGUAGES && printf 'enabled by ProxMenux\n' || + printf 'not detected\n' + printf 'APT forced IPv4: ' + TAPM_POST_EXACT_APT_IPV4 && printf 'enabled by ProxMenux\n' || + printf 'not detected\n' + + printf '\nTAPM managed files:\n' + while IFS= read -r report_file; do + printf ' %-58s %s\n' "$report_file" \ + "$([[ -e "$(TAPM_POST_PATH "$report_file")" ]] && + printf configured || printf absent)" + done < <(TAPM_POST_MANAGED_FILES) + + state_file="$(TAPM_POST_PATH "$TAPM_POST_STATE_DIR/state")" + if [[ -f "$state_file" ]]; then + printf '\nLast TAPM state:\n' + sed 's/^/ /' "$state_file" + fi +} + +TAPM_POST_MIGRATION_PLAN() { + printf '\nRecognized migration plan:\n' + printf ' %-34s %s\n' 'ProxMenux application' \ + "$([[ -d "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" ]] && + printf 'remove after validation' || printf 'not detected')" + printf ' %-34s %s\n' 'ProxMenux Monitor service' \ + "$([[ -e "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-monitor.service')" ]] && + printf 'stop and remove' || printf 'not detected')" + printf ' %-34s %s\n' '/bin/gzip replacement' \ + "$(TAPM_POST_PROXMENUX_GZIP_WRAPPER && + printf 'repair and verify' || printf 'not detected')" + printf ' %-34s %s\n' 'journald replacement' \ + "$(TAPM_POST_PROXMENUX_JOURNALD && + printf 'replace with TAPM drop-in' || printf 'not detected')" + printf ' %-34s %s\n' 'logrotate replacement' \ + "$(TAPM_POST_PROXMENUX_LOGROTATE && + printf 'restore or repair' || printf 'not detected')" + printf ' %-34s %s\n' 'APT language suppression' \ + "$(TAPM_POST_EXACT_APT_LANGUAGES && + printf 'remove' || printf 'not detected')" + printf ' %-34s %s\n' 'Unconditional APT IPv4' \ + "$(TAPM_POST_EXACT_APT_IPV4 && + printf 'remove' || printf 'not detected')" + printf ' %-34s %s\n' 'Shared dependencies' 'retain' + printf ' %-34s %s\n' 'Unknown administrator files' 'retain and report' +} diff --git a/inc/rmm.inc b/inc/rmm.inc new file mode 100644 index 0000000..57134ef --- /dev/null +++ b/inc/rmm.inc @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +TAPM_RMM_TOKEN_FROM_URL() { + local url="${1:-}" + local token_pattern + + token_pattern='TKN([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})/RUN(/|$)' + if [[ "$url" =~ $token_pattern ]]; then + printf '%s' "${BASH_REMATCH[1],,}" + return 0 + fi + return 1 +} + +TAPM_RMM_ENSURE_SUDO() { + if command -v sudo >/dev/null 2>&1; then + return 0 + fi + command -v apt-get >/dev/null 2>&1 || return 1 + + printf 'The RMM installer requires sudo; installing it now...\n' + apt-get update && + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends sudo +} diff --git a/inc/runtime-config.inc b/inc/runtime-config.inc new file mode 100644 index 0000000..b6bc307 --- /dev/null +++ b/inc/runtime-config.inc @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +TAPM_CONFIG_FILE="${TAPM_CONFIG_FILE:-/etc/ta-proxmenu/config.env}" + +TAPM_CONFIG_READ_VALUE() { + local key="$1" + local value='' + + if [[ -r "$TAPM_CONFIG_FILE" ]]; then + value="$( + sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" \ + "$TAPM_CONFIG_FILE" | + tail -n 1 + )" + value="${value#\"}" + value="${value%\"}" + value="${value#\'}" + value="${value%\'}" + fi + printf '%s' "$value" +} + +TAPM_VALID_BROKER_ORIGIN() { + [[ "${1:-}" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]] +} + +TAPM_VALID_GITEA_DOMAIN() { + [[ "${1:-}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]] +} + +TAPM_LOAD_RUNTIME_CONFIG() { + if [[ -z "${TAPM_BROKER_URL:-}" ]]; then + TAPM_BROKER_URL="$(TAPM_CONFIG_READ_VALUE TAPM_BROKER_URL)" + fi + if [[ -z "${GITEA_DOMAIN:-}" ]]; then + GITEA_DOMAIN="$(TAPM_CONFIG_READ_VALUE GITEA_DOMAIN)" + fi + + TAPM_BROKER_URL="${TAPM_BROKER_URL:-}" + TAPM_BROKER_URL="${TAPM_BROKER_URL%/}" + GITEA_DOMAIN="${GITEA_DOMAIN:-}" + if TAPM_VALID_GITEA_DOMAIN "$GITEA_DOMAIN"; then + GITEA_URL="https://${GITEA_DOMAIN}" + else + GITEA_URL='' + fi +} + +TAPM_ENSURE_RUNTIME_CONFIG() { + local broker_url + local config_dir + local gitea_domain + local input_device='/dev/tty' + local temporary_file + + TAPM_LOAD_RUNTIME_CONFIG + if [[ -r "$TAPM_CONFIG_FILE" ]] && + TAPM_VALID_BROKER_ORIGIN "$TAPM_BROKER_URL" && + TAPM_VALID_GITEA_DOMAIN "$GITEA_DOMAIN"; then + return 0 + fi + + if [[ "${TAPM_CONFIG_TEST_STDIN:-0}" == 1 ]]; then + input_device='/dev/stdin' + exec 3>&2 + elif [[ ! -r /dev/tty || ! -w /dev/tty ]]; then + printf 'TA-ProxMenu requires %s with TAPM_BROKER_URL and GITEA_DOMAIN.\n' \ + "$TAPM_CONFIG_FILE" >&2 + return 1 + else + exec 3>/dev/tty + fi + + printf '\nTA-ProxMenu V2 requires deployment service configuration.\n' \ + >&3 + while true; do + printf 'TAPM broker HTTPS origin (example: https://tapm.example.com): ' \ + >&3 + IFS= read -r broker_url <"$input_device" || return 1 + broker_url="${broker_url%/}" + TAPM_VALID_BROKER_ORIGIN "$broker_url" && break + printf 'Enter an HTTPS origin without a path.\n' >&3 + done + while true; do + printf 'Git hostname (example: git.example.com): ' >&3 + IFS= read -r gitea_domain <"$input_device" || return 1 + TAPM_VALID_GITEA_DOMAIN "$gitea_domain" && break + printf 'Enter a hostname without https:// or a path.\n' >&3 + done + + config_dir="${TAPM_CONFIG_FILE%/*}" + [[ "$config_dir" != "$TAPM_CONFIG_FILE" ]] || config_dir='.' + mkdir -p "$config_dir" || return 1 + temporary_file="$(mktemp "${TAPM_CONFIG_FILE}.tmp.XXXXXX")" || return 1 + if ! { + printf 'TAPM_BROKER_URL=%s\n' "$broker_url" + printf 'GITEA_DOMAIN=%s\n' "$gitea_domain" + } >"$temporary_file" || + ! chmod 0600 "$temporary_file" || + ! mv -f "$temporary_file" "$TAPM_CONFIG_FILE"; then + rm -f "$temporary_file" + return 1 + fi + + TAPM_BROKER_URL="$broker_url" + GITEA_DOMAIN="$gitea_domain" + GITEA_URL="https://${GITEA_DOMAIN}" + printf 'Saved TA-ProxMenu configuration to %s.\n\n' "$TAPM_CONFIG_FILE" \ + >&3 + exec 3>&- +} diff --git a/inc/secure-input.inc b/inc/secure-input.inc new file mode 100644 index 0000000..ea14dff --- /dev/null +++ b/inc/secure-input.inc @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +TAPM_READ_MASKED() { + local destination="$1" + local character='' + local value='' + + while IFS= read -r -s -n 1 character; do + if [[ -z "$character" ]]; then + break + fi + case "$character" in + $'\177' | $'\b') + if [[ -n "$value" ]]; then + value="${value%?}" + printf '\b \b' + fi + ;; + *) + value+="$character" + printf '*' + ;; + esac + done + printf '\n' + printf -v "$destination" '%s' "$value" +} diff --git a/inc/virtio-helpers.inc b/inc/virtio-helpers.inc new file mode 100644 index 0000000..30a8606 --- /dev/null +++ b/inc/virtio-helpers.inc @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Pure VirtIO filename helpers shared by TA-ProxMenu and its tests. + +TAPM_VIRTIO_FILENAME_FROM_URL() { + local url="${1%%\?*}" + local filename="${url##*/}" + + [[ "$filename" =~ ^virtio-win(-0\.1\.[0-9]+)?\.iso$ ]] || return 1 + printf '%s\n' "$filename" +} + +TAPM_VIRTIO_LABELED_FILENAME() { + local source_filename="$1" + local label="$2" + local version='' + + [[ "$label" =~ ^[a-z0-9-]+$ ]] || return 1 + if [[ "$source_filename" =~ ^virtio-win-(0\.1\.[0-9]+)\.iso$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ "$source_filename" != 'virtio-win.iso' ]]; then + return 1 + fi + + printf 'virtio-win-%s%s.iso\n' "$label" "${version:+-${version}}" +} + +TAPM_VIRTIO_CACHE_VALID() { + local checked_at="${1:-}" + local now="${2:-}" + local max_age="${3:-}" + local filename="${4:-}" + + [[ "$checked_at" =~ ^[0-9]+$ && + "$now" =~ ^[0-9]+$ && + "$max_age" =~ ^[1-9][0-9]*$ && + "$filename" =~ ^virtio-win(-[0-9]+\.[0-9]+\.[0-9]+)?\.iso$ ]] || + return 1 + (( now >= checked_at && now - checked_at < max_age )) +} diff --git a/install-pulse.sh b/install-pulse.sh new file mode 100755 index 0000000..27c7820 --- /dev/null +++ b/install-pulse.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Standalone TA-managed Pulse LXC deployment bootstrap for Proxmox VE. + +set -u -o pipefail + +TAPM_PULSE_SOURCE_BRANCH="${TAPM_PULSE_SOURCE_BRANCH:-V2}" +TAPM_PULSE_SOURCE_BASE="${TAPM_PULSE_SOURCE_BASE:-https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/${TAPM_PULSE_SOURCE_BRANCH}}" +TAPM_PULSE_BOOTSTRAP_DIR='' +TAPM_TEMP_DIR='' +declare -a TAPM_TEMP_DIRS=() +declare -A idsCL=() + +TAPM_PULSE_VALID_SOURCE_BRANCH() { + local branch="${1:-}" + + [[ -n "$branch" && + "$branch" =~ ^[A-Za-z0-9._/-]+$ && + "$branch" != /* && + "$branch" != */ && + "$branch" != *..* ]] +} + +TAPM_PULSE_VALID_SOURCE_BASE() { + local url="${1:-}" + + [[ "$url" == https://* && + "$url" != *$'\n'* && + "$url" != *$'\r'* && + "$url" != *'"'* && + "$url" != *\\* && + "$url" != *[[:space:]]* ]] +} + +TAPM_PULSE_DEFINE_COLORS() { + if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + idsCL[Default]=$'\e[0m' + idsCL[Red]=$'\e[31m' + idsCL[Green]=$'\e[32m' + idsCL[White]=$'\e[97m' + idsCL[LightRed]=$'\e[91m' + idsCL[LightGreen]=$'\e[92m' + idsCL[LightYellow]=$'\e[93m' + idsCL[LightCyan]=$'\e[96m' + else + idsCL[Default]='' + idsCL[Red]='' + idsCL[Green]='' + idsCL[White]='' + idsCL[LightRed]='' + idsCL[LightGreen]='' + idsCL[LightYellow]='' + idsCL[LightCyan]='' + fi +} + +TAPM_CLEAN_TEMP_DIR() { + local temp_dir="${1:-}" + + if [[ "$temp_dir" == /tmp/ta-proxmenu-* && -d "$temp_dir" ]]; then + rm -rf -- "$temp_dir" + fi +} + +TAPM_CLEAN_ALL_TEMP_DIRS() { + local temp_dir + + for temp_dir in "${TAPM_TEMP_DIRS[@]}"; do + TAPM_CLEAN_TEMP_DIR "$temp_dir" + done + TAPM_CLEAN_TEMP_DIR "$TAPM_PULSE_BOOTSTRAP_DIR" +} + +TAPM_CREATE_TEMP_DIR() { + local label="${1:-installer}" + + TAPM_TEMP_DIR="$(mktemp -d "/tmp/ta-proxmenu-${label}.XXXXXX")" || { + echo -e "${idsCL[LightRed]}Unable to create a temporary installer directory.${idsCL[Default]}" + return 1 + } + if ! chmod 0700 "$TAPM_TEMP_DIR"; then + TAPM_CLEAN_TEMP_DIR "$TAPM_TEMP_DIR" + echo -e "${idsCL[LightRed]}Unable to secure the temporary installer directory.${idsCL[Default]}" + return 1 + fi + TAPM_TEMP_DIRS+=("$TAPM_TEMP_DIR") +} + +TAPM_DOWNLOAD_HTTPS() { + local url="$1" + local destination="$2" + local label="${3:-Installer}" + + if ! TAPM_PULSE_VALID_SOURCE_BASE "$url"; then + echo -e "${idsCL[LightRed]}${label} requires a valid HTTPS URL.${idsCL[Default]}" + return 1 + fi + if ! printf 'url = "%s"\n' "$url" | + curl --fail --location --silent --show-error \ + --proto '=https' --proto-redir '=https' \ + --output "$destination" --config -; then + echo -e "${idsCL[LightRed]}${label} download failed.${idsCL[Default]}" + return 1 + fi + if [[ ! -s "$destination" ]]; then + echo -e "${idsCL[LightRed]}${label} download was empty.${idsCL[Default]}" + return 1 + fi +} + +EXIT1() { + stty echo 2>/dev/null || true + printf '%s' "${idsCL[Default]}" +} + +SELECT_MENU() { + local title="$1" + local labels_name="$2" + local values_name="$3" + local allow_back="${4:-1}" + local -n labels_ref="$labels_name" + local -n values_ref="$values_name" + local selected=0 + local key sequence index + + while true; do + if [[ -t 1 ]]; then + clear 2>/dev/null || printf '\e[H\e[2J' + fi + echo + echo -e " ${idsCL[LightCyan]}${title}${idsCL[Default]}" + echo + for index in "${!labels_ref[@]}"; do + if (( index == selected )); then + printf '\e[7m %d %-64s\e[0m\n' \ + "$((index + 1))" "${labels_ref[$index]}" + else + printf ' %d %s\n' "$((index + 1))" "${labels_ref[$index]}" + fi + done + echo + if (( allow_back == 1 )); then + echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" + else + echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" + fi + + IFS= read -rsn1 key + case "$key" in + "") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + [1-9]) + index=$((10#$key - 1)) + if (( index < ${#values_ref[@]} )); then + MENU_SELECTION="${values_ref[$index]}" + return 0 + fi + ;; + [Qq]) + MENU_SELECTION=quit + return 0 + ;; + [Bb]) + if (( allow_back == 1 )); then + MENU_SELECTION=back + return 0 + fi + ;; + $'\e') + sequence='' + IFS= read -rsn2 -t 0.1 sequence || true + case "$sequence" in + "[A"|"OA") + ((selected = (selected - 1 + ${#labels_ref[@]}) % ${#labels_ref[@]})) + ;; + "[B"|"OB") + ((selected = (selected + 1) % ${#labels_ref[@]})) + ;; + "[C"|"OC") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + "[D"|"OD"|"") + if (( allow_back == 1 )); then + MENU_SELECTION=back + return 0 + fi + ;; + esac + ;; + esac + done +} + +TAPM_PULSE_STANDALONE_MAIN() { + local iso_helpers pulse_module + + TAPM_PULSE_DEFINE_COLORS + if (( BASH_VERSINFO[0] < 4 || + (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then + echo "Pulse deployment requires Bash 4.3 or newer." >&2 + return 1 + fi + if (( EUID != 0 )); then + echo "Run this installer as root on a Proxmox VE host." >&2 + return 1 + fi + for command in curl pct pvesh; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command '${command}' was not found." >&2 + return 1 + fi + done + if ! TAPM_PULSE_VALID_SOURCE_BRANCH "$TAPM_PULSE_SOURCE_BRANCH" || + ! TAPM_PULSE_VALID_SOURCE_BASE "$TAPM_PULSE_SOURCE_BASE"; then + echo "The configured TA-ProxMenu source branch or URL is invalid." >&2 + return 1 + fi + + TAPM_PULSE_BOOTSTRAP_DIR="$( + mktemp -d /tmp/ta-proxmenu-pulse-bootstrap.XXXXXX + )" || return 1 + chmod 0700 "$TAPM_PULSE_BOOTSTRAP_DIR" || return 1 + iso_helpers="${TAPM_PULSE_BOOTSTRAP_DIR}/deploy-iso-nfs-lxc.sh" + pulse_module="${TAPM_PULSE_BOOTSTRAP_DIR}/deploy-pulse-lxc.sh" + + echo "Downloading the TA-managed Pulse deployment components..." + TAPM_DOWNLOAD_HTTPS \ + "${TAPM_PULSE_SOURCE_BASE}/inc/deploy-iso-nfs-lxc.sh" \ + "$iso_helpers" "LXC storage helper" || return 1 + TAPM_DOWNLOAD_HTTPS \ + "${TAPM_PULSE_SOURCE_BASE}/inc/deploy-pulse-lxc.sh" \ + "$pulse_module" "Pulse deployment module" || return 1 + bash -n "$iso_helpers" "$pulse_module" || { + echo "A downloaded Pulse deployment component failed validation." >&2 + return 1 + } + grep -q '^TAPM_ISO_NFS_SELECT_STORAGE()' "$iso_helpers" && + grep -q '^TAPM_DEPLOY_PULSE_LXC()' "$pulse_module" || { + echo "The downloaded files were not recognized as TAPM deployment components." >&2 + return 1 + } + + # shellcheck disable=SC1090 + source "$iso_helpers" + # shellcheck disable=SC1090 + source "$pulse_module" + TAPM_DEPLOY_PULSE_LXC +} + +if [[ "${TAPM_PULSE_STANDALONE_NO_MAIN:-0}" != 1 ]]; then + trap TAPM_CLEAN_ALL_TEMP_DIRS EXIT + TAPM_PULSE_STANDALONE_MAIN "$@" +fi diff --git a/install-ta_proxmenu.sh b/install-ta_proxmenu.sh new file mode 100755 index 0000000..dfc5d81 --- /dev/null +++ b/install-ta_proxmenu.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Install TA-ProxMenu on a Proxmox VE host. + +set -Eeuo pipefail + +readonly INSTALL_ROOT='/opt/idssys' +readonly TAPM_DIR="${INSTALL_ROOT}/ta-proxmenu" +readonly DEFAULTS_DIR="${INSTALL_ROOT}/defaults" +readonly TAPM_REPOSITORY='https://tagit.technologyarch.com/TAI/TA-ProxMenu.git' +readonly DEFAULTS_REPOSITORY='https://git.scity.us/voltron/iDS-Defaults.git' +readonly TAPM_LAUNCHER='/usr/local/bin/tapm' +readonly REQUESTED_BRANCH="${TAPM_BRANCH:-}" + +declare -a TEMP_PATHS=() + +cleanup() { + local path + + for path in "${TEMP_PATHS[@]}"; do + [[ -e "$path" ]] && rm -rf -- "$path" + done +} + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +clone_repository() { + local repository="$1" + local destination="$2" + local label="$3" + local branch="${4:-}" + local temporary + + if [[ -d "${destination}/.git" ]]; then + printf '%s is already installed at %s; leaving it unchanged.\n' \ + "$label" "$destination" + return + fi + + [[ ! -e "$destination" ]] || + fail "${destination} already exists but is not a Git repository." + + temporary="${destination}.install.$$" + TEMP_PATHS+=("$temporary") + + printf 'Cloning %s...\n' "$label" + if [[ -n "$branch" ]]; then + git clone --branch "$branch" --single-branch \ + "$repository" "$temporary" + else + git clone "$repository" "$temporary" + fi + + mv "$temporary" "$destination" +} + +install_launcher() { + local current_target='' + + if [[ -L "$TAPM_LAUNCHER" ]]; then + current_target="$(readlink "$TAPM_LAUNCHER")" + if [[ "$current_target" == "${TAPM_DIR}/run.sh" ]]; then + return + fi + + fail "${TAPM_LAUNCHER} points to ${current_target}; it was not replaced." + fi + + [[ ! -e "$TAPM_LAUNCHER" ]] || + fail "${TAPM_LAUNCHER} already exists and was not replaced." + + ln -s "${TAPM_DIR}/run.sh" "$TAPM_LAUNCHER" +} + +trap cleanup EXIT + +(( EUID == 0 )) || fail 'Run this installer as root.' +command -v pveversion >/dev/null 2>&1 || + fail 'This installer must be run on a Proxmox VE host.' + +if [[ -n "$REQUESTED_BRANCH" ]] && + [[ ! "$REQUESTED_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]]; then + fail "Invalid TAPM_BRANCH value: ${REQUESTED_BRANCH}" +fi + +printf '\nTA-ProxMenu Installation Script\n\n' + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates curl git jq python3 wget + +mkdir -p "$INSTALL_ROOT" + +clone_repository \ + "$DEFAULTS_REPOSITORY" "$DEFAULTS_DIR" 'iDSSYS Defaults' +clone_repository \ + "$TAPM_REPOSITORY" "$TAPM_DIR" 'TA-ProxMenu' "$REQUESTED_BRANCH" + +[[ -x "${TAPM_DIR}/run.sh" ]] || + fail "${TAPM_DIR}/run.sh is missing or is not executable." +[[ -r "${DEFAULTS_DIR}/colors.inc" ]] || + fail "${DEFAULTS_DIR}/colors.inc is missing." +[[ -r "${DEFAULTS_DIR}/default.inc" ]] || + fail "${DEFAULTS_DIR}/default.inc is missing." + +install_launcher + +# Load the standard colors only after the trusted defaults repository exists. +# shellcheck disable=SC1091 +source "${DEFAULTS_DIR}/colors.inc" + +printf '\n%bTA-ProxMenu has been installed.%b\n\n' \ + "${idsCL[Yellow]}" "${idsCL[Default]}" +printf 'Run it with: %btapm%b\n\n' \ + "${idsCL[Green]}" "${idsCL[Default]}" + +if [[ -n "$REQUESTED_BRANCH" ]]; then + printf 'Installed branch: %b%s%b\n\n' \ + "${idsCL[Green]}" "$REQUESTED_BRANCH" "${idsCL[Default]}" +fi diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index df3ccf3..8771d1d 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -2,421 +2,2759 @@ # TA-Proxmenu - Proxmox Setup Scripts for TA Use -[ "${2}" != "q" ] && source /opt/idssys/defaults/colors.inc -source /opt/idssys/defaults/default.inc +[ "${2:-}" != "q" ] && source /opt/idssys/ta-proxmenu/colors.inc source /opt/idssys/ta-proxmenu/defaults.inc +source /opt/idssys/ta-proxmenu/inc/git-update.inc +source /opt/idssys/ta-proxmenu/inc/ha-status.inc +source /opt/idssys/ta-proxmenu/inc/header-info.inc +source /opt/idssys/ta-proxmenu/inc/post-install.inc +source /opt/idssys/ta-proxmenu/inc/rmm.inc +source /opt/idssys/ta-proxmenu/inc/secure-input.inc +source /opt/idssys/ta-proxmenu/inc/cpu-compat.inc +source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh +source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh +source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc -# An older run.sh may update main on disk while continuing to execute its -# already-loaded code. Hand V2 to the new launcher loaded from disk. -if [[ "${1:-}" == "V2" ]]; then - exec /opt/idssys/ta-proxmenu/run.sh V2 -fi +ACTION_REQUESTED=0 +[[ -n "${action:-}" ]] && ACTION_REQUESTED=1 + +FINISH_ACTION() { + (( ACTION_REQUESTED == 1 )) && exit 0 + ENTER2CONTINUE +} + +FINISH_FAILED_ACTION() { + (( ACTION_REQUESTED == 1 )) && exit 1 + ENTER2CONTINUE + return 1 +} + +declare -a TAPM_TEMP_DIRS=() +TAPM_TEMP_DIR='' + +TAPM_CLEAN_TEMP_DIR() { + local temp_dir="${1:-}" + + if [[ "$temp_dir" == /tmp/ta-proxmenu-* && -d "$temp_dir" ]]; then + rm -rf -- "$temp_dir" + fi +} + +TAPM_CLEAN_ALL_TEMP_DIRS() { + local temp_dir + + for temp_dir in "${TAPM_TEMP_DIRS[@]}"; do + TAPM_CLEAN_TEMP_DIR "$temp_dir" + done +} + +TAPM_CREATE_TEMP_DIR() { + local label="${1:-installer}" + + TAPM_TEMP_DIR="$(mktemp -d "/tmp/ta-proxmenu-${label}.XXXXXX")" || { + echo -e "${idsCL[LightRed]}Unable to create a temporary installer directory.${idsCL[Default]}" + return 1 + } + if ! chmod 0700 "$TAPM_TEMP_DIR"; then + TAPM_CLEAN_TEMP_DIR "$TAPM_TEMP_DIR" + echo -e "${idsCL[LightRed]}Unable to secure the temporary installer directory.${idsCL[Default]}" + return 1 + fi + TAPM_TEMP_DIRS+=("$TAPM_TEMP_DIR") +} + +TAPM_VALID_HTTPS_URL() { + local url="${1:-}" + + [[ "$url" == https://* && + "$url" != *$'\n'* && + "$url" != *$'\r'* && + "$url" != *'"'* && + "$url" != *\\* && + "$url" != *[[:space:]]* ]] +} + +TAPM_DOWNLOAD_HTTPS() { + local url="$1" + local destination="$2" + local label="${3:-Installer}" + + if ! TAPM_VALID_HTTPS_URL "$url"; then + echo -e "${idsCL[LightRed]}${label} requires a valid HTTPS URL.${idsCL[Default]}" + return 1 + fi + + if ! printf 'url = "%s"\n' "$url" | + curl --fail --location --silent --show-error \ + --proto '=https' --proto-redir '=https' \ + --output "$destination" --config -; then + echo -e "${idsCL[LightRed]}${label} download failed.${idsCL[Default]}" + return 1 + fi + + if [[ ! -s "$destination" ]]; then + echo -e "${idsCL[LightRed]}${label} download was empty.${idsCL[Default]}" + return 1 + fi +} + +TAPM_DOWNLOAD_SHA256() { + local url="$1" + local destination="$2" + local expected_sha256="${3,,}" + local label="${4:-Installer}" + local actual_sha256 + + TAPM_DOWNLOAD_HTTPS "$url" "$destination" "$label" || return 1 + actual_sha256="$(sha256sum "$destination" | cut -d' ' -f1)" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + rm -f -- "$destination" + echo -e "${idsCL[LightRed]}${label} checksum did not match; the file was removed.${idsCL[Default]}" + return 1 + fi +} + +TAPM_PACKAGE_INSTALLED() { + dpkg-query -W -f='${Status}' "$1" 2>/dev/null | + grep -q '^install ok installed$' +} + +TAPM_WAIT_FOR_SERVICE() { + local service="$1" + local attempts="${2:-30}" + local attempt=0 + + while (( attempt < attempts )); do + systemctl is-active --quiet "$service" && return 0 + sleep 1 + ((attempt++)) + done + + return 1 +} + +TAPM_WAIT_FOR_TCP_PORT() { + local port="$1" + local attempts="${2:-30}" + local attempt=0 + + while (( attempt < attempts )); do + if ss -H -lnt 2>/dev/null | + awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit !found }'; then + return 0 + fi + sleep 1 + ((attempt++)) + done + + return 1 +} + +trap TAPM_CLEAN_ALL_TEMP_DIRS EXIT + +TAPM_CLEAR_AUTHORIZATION() { + unset TAPM_SESSION_TOKEN TAPM_PACKAGE_URL TAPM_PACKAGE_SHA256 TAPM_PACKAGE_VERSION +} + +TAPM_AUTHORIZE() { + local required_action="${1:-}" + local required_package="${2:-}" + local authorization_label="${3:-this installation}" + local deploycode exchange_response host_fingerprint + local -a access + + TAPM_CLEAR_AUTHORIZATION + if ! TAPM_VALID_HTTPS_URL "${TAPM_BROKER_URL:-}"; then + echo -e "${idsCL[LightRed]}TAPM_BROKER_URL is not configured with a valid HTTPS origin.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}Set it in /etc/ta-proxmenu/config.env before authorizing this installation.${idsCL[Default]}" + return 1 + fi + if ! command -v python3 >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}Python 3 is required to authorize ${authorization_label}.${idsCL[Default]}" + return 1 + fi + echo + echo -en "${idsCL[LightYellow]}Paste the TAPM deployment code: ${idsCL[Default]}" + TAPM_READ_MASKED deploycode + deploycode="${deploycode^^}" + + host_fingerprint="$(sha256sum /etc/machine-id | cut -d' ' -f1)" + 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"], "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" + )" || { + unset deploycode host_fingerprint exchange_response + echo -e "${idsCL[LightRed]}TAPM could not authorize ${authorization_label}.${idsCL[Default]}" + return 1 + } + unset deploycode host_fingerprint + + mapfile -t access < <( + printf '%s' "$exchange_response" | + TAPM_REQUIRED_ACTION="$required_action" TAPM_REQUIRED_PACKAGE="$required_package" \ + python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +required_action = os.environ["TAPM_REQUIRED_ACTION"] +required_package = os.environ["TAPM_REQUIRED_PACKAGE"] +if required_action and required_action not in data.get("actions", []): + raise SystemExit(1) +package = None +if required_package: + package = next((item for item in data.get("packages", []) if item.get("slug") == required_package), None) + if not package: + raise SystemExit(1) +print(data.get("session_token", "")) +print(package.get("download_url", "") if package else "") +print(package.get("sha256", "") if package else "") +print(package.get("version", "") if package else "") +' + ) || true + unset exchange_response + + if [[ ${#access[@]} -ne 4 || + ! "${access[0]}" =~ ^[A-Za-z0-9._~-]+$ ]]; then + unset access + echo -e "${idsCL[LightRed]}This deployment code does not authorize ${authorization_label}.${idsCL[Default]}" + return 1 + fi + if [[ -n "$required_package" && + ( -z "${access[1]}" || ! "${access[2]}" =~ ^[0-9a-fA-F]{64}$ ) ]]; then + unset access + echo -e "${idsCL[LightRed]}The authorized package metadata is incomplete.${idsCL[Default]}" + return 1 + fi + + TAPM_SESSION_TOKEN="${access[0]}" + TAPM_PACKAGE_URL="${access[1]}" + TAPM_PACKAGE_SHA256="${access[2],,}" + TAPM_PACKAGE_VERSION="${access[3]}" + unset access + return 0 +} INSTALL_PULSE() { - echo - bash <(curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh) - echo - echo -e "\n${idsCL[Green]}Pulse has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + if ! TAPM_DEPLOY_PULSE_LXC; then + echo -e "${idsCL[LightRed]}Pulse deployment failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + FINISH_ACTION } INSTALL_ACRONIS() { - read -n 1 -p "Are you sure you wish to install Acronis (Y/n)?" choice - case "$choice" in - [Nn]) MAIN_MENU;; - * ) - echo - cd /tmp - wget "https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0" -O ./acronisinstall - chmod +x ./acronisinstall - ./acronisinstall - rm -f ./acronisinstall - echo - echo -e "\n${idsCL[Green]}Acronis has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - ;; - esac -} + local installer + local temp_dir + local url='https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0' -INSTALL_PROXMENUX() { -# read -n 1 -p "Are you sure you wish to install ProxMenux (Y/n)?" choice -# case "$choice" in -# [Nn]) MAIN_MENU;; -# * ) -# echo - bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)" - # systemctl disable --now proxmenux-monitor - menu -# echo -e "\n${idsCL[Green]}ProxMenux has been installed${idsCL[Default]}" -# [ ${action-x} ] && exit 0 || ENTER2CONTINUE -# esac -} + echo + if ! TAPM_AUTHORIZE "install-acronis" "" "Acronis installation"; then + FINISH_FAILED_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION -PROXMENUX_POST_INSTALL() { - PMFLDR='/usr/local/share/proxmenux/scripts/post_install' - [ ! -f ${PMFLDR}/customizable_post_install.sh ] && INSTALL_PROXMENUX - bash ${PMFLDR}/customizable_post_install.sh - - touch /opt/.PROXMENUX_POST_INSTALL - [ -s /etc/apt/sources.list ] && cat /dev/null > /etc/apt/sources.list - + if ! TAPM_CREATE_TEMP_DIR acronis; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/acronisinstall" + + if ! TAPM_DOWNLOAD_HTTPS "$url" "$installer" 'Acronis installer' || + ! chmod 0700 "$installer" || + ! (cd "$temp_dir" && ./acronisinstall) || + ! TAPM_PACKAGE_INSTALLED cyberprotect; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Acronis installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo + echo -e "\n${idsCL[Green]}Acronis has been installed and verified.${idsCL[Default]}" + FINISH_ACTION } INSTALL_GLANCES() { - read -n 1 -p "Are you sure you wish to install Glances (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - apt install glances -y - echo -e "\n${idsCL[Green]}Glances has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - esac + echo + if ! DEBIAN_FRONTEND=noninteractive apt-get install glances -y || + ! TAPM_PACKAGE_INSTALLED glances; then + echo -e "\n${idsCL[LightRed]}Glances installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + echo -e "\n${idsCL[Green]}Glances has been installed and verified.${idsCL[Default]}" + FINISH_ACTION } INSTALL_SCREENCONNECT() { - read -n 1 -p "Are you sure you wish to install ScreenConnect (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" - read -e SCURL - wget "${SCURL}" -O /tmp/scinstall - dpkg -i /tmp/scinstall - apt install --fix-broken -y - apt remove "connectwis*" -y > /dev/null 2>&1 - dpkg -i /tmp/scinstall - rm -f /tmp/scinstall - systemctl disable --now proxmenux-monitor - echo -e "\n${idsCL[Green]}ScreenConnect has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - esac + local SCURL='' + local installer + local temp_dir + + echo + if ! TAPM_AUTHORIZE "install-screenconnect" "" "ScreenConnect installation"; then + FINISH_FAILED_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION + echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" + TAPM_READ_MASKED SCURL + [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } + if ! TAPM_CREATE_TEMP_DIR screenconnect; then + unset SCURL + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/screenconnect.deb" + + if ! TAPM_DOWNLOAD_HTTPS "$SCURL" "$installer" 'ScreenConnect installer'; then + unset SCURL + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + unset SCURL + + if ! dpkg -i "$installer"; then + if ! DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}ScreenConnect dependency installation failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + DEBIAN_FRONTEND=noninteractive apt-get remove 'connectwis*' -y >/dev/null 2>&1 || true + if ! dpkg -i "$installer" || + ! TAPM_WAIT_FOR_SERVICE 'connectwise*'; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}ScreenConnect installation failed or its service is not active.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + systemctl disable --now proxmenux-monitor >/dev/null 2>&1 || true + echo -e "\n${idsCL[Green]}ScreenConnect has been installed and verified.${idsCL[Default]}" + FINISH_ACTION } INSTALL_RMM() { - read -n 1 -p "Are you sure you wish to install RMM (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo + local RMMURL='' + local TOKEN='' + local installer + local installer_dir + local temp_dir + + echo + if ! TAPM_AUTHORIZE "install-rmm" "" "RMM installation"; then + FINISH_FAILED_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION - echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" - read -e RMMURL - wget "${RMMURL}" -O /tmp/rmminstall - TOKEN="$(echo ${RMMURL} | awk -F 'TKN' '{print $2}' | awk -F '/RUN' '{print $1}')" - CMD="TOKEN=${TOKEN} bash /tmp/rmminstall" - eval ${CMD} - systemctl restart ITSPlatform - # rm -f /tmp/rmminstall - - echo -e "\n${idsCL[Green]}RMM has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - esac + echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" + TAPM_READ_MASKED RMMURL + [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } + if ! TOKEN="$(TAPM_RMM_TOKEN_FROM_URL "$RMMURL")"; then + echo "Unable to extract the RMM token from the URL." + unset RMMURL TOKEN + FINISH_FAILED_ACTION + return + fi + + if ! TAPM_RMM_ENSURE_SUDO; then + unset RMMURL TOKEN + echo -e "${idsCL[LightRed]}Unable to install the sudo dependency required by the RMM installer.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! TAPM_CREATE_TEMP_DIR rmm; then + unset RMMURL TOKEN + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + # Preserve the token-bearing portion of the original URL because the + # vendor installer also attempts to derive TOKEN from its own pathname. + installer_dir="${temp_dir}/ITSPlatform_TKN${TOKEN}/RUN" + if ! mkdir -p "$installer_dir" || ! chmod 0700 "$installer_dir"; then + unset RMMURL TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + installer="${installer_dir}/setup" + if ! TAPM_DOWNLOAD_HTTPS "$RMMURL" "$installer" 'RMM installer'; then + unset RMMURL TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + unset RMMURL + + if ! TOKEN="$TOKEN" bash "$installer"; then + unset TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}RMM installation failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + unset TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + + if ! systemctl restart ITSPlatform || + ! TAPM_WAIT_FOR_SERVICE ITSPlatform; then + echo -e "${idsCL[LightRed]}RMM installed, but the ITSPlatform service is not active.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}RMM has been installed and verified.${idsCL[Default]}" + FINISH_ACTION } INSTALL_S1() { + local installer + local s1token='' + local temp_dir + + if ! TAPM_AUTHORIZE "" "$S1_BROKER_PACKAGE" "SentinelOne installation"; then + FINISH_FAILED_ACTION + return + fi echo + + if ! TAPM_CREATE_TEMP_DIR sentinelone; then + TAPM_CLEAR_AUTHORIZATION + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/${S1_PACKAGE}" + + if ! TAPM_VALID_HTTPS_URL "$TAPM_PACKAGE_URL"; then + TAPM_CLEAR_AUTHORIZATION + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The authorized SentinelOne package URL is not valid HTTPS.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + if ! printf 'header = "Authorization: Bearer %s"\nurl = "%s"\n' \ + "$TAPM_SESSION_TOKEN" "$TAPM_PACKAGE_URL" | + curl --fail --location --silent --show-error --config - \ + --proto '=https' --proto-redir '=https' \ + --output "$installer"; then + TAPM_CLEAR_AUTHORIZATION + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The SentinelOne installer download failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + if [[ ! -s "$installer" || + "$(sha256sum "$installer" | cut -d' ' -f1)" != "$TAPM_PACKAGE_SHA256" ]]; then + TAPM_CLEAR_AUTHORIZATION + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The SentinelOne installer checksum did not match. The file was removed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION + echo -en "${idsCL[LightYellow]}Paste the customers SentinelOne Site Token: ${idsCL[Default]}" - read -e s1token - cd /tmp - wget "https://git.scity.us/TAI/files/raw/branch/main/SentinelAgent_linux_x86_64_v26_1_1_31.deb" - dpkg -i ./SentinelAgent_linux_x86_64*.deb - /opt/sentinelone/bin/sentinelctl management token set ${s1token} - /opt/sentinelone/bin/sentinelctl control start - rm -f ./SentinelAgent_linux_x86_64*.deb + TAPM_READ_MASKED s1token + [[ -n "$s1token" ]] || { + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo "No SentinelOne site token supplied." + FINISH_FAILED_ACTION + return + } + if ! dpkg -i "$installer" || + ! /opt/sentinelone/bin/sentinelctl management token set "$s1token" || + ! /opt/sentinelone/bin/sentinelctl control start || + ! TAPM_PACKAGE_INSTALLED sentinelagent; then + unset s1token + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}SentinelOne installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + unset s1token + TAPM_CLEAN_TEMP_DIR "$temp_dir" - echo -e "\n${idsCL[Green]}SentinelOne Agent has been installed. Make sure its added to a \"DETECT ONLY\" policy${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + echo -e "\n${idsCL[Green]}SentinelOne Agent has been installed and verified. Make sure it is added to a \"DETECT ONLY\" policy.${idsCL[Default]}" + FINISH_ACTION +} + +TAPM_SYSTEM_PRODUCT_NAME() { + local product_name='' + + if [[ -r /sys/class/dmi/id/product_name ]]; then + read -r product_name /dev/null 2>&1; then + product_name="$(dmidecode -s system-product-name 2>/dev/null)" + fi + + printf '%s\n' "$product_name" +} + +TAPM_OMSA_SUPPORTED_HARDWARE() { + local product_name + + product_name="$(TAPM_SYSTEM_PRODUCT_NAME)" + [[ "$product_name" =~ PowerEdge[[:space:]]+[[:alpha:]]+[0-9](3|4)[0-9][[:alnum:]-]* ]] +} + +TAPM_OMSA_SUPPORTED_PLATFORM() { + local architecture + local codename + local os_version + local pve_version + + architecture="$(dpkg --print-architecture 2>/dev/null)" + os_version="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}")" + codename="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_CODENAME:-}")" + pve_version="$(pveversion 2>/dev/null | head -n 1)" + + [[ "$architecture" == "amd64" && + "$os_version" == "13" && + "$codename" == "trixie" && + "$pve_version" == pve-manager/9.* ]] } INSTALL_OMSA() { - read -n 1 -p "Are you sure you wish to install Dell OpenManage Administrator (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - mkdir /tmp/omsa - cd /tmp/omsa - apt install -y gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 - mkdir -p /etc/apt/keyrings - wget -qO - https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc | gpg --dearmor -o /etc/apt/keyrings/linux.dell.com.gpg - chmod +r /etc/apt/keyrings/linux.dell.com.gpg - echo "deb [signed-by=/etc/apt/keyrings/linux.dell.com.gpg] http://linux.dell.com/repo/community/openmanage/11000/jammy jammy main" > /etc/apt/sources.list.d/linux.dell.com.list - apt update - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman1t64_2.6.5-0ubuntu16_amd64.deb - # wget -c http://http.us.debian.org/debian/pool/main/libx/libxml2/libxml2-16_2.15.1+dfsg-2+b1_amd64.deb - wget -c http://http.us.debian.org/debian/pool/main/libx/libxml2/libxml2-16_2.15.2+dfsg-0.1_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-sfcc/libcimcclient0_2.2.8-0ubuntu2_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/openwsman_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/multiverse/c/cim-schema/cim-schema_2.48.0-0ubuntu1_all.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-sfc-common/libsfcutil0_1.0.1-0ubuntu4_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/multiverse/s/sblim-sfcb/sfcb_1.4.9-0ubuntu7_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-cmpi-devel/libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb - wget -c http://ftp.us.debian.org/debian/pool/main/o/openssl/libssl1.1_1.1.1w-0+deb11u1_amd64.deb - dpkg -i libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb - dpkg -i libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libxml2-16_2.15.2+dfsg-0.1_amd64.deb - dpkg -i libwsman1t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libcimcclient0_2.2.8-0ubuntu2_amd64.deb - dpkg -i openwsman_2.6.5-0ubuntu16_amd64.deb - dpkg -i cim-schema_2.48.0-0ubuntu1_all.deb - dpkg -i libsfcutil0_1.0.1-0ubuntu4_amd64.deb - dpkg -i sfcb_1.4.9-0ubuntu7_amd64.deb - dpkg -i libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb - dpkg -i libssl1.1_1.1.1w-0+deb11u1_amd64.deb - apt install -y srvadmin-all - /opt/dell/srvadmin/sbin/srvadmin-services.sh start - rm -Rf /tmp/omsa - - echo -e "\n${idsCL[Green]}Dell OMSA has been installed${idsCL[Default]}" - echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - esac -} + local base_url='https://archive.ubuntu.com/ubuntu/pool' + local dell_key + local dell_keyring + local dell_source + local index + local product_name + local temp_dir + local -a filenames=( + 'libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb' + 'libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb' + 'libxml2-16_2.15.2+dfsg-0.1_amd64.deb' + 'libwsman1t64_2.6.5-0ubuntu16_amd64.deb' + 'libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb' + 'libcimcclient0_2.2.8-0ubuntu2_amd64.deb' + 'openwsman_2.6.5-0ubuntu16_amd64.deb' + 'cim-schema_2.48.0-0ubuntu1_all.deb' + 'libsfcutil0_1.0.1-0ubuntu4_amd64.deb' + 'sfcb_1.4.9-0ubuntu7_amd64.deb' + 'libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb' + 'libssl1.1_1.1.1w-0+deb11u1_amd64.deb' + ) + local -a urls=( + "${base_url}/universe/o/openwsman/${filenames[0]}" + "${base_url}/universe/o/openwsman/${filenames[1]}" + 'https://snapshot.debian.org/file/32f51d914435fd29ce31af7ba17525f6276ea58d' + "${base_url}/universe/o/openwsman/${filenames[3]}" + "${base_url}/universe/o/openwsman/${filenames[4]}" + "${base_url}/universe/s/sblim-sfcc/${filenames[5]}" + "${base_url}/universe/o/openwsman/${filenames[6]}" + "${base_url}/multiverse/c/cim-schema/${filenames[7]}" + "${base_url}/universe/s/sblim-sfc-common/${filenames[8]}" + "${base_url}/multiverse/s/sblim-sfcb/${filenames[9]}" + "${base_url}/universe/s/sblim-cmpi-devel/${filenames[10]}" + "https://deb.debian.org/debian/pool/main/o/openssl/${filenames[11]}" + ) + local -a checksums=( + '42fdd34722ac1304427f80c4176ee781d057f05669d485f0ee1da4d87df7488c' + '6d1855a2e8263e9a578b4c0bb7a963a6d99dc8d2ef41e287725799dcad0c6cb3' + '8571682a07f329bb462569502b57aced4866e5b95c2db3ec7e5414a5b3bbdc14' + '61b91e8f234c5f2f87b4bce3534bc2f2304d50cd2fd95f426f12fc73d80e27b4' + '5d3f948ab605b4973b399f53bd62bddd70eb01161769a0d39a811399fe7c2daf' + '14b9ac374f88bd44e57395e87faa76d99d02e242c813fe30083d5bbfafec5870' + '62b30fcf41dae0c1d841f67a46049b7dfa4dfffe314e4226a776eb134605b7fc' + 'a87d16d41e81092c7ada43824a97cf79fab18c4a3722ef6f0476ad697a3d9ab7' + 'ba890cf5f2359befd3da1e5763672ef8b03d5424fa4e3d1ef21c9d52884af247' + '3eb5dce0a873f8eb77174fdd5e02ac55a989f7a73bd5ef8c8aae501d225d7524' + '284acfbb6d675496046ee46e6d5ea6c70ceafa3781cf1eece04f612cbadf117c' + 'aadf8b4b197335645b230c2839b4517aa444fd2e8f434e5438c48a18857988f7' + ) + local -a package_paths=() -DOWNLOAD_VIRTIO() { - - echo -e "\n${idsCL[LightCyan]}Current \"Stable\" version available for download: ${idsCL[White]}${VIRTIO_FILE}${idsCL[Default]}" - if [ -f ${dldir}/${VIRTIO_FILE} ]; then - echo -en "\n${idsCL[LightRed]}Removing existing download ... " - rm -f ${DLDIR}/${VIRTIO_FILE} - echo -e "${idsCL[Red]}Done${idsCL[Default]}" - fi - wget -q -F -P ${DLDIR} ${VIRTIO_DOWNLOAD_URL} & - echo -e "\n${idsCL[LightCyan]}Downloading will continue in the background\n" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE -} - -DETECT_CPU(){ - # if [ ! -f /etc/apt/sources.list.d/proxlb.list ]; then - # echo "deb https://repo.gyptazy.com/stable /" > /etc/apt/sources.list.d/proxlb.list - # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg - # apt-get update - # fi - if [ ! -f /etc/apt/sources.list.d/gyptazy.list ]; then - curl https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key -o /etc/apt/keyrings/gyptazy.asc - echo "deb [signed-by=/etc/apt/keyrings/gyptazy.asc] https://packages.gyptazy.com/api/packages/gyptazy/debian trixie main" | sudo tee -a /etc/apt/sources.list.d/gyptazy.list - apt update + echo + product_name="$(TAPM_SYSTEM_PRODUCT_NAME)" + if ! TAPM_OMSA_SUPPORTED_HARDWARE; then + echo -e "${idsCL[LightRed]}Dell OMSA legacy installation is limited to PowerEdge x30/x40 systems.${idsCL[Default]}" + echo "Detected system: ${product_name:-Unknown}" + FINISH_FAILED_ACTION + return fi - if [ "$(dpkg -l | awk '/proxclmc/ {print }'|wc -l)" -eq 0 ]; then - apt -y install proxclmc - fi + if ! TAPM_OMSA_SUPPORTED_PLATFORM; then + echo -e "${idsCL[LightRed]}This legacy OMSA package set requires PVE 9 on Debian 13 (Trixie), amd64.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "${idsCL[LightYellow]}Installing legacy Dell OMSA 11.0 compatibility packages on ${product_name}.${idsCL[Default]}" + + if ! TAPM_CREATE_TEMP_DIR omsa; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + dell_key="${temp_dir}/dell-openmanage.asc" + dell_keyring="${temp_dir}/linux.dell.com.gpg" + dell_source="${temp_dir}/linux.dell.com.list" + + if ! TAPM_DOWNLOAD_SHA256 \ + 'https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc' \ + "$dell_key" \ + '92f9622bf300f1fc8a4ef12d8e5efef6511b089c63c15e635cfc7429499e86d4' \ + 'Dell OpenManage signing key'; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + + for index in "${!filenames[@]}"; do + package_paths+=("${temp_dir}/${filenames[$index]}") + if ! TAPM_DOWNLOAD_SHA256 "${urls[$index]}" "${package_paths[$index]}" \ + "${checksums[$index]}" "${filenames[$index]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + done + + if ! apt-get update || + ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 || + ! gpg --batch --yes --dearmor --output "$dell_keyring" "$dell_key"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Unable to prepare the Dell OMSA repository.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! printf '%s\n' \ + 'deb [signed-by=/etc/apt/keyrings/linux.dell.com.gpg] https://linux.dell.com/repo/community/openmanage/11000/jammy jammy main' \ + >"$dell_source" || + ! mkdir -p /etc/apt/keyrings || + ! install -m 0644 "$dell_keyring" /etc/apt/keyrings/linux.dell.com.gpg || + ! install -m 0644 "$dell_source" /etc/apt/sources.list.d/linux.dell.com.list; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Unable to write the Dell OMSA repository configuration.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! apt-get update; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The Dell OMSA repository could not be refreshed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! dpkg -i "${package_paths[@]}"; then + if ! DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y || + ! dpkg -i "${package_paths[@]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The legacy OMSA compatibility packages could not be installed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y srvadmin-all || + ! TAPM_PACKAGE_INSTALLED srvadmin-all || + [[ ! -x /opt/dell/srvadmin/sbin/srvadmin-services.sh ]] || + ! /opt/dell/srvadmin/sbin/srvadmin-services.sh start || + ! TAPM_WAIT_FOR_TCP_PORT 1311 30; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Dell OMSA installation failed or port 1311 did not become available.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "\n${idsCL[Green]}Dell OMSA has been installed and verified.${idsCL[Default]}" + echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" + FINISH_ACTION +} + +VIRTIO_STABLE_CHECKED=0 +VIRTIO_STABLE_STATUS='unknown' +VIRTIO_STABLE_FILE='' +VIRTIO_STABLE_CACHE_FILE="${VIRTIO_STABLE_CACHE_FILE:-/var/cache/ta-proxmenu/virtio-stable}" +VIRTIO_STABLE_CACHE_SECONDS=86400 +VIRTIO_LAST_FILE='' +DLDIR='' +VIRTIO_SELECTED_STORAGE='' +declare -a VIRTIO_STORAGE_IDS=() +declare -a VIRTIO_STORAGE_TYPES=() +declare -a VIRTIO_STORAGE_DIRS=() + +TAPM_REFRESH_ISO_STORAGES() { + local storage + local type + local status + local probe_path + + VIRTIO_STORAGE_IDS=() + VIRTIO_STORAGE_TYPES=() + VIRTIO_STORAGE_DIRS=() + command -v pvesm >/dev/null 2>&1 || return 1 + + while read -r storage type status _; do + [[ "$storage" != 'Name' && "$status" == 'active' ]] || continue + probe_path="$( + pvesm path "${storage}:iso/tapm-path-probe.iso" 2>/dev/null + )" || continue + [[ "$probe_path" == /*/* ]] || continue + + VIRTIO_STORAGE_IDS+=("$storage") + VIRTIO_STORAGE_TYPES+=("$type") + VIRTIO_STORAGE_DIRS+=("${probe_path%/*}") + done < <(pvesm status --content iso --enabled 1 2>/dev/null) + + (( ${#VIRTIO_STORAGE_IDS[@]} > 0 )) +} + +TAPM_SELECT_ISO_STORAGE() { + local index + local -a labels=() + local -a values=() + + if ! TAPM_REFRESH_ISO_STORAGES; then + echo -e "${idsCL[LightRed]}No active, enabled Proxmox storage supporting ISO images was found.${idsCL[Default]}" + ENTER2CONTINUE + return 1 + fi + + if (( ${#VIRTIO_STORAGE_IDS[@]} == 1 )); then + index=0 + else + for index in "${!VIRTIO_STORAGE_IDS[@]}"; do + labels+=("${VIRTIO_STORAGE_IDS[$index]} (${VIRTIO_STORAGE_TYPES[$index]}) — ${VIRTIO_STORAGE_DIRS[$index]}") + values+=("storage:${index}") + done + SELECT_MENU "Select ISO Storage" labels values + case "$MENU_SELECTION" in + storage:*) index="${MENU_SELECTION#storage:}";; + quit) EXIT1; exit 0;; + *) return 1;; + esac + fi + + VIRTIO_SELECTED_STORAGE="${VIRTIO_STORAGE_IDS[$index]}" + DLDIR="${VIRTIO_STORAGE_DIRS[$index]}" + echo -e "\n${idsCL[LightCyan]}ISO storage: ${VIRTIO_SELECTED_STORAGE} (${DLDIR})${idsCL[Default]}" +} + +TAPM_VIRTIO_FILE_EXISTS() { + local filename="$1" + local directory + + for directory in "${VIRTIO_STORAGE_DIRS[@]}"; do + [[ -f "${directory}/${filename}" ]] && return 0 + done + return 1 +} + +TAPM_ANY_LOCAL_VIRTIO_ISOS() { + local directory + + for directory in "${VIRTIO_STORAGE_DIRS[@]}"; do + compgen -G "${directory}/virtio-win*.iso" >/dev/null && return 0 + done + return 1 +} + +TAPM_REFRESH_VIRTIO_LOCAL_STATUS() { + [[ -n "$VIRTIO_STABLE_FILE" ]] || return + + if TAPM_VIRTIO_FILE_EXISTS "$VIRTIO_STABLE_FILE"; then + VIRTIO_STABLE_STATUS='current' + elif TAPM_ANY_LOCAL_VIRTIO_ISOS; then + VIRTIO_STABLE_STATUS='update' + else + VIRTIO_STABLE_STATUS='available' + fi +} + +TAPM_CHECK_VIRTIO_STABLE() { + local force="${1:-0}" + local cached_at='' + local cached_filename='' + local effective_url + local now + local source_filename + local cache_temp + + if (( VIRTIO_STABLE_CHECKED == 1 && force == 0 )); then + return + fi + + VIRTIO_STABLE_CHECKED=1 + VIRTIO_STABLE_STATUS='unavailable' + VIRTIO_STABLE_FILE='' + now="$(date +%s)" + + if (( force == 0 )) && [[ -r "$VIRTIO_STABLE_CACHE_FILE" ]]; then + IFS='|' read -r cached_at cached_filename <"$VIRTIO_STABLE_CACHE_FILE" + if TAPM_VIRTIO_CACHE_VALID \ + "$cached_at" "$now" "$VIRTIO_STABLE_CACHE_SECONDS" "$cached_filename"; then + VIRTIO_STABLE_FILE="$( + TAPM_VIRTIO_LABELED_FILENAME "$cached_filename" latest + )" || return 1 + TAPM_REFRESH_VIRTIO_LOCAL_STATUS + return 0 + fi + fi + + effective_url="$( + curl --fail --location --silent --show-error --head \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 5 --max-time 15 \ + --output /dev/null --write-out '%{url_effective}' \ + "$VIRTIO_STABLE_URL" 2>/dev/null + )" || return 1 + source_filename="$(TAPM_VIRTIO_FILENAME_FROM_URL "$effective_url")" || + return 1 + VIRTIO_STABLE_FILE="$(TAPM_VIRTIO_LABELED_FILENAME "$source_filename" latest)" || + return 1 + if mkdir -p "$(dirname "$VIRTIO_STABLE_CACHE_FILE")" 2>/dev/null; then + cache_temp="$(mktemp "${VIRTIO_STABLE_CACHE_FILE}.tmp.XXXXXX")" || cache_temp='' + if [[ -n "$cache_temp" ]]; then + if printf '%s|%s\n' "$now" "$source_filename" >"$cache_temp" && + chmod 0644 "$cache_temp"; then + mv -f "$cache_temp" "$VIRTIO_STABLE_CACHE_FILE" || + rm -f "$cache_temp" + else + rm -f "$cache_temp" + fi + fi + fi + TAPM_REFRESH_VIRTIO_LOCAL_STATUS +} + +TAPM_VALID_VIRTIO_ISO() { + local iso_file="$1" + local iso_signature + local size + + size="$(stat -c '%s' "$iso_file" 2>/dev/null)" || return 1 + (( size >= 10 * 1024 * 1024 )) || return 1 + iso_signature="$( + dd if="$iso_file" bs=1 skip=32769 count=5 status=none 2>/dev/null + )" + [[ "$iso_signature" == 'CD001' ]] +} + +TAPM_DOWNLOAD_VIRTIO_ISO() { + local url="$1" + local expected_filename="${2:-}" + local filename_label="$3" + local description="${4:-VirtIO driver ISO}" + local effective_url + local filename + local final_file + local partial_file + local size + + VIRTIO_LAST_FILE='' + if ! TAPM_VALID_HTTPS_URL "$url" || + [[ "$url" != https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/* ]]; then + echo -e "${idsCL[LightRed]}The VirtIO download URL is not an approved Fedora VirtIO-Win URL.${idsCL[Default]}" + return 1 + fi + + if [[ ! -d "$DLDIR" ]] && ! install -d -m 0755 "$DLDIR"; then + echo -e "${idsCL[LightRed]}Could not create the ISO destination: ${DLDIR}.${idsCL[Default]}" + return 1 + fi + if [[ ! -w "$DLDIR" ]]; then + echo -e "${idsCL[LightRed]}The ISO destination is not writable: ${DLDIR}.${idsCL[Default]}" + return 1 + fi + + partial_file="$(mktemp "${DLDIR}/.tapm-virtio.part.XXXXXX")" || { + echo -e "${idsCL[LightRed]}Could not create a temporary file on ${DLDIR}.${idsCL[Default]}" + return 1 + } + chmod 0600 "$partial_file" + + echo -e "\n${idsCL[LightCyan]}Downloading ${description}...${idsCL[Default]}\n" + effective_url="$( + curl --fail --location --show-error --progress-bar \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 \ + --output "$partial_file" --write-out '%{url_effective}' \ + "$url" + )" || { + rm -f -- "$partial_file" + echo -e "\n${idsCL[LightRed]}The VirtIO ISO download failed. Existing ISOs were not changed.${idsCL[Default]}" + return 1 + } + + filename="$(TAPM_VIRTIO_FILENAME_FROM_URL "$effective_url")" || { + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}The VirtIO source returned an unexpected filename.${idsCL[Default]}" + return 1 + } + if [[ -n "$expected_filename" && "$filename" != "$expected_filename" ]]; then + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}The VirtIO archive returned ${filename}; expected ${expected_filename}.${idsCL[Default]}" + return 1 + fi + filename="$(TAPM_VIRTIO_LABELED_FILENAME "$filename" "$filename_label")" || { + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}Could not create a safe local filename for the VirtIO ISO.${idsCL[Default]}" + return 1 + } + + if ! TAPM_VALID_VIRTIO_ISO "$partial_file"; then + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}The downloaded file did not pass ISO validation and was removed.${idsCL[Default]}" + return 1 + fi + + final_file="${DLDIR}/${filename}" + if ! chmod 0644 "$partial_file" || + ! mv -f -- "$partial_file" "$final_file"; then + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}Could not install the validated VirtIO ISO in ${DLDIR}.${idsCL[Default]}" + return 1 + fi + + size="$(stat -c '%s' "$final_file")" + VIRTIO_LAST_FILE="$filename" + echo -e "\n${idsCL[Green]}VirtIO ISO downloaded and validated.${idsCL[Default]}" + printf ' File: %s\n' "$filename" + printf ' Size: %s\n' "$(numfmt --to=iec-i --suffix=B "$size")" + printf ' Storage: %s\n' "$VIRTIO_SELECTED_STORAGE" + printf ' Destination: %s\n' "$DLDIR" +} + +DOWNLOAD_VIRTIO_STABLE() { + TAPM_SELECT_ISO_STORAGE || return + if ! TAPM_DOWNLOAD_VIRTIO_ISO "$VIRTIO_STABLE_URL" '' latest \ + 'current stable VirtIO drivers'; then + FINISH_FAILED_ACTION + return + fi + + VIRTIO_STABLE_FILE="$VIRTIO_LAST_FILE" + VIRTIO_STABLE_STATUS='current' + FINISH_ACTION +} + +DOWNLOAD_VIRTIO_ARCHIVE() { + local release="$1" + local description="$2" + local filename_label="${3:-archive}" + local iso_version="${release%-*}" + local filename="virtio-win-${iso_version}.iso" + local url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-${release}/${filename}" + + TAPM_SELECT_ISO_STORAGE || return + if ! TAPM_DOWNLOAD_VIRTIO_ISO "$url" "$filename" "$filename_label" "$description"; then + FINISH_FAILED_ACTION + return + fi + FINISH_ACTION +} + +DOWNLOAD_CUSTOM_VIRTIO_ARCHIVE() { + local release echo - proxclmc + read -r -p "Archived VirtIO release (example: 0.1.229-1; blank cancels): " release + [[ -n "$release" ]] || return + if [[ ! "$release" =~ ^0\.1\.[0-9]+-[0-9]+$ ]]; then + echo -e "${idsCL[LightRed]}Use a release in the form 0.1.229-1.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + DOWNLOAD_VIRTIO_ARCHIVE "$release" "VirtIO archive ${release}" +} + +SHOW_LOCAL_VIRTIO_ISOS() { + local index + local file + local found=0 + + MENU_HEADER echo - echo -en "${idsCL[LightCyan]}Would you like to set '${idsCL[LightGreen]}cpu: $(proxclmc --list-only)${idsCL[LightCyan]}' on all VMs (y/N)?${idsCL[Default]} " - read -n 1 choice - case "$choice" in - [Yy]) - sed -i "/cpu:/c cpu: $(proxclmc --list-only)" /etc/pve/nodes/*/qemu-server/*.conf + echo -e " ${idsCL[LightCyan]}Downloaded VirtIO ISOs${idsCL[Default]}" + echo + if TAPM_REFRESH_ISO_STORAGES; then + for index in "${!VIRTIO_STORAGE_IDS[@]}"; do + echo -e " ${idsCL[LightCyan]}${VIRTIO_STORAGE_IDS[$index]}${idsCL[Default]} (${VIRTIO_STORAGE_TYPES[$index]})" + echo " ${VIRTIO_STORAGE_DIRS[$index]}" + while IFS= read -r -d '' file; do + found=1 + printf ' %-42s %10s %s\n' \ + "${file##*/}" \ + "$(numfmt --to=iec-i --suffix=B "$(stat -c '%s' "$file")")" \ + "$(date --date="@$(stat -c '%Y' "$file")" '+%F %R')" + done < <( + find "${VIRTIO_STORAGE_DIRS[$index]}" -maxdepth 1 -type f \ + -name 'virtio-win*.iso' -print0 2>/dev/null | sort -z + ) echo - echo -e "\n${idsCL[Green]}All VM's have been reconfigured\n${idsCL[LightCyan]}This will require the VM's to be powered off and then turned back on in order to take effect${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - ;; - *) echo;; - esac - + done + else + echo -e " ${idsCL[LightRed]}No active ISO-capable storage was found.${idsCL[Default]}" + fi + (( found == 1 )) || echo " No VirtIO ISOs are currently downloaded." + echo + read -r -p " Press ENTER to return..." _ } -RESTART_PVE_SERVICES(){ - if [ "${1}" == "" ]; then - echo -en "${idsCL[LightCyan]}Would you like to restart all Proxmox services on the local host (Y/n)?${idsCL[Default]} " - read -n 1 choice - else - choice=${1} - fi - case "${choice}" in - [Nn]) echo;; - *) echo - echo -en "\n${idsCL[Yellow]}Restarting services ... " - #systemctl restart pve-cluster pvedaemon pvestatd pveproxy pve-ha-lrm pve-ha-crm - systemctl restart pve-cluster pvedaemon pvestatd pveproxy - echo -e "${idsCL[Green]}Done${idsCL[Default]}" - echo -e "\n${idsCL[Green]}This hosts Proxmox services have been restarted${idsCL[Default]}\n" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - ;; - esac +VIRTIO_MENU() { + local stable_label + local -a labels + local -a values=( + "stable" + "server2016" + "server2012" + "server2008r2" + "server2008" + "archive" + "local" + "refresh" + ) + + echo -en "\n${idsCL[LightCyan]}Checking the current stable VirtIO release...${idsCL[Default]} " + TAPM_REFRESH_ISO_STORAGES || true + TAPM_CHECK_VIRTIO_STABLE 0 || true + echo + + while true; do + TAPM_REFRESH_ISO_STORAGES || true + TAPM_REFRESH_VIRTIO_LOCAL_STATUS + case "$VIRTIO_STABLE_STATUS" in + current) + stable_label="Current stable drivers (${VIRTIO_STABLE_FILE} downloaded)" + ;; + update) + stable_label="Current stable drivers (${VIRTIO_STABLE_FILE} update available)" + ;; + available) + stable_label="Current stable drivers (${VIRTIO_STABLE_FILE})" + ;; + *) + stable_label="Current stable drivers (version check unavailable)" + ;; + esac + + labels=( + "$stable_label" + "Windows Server 2016 compatibility ISO (0.1.240)" + "Windows Server 2012/R2 compatibility ISO (0.1.189)" + "Windows Server 2008 R2 compatibility ISO (0.1.172)" + "Windows Server 2008 compatibility ISO (0.1.141)" + "Download another archived VirtIO release" + "View downloaded VirtIO ISOs" + "Refresh stable-version check" + ) + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2016-0.1.240.iso' && + labels[1]+=" (downloaded)" + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2012r2-0.1.189.iso' && + labels[2]+=" (downloaded)" + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2008r2-0.1.172.iso' && + labels[3]+=" (downloaded)" + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2008-0.1.141.iso' && + labels[4]+=" (downloaded)" + + SELECT_MENU "VirtIO Driver Downloads" labels values + case "$MENU_SELECTION" in + stable) DOWNLOAD_VIRTIO_STABLE;; + server2016) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.240-1 \ + "Windows Server 2016 compatibility drivers" server-2016 + ;; + server2012) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.189-1 \ + "Windows Server 2012/R2 compatibility drivers" server-2012r2 + ;; + server2008r2) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.172-1 \ + "Windows Server 2008 R2 compatibility drivers" server-2008r2 + ;; + server2008) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.141-1 \ + "Windows Server 2008 compatibility drivers" server-2008 + ;; + archive) DOWNLOAD_CUSTOM_VIRTIO_ARCHIVE;; + local) SHOW_LOCAL_VIRTIO_ISOS;; + refresh) + echo -en "\n${idsCL[LightCyan]}Refreshing the stable VirtIO release...${idsCL[Default]} " + TAPM_CHECK_VIRTIO_STABLE 1 || true + echo + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done } -SET_VM_SHUTDOWNTIMEOUT(){ - if [ "${1}" == "" ]; then - echo -en "${idsCL[LightCyan]}Would you like to set all VM's shutdown timeout to 180secs (Y/n)?${idsCL[Default]} " - read -n 1 choice - else - choice=${1} - fi - case "${choice}" in - [Nn]) echo;; - *) - echo "Updating all VM's shutdown timeout to 180 seconds..." - sed -E -i 's/(down=)[0-9]+/\1180/g' /etc/pve/nodes/*/qemu-server/*.conf - [ ${action-x} ] && exit 0 || ENTER2CONTINUE - ;; - esac +TAPM_INSTALL_PROXCLMC() { + local key_url='https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key' + local keyring='/etc/apt/keyrings/gyptazy.asc' + local repository_file='/etc/apt/sources.list.d/gyptazy.list' + local repository_line='deb [signed-by=/etc/apt/keyrings/gyptazy.asc] https://packages.gyptazy.com/api/packages/gyptazy/debian trixie main' + local temp_dir + local temp_key + + TAPM_PACKAGE_INSTALLED proxclmc && command -v proxclmc >/dev/null 2>&1 && + return 0 + + TAPM_CREATE_TEMP_DIR proxclmc || return 1 + temp_dir="$TAPM_TEMP_DIR" + temp_key="${temp_dir}/gyptazy.asc" + + TAPM_DOWNLOAD_HTTPS "$key_url" "$temp_key" "ProxCLMC repository key" || + return 1 + if ! command -v gpg >/dev/null 2>&1 || + ! gpg --batch --show-keys "$temp_key" >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}The downloaded ProxCLMC repository key is not a valid OpenPGP key.${idsCL[Default]}" + return 1 + fi + + if ! install -d -m 0755 /etc/apt/keyrings || + ! install -m 0644 "$temp_key" "$keyring" || + ! printf '%s\n' "$repository_line" >"$repository_file" || + ! chmod 0644 "$repository_file"; then + echo -e "${idsCL[LightRed]}Could not configure the ProxCLMC package repository.${idsCL[Default]}" + return 1 + fi + + if ! apt-get update || + ! apt-get install -y proxclmc || + ! command -v proxclmc >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}ProxCLMC could not be installed.${idsCL[Default]}" + return 1 + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" } -MAINTENANCE_MODE(){ - if ha-manager status | grep $(hostname) | grep "maintenance mode" &> /dev/null; then +TAPM_CLUSTER_QEMU_GUESTS() { + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for guest in json.load(sys.stdin): + if guest.get("type") != "qemu": + continue + print( + guest.get("vmid", ""), + str(guest.get("name", "")) + .replace("\x1f", " ") + .replace("\r", " ") + .replace("\n", " "), + guest.get("node", ""), + "yes" if guest.get("template") in (1, True, "1") else "no", + sep="\x1f", + ) +' +} + +TAPM_QEMU_CPU_MODEL() { + local vmid="$1" + local node="$2" + local config_file + local config_output + local cpu_model + + [[ "$vmid" =~ ^[1-9][0-9]{2,8}$ ]] || return 1 + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 1 + config_file="/etc/pve/nodes/${node}/qemu-server/${vmid}.conf" + if [[ -r "$config_file" ]]; then + cpu_model="$( + awk ' +$1 == "cpu:" { + sub(/^[^:]*:[[:space:]]*/, "") + print + exit +} +' "$config_file" + )" || return 1 + [[ -n "$cpu_model" ]] || cpu_model='kvm64' + printf '%s\n' "$cpu_model" + return 0 + fi + + # Fall back to the node-aware API if the shared pmxcfs entry is briefly + # unavailable, such as while cluster membership is changing. + config_output="$( + pvesh get "/nodes/${node}/qemu/${vmid}/config" \ + --output-format json 2>/dev/null + )" || return 1 + cpu_model="$( + QEMU_CONFIG_JSON="$config_output" python3 -c ' +import json, os +try: + config = json.loads(os.environ["QEMU_CONFIG_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +cpu = config.get("cpu", "kvm64") +print(cpu if isinstance(cpu, str) and cpu.strip() else "kvm64") +' 2>/dev/null + )" || return 1 + [[ -n "$cpu_model" ]] || cpu_model='kvm64' + printf '%s\n' "$cpu_model" +} + +TAPM_SET_QEMU_CPU_MODEL() { + local vmid="$1" + local node="$2" + local cpu_model="$3" + + [[ "$vmid" =~ ^[1-9][0-9]{2,8}$ ]] || return 1 + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 1 + [[ "$cpu_model" =~ ^x86-64-v(1|2-AES|3|4)$ ]] || return 1 + pvesh set "/nodes/${node}/qemu/${vmid}/config" \ + --cpu "$cpu_model" +} + +TAPM_NODE_CPU_NAME() { + local node="${1:?node is required}" + local status_json + + status_json="$( + pvesh get "/nodes/${node}/status" --output-format json 2>/dev/null + )" || return 1 + + TAPM_NODE_STATUS_JSON="$status_json" python3 -c ' +import json +import os + +try: + status = json.loads(os.environ["TAPM_NODE_STATUS_JSON"]) +except (KeyError, TypeError, ValueError): + raise SystemExit(1) + +cpu_info = status.get("cpuinfo") or {} +model = str(cpu_info.get("model") or "").strip() +if not model: + raise SystemExit(1) + +print(" ".join(model.split())) +' 2>/dev/null +} + +TAPM_SHOW_CLUSTER_CPU_TABLE() { + local proxclmc_output + local host_rows + local node + local address + local host_max + local cpu_name + local generation + + proxclmc_output="$(proxclmc 2>/dev/null)" || proxclmc_output="" + host_rows="$(printf '%s\n' "$proxclmc_output" | TAPM_PROXCLMC_HOST_ROWS)" + + printf '\nCluster host CPU capabilities:\n\n' + + if [[ -z "$host_rows" ]]; then + echo -e " ${idsCL[Yellow]}Per-host CPU details were unavailable from ProxCLMC.${idsCL[Default]}" + return 0 + fi + + printf ' %-16s %-42s %-34s %-14s\n' \ + 'HOST' 'PHYSICAL CPU' 'GENERATION / FAMILY' 'BEST VM MODEL' + printf ' %-16s %-42s %-34s %-14s\n' \ + '----------------' '------------------------------------------' \ + '----------------------------------' '--------------' + + while IFS=$'\034' read -r node address host_max; do + [[ -n "$node" ]] || continue + + cpu_name="$(TAPM_NODE_CPU_NAME "$node" 2>/dev/null || true)" + if [[ -n "$cpu_name" ]]; then + generation="$(TAPM_CPU_GENERATION_FROM_NAME "$cpu_name")" + else + cpu_name='Unavailable' + generation='Unavailable' + fi + + printf ' %-16.16s %-42.42s %-34.34s %-14s\n' \ + "$node" "$cpu_name" "$generation" "$host_max" + done <<< "$host_rows" +} + +DETECT_CPU() { + local answer + local cpu_model + local current_cpu + local guest + local guest_output + local name + local node + local template + local vmid + local -a changes=() + local -a failures=() + local -a successes=() + + for command in apt-get curl gpg install pvesh python3; do + if ! command -v "$command" >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}${command} is required for CPU compatibility detection.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + done + + if ! TAPM_INSTALL_PROXCLMC; then + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[LightCyan]}Analyzing CPU compatibility across the cluster...${idsCL[Default]}" + cpu_model="$(proxclmc --list-only 2>/dev/null)" || { + echo -e "${idsCL[LightRed]}ProxCLMC could not determine a compatible CPU model.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + cpu_model="${cpu_model//$'\r'/}" + cpu_model="${cpu_model//$'\n'/}" + if [[ ! "$cpu_model" =~ ^x86-64-v(1|2-AES|3|4)$ ]]; then + echo -e "${idsCL[LightRed]}ProxCLMC returned an unexpected CPU model: ${cpu_model:-empty}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_SHOW_CLUSTER_CPU_TABLE + printf '\nRecommended cluster-wide VM CPU baseline: %s\n' "$cpu_model" + printf '%s\n' \ + 'This is the highest generic model shared by every node for HA placement,' \ + 'load balancing, and moving workloads between cluster hosts.' + + guest_output="$(TAPM_CLUSTER_QEMU_GUESTS)" || { + echo -e "${idsCL[LightRed]}Could not read cluster QEMU resources.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + if [[ -z "$guest_output" ]]; then + echo -e "\n${idsCL[LightCyan]}No QEMU VMs or templates were found in the cluster.${idsCL[Default]}" + FINISH_ACTION + return + fi + + while IFS=$'\x1f' read -r vmid name node template; do + [[ -n "$vmid" ]] || continue + current_cpu="$(TAPM_QEMU_CPU_MODEL "$vmid" "$node")" || { + echo -e "${idsCL[LightRed]}Could not read the CPU configuration for VMID ${vmid}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + [[ "$current_cpu" == "$cpu_model" ]] && continue + changes+=( + "${vmid}"$'\x1f'"${name}"$'\x1f'"${node}"$'\x1f'"${template}"$'\x1f'"${current_cpu}" + ) + done <<< "$guest_output" + + if (( ${#changes[@]} == 0 )); then + echo -e "\n${idsCL[Green]}All QEMU VMs and templates already use ${cpu_model}.${idsCL[Default]}" + FINISH_ACTION + return + fi + + printf '\n%-7s %-28s %-20s %-10s %-24s %s\n' \ + 'VMID' 'NAME' 'NODE' 'TEMPLATE' 'CURRENT CPU' 'PROPOSED CPU' + printf '%-7s %-28s %-20s %-10s %-24s %s\n' \ + '-------' '----------------------------' '--------------------' \ + '----------' '------------------------' '----------------' + for guest in "${changes[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + printf '%-7s %-28.28s %-20.20s %-10s %-24.24s %s\n' \ + "$vmid" "${name:-unnamed}" "$node" "$template" "$current_cpu" "$cpu_model" + done + + echo -en "\n${idsCL[LightCyan]}Apply cluster-wide baseline ${cpu_model} to ${#changes[@]} VM(s) and template(s) (y/N)?${idsCL[Default]} " + read -r -n 1 answer + echo + [[ "$answer" =~ ^[Yy]$ ]] || return + + for guest in "${changes[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + if TAPM_SET_QEMU_CPU_MODEL "$vmid" "$node" "$cpu_model" && + [[ "$(TAPM_QEMU_CPU_MODEL "$vmid" "$node")" == "$cpu_model" ]]; then + successes+=("$guest") + else + failures+=("$guest") + fi + done + + printf '\nCPU model update summary:\n' + printf ' Successful: %d\n' "${#successes[@]}" + printf ' Failed: %d\n' "${#failures[@]}" + + if (( ${#failures[@]} > 0 )); then + printf '\nFailed VM/template updates:\n' + for guest in "${failures[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + printf ' %s (%s) on %s\n' "$vmid" "${name:-unnamed}" "$node" + done + echo -e "\n${idsCL[LightRed]}One or more CPU model updates failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}The CPU model was updated to ${cpu_model}.${idsCL[Default]}" + echo -e "${idsCL[LightCyan]}Running VMs must be fully shut down and started again before the new CPU model takes effect.${idsCL[Default]}" + FINISH_ACTION +} + +RESTART_SERVICE_GROUP() { + local description="$1" + shift + local -a services=("$@") + local service + local failed=0 + local choice + + if (( ${RESTART_ASSUME_YES:-0} == 1 )); then + choice="y" + else + echo -en "${idsCL[LightCyan]}Restart ${description} on this host (Y/n)?${idsCL[Default]} " + read -r -n 1 choice + echo + fi + [[ "$choice" =~ ^[Nn]$ ]] && return + + echo -e "\n${idsCL[Yellow]}Restarting ${description}...${idsCL[Default]}" + if ! systemctl restart "${services[@]}"; then + failed=1 + fi + + echo + for service in "${services[@]}"; do + if systemctl is-active --quiet "$service"; then + echo -e " ${idsCL[Green]}[active]${idsCL[Default]} ${service}" + else + echo -e " ${idsCL[Red]}[failed]${idsCL[Default]} ${service}" + failed=1 + fi + done + + if (( failed == 0 )); then + echo -e "\n${idsCL[Green]}Service restart completed successfully.${idsCL[Default]}" + else + echo -e "\n${idsCL[Red]}One or more services failed to restart.${idsCL[Default]}" + echo "Review: journalctl -u --since '-10 minutes'" + fi + + FINISH_ACTION +} + +RESTART_CLUSTER_FILESYSTEM() { + local choice + + echo -e "${idsCL[LightYellow]}This temporarily interrupts /etc/pve (pmxcfs) and cluster configuration access.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}It does not restart Corosync or the HA daemons.${idsCL[Default]}" + + if systemctl is-active --quiet corosync && + ! timeout 5 pvecm status 2>/dev/null | + grep -Eq '^Quorate:[[:space:]]+Yes[[:space:]]*$'; then + echo -e "\n${idsCL[Red]}The cluster is not quorate. pve-cluster will not be restarted.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + echo -en "\n${idsCL[LightCyan]}Restart pve-cluster on this host (y/N)?${idsCL[Default]} " + read -r -n 1 choice + echo + [[ "$choice" =~ ^[Yy]$ ]] || return + + if systemctl restart pve-cluster && + systemctl is-active --quiet pve-cluster && + timeout 15 bash -c 'until test -d /etc/pve/nodes; do sleep 1; done'; then + echo -e "\n${idsCL[Green]}pve-cluster restarted and /etc/pve is available.${idsCL[Default]}" + else + echo -e "\n${idsCL[Red]}pve-cluster did not recover normally.${idsCL[Default]}" + echo "Review: journalctl -u pve-cluster --since '-10 minutes'" + fi + + ENTER2CONTINUE +} + +RESTART_PVE_SERVICES() { + local requested_choice="${1:-}" + local RESTART_ASSUME_YES=0 + + [[ "$requested_choice" =~ ^[Nn]$ ]] && return + [[ "$requested_choice" =~ ^[Yy]$ ]] && RESTART_ASSUME_YES=1 + + RESTART_SERVICE_GROUP "core Proxmox management services" \ + pvedaemon pveproxy pvestatd pvescheduler +} + +HA_NODE_IN_MAINTENANCE() { + local node="$1" + local ha_status + + ha_status="$(ha-manager status 2>/dev/null)" || return 2 + grep -F "lrm ${node} " <<< "$ha_status" | + grep -q "maintenance mode" +} + +WAIT_FOR_HA_MAINTENANCE_STATE() { + local node="$1" + local expected_state="$2" + local attempts="${3:-30}" + local attempt=0 + local active=0 + local state_result + + while (( attempt < attempts )); do + active=0 + HA_NODE_IN_MAINTENANCE "$node" + state_result=$? + [[ "$state_result" -eq 0 ]] && active=1 + if [[ "$state_result" -gt 1 ]]; then + sleep 1 + ((attempt++)) + continue + fi + if [[ "$expected_state" == "enabled" && "$active" -eq 1 ]] || + [[ "$expected_state" == "disabled" && "$active" -eq 0 ]]; then + return 0 + fi + sleep 1 + ((attempt++)) + done + return 1 +} + +MAINTENANCE_MODE() { + local choice + local local_node + local maintenance_state + local_node="$(hostname -s)" + + HA_NODE_IN_MAINTENANCE "$local_node" + maintenance_state=$? + if [[ "$maintenance_state" -gt 1 ]]; then + echo -e "\n${idsCL[LightRed]}Could not read HA maintenance status for ${local_node}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ "$maintenance_state" -eq 0 ]]; then echo -en "${idsCL[LightCyan]}Take the local host out of maintenance mode (Y/n)?${idsCL[Default]} " else echo -en "${idsCL[LightCyan]}Put the local host into maintenance mode (Y/n)?${idsCL[Default]} " fi - read -n 1 choice + read -r -n 1 choice case "$choice" in - [Nn]) echo;; - *) echo - if ha-manager status | grep $(hostname) | grep "maintenance mode" &> /dev/null; then - ha-manager crm-command node-maintenance disable $(hostname) & + [Nn]) echo;; + *) echo + if [[ "$maintenance_state" -eq 0 ]]; then + if ! ha-manager crm-command node-maintenance disable "$local_node" || + ! WAIT_FOR_HA_MAINTENANCE_STATE "$local_node" disabled; then + echo -e "\n${idsCL[LightRed]}Failed to take ${local_node} out of HA maintenance mode.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi echo -e "\n${idsCL[Green]}This host will be taken out of maintenance mode${idsCL[Default]}\n" else - ha-manager crm-command node-maintenance enable $(hostname) & + if ! ha-manager crm-command node-maintenance enable "$local_node"; then + echo -e "\n${idsCL[LightRed]}Failed to request HA maintenance mode for ${local_node}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi echo -e "\n${idsCL[Green]}This host will be entered into maintenance mode${idsCL[Default]}\n" + if ! bash /opt/idssys/ta-proxmenu/inc/evacuate-proxmox-node.sh; then + echo -e "\n${idsCL[LightRed]}Evacuation did not complete. ${local_node} remains in HA maintenance mode.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi fi - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION ;; esac } INSTALL_KEEPALIVE() { - read -n 1 -p "Are you sure you wish to install Keepalive on all Hosts (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo + echo - source /opt/idssys/ta-proxmenu/inc/deploy-proxmox-keepalived.sh + bash /opt/idssys/ta-proxmenu/inc/deploy-proxmox-keepalived.sh - echo -e "\n${idsCL[Green]}Keepalive has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + echo -e "\n${idsCL[Green]}Keepalive has been installed${idsCL[Default]}" + FINISH_ACTION +} + +UPDATE_CACHE_DIR='/var/cache/ta-proxmenu' +UPDATE_CACHE_FILE="${UPDATE_CACHE_DIR}/update-status" +UPDATE_CACHE_SECONDS=86400 +UPDATE_CHECK_PID='' +UPDATE_STATUS='unknown' +UPDATE_REMOTE_COMMIT='' + +WRITE_UPDATE_CACHE() { + local status="$1" + local branch="$2" + local local_commit="$3" + local remote_commit="$4" + local cache_temp + + mkdir -p "$UPDATE_CACHE_DIR" || return 1 + cache_temp="$(mktemp "${UPDATE_CACHE_DIR}/.update-status.XXXXXX")" || return 1 + printf '%s|%s|%s|%s|%s\n' \ + "$(date +%s)" "$branch" "$local_commit" "$remote_commit" "$status" \ + >"$cache_temp" + chmod 0644 "$cache_temp" + mv -f "$cache_temp" "$UPDATE_CACHE_FILE" +} + +UPDATE_CHECK_WORKER() { + local branch + local local_commit + local remote_commit + local status + + branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + local_commit="$(git -C "$FOLDER" rev-parse HEAD 2>/dev/null)" + + if [[ -z "$branch" || -z "$local_commit" ]]; then + WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" + return + fi + if TAPM_GIT_WORKTREE_DIRTY "$FOLDER"; then + WRITE_UPDATE_CACHE "dirty" "$branch" "$local_commit" "" + return + fi + + if ! TAPM_GIT_FETCH_BRANCH "$FOLDER" "$branch" 30 >/dev/null 2>&1; then + WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" + return + fi + remote_commit="$(git -C "$FOLDER" rev-parse "refs/remotes/origin/${branch}" 2>/dev/null)" + status="$(TAPM_GIT_BRANCH_STATE "$FOLDER" "$branch")" + + WRITE_UPDATE_CACHE "$status" "$branch" "$local_commit" "$remote_commit" +} + +START_UPDATE_CHECK() { + if [[ -n "$UPDATE_CHECK_PID" ]] && + kill -0 "$UPDATE_CHECK_PID" 2>/dev/null; then + return + fi + UPDATE_CHECK_WORKER >/dev/null 2>&1 & + UPDATE_CHECK_PID="$!" +} + +LOAD_UPDATE_STATUS() { + local checked_at + local cached_branch + local cached_local + local cached_remote + local cached_status + local current_branch + local current_commit + local current_dirty=0 + local now + + UPDATE_STATUS='unknown' + UPDATE_REMOTE_COMMIT='' + current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + current_commit="$(git -C "$FOLDER" rev-parse HEAD 2>/dev/null)" + TAPM_GIT_WORKTREE_DIRTY "$FOLDER" && current_dirty=1 + now="$(date +%s)" + + if [[ -r "$UPDATE_CACHE_FILE" ]]; then + IFS='|' read -r checked_at cached_branch cached_local cached_remote cached_status \ + <"$UPDATE_CACHE_FILE" + if [[ "$checked_at" =~ ^[0-9]+$ ]] && + (( now - checked_at < UPDATE_CACHE_SECONDS )) && + [[ "$cached_branch" == "$current_branch" ]] && + [[ "$cached_local" == "$current_commit" ]] && + [[ "$cached_status" =~ ^(current|behind|ahead|diverged|dirty|unavailable)$ ]] && + { [[ "$cached_status" == "dirty" && "$current_dirty" -eq 1 ]] || + [[ "$cached_status" != "dirty" && "$current_dirty" -eq 0 ]]; }; then + UPDATE_STATUS="$cached_status" + UPDATE_REMOTE_COMMIT="$cached_remote" + return + fi + fi + + UPDATE_STATUS='checking' + START_UPDATE_CHECK +} + +UPDATE_LAST_CHECKED_DISPLAY() { + local checked_at + + [[ -r "$UPDATE_CACHE_FILE" ]] || { + printf '%s\n' 'never' + return + } + IFS='|' read -r checked_at _ <"$UPDATE_CACHE_FILE" + [[ "$checked_at" =~ ^[0-9]+$ ]] || { + printf '%s\n' 'unknown' + return + } + date --date="@${checked_at}" '+%Y-%m-%d %H:%M %Z' 2>/dev/null || + printf '%s\n' 'unknown' +} + +FORCE_UPDATE_CHECK() { + local attempts=0 + + rm -f "$UPDATE_CACHE_FILE" + UPDATE_CHECK_PID='' + START_UPDATE_CHECK + + echo -en "${idsCL[LightCyan]}Checking current branch for updates" + while (( attempts < 20 )); do + sleep 0.5 + LOAD_UPDATE_STATUS + [[ "$UPDATE_STATUS" != "checking" ]] && break + echo -n "." + ((attempts++)) + done + echo -e "${idsCL[Default]}" + + case "$UPDATE_STATUS" in + behind) + echo -e "${idsCL[LightYellow]}An update is available for the current branch.${idsCL[Default]}" + ;; + current) + echo -e "${idsCL[Green]}TA-ProxMenu is current.${idsCL[Default]}" + ;; + ahead) + echo -e "${idsCL[LightYellow]}The current branch has local commits not on origin.${idsCL[Default]}" + ;; + diverged) + echo -e "${idsCL[LightRed]}The current branch has diverged from origin; automatic update is disabled.${idsCL[Default]}" + ;; + dirty) + echo -e "${idsCL[LightYellow]}The TA-ProxMenu repository has local file changes.${idsCL[Default]}" + ;; + *) + echo -e "${idsCL[Red]}The update status could not be determined.${idsCL[Default]}" + ;; esac + + ENTER2CONTINUE } +MENU_HEADER() { + local header_title='TA-ProxMenu - Proxmox Setup Scripts' + local header_width=75 + local status_color='' + local status_display='' + local version_color="${idsCL[White]}" + local right_width + local spacer_width + + TAPM_LOAD_HEADER_INFO + + # Reuse a settled update result instead of running Git checks on every + # arrow-key redraw. Continue refreshing only while the worker is pending. + if [[ "$UPDATE_STATUS" == 'unknown' || "$UPDATE_STATUS" == 'checking' ]]; then + LOAD_UPDATE_STATUS + fi + case "$UPDATE_STATUS" in + behind) + status_display='** UPDATE AVAILABLE **' + status_color="${idsCL[LightYellow]}" + version_color="${idsCL[LightYellow]}" + ;; + ahead) + status_display='** LOCAL COMMITS **' + status_color="${idsCL[LightYellow]}" + version_color="${idsCL[LightYellow]}" + ;; + diverged) + status_display='** BRANCH DIVERGED **' + status_color="${idsCL[LightRed]}" + version_color="${idsCL[LightRed]}" + ;; + dirty) + status_display='** LOCAL CHANGES **' + status_color="${idsCL[LightYellow]}" + version_color="${idsCL[LightYellow]}" + ;; + checking) + status_display='(checking for updates)' + status_color="${idsCL[LightCyan]}" + version_color="${idsCL[LightCyan]}" + ;; + esac + + right_width="${#VERS}" + if [[ -n "$status_display" ]]; then + right_width=$((right_width + ${#status_display} + 2)) + fi + spacer_width=$((header_width - 1 - ${#header_title} - right_width)) + ((spacer_width < 2)) && spacer_width=2 + + clear + echo + printf ' %b%s%b%*s' \ + "${idsCL[Green]}" "$header_title" "${idsCL[Default]}" "$spacer_width" '' + if [[ -n "$status_display" ]]; then + printf '%b%s%b ' "$status_color" "$status_display" "${idsCL[Default]}" + fi + printf '%b%s%b\n' "$version_color" "$VERS" "${idsCL[Default]}" + echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" + echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" + echo -e " IP Address: ${idsCL[Cyan]}${RNIP:-Unavailable}${idsCL[Default]}" + echo -e " Proxmox VE: ${idsCL[Cyan]}${TAPM_HEADER_PVE_VERSION}${idsCL[Default]} Cluster: ${idsCL[Cyan]}${TAPM_HEADER_CLUSTER}${idsCL[Default]}" + echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" +} + +SELECT_MENU() { + local title="$1" + local labels_name="$2" + local values_name="$3" + local allow_back="${4:-1}" + local allow_space="${5:-0}" + local initial_selected="${6:-0}" + local -n labels_ref="$labels_name" + local -n values_ref="$values_name" + local selected="$initial_selected" + local key + local sequence + local index + + [[ "$selected" =~ ^[0-9]+$ ]] || selected=0 + (( selected < ${#labels_ref[@]} )) || selected=0 + while true; do + MENU_HEADER + echo + echo -e " ${idsCL[LightCyan]}${title}${idsCL[Default]}" + echo + + for index in "${!labels_ref[@]}"; do + if (( index == selected )); then + printf '\e[7m %d %-64s\e[0m\n' "$((index + 1))" "${labels_ref[$index]}" + else + printf ' %d %s\n' "$((index + 1))" "${labels_ref[$index]}" + fi + done + + echo + if (( allow_back == 1 && allow_space == 1 )); then + echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select ←/Esc/B Back Q Quit" + elif (( allow_back == 1 )); then + echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" + elif (( allow_space == 1 )); then + echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select Q Quit" + else + echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" + fi + + MENU_INPUT="" + MENU_SELECTED_INDEX="$selected" + key="" + if [[ "$UPDATE_STATUS" == "checking" ]]; then + IFS= read -rsn1 -t 1 key || continue + else + IFS= read -rsn1 key + fi + case "$key" in + "") + MENU_INPUT="enter" + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + " ") + if (( allow_space == 1 )); then + MENU_INPUT="space" + MENU_SELECTION="${values_ref[$selected]}" + return 0 + fi + ;; + [1-9]) + index=$((10#$key - 1)) + if (( index < ${#values_ref[@]} )); then + selected="$index" + MENU_SELECTED_INDEX="$selected" + MENU_INPUT="number" + MENU_SELECTION="${values_ref[$index]}" + return 0 + fi + ;; + [Qq]) + MENU_SELECTION="quit" + return 0 + ;; + [Bb]) + if (( allow_back == 1 )); then + MENU_SELECTION="back" + return 0 + fi + ;; + $'\e') + sequence="" + IFS= read -rsn2 -t 0.1 sequence || true + case "$sequence" in + "[A"|"OA") + (( selected = (selected - 1 + ${#labels_ref[@]}) % ${#labels_ref[@]} )) + ;; + "[B"|"OB") + (( selected = (selected + 1) % ${#labels_ref[@]} )) + ;; + "[C"|"OC") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + "[D"|"OD"|"") + if (( allow_back == 1 )); then + MENU_SELECTION="back" + return 0 + fi + ;; + "[H") + selected=0 + ;; + "[F") + selected=$((${#labels_ref[@]} - 1)) + ;; + esac + ;; + esac + done +} + +TAPM_POST_PROFILE_SUMMARY() { + echo + echo -e " ${idsCL[LightCyan]}Recommended TAPM host profile${idsCL[Default]}" + echo + echo " [x] System utilities and CPU-appropriate microcode" + echo " [x] Chrony time synchronization (existing timezone preserved)" + echo " [x] Kernel panic recovery (30 seconds)" + echo " [x] Conservative inotify limits" + echo " [x] Persistent journald (1 GiB / 30 days)" + echo " [x] Verify and repair log rotation" + echo " [x] Host memory behavior (swappiness 10)" + echo " [x] Network safeguards" + echo " [x] BBR and TCP Fast Open" + echo " [x] Native parallel gzip support for vzdump" + echo " [ ] APT network compatibility / conditional IPv4 (optional)" + echo + echo " Global vzdump bandwidth and I/O-priority overrides are not applied." +} + +TAPM_POST_FAILURE_RECOVERY() { + local backup_dir="$1" + local mode="$2" + local -a labels=( + "Retry the selected profile" + "Restore the pre-change backup" + "Stop and return to the menu" + ) + local -a values=(retry restore stop) + + while true; do + echo + [[ -n "$backup_dir" ]] && echo " Pre-change backup: ${backup_dir}" + SELECT_MENU "Host Configuration Recovery" labels values + case "$MENU_SELECTION" in + retry) + if TAPM_POST_APPLY_PROFILE "$mode"; then + echo -e "\n${idsCL[Green]}The profile retry completed successfully.${idsCL[Default]}" + echo " New backup: ${TAPM_POST_LAST_BACKUP}" + return 0 + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-The retry failed.}${idsCL[Default]}" + ;; + restore) + if [[ -n "$backup_dir" ]] && + TAPM_POST_RESTORE_BACKUP "$backup_dir"; then + echo -e "\n${idsCL[Green]}The pre-change configuration was restored.${idsCL[Default]}" + return 0 + fi + echo -e "\n${idsCL[LightRed]}The pre-change backup could not be restored.${idsCL[Default]}" + ;; + stop|back) return 1 ;; + quit) EXIT1; exit 0 ;; + esac + done +} + +TAPM_POST_APPLY_RECOMMENDED() { + local backup_dir + local confirmation + + TAPM_POST_DEFAULT_SELECTIONS + TAPM_POST_PROFILE_SUMMARY + read -r -p " Apply this profile to the current host (type yes to continue)? " \ + confirmation + [[ "${confirmation,,}" == yes ]] || return + + if TAPM_POST_APPLY_PROFILE apply; then + echo -e "\n${idsCL[Green]}The TAPM host profile was applied and verified.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-Host profile application failed.}${idsCL[Default]}" + backup_dir="$TAPM_POST_LAST_BACKUP" + if TAPM_POST_FAILURE_RECOVERY "$backup_dir" apply; then + FINISH_ACTION + else + FINISH_FAILED_ACTION + fi +} + +TAPM_POST_MIGRATE_PROXMENUX() { + local backup_dir + local confirmation + + if ! TAPM_POST_PROXMENUX_DETECTED; then + echo -e "\n${idsCL[LightYellow]}No ProxMenux installation or recognized artifacts were detected.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + TAPM_POST_DEFAULT_SELECTIONS + TAPM_POST_PROFILE_SUMMARY + TAPM_POST_MIGRATION_PLAN + echo + echo -e " ${idsCL[LightYellow]}Migration will repair recognized ProxMenux settings, restore gzip," + echo -e " apply the TAPM profile, and then remove ProxMenux itself.${idsCL[Default]}" + echo " Shared packages such as dialog, jq, curl, Git, and Python are retained." + echo + read -r -p " Migrate this host and remove ProxMenux (type yes to continue)? " \ + confirmation + [[ "${confirmation,,}" == yes ]] || return + + if TAPM_POST_APPLY_PROFILE migrate; then + echo -e "\n${idsCL[Green]}The host was migrated to the TAPM profile and ProxMenux was removed.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-ProxMenux migration failed.}${idsCL[Default]}" + echo " ProxMenux removal is the final migration step; earlier failures leave it installed." + backup_dir="$TAPM_POST_LAST_BACKUP" + if TAPM_POST_FAILURE_RECOVERY "$backup_dir" migrate; then + FINISH_ACTION + else + FINISH_FAILED_ACTION + fi +} + +TAPM_POST_FLAG_LABEL() { + local value="$1" + local label="$2" + + (( value == 1 )) && printf '[x] %s' "$label" || printf '[ ] %s' "$label" +} + +TAPM_POST_TOGGLE_FLAG() { + local variable_name="$1" + local current_value="${!variable_name}" + + (( current_value == 1 )) && + printf -v "$variable_name" '%d' 0 || + printf -v "$variable_name" '%d' 1 +} + +TAPM_POST_CUSTOMIZE() { + local -a labels + local -a values + local selected_index=0 + + TAPM_POST_DEFAULT_SELECTIONS + while true; do + labels=( + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_UTILITIES" "System utilities")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_TIME" "Time synchronization")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_PANIC" "Kernel panic recovery")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_LIMITS" "Conservative inotify limits")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_JOURNALD" "Journald — 1 GiB / 30 days")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_LOGROTATE" "Verify and repair log rotation")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_MEMORY" "Host memory behavior")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_NETWORK" "Network safeguards")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_BBR" "BBR and TCP Fast Open")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_PIGZ" "Native parallel gzip for vzdump")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_APT_NETWORK" "APT network compatibility check")" + "Apply selected profile" + ) + values=( + utilities time panic limits journald logrotate memory network bbr pigz + apt_network apply + ) + SELECT_MENU "Customize TAPM Host Profile" labels values 1 1 \ + "$selected_index" + selected_index="$MENU_SELECTED_INDEX" + if [[ "$MENU_INPUT" == space && "$MENU_SELECTION" == apply ]]; then + continue + fi + case "$MENU_SELECTION" in + utilities) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_UTILITIES ;; + time) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_TIME ;; + panic) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_PANIC ;; + limits) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_LIMITS ;; + journald) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_JOURNALD ;; + logrotate) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_LOGROTATE ;; + memory) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_MEMORY ;; + network) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_NETWORK ;; + bbr) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_BBR ;; + pigz) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_PIGZ ;; + apt_network) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_APT_NETWORK ;; + apply) + local backup_dir + local confirmation + echo + read -r -p " Apply the selected profile (type yes to continue)? " \ + confirmation + [[ "${confirmation,,}" == yes ]] || continue + if TAPM_POST_APPLY_PROFILE custom; then + echo -e "\n${idsCL[Green]}The custom TAPM profile was applied.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-Custom profile application failed.}${idsCL[Default]}" + backup_dir="$TAPM_POST_LAST_BACKUP" + if TAPM_POST_FAILURE_RECOVERY "$backup_dir" custom; then + FINISH_ACTION + else + FINISH_FAILED_ACTION + fi + return + ;; + back) return ;; + quit) EXIT1; exit 0 ;; + esac + done +} + +TAPM_POST_SHOW_AUDIT() { + MENU_HEADER + echo + TAPM_POST_AUDIT + FINISH_ACTION +} + +TAPM_POST_SHOW_REPORT() { + local report='/var/lib/ta-proxmenu/post-install/last-report.txt' + + MENU_HEADER + echo + if [[ -f "$report" ]]; then + cat "$report" + else + echo -e " ${idsCL[LightYellow]}No completed TAPM host configuration report is available.${idsCL[Default]}" + fi + FINISH_ACTION +} + +TAPM_POST_RESTORE_MENU() { + local backup_base='/var/backups/ta-proxmenu/post-install' + local -a labels=() + local -a values=() + local backup_dir + local confirmation + + if [[ -d "$backup_base" ]]; then + while IFS= read -r backup_dir; do + [[ -f "${backup_dir}/manifest.tsv" ]] || continue + labels+=("${backup_dir##*/}") + values+=("$backup_dir") + done < <(find "$backup_base" -mindepth 1 -maxdepth 1 -type d | + sort -r) + fi + if (( ${#labels[@]} == 0 )); then + echo -e "\n${idsCL[LightYellow]}No TAPM host configuration backups were found.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + SELECT_MENU "Restore TAPM Host Configuration Backup" labels values + case "$MENU_SELECTION" in + back) return ;; + quit) EXIT1; exit 0 ;; + esac + backup_dir="$MENU_SELECTION" + echo + echo " Selected backup: $backup_dir" + echo " Restoring may also reinstate files or services removed during migration." + read -r -p " Restore this backup (type restore to continue)? " confirmation + [[ "${confirmation,,}" == restore ]] || return + if TAPM_POST_RESTORE_BACKUP "$backup_dir"; then + echo -e "\n${idsCL[Green]}The selected configuration backup was restored.${idsCL[Default]}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}The selected backup could not be fully restored.${idsCL[Default]}" + FINISH_FAILED_ACTION +} + +TAPM_POST_VZDUMP_APPLY() { + local mode="$1" + local bandwidth="${2:-}" + local ionice="${3:-}" + + TAPM_POST_LAST_ERROR='' + if ! TAPM_POST_PRECHECK; then + echo -e "\n${idsCL[LightRed]}$TAPM_POST_LAST_ERROR${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + if ! TAPM_POST_BACKUP >/dev/null; then + echo -e "\n${idsCL[LightRed]}The vzdump configuration backup failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + case "$mode" in + defaults) + TAPM_POST_REMOVE_COLON_KEY /etc/vzdump.conf bwlimit && + TAPM_POST_REMOVE_COLON_KEY /etc/vzdump.conf ionice + ;; + maximum) + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf bwlimit 0 && + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf ionice 5 + ;; + custom) + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf bwlimit "$bandwidth" && + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf ionice "$ionice" + ;; + esac + if (( $? == 0 )); then + echo -e "\n${idsCL[Green]}vzdump performance settings were updated.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}vzdump performance settings could not be updated.${idsCL[Default]}" + FINISH_FAILED_ACTION +} + +TAPM_POST_VZDUMP_MENU() { + local -a labels=( + "Use Proxmox defaults — recommended" + "Maximum throughput — unlimited, ionice 5" + "Set a custom bandwidth limit" + "Restore a TAPM configuration backup" + ) + local -a values=(defaults maximum custom restore) + local confirmation + local bandwidth_mib + local bandwidth_kib + local ionice + + SELECT_MENU "Configure vzdump Performance" labels values + case "$MENU_SELECTION" in + defaults) + TAPM_POST_VZDUMP_APPLY defaults + ;; + maximum) + echo + echo -e "${idsCL[LightYellow]}This can saturate shared storage and increase guest latency.${idsCL[Default]}" + read -r -p " Enable maximum throughput (type yes to continue)? " confirmation + [[ "${confirmation,,}" == yes ]] && + TAPM_POST_VZDUMP_APPLY maximum + ;; + custom) + echo + while true; do + read -r -p " Bandwidth limit in MiB/s (0 means unlimited): " bandwidth_mib + [[ "$bandwidth_mib" =~ ^[0-9]+$ ]] && break + echo " Enter a nonnegative whole number." + done + while true; do + read -r -p " I/O priority [0-8, Proxmox default is 7]: " ionice + [[ "$ionice" =~ ^[0-8]$ ]] && break + echo " Enter a value from 0 through 8." + done + bandwidth_kib=$((bandwidth_mib * 1024)) + TAPM_POST_VZDUMP_APPLY custom "$bandwidth_kib" "$ionice" + ;; + restore) TAPM_POST_RESTORE_MENU ;; + back) return ;; + quit) EXIT1; exit 0 ;; + esac +} + +TAPM_HOST_CONFIGURATION_MENU() { + local -a labels + local -a values + + while true; do + labels=( + "Audit current host" + "Apply/repair recommended TAPM profile" + "Customize TAPM profile" + ) + values=(audit apply customize) + if TAPM_POST_PROXMENUX_DETECTED; then + labels+=("** Migrate from ProxMenux — RECOMMENDED **") + values+=(migrate) + fi + labels+=( + "Configure vzdump performance" + "Restore a TAPM configuration backup" + "View last configuration report" + ) + values+=(vzdump restore report) + + SELECT_MENU "TAPM Host Configuration" labels values + case "$MENU_SELECTION" in + audit) TAPM_POST_SHOW_AUDIT ;; + apply) TAPM_POST_APPLY_RECOMMENDED ;; + customize) TAPM_POST_CUSTOMIZE ;; + migrate) TAPM_POST_MIGRATE_PROXMENUX ;; + vzdump) TAPM_POST_VZDUMP_MENU ;; + restore) TAPM_POST_RESTORE_MENU ;; + report) TAPM_POST_SHOW_REPORT ;; + back) return ;; + quit) EXIT1; exit 0 ;; + esac + done +} + +SHOW_ABOUT() { + MENU_HEADER + echo + echo -e " ${idsCL[LightCyan]}About TA-ProxMenu${idsCL[Default]}" + echo + echo " Version: ${VERS}" + echo " Install: /opt/idssys/ta-proxmenu" + echo " Branch: $(git -C /opt/idssys/ta-proxmenu branch --show-current 2>/dev/null || echo unknown)" + echo + read -r -p " Press ENTER to return..." _ +} + +HOST_SETUP_MENU() { + local -a labels + local -a values + + while true; do + labels=("TAPM Host Configuration") + values=("post_install") + TAPM_POST_PROXMENUX_DETECTED && + labels[0]="TAPM Host Configuration — ProxMenux migration recommended" + labels+=("Analyze VM CPU compatibility for HA and workload mobility") + values+=("cpu") + + # Discover ISO storage only after the VirtIO submenu is selected. + labels+=("VirtIO driver downloads") + values+=("virtio") + + command -v glances >/dev/null 2>&1 && + labels+=("Glances CLI monitor (installed)") || + labels+=("Install Glances CLI monitor") + values+=("glances") + + if TAPM_OMSA_SUPPORTED_HARDWARE; then + TAPM_PACKAGE_INSTALLED srvadmin-all && + labels+=("Dell OMSA - Legacy Dell Hosts (installed)") || + labels+=("Install Dell OMSA - Legacy Dell Hosts") + values+=("omsa") + fi + + SELECT_MENU "Host Setup" labels values + case "$MENU_SELECTION" in + post_install) TAPM_HOST_CONFIGURATION_MENU;; + cpu) DETECT_CPU;; + virtio) VIRTIO_MENU;; + glances) INSTALL_GLANCES;; + omsa) INSTALL_OMSA;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +MONITORING_MENU() { + local -a labels + local -a values=("pulse" "rmm" "acronis" "sentinelone" "screenconnect") + local cluster_resources + local pulse_status + + while true; do + TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS + pulse_status=$? + if (( pulse_status == 2 )); then + cluster_resources="$( + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null + )" + TAPM_PULSE_RESOURCE_INSTALLED "$cluster_resources" + pulse_status=$? + fi + if (( pulse_status == 0 )); then + labels=("Pulse monitoring (installed)") + else + labels=($'Install \e[36mPulse\e[39m monitoring') + fi + + if systemctl is-active --quiet ITSPlatform; then + labels+=("ConnectWise RMM agent (installed)") + else + labels+=($'Install \e[36mConnectWise RMM\e[39m agent') + fi + + if dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null | + grep -q "install ok installed"; then + labels+=("Acronis backup agent (installed)") + else + labels+=($'Install \e[36mAcronis\e[39m backup agent') + fi + + if dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | + grep -q "install ok installed"; then + labels+=("SentinelOne agent (installed)") + else + labels+=($'Install \e[36mSentinelOne\e[39m agent') + fi + + if systemctl is-active --quiet 'connectwise*'; then + labels+=("ScreenConnect agent (installed)") + else + labels+=($'Install \e[36mScreenConnect\e[39m agent') + fi + + SELECT_MENU "Monitoring & Agents" labels values + case "$MENU_SELECTION" in + pulse) INSTALL_PULSE;; + rmm) INSTALL_RMM;; + acronis) INSTALL_ACRONIS;; + sentinelone) INSTALL_S1;; + screenconnect) INSTALL_SCREENCONNECT;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +CLUSTER_MENU() { + local -a labels + local -a values=("maintenance" "services" "keepalived" "iso_nfs") + local maintenance_status + local node + + while true; do + node="$(hostname -s)" + TAPM_HA_NODE_IN_MAINTENANCE "$node" + maintenance_status=$? + if (( maintenance_status == 2 )); then + ha-manager status | + grep -F "$node" | + grep -q "maintenance mode" + maintenance_status=$? + fi + if (( maintenance_status == 0 )); then + labels=("Take this host out of maintenance mode") + else + labels=("Put this host into maintenance mode and evacuate guests") + fi + + labels+=("Proxmox service recovery") + dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" && + labels+=("Deploy/reconfigure Keepalived (installed locally)") || + labels+=("Deploy Keepalived on all cluster hosts") + labels+=("Create shared ISO storage using an LXC NFS server") + + SELECT_MENU "Cluster & Maintenance" labels values + case "$MENU_SELECTION" in + maintenance) MAINTENANCE_MODE;; + services) SERVICE_RECOVERY_MENU;; + keepalived) INSTALL_KEEPALIVE;; + iso_nfs) + TAPM_DEPLOY_ISO_NFS_LXC + FINISH_ACTION + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +SERVICE_RECOVERY_MENU() { + local -a labels=( + "Restart Web UI and API (pveproxy, pvedaemon)" + "Restart statistics collection (pvestatd)" + "Restart task scheduler (pvescheduler)" + "Restart core management services" + "Restart cluster filesystem (pve-cluster)" + ) + local -a values=("web" "statistics" "scheduler" "core" "clusterfs") + + while true; do + SELECT_MENU "Proxmox Service Recovery" labels values + case "$MENU_SELECTION" in + web) + RESTART_SERVICE_GROUP "Web UI and API services" pveproxy pvedaemon + ;; + statistics) + RESTART_SERVICE_GROUP "statistics collection" pvestatd + ;; + scheduler) + RESTART_SERVICE_GROUP "task scheduler" pvescheduler + ;; + core) + RESTART_PVE_SERVICES + ;; + clusterfs) + RESTART_CLUSTER_FILESYSTEM + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +SWITCH_SCRIPT_BRANCH() { + local target_branch="$1" + local current_branch + local choice + local target_state='' + + current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + if [[ "$target_branch" == "$current_branch" ]]; then + echo -e "\n${idsCL[Green]}TA-ProxMenu is already using '${target_branch}'.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + if [[ -n "$(git -C "$FOLDER" status --porcelain --untracked-files=normal)" ]]; then + echo -e "\n${idsCL[Red]}The installed TA-ProxMenu repository has local changes.${idsCL[Default]}" + echo "Branch switching was refused to protect those files." + echo + git -C "$FOLDER" status --short + ENTER2CONTINUE + return + fi + + echo -en "\n${idsCL[LightCyan]}Switch TA-ProxMenu from '${current_branch}' to '${target_branch}' (y/N)?${idsCL[Default]} " + read -r -n 1 choice + echo + [[ "$choice" =~ ^[Yy]$ ]] || return + + if ! TAPM_GIT_FETCH_BRANCH "$FOLDER" "$target_branch" 30; then + echo -e "${idsCL[Red]}Failed to fetch '${target_branch}' from origin.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + if git -C "$FOLDER" show-ref --verify --quiet "refs/heads/${target_branch}"; then + target_state="$( + TAPM_GIT_RELATION "$FOLDER" "refs/heads/${target_branch}" \ + "refs/remotes/origin/${target_branch}" + )" || { + echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}" + ENTER2CONTINUE + return + } + case "$target_state" in + ahead) + echo -e "${idsCL[LightYellow]}The local '${target_branch}' branch has commits not on origin.${idsCL[Default]}" + echo "Branch switching was refused to preserve those commits." + ENTER2CONTINUE + return + ;; + diverged) + echo -e "${idsCL[LightRed]}The local '${target_branch}' branch has diverged from origin.${idsCL[Default]}" + echo "Branch switching was refused; manual Git review is required." + ENTER2CONTINUE + return + ;; + esac + + git -C "$FOLDER" switch "$target_branch" || { + ENTER2CONTINUE + return + } + else + git -C "$FOLDER" switch --create "$target_branch" \ + --track "origin/${target_branch}" || { + ENTER2CONTINUE + return + } + fi + + if [[ "$target_state" == "behind" ]]; then + if ! TAPM_GIT_FAST_FORWARD "$FOLDER" "$target_branch"; then + echo -e "${idsCL[Red]}Failed to fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + fi + + rm -f "$UPDATE_CACHE_FILE" + echo -e "\n${idsCL[Green]}Now using TA-ProxMenu branch '${target_branch}'.${idsCL[Default]}" + sleep 1 + exec /opt/idssys/ta-proxmenu/run.sh +} + +BRANCH_MANAGEMENT_MENU() { + local -a labels=() + local -a values=() + local branch + local branch_version + local current_branch + + MENU_HEADER + echo + echo -e " ${idsCL[LightCyan]}Refreshing remote branch list...${idsCL[Default]}" + + if ! timeout 30 git -C "$FOLDER" fetch origin \ + '+refs/heads/*:refs/remotes/origin/*' --prune >/dev/null 2>&1; then + echo -e "\n${idsCL[Red]}Could not retrieve branches from origin.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + while IFS= read -r branch; do + [[ -n "$branch" && "$branch" != "HEAD" ]] || continue + branch_version="$( + git -C "$FOLDER" show "refs/remotes/origin/${branch}:defaults.inc" \ + 2>/dev/null | + awk -F"'" '/^VERS=/{ print $2; exit }' + )" + + if [[ "$branch" == "$current_branch" ]]; then + labels+=("${branch} (current, version ${branch_version:-unknown})") + else + labels+=("${branch} (version ${branch_version:-unknown})") + fi + values+=("branch:${branch}") + done < <( + git -C "$FOLDER" for-each-ref \ + --format='%(refname:strip=3)' refs/remotes/origin | + sort -V + ) + + if (( ${#labels[@]} == 0 )); then + echo -e "\n${idsCL[Red]}No remote branches were found.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + while true; do + SELECT_MENU "Git Branch Management" labels values + case "$MENU_SELECTION" in + branch:*) + SWITCH_SCRIPT_BRANCH "${MENU_SELECTION#branch:}" + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +UTILITIES_MENU() { + local -a labels + local -a values=("install_update" "check_update" "branches" "about") + local choice + local last_checked + + while true; do + LOAD_UPDATE_STATUS + case "$UPDATE_STATUS" in + behind) + labels=("Install available update") + ;; + current) + labels=("TA-ProxMenu is current") + ;; + ahead) + labels=("Local branch is ahead of origin") + ;; + diverged) + labels=("Local branch has diverged from origin") + ;; + dirty) + labels=("Local repository has uncommitted changes") + ;; + checking) + labels=("Update check in progress") + ;; + *) + labels=("Update status unavailable") + ;; + esac + last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)" + labels+=( + "Check again now "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m' + "Git branch management" + "Version and installation information" + ) + + SELECT_MENU "TA-ProxMenu Management" labels values + case "$MENU_SELECTION" in + install_update) + if [[ "$UPDATE_STATUS" != "behind" ]]; then + echo -e "\n${idsCL[LightCyan]}No available update is currently detected.${idsCL[Default]}" + ENTER2CONTINUE + continue + fi + echo -en "\n${idsCL[LightCyan]}Install the update for the current branch (y/N)?${idsCL[Default]} " + read -r -n 1 choice + echo + if [[ "$choice" =~ ^[Yy]$ ]] && + /opt/idssys/ta-proxmenu/run.sh update; then + exec /opt/idssys/ta-proxmenu/run.sh + fi + ENTER2CONTINUE + ;; + check_update) FORCE_UPDATE_CHECK;; + branches) BRANCH_MANAGEMENT_MENU;; + about) SHOW_ABOUT;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + MAIN_MENU() { - echo -en "${idsCL[LightCyan]}Pulling host info ... " - CRES=$(pvesh get /cluster/resources) - DPL=$(dpkg -l) - echo -e "${idsCL[Green]}Done${idsCL[Default]}" - - while : - do - clear - echo - echo -e " ${idsCL[Green]}TA-Proxmenu - Proxmox Setup Scripts${idsCL[Default]} ${idsCL[Default]}${VERS}" - echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" - echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" - echo -e " IP Address: ${idsCL[Cyan]}${RNIP}${idsCL[Default]}" - echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" - echo - - echo -en "${idsCL[White]} [${idsCL[LightYellow]}0${idsCL[Default]}] ${idsCL[White]}Run Post-Install Script${idsCL[Default]}" - [ -f /opt/.PROXMENUX_POST_INSTALL ] && echo -e "${idsCL[Cyan]} - Has been ran prevously${idsCL[Default]}" || echo - echo -e "${idsCL[White]} [${idsCL[LightYellow]}1${idsCL[Default]}] ${idsCL[White]}Detect CPU-Arch for Live Migrations${idsCL[Default]}" - if ! echo ${CRES} | grep -i pulse &> /dev/null ; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}2${idsCL[Default]}] ${idsCL[White]}Install Pulse Monitoring${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [2] Pulse Monitoring is already installed${idsCL[Default]}" - fi - if [ -f ${DLDIR}/${VIRTIO_FILE} ]; then - echo -e "${idsCL[DarkGray]} [3] Current VirtIO drivers already downloaded to 'local' on this host${idsCL[Default]}" - elif [ -f ${DLDIR}/virtio*.iso ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}3${idsCL[Default]}] ${idsCL[LightGreen]}**${idsCL[White]}Download the available updated Win-VirtIO drivers to 'local' on this host${idsCL[Default]}" - else - echo -e "${idsCL[White]} [${idsCL[LightYellow]}3${idsCL[Default]}] ${idsCL[White]}Download the current Win-VirtIO drivers to 'local' on this host${idsCL[Default]}" - fi - if ! command -v glances &> /dev/null; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}4${idsCL[Default]}] ${idsCL[White]}Install Glances (CLI Monitor)${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [4] Glances is already installed${idsCL[Default]}" - fi - if [ "$(echo ${DPL} | awk '/srvadmin-all/ {print }'|wc -l)" -eq 0 ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}5${idsCL[Default]}] ${idsCL[White]}Install Dell OpenManage Server Administrator${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [5] Dell OMSA is already installed - ${idsCL[Cyan]}https://${RNIP}:1311" - fi - echo - if [ "$(systemctl is-active ITSPlatform)" != "active" ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}6${idsCL[Default]}] ${idsCL[White]}Install ConnectWise RMM Agent${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [6] ConnectWise RMM Agent is already installed${idsCL[Default]}" - fi - if [ "$(echo ${DPL} | awk '/cyberprotect/ {print }'|wc -l)" -eq 0 ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}7${idsCL[Default]}] ${idsCL[White]}Install Acronis Backup Agent${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [7] Acronis Backup is already installed${idsCL[Default]}" - fi - if [ "$(echo ${DPL} | awk '/sentinelagent/ {print }'|wc -l)" -eq 0 ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}8${idsCL[Default]}] ${idsCL[White]}Install SentinelOne Agent (v25_4_2_21)${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [8] SentinelOne is already installed${idsCL[Default]}" - fi - echo - if [ "$(systemctl is-active connectwise*)" != "active" ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}S${idsCL[Default]}] ${idsCL[White]}Install ScreenConnect Agent${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [S] ScreenConnect is already installed${idsCL[Default]}" - fi - echo - if ha-manager status | grep $(hostname) | grep "maintenance mode" &> /dev/null; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}M${idsCL[Default]}] ${idsCL[White]}Take Host out of Maintenance Mode${idsCL[Default]}" - else - echo -e "${idsCL[White]} [${idsCL[LightYellow]}M${idsCL[Default]}] ${idsCL[White]}Put Host into Maintenance Mode${idsCL[Default]}" - fi - echo -e "${idsCL[White]} [${idsCL[LightYellow]}R${idsCL[Default]}] ${idsCL[White]}Restart Proxmox Services${idsCL[Default]}" - echo - echo -e "${idsCL[White]} [${idsCL[LightYellow]}K${idsCL[Default]}] ${idsCL[White]}Install Keepalive on all Hosts${idsCL[Default]}" - echo -e "${idsCL[White]} [${idsCL[LightYellow]}G${idsCL[Default]}] ${idsCL[White]}Change TA-ProxMenu Git URL on all Hosts${idsCL[Default]}" - echo - echo -e "${idsCL[White]} [${idsCL[LightYellow]}Q${idsCL[Default]}] ${idsCL[White]}Quit${idsCL[Default]}" - echo - echo - echo -e -n "${idsCL[Yellow]}Enter ${idsCL[LightYellow]}option${idsCL[Yellow]} from above:${idsCL[Default]} " - read -n 1 opt - echo - case $opt in - [0]) PROXMENUX_POST_INSTALL;; - [1]) DETECT_CPU;; - [2]) INSTALL_PULSE;; - [3]) DOWNLOAD_VIRTIO;; - [4]) INSTALL_GLANCES;; - [5]) INSTALL_OMSA;; - [6]) INSTALL_RMM;; - [7]) INSTALL_ACRONIS;; - [8]) INSTALL_S1;; - - [Ss]) INSTALL_SCREENCONNECT;; - [Mm]) MAINTENANCE_MODE;; - [Rr]) RESTART_PVE_SERVICES;; - [Kk]) INSTALL_KEEPALIVE;; - [Gg]) exec /opt/idssys/ta-proxmenu/run.sh git-url;; - [Qq]) EXIT1; exit 0;; - *) echo -e "Thats an invaild option,\nplease select a valid option only."; sleep 1;; + local -a labels=( + "Host Setup" + "Monitoring & Agents" + "Cluster & Maintenance" + "TA-ProxMenu Management" + "Quit" + ) + local -a values=("host" "monitoring" "cluster" "utilities" "quit") + + while true; do + SELECT_MENU "Main Menu" labels values 0 + case "$MENU_SELECTION" in + host) HOST_SETUP_MENU;; + monitoring) MONITORING_MENU;; + cluster) CLUSTER_MENU;; + utilities) UTILITIES_MENU;; + quit) EXIT1; exit 0;; esac done } -if [ ${action-x} ]; then - case $action in +if (( ACTION_REQUESTED == 1 )); then + case "$action" in pulse) INSTALL_PULSE;; rmm) INSTALL_RMM;; omsa) INSTALL_OMSA;; glances) INSTALL_GLANCES;; acronis) INSTALL_ACRONIS;; - proxmenux) [ ! -f /usr/local/bin/menu ] && INSTALL_PROXMENUX || /usr/local/bin/menu;; + post-install|post_install|proxmenux) TAPM_HOST_CONFIGURATION_MENU;; + virtio) VIRTIO_MENU;; + sentinelone|s1) INSTALL_S1;; screenconnect) INSTALL_SCREENCONNECT;; - restart) RESTART_PVE_SERVICES ${2};; + restart) RESTART_PVE_SERVICES "${2:-}";; cpu) DETECT_CPU;; - mm) MAINTENANCE_MODE;; - timeout) SET_VM_SHUTDOWNTIMEOUT ${2};; - git-url) exec /opt/idssys/ta-proxmenu/run.sh git-url "${2:-}";; + maintenance|mm) MAINTENANCE_MODE;; + keepalived) INSTALL_KEEPALIVE;; + iso-nfs|iso_nfs) TAPM_DEPLOY_ISO_NFS_LXC;; *) MAIN_MENU;; esac else @@ -424,6 +2762,3 @@ else fi exit 0 - - - diff --git a/run.sh b/run.sh index 1fc3b46..f453d14 100755 --- a/run.sh +++ b/run.sh @@ -1,267 +1,196 @@ #!/usr/bin/env bash -# TA-Proxmenu preloader +# TA-ProxMenu preloader -[ "${2}" != "q" ] && source /opt/idssys/defaults/colors.inc -source /opt/idssys/defaults/default.inc +source /opt/idssys/ta-proxmenu/inc/runtime-config.inc +TAPM_ENSURE_RUNTIME_CONFIG || exit 1 + +source /opt/idssys/ta-proxmenu/inc/git-update.inc +source /opt/idssys/ta-proxmenu/inc/cluster-update.inc + +[ "${2:-}" != "q" ] && source /opt/idssys/ta-proxmenu/colors.inc source /opt/idssys/ta-proxmenu/defaults.inc +TAPM_FLEET_START "$VERS" +trap 'TAPM_FLEET_FINISH "$?"' EXIT -VALID_GIT_URL() { - [[ "$1" =~ ^https?://[A-Za-z0-9._-]+(:[0-9]+)?/[A-Za-z0-9._/-]+$ ]] -} - -SWITCH_GIT_URL() { - local repository='/opt/idssys/ta-proxmenu' - local new_url="${1:-}" - local assume_yes="${2:-}" - local local_hostname - local local_short_hostname - local node - local node_output - local successful=0 - local failed=0 - local -a cluster_nodes=() +UPDATE_REPOSITORY() { + local repository="$1" + local branch="$2" + local label="$3" + local current_branch + local state if [[ ! -d "${repository}/.git" ]]; then - echo -e "${idsCL[Red]}TA-ProxMenu is not a Git repository.${idsCL[Default]}" + echo -e "${idsCL[Red]}${label} is not a Git repository.${idsCL[Default]}" return 1 fi - if [[ -z "$new_url" ]]; then - echo -en "${idsCL[LightCyan]}New TA-ProxMenu Git URL: ${idsCL[Default]}" - read -r -e new_url + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + echo -e "${idsCL[Red]}${label} is in a detached HEAD state; update skipped.${idsCL[Default]}" + return 1 fi - if ! VALID_GIT_URL "$new_url"; then - echo -e "${idsCL[Red]}Enter a complete HTTP or HTTPS repository URL without embedded credentials.${idsCL[Default]}" - echo -e "${idsCL[LightYellow]}Example: https://git.example.com/organization/TA-ProxMenu.git${idsCL[Default]}" + if [[ "$current_branch" != "$branch" ]]; then + echo -e "${idsCL[Red]}${label} is on '${current_branch}', not '${branch}'; update skipped.${idsCL[Default]}" + return 1 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + echo -e "${idsCL[LightYellow]}${label} has local changes; update skipped to preserve them.${idsCL[Default]}" + git -C "$repository" status --short return 1 fi - if command -v pvecm >/dev/null 2>&1; then - mapfile -t cluster_nodes < <( - pvecm nodes 2>/dev/null | - awk ' - $1 ~ /^(0x)?[[:xdigit:]]+$/ && - $2 ~ /^[0-9]+$/ && - $3 != "" { print $3 } - ' - ) - fi - if (( ${#cluster_nodes[@]} == 0 )); then - cluster_nodes=("$(hostname -s)") + if ! TAPM_GIT_FETCH_BRANCH "$repository" "$branch" 30 >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Could not fetch branch '${branch}' for ${label}.${idsCL[Default]}" + return 1 fi - echo - echo -e "${idsCL[LightCyan]}Repository URL:${idsCL[Default]} $new_url" - echo -e "${idsCL[LightCyan]}Target nodes:${idsCL[Default]} ${cluster_nodes[*]}" - if [[ "$assume_yes" != "--yes" ]]; then - echo -en "${idsCL[LightYellow]}Change origin on every listed node (y/N)? ${idsCL[Default]}" - read -r -n 1 choice - echo - [[ "$choice" == [Yy] ]] || { - echo -e "${idsCL[LightYellow]}Git URL migration cancelled.${idsCL[Default]}" + state="$(TAPM_GIT_BRANCH_STATE "$repository" "$branch")" + case "$state" in + current) + echo -e "${idsCL[Green]}${label} is current (${branch}).${idsCL[Default]}" + return 0 + ;; + behind) + echo -en "${idsCL[LightCyan]}Updating ${label} (${branch})...${idsCL[Default]}" + if TAPM_GIT_FAST_FORWARD "$repository" "$branch" >/dev/null 2>&1; then + echo -e " ${idsCL[Green]}Done${idsCL[Default]}" + return 0 + fi + echo -e " ${idsCL[Red]}Failed; local files were preserved.${idsCL[Default]}" return 1 - } - fi - - local_hostname="$(hostname)" - local_short_hostname="$(hostname -s)" - for node in "${cluster_nodes[@]}"; do - echo -en "${idsCL[LightCyan]}${node}:${idsCL[Default]} " - if [[ "$node" == "$local_hostname" || "$node" == "$local_short_hostname" ]]; then - if ! GIT_TERMINAL_PROMPT=0 timeout 20 \ - git -C "$repository" ls-remote --exit-code \ - "$new_url" refs/heads/main >/dev/null 2>&1; then - echo -e "${idsCL[Red]}new repository is unavailable or requires credentials${idsCL[Default]}" - ((failed += 1)) - continue - fi - if git -C "$repository" remote set-url origin "$new_url"; then - echo -e "${idsCL[Green]}origin updated${idsCL[Default]}" - ((successful += 1)) - else - echo -e "${idsCL[Red]}could not update origin${idsCL[Default]}" - ((failed += 1)) - fi - continue - fi - - node_output="$( - ssh \ - -o BatchMode=yes \ - -o ConnectTimeout=10 \ - "root@${node}" \ - "test -d '${repository}/.git' && - GIT_TERMINAL_PROMPT=0 timeout 20 git -C '${repository}' ls-remote --exit-code '${new_url}' refs/heads/main >/dev/null 2>&1 && - git -C '${repository}' remote set-url origin '${new_url}'" \ - 2>&1 - )" - if [[ $? -eq 0 ]]; then - echo -e "${idsCL[Green]}origin updated${idsCL[Default]}" - ((successful += 1)) - else - echo -e "${idsCL[Red]}failed${idsCL[Default]}" - [[ -n "$node_output" ]] && echo " $node_output" - ((failed += 1)) - fi - done - - echo - echo -e "${idsCL[Green]}Updated nodes: ${successful}${idsCL[Default]}" - if ((failed > 0)); then - echo -e "${idsCL[Red]}Failed nodes: ${failed}${idsCL[Default]}" - echo -e "${idsCL[LightYellow]}A failed node was left on its existing origin URL.${idsCL[Default]}" - return 1 - fi - echo -e "${idsCL[Green]}Every installed cluster node now uses the new Git URL.${idsCL[Default]}" + ;; + ahead) + echo -e "${idsCL[LightYellow]}${label} is ahead of origin; its local commits were preserved.${idsCL[Default]}" + return 1 + ;; + diverged) + echo -e "${idsCL[LightYellow]}${label} has diverged from origin; automatic update was refused.${idsCL[Default]}" + return 1 + ;; + *) + echo -e "${idsCL[Red]}Could not determine the update state for ${label}.${idsCL[Default]}" + return 1 + ;; + esac } -SWITCH_TO_V2() { +SWITCH_TAPM_BRANCH() { + local target_branch="$1" local repository='/opt/idssys/ta-proxmenu' local current_branch - local head_commit - local local_commit - local main_commit - local remote_commit + local target_state='' + if ! git check-ref-format --branch "$target_branch" >/dev/null 2>&1; then + echo -e "${idsCL[Red]}'${target_branch}' is not a valid Git branch name.${idsCL[Default]}" + return 1 + fi if [[ ! -d "${repository}/.git" ]]; then echo -e "${idsCL[Red]}TA-ProxMenu is not a Git repository.${idsCL[Default]}" return 1 fi current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" - if [[ "$current_branch" == "V2" ]]; then - exec /opt/idssys/ta-proxmenu/run.sh + if [[ -z "$current_branch" ]]; then + echo -e "${idsCL[Red]}TA-ProxMenu is in a detached HEAD state; branch switching was refused.${idsCL[Default]}" + return 1 fi - if [[ -n "$(git -C "$repository" status --porcelain --untracked-files=normal)" ]]; then + if [[ "$target_branch" == "$current_branch" ]]; then + echo -e "${idsCL[Green]}TA-ProxMenu is already using '${target_branch}'.${idsCL[Default]}" + return 0 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then echo -e "${idsCL[LightYellow]}TA-ProxMenu has local changes; branch switching was refused to preserve them.${idsCL[Default]}" git -C "$repository" status --short return 1 fi - echo -e "${idsCL[LightCyan]}Fetching TA-ProxMenu branch 'V2'...${idsCL[Default]}" - if ! timeout 30 git -C "$repository" fetch --prune origin \ - '+refs/heads/main:refs/remotes/origin/main' \ - '+refs/heads/V2:refs/remotes/origin/V2' >/dev/null 2>&1; then - echo -e "${idsCL[Red]}Could not fetch branch 'V2' from origin.${idsCL[Default]}" + echo -e "${idsCL[LightCyan]}Fetching TA-ProxMenu branch '${target_branch}'...${idsCL[Default]}" + if ! TAPM_GIT_FETCH_BRANCH "$repository" "$target_branch" 30 >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Could not fetch branch '${target_branch}' from origin.${idsCL[Default]}" return 1 fi - if [[ -z "$current_branch" ]]; then - head_commit="$(git -C "$repository" rev-parse --verify 'HEAD^{commit}' 2>/dev/null)" - main_commit="$(git -C "$repository" rev-parse --verify \ - 'refs/remotes/origin/main^{commit}' 2>/dev/null)" - if [[ -z "$head_commit" || "$head_commit" != "$main_commit" ]]; then - echo -e "${idsCL[Red]}TA-ProxMenu has an unrecognized detached HEAD; branch switching was refused.${idsCL[Default]}" + if git -C "$repository" show-ref --verify --quiet "refs/heads/${target_branch}"; then + target_state="$( + TAPM_GIT_RELATION "$repository" "refs/heads/${target_branch}" \ + "refs/remotes/origin/${target_branch}" + )" || { + echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}" return 1 - fi - echo -e "${idsCL[LightCyan]}Recovered the legacy updater's detached main checkout.${idsCL[Default]}" - fi + } + case "$target_state" in + ahead) + echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has commits not on origin; switching was refused.${idsCL[Default]}" + return 1 + ;; + diverged) + echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has diverged from origin; switching was refused.${idsCL[Default]}" + return 1 + ;; + esac - remote_commit="$(git -C "$repository" rev-parse --verify \ - 'refs/remotes/origin/V2^{commit}' 2>/dev/null)" || { - echo -e "${idsCL[Red]}Origin does not provide a usable V2 branch.${idsCL[Default]}" - return 1 - } - - if git -C "$repository" show-ref --verify --quiet refs/heads/V2; then - local_commit="$(git -C "$repository" rev-parse --verify \ - 'refs/heads/V2^{commit}' 2>/dev/null)" || return 1 - - if [[ "$local_commit" != "$remote_commit" ]] && - ! git -C "$repository" merge-base --is-ancestor \ - "$local_commit" "$remote_commit"; then - echo -e "${idsCL[LightYellow]}Local branch 'V2' has local-only or diverged commits; switching was refused.${idsCL[Default]}" - return 1 - fi - - git -C "$repository" switch V2 >/dev/null || return 1 - if [[ "$local_commit" != "$remote_commit" ]] && - ! git -C "$repository" merge --ff-only \ - refs/remotes/origin/V2 >/dev/null; then - echo -e "${idsCL[Red]}Could not fast-forward V2; no commits were discarded.${idsCL[Default]}" - return 1 - fi + git -C "$repository" switch "$target_branch" >/dev/null || return 1 else - git -C "$repository" switch --create V2 \ - --track origin/V2 >/dev/null || return 1 + git -C "$repository" switch --create "$target_branch" \ + --track "origin/${target_branch}" >/dev/null || return 1 + fi + + if [[ "$target_state" == "behind" ]] && + ! TAPM_GIT_FAST_FORWARD "$repository" "$target_branch" >/dev/null; then + echo -e "${idsCL[Red]}Could not fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}" + return 1 fi rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true - echo -e "${idsCL[Green]}TA-ProxMenu is now using branch 'V2'. Loading the V2 menu...${idsCL[Default]}" + echo -e "${idsCL[Green]}TA-ProxMenu is now using branch '${target_branch}'. Loading its menu...${idsCL[Default]}" exec /opt/idssys/ta-proxmenu/run.sh } -if [[ "${1:-}" == "V2" ]]; then - SWITCH_TO_V2 - exit $? -fi +INSTALL_LOCAL_UPDATES() { + local current_branch + local update_failed=0 -if [[ "${1:-}" == "git-url" ]]; then - SWITCH_GIT_URL "${2:-}" "${3:-}" - exit $? -fi + echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" -if [[ "${noupdate}" != *" ${1} "* ]] && [[ "${noupdate}" != *" ${2} "* ]]; then - if GIT_TERMINAL_PROMPT=0 timeout 15 git -C /opt/idssys/ta-proxmenu \ - ls-remote --exit-code origin refs/heads/main >/dev/null 2>&1; then - if [ "${1}" != "tapm" ]; then - echo -en "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" - echo "" - udtd=0 - fi + current_branch="$(git -C /opt/idssys/ta-proxmenu branch --show-current)" + if [ -z "$current_branch" ]; then + echo -e "${idsCL[Red]}TA-ProxMenu is in a detached HEAD state; update skipped${idsCL[Default]}" + update_failed=1 + else + UPDATE_REPOSITORY /opt/idssys/ta-proxmenu "$current_branch" "TA-ProxMenu" || + update_failed=1 + fi - if [ "${1}" != "tapm" ]; then - if [ ! -d /opt/idssys/defaults ]; then - git clone https://git.scity.us/voltron/iDS-Defaults.git /opt/idssys/defaults - else - cd /opt/idssys/defaults - if [ "`git log --pretty=%H ...refs/heads/master^ | head -n 1`" != "`git ls-remote origin -h refs/heads/master |cut -f1`" ]; then - if [ "${1}" != "tapm" ]; then - echo -en "\e[1A"; - echo -en "\e[0K\r${idsCL[LightCyan]}Updating iDSSYS-Defaults...${idsCL[Default]}" - udtd=1 - fi - git fetch origin master >/dev/null 2>&1 - git reset --hard origin/master >/dev/null 2>&1 - git reflog expire --expire=now --all >/dev/null 2>&1 - git repack -ad >/dev/null 2>&1 - git prune >/dev/null 2>&1 - git pull >/dev/null 2>&1 - [ "${1}" != "tapm" ] && echo -e "${idsCL[Green]}Done${idsCL[Default]}" - fi - fi - fi + rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true - cd /opt/idssys/ta-proxmenu - if [ "`git log --pretty=%H ...refs/heads/main^ | head -n 1`" != "`git ls-remote origin -h refs/heads/main |cut -f1`" ]; then - if [ "${1}" != "tapm" ]; then - [ ${udtd} -eq 0 ] && echo -en "\e[1A"; - echo -en "\e[0K\r${idsCL[LightCyan]}Updating TA-Proxmenu...${idsCL[Default]}" - fi - git fetch origin main >/dev/null 2>&1 - git reset --hard origin/main >/dev/null 2>&1 - git reflog expire --expire=now --all >/dev/null 2>&1 - git repack -ad >/dev/null 2>&1 - git prune >/dev/null 2>&1 - git pull >/dev/null 2>&1 - - if [ "${1}" != "tapm" ]; then - source /opt/idssys/ta-proxmenu/defaults.inc - # echo -en "\e[1A"; - # echo -e "\e[0K\r ${idsCL[Green]}Updated to v${VERS}${idsCL[Default]}" - echo -e " ${idsCL[Green]}Updated to v${VERS}${idsCL[Default]}\n" - fi - elif [ "${1}" != "tapm" ] && [ ${udtd} -eq 0 ]; then - echo -e "\e[1A\e[0K\r ${idsCL[Green]}No updates available${idsCL[Default]}\n" - fi + if (( update_failed == 0 )); then + source /opt/idssys/ta-proxmenu/defaults.inc + echo -e "${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" + return 0 + fi - else - echo -e "${idsCL[Red]}Could not connect to the configured TA-ProxMenu Git origin for updates${idsCL[Default]}" - fi -fi + return 1 +} -if [ "${1}" != "tapm" ] && [ "${1}" != "update" ] && [ "${1}" != "u" ]; then - /opt/idssys/ta-proxmenu/proxmenu-scripts.sh $1 $2 $3 $4 -fi +case "${1:-}" in + update|u) + if [[ "${2:-}" == "--local-only" ]]; then + INSTALL_LOCAL_UPDATES + else + INSTALL_CLUSTER_UPDATES + fi + exit $? + ;; + main) + SWITCH_TAPM_BRANCH "$1" + exit $? + ;; + tapm) + exit 0 + ;; +esac -exit 0 +/opt/idssys/ta-proxmenu/proxmenu-scripts.sh \ + "${1:-}" "${2:-}" "${3:-}" "${4:-}" + +exit $? diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..ce2aa4e --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +declare -a shell_files=() +declare -a test_files=() +failures=0 +required_command='' + +if (( BASH_VERSINFO[0] < 4 || + (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then + printf 'TA-ProxMenu tests require Bash 4.3 or newer; found %s.\n' \ + "$BASH_VERSION" >&2 + exit 1 +fi + +for required_command in git python3; do + if ! command -v "$required_command" >/dev/null 2>&1; then + printf 'TA-ProxMenu tests require %s.\n' "$required_command" >&2 + exit 1 + fi +done + +while IFS= read -r -d '' file; do + shell_files+=("$file") +done < <( + find "$TEST_ROOT" -type f \( -name '*.sh' -o -name '*.inc' \) \ + -not -path '*/.git/*' -print0 | + sort -z +) + +printf 'Bash syntax\n' +for file in "${shell_files[@]}"; do + if ! bash -n "$file"; then + (( failures += 1 )) + fi +done +(( failures == 0 )) && printf ' PASS: %d files\n' "${#shell_files[@]}" + +printf '\nGit whitespace\n' +if git -C "$TEST_ROOT" diff --check && + git -C "$TEST_ROOT" diff --cached --check; then + printf ' PASS\n' +else + (( failures += 1 )) +fi + +printf '\nShellCheck\n' +if command -v shellcheck >/dev/null 2>&1; then + # These rules cannot follow TAPM's runtime-sourced defaults/colors or + # functions invoked indirectly through traps and menu dispatch. + if shellcheck -x -e SC1091,SC2034,SC2154,SC2317 "${shell_files[@]}"; then + printf ' PASS\n' + else + (( failures += 1 )) + fi +else + printf ' SKIP: shellcheck is not installed\n' +fi + +while IFS= read -r -d '' file; do + test_files+=("$file") +done < <( + find "${TEST_ROOT}/tests" -maxdepth 1 -type f -name 'test-*.sh' \ + -print0 | sort -z +) + +printf '\nBehavior tests\n' +for file in "${test_files[@]}"; do + printf ' %-30s ' "${file##*/}" + if bash "$file"; then + : + else + (( failures += 1 )) + fi +done + +printf '\n' +if (( failures > 0 )); then + printf 'FAILED: %d check group(s) failed.\n' "$failures" >&2 + exit 1 +fi +printf 'All TA-ProxMenu checks passed.\n' diff --git a/tests/test-cluster-update.sh b/tests/test-cluster-update.sh new file mode 100644 index 0000000..9d0f288 --- /dev/null +++ b/tests/test-cluster-update.sh @@ -0,0 +1,79 @@ +#!/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" + +TAPM_CLUSTER_NODE_ROWS() { + printf 'pve1\tonline\npve2\tonline\n' +} +TAPM_UPDATE_REMOTE_NODE() { + printf 'remote update output\n' +} +INSTALL_LOCAL_UPDATES() { + printf 'local update output\n' +} +expected_output=$'Updating TA-ProxMenu across 2 cluster node(s)...\n\nUpdating remote node pve2...\nremote update output\nRemote node pve2 is updated.\n\nUpdating local node pve1...\nlocal update output\nLocal node pve1 is updated.\n\nTA-ProxMenu is updated on all 2 cluster node(s).' +assert_equal "$expected_output" \ + "$(INSTALL_CLUSTER_UPDATES)" \ + "each node update is grouped with blank lines only between node blocks" + +finish_tests diff --git a/tests/test-cpu-compat.sh b/tests/test-cpu-compat.sh new file mode 100644 index 0000000..0712bc4 --- /dev/null +++ b/tests/test-cpu-compat.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${TEST_DIR}/.." && pwd)" + +source "${REPO_DIR}/inc/cpu-compat.inc" + +assert_equal() { + local expected="${1:?expected value is required}" + local actual="${2:?actual value is required}" + local description="${3:?description is required}" + + if [[ "$actual" != "$expected" ]]; then + printf 'FAIL: %s\nExpected: %s\nActual: %s\n' \ + "$description" "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_equal \ + "Broadwell-EP" \ + "$(TAPM_CPU_GENERATION_FROM_NAME "Intel(R) Xeon(R) CPU E5-2690 v4 @ 2.60GHz")" \ + "identifies Xeon E5 v4" + +assert_equal \ + "3rd Gen Xeon Scalable (Ice Lake)" \ + "$(TAPM_CPU_GENERATION_FROM_NAME "Intel(R) Xeon(R) Gold 6338 CPU @ 2.00GHz")" \ + "identifies third-generation Xeon Scalable" + +assert_equal \ + "3rd Gen EPYC (Milan / Zen 3)" \ + "$(TAPM_CPU_GENERATION_FROM_NAME "AMD EPYC 7543 32-Core Processor")" \ + "identifies EPYC Milan" + +proxclmc_fixture='node-a | 10.0.0.1 | x86-64-v4 +node-b | 10.0.0.2 | x86-64-v3 + +Cluster CPU type: x86-64-v3' + +expected_rows=$'node-a\03410.0.0.1\034x86-64-v4\nnode-b\03410.0.0.2\034x86-64-v3' +actual_rows="$(printf '%s\n' "$proxclmc_fixture" | TAPM_PROXCLMC_HOST_ROWS)" + +assert_equal "$expected_rows" "$actual_rows" "parses ProxCLMC host rows" + +printf 'CPU compatibility tests passed.\n' diff --git a/tests/test-evacuation.sh b/tests/test-evacuation.sh new file mode 100755 index 0000000..665479e --- /dev/null +++ b/tests/test-evacuation.sh @@ -0,0 +1,101 @@ +#!/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/evacuate-proxmox-node.sh" + +test_stopped_ha_guest_does_not_block_wait() { + LOCAL_NODE=node1 + HA_WAIT_SECONDS=0 + get_local_guests() { + printf '%s\n' \ + $'100\x1fqemu\x1fstopped\x1fshutdown-ha-vm\x1f1024' \ + $'101\x1fqemu\x1frunning\x1fnon-ha-vm\x1f1024' + } + get_ha_guest_ids() { + printf '%s\n' 100 + } + wait_for_ha_evacuation +} + +test_running_ha_guest_blocks_wait() { + LOCAL_NODE=node1 + HA_WAIT_SECONDS=0 + get_local_guests() { + printf '%s\n' $'100\x1fqemu\x1frunning\x1frunning-ha-vm\x1f1024' + } + get_ha_guest_ids() { + printf '%s\n' 100 + } + wait_for_ha_evacuation +} + +assert_success "stopped HA guest does not block evacuation wait" \ + test_stopped_ha_guest_does_not_block_wait +assert_failure "running HA guest still blocks evacuation wait" \ + test_running_ha_guest_blocks_wait + +set_routing_fixture() { + MIGRATION_NODES=(node2 node3) + NODE_STORAGE_CACHE=() + NODE_STORAGE_CACHE[node2]='shared-a,shared-b' + NODE_STORAGE_CACHE[node3]='shared-a,shared-c' + PREFERRED_TARGET=node2 +} + +set_routing_fixture +assert_success "preferred destination is eligible" \ + choose_destination shared-a '' 0 +assert_equal node2 "$CHOSEN_DESTINATION" "preferred destination selected" + +set_routing_fixture +assert_success "affinity fallback is available" \ + choose_destination shared-a node3 1 +assert_equal node3 "$CHOSEN_DESTINATION" "affinity destination selected" +assert_equal "routed to satisfy HA node-affinity preference" "$CHOSEN_NOTE" \ + "affinity routing explanation" + +set_routing_fixture +assert_success "storage fallback is available" \ + choose_destination shared-c '' 0 +assert_equal node3 "$CHOSEN_DESTINATION" "storage-compatible destination selected" + +set_routing_fixture +assert_failure "strict affinity blocks two noncompliant destinations" \ + choose_destination shared-a node4 1 + +set_routing_fixture +MIGRATION_NODES=(node2) +assert_success "sole eligible node overrides affinity" \ + choose_destination shared-a node4 1 +assert_equal node2 "$CHOSEN_DESTINATION" "sole eligible destination selected" +assert_equal "only eligible node; HA node-affinity preference overridden" \ + "$CHOSEN_NOTE" "sole-node override explanation" + +TEST_RULES_FILE="$(mktemp /tmp/tapm-ha-rules.XXXXXX)" +cleanup_evacuation_tests() { + if [[ "$TEST_RULES_FILE" == /tmp/tapm-ha-rules.* ]]; then + rm -f -- "$TEST_RULES_FILE" + fi +} +trap cleanup_evacuation_tests EXIT + +printf '%s\n' \ + 'node-affinity: preferred-nodes' \ + ' resources vm:210,ct:211' \ + ' nodes node3:20,node2:10' \ + ' strict 1' >"$TEST_RULES_FILE" +HA_RULES_FILE="$TEST_RULES_FILE" + +assert_equal $'preferred-nodes\x1f1\x1fnode3,node2' \ + "$(get_guest_node_affinity qemu 210)" \ + "QEMU affinity parsing" +assert_equal $'preferred-nodes\x1f1\x1fnode3,node2' \ + "$(get_guest_node_affinity lxc 211)" \ + "LXC affinity parsing" +assert_equal $'none\x1f0\x1f' \ + "$(get_guest_node_affinity qemu 999)" \ + "guest without affinity" + +finish_tests diff --git a/tests/test-fleet.sh b/tests/test-fleet.sh new file mode 100644 index 0000000..2fd65a7 --- /dev/null +++ b/tests/test-fleet.sh @@ -0,0 +1,148 @@ +#!/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_HOSTNAME='pve01' +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 payload["hostname"] == "pve01" +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_should_fail=0 +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" && "$data_path" != '-' ]]; then + cp "$data_path" "${test_dir}/curl-body-${count}.json" + fi + cat >"${test_dir}/curl-config-${count}" + (( curl_should_fail == 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["hostname"] == "pve01" +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 '0\n' >"${test_dir}/curl-count" +TAPM_FLEET_ENROLLED=0 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_LAST_VERSION='' +TAPM_FLEET_COLLECT_METADATA() { + TAPM_FLEET_HOSTNAME='pve01' + TAPM_FLEET_GIT_COMMIT='0123456789abcdef0123456789abcdef01234567' + TAPM_FLEET_PVE_VERSION='pve-manager/9.2.4' + TAPM_FLEET_OS_VERSION='Debian GNU/Linux 13 (trixie)' + TAPM_FLEET_KERNEL_VERSION='6.14.11-5-pve' + TAPM_FLEET_ARCHITECTURE='amd64' + TAPM_FLEET_CLUSTERED=true +} + +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +if [[ "$(cat "${test_dir}/curl-count")" != 2 ]]; then + printf 'FAIL: initial run did not make exactly registration + completion calls\n' >&2 + exit 1 +fi +TAPM_FLEET_LOAD_IDENTITY +if [[ "$TAPM_FLEET_ENROLLED" != 1 || + "$TAPM_FLEET_LAST_VERSION" != '2026.7.28-9' ]]; then + printf 'FAIL: successful fleet enrollment was not persisted\n' >&2 + exit 1 +fi + +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +if [[ "$(cat "${test_dir}/curl-count")" != 3 ]]; then + printf 'FAIL: normal run made more than one broker call\n' >&2 + exit 1 +fi + +curl_should_fail=1 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +TAPM_FLEET_LOAD_IDENTITY +if [[ "$TAPM_FLEET_ENROLLED" != 0 ]]; then + printf 'FAIL: failed fleet event did not schedule next-run re-enrollment\n' >&2 + exit 1 +fi + +curl_should_fail=0 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +TAPM_FLEET_LOAD_IDENTITY +if [[ "$TAPM_FLEET_ENROLLED" != 1 ]]; then + printf 'FAIL: fleet client did not recover enrollment after a failed event\n' >&2 + exit 1 +fi + +printf 'PASS: fleet identity and event client\n' diff --git a/tests/test-git-update.sh b/tests/test-git-update.sh new file mode 100755 index 0000000..3439e9d --- /dev/null +++ b/tests/test-git-update.sh @@ -0,0 +1,82 @@ +#!/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/git-update.inc" + +assert_success "automatic Git check cache is 24 hours" \ + grep -q '^UPDATE_CACHE_SECONDS=86400$' "${TEST_ROOT}/proxmenu-scripts.sh" + +TEST_TEMP_DIR="$(mktemp -d /tmp/tapm-git-tests.XXXXXX)" +TEST_REPOSITORY="${TEST_TEMP_DIR}/repository" + +cleanup_git_tests() { + if [[ "$TEST_TEMP_DIR" == /tmp/tapm-git-tests.* && -d "$TEST_TEMP_DIR" ]]; then + rm -rf -- "$TEST_TEMP_DIR" + fi +} +trap cleanup_git_tests EXIT + +git init -q -b main "$TEST_REPOSITORY" +git -C "$TEST_REPOSITORY" config user.name 'TA-ProxMenu Tests' +git -C "$TEST_REPOSITORY" config user.email 'tapm-tests@example.invalid' + +printf 'first\n' >"${TEST_REPOSITORY}/fixture.txt" +git -C "$TEST_REPOSITORY" add fixture.txt +git -C "$TEST_REPOSITORY" commit -q -m first +commit_a="$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" + +printf 'second\n' >>"${TEST_REPOSITORY}/fixture.txt" +git -C "$TEST_REPOSITORY" commit -q -am second +commit_b="$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" + +git -C "$TEST_REPOSITORY" switch -q --detach "$commit_a" +printf 'alternate\n' >"${TEST_REPOSITORY}/alternate.txt" +git -C "$TEST_REPOSITORY" add alternate.txt +git -C "$TEST_REPOSITORY" commit -q -m alternate +commit_c="$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" + +git -C "$TEST_REPOSITORY" update-ref refs/tapm/a "$commit_a" +git -C "$TEST_REPOSITORY" update-ref refs/tapm/b "$commit_b" +git -C "$TEST_REPOSITORY" update-ref refs/tapm/c "$commit_c" + +assert_equal current \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/a refs/tapm/a)" \ + "equal commits" +assert_equal behind \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/a refs/tapm/b)" \ + "local commit behind remote" +assert_equal ahead \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/b refs/tapm/a)" \ + "local commit ahead of remote" +assert_equal diverged \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/b refs/tapm/c)" \ + "diverged commits" + +git -C "$TEST_REPOSITORY" switch -q -C main "$commit_a" +git -C "$TEST_REPOSITORY" update-ref refs/remotes/origin/main "$commit_a" +assert_equal current \ + "$(TAPM_GIT_BRANCH_STATE "$TEST_REPOSITORY" main)" \ + "clean current branch" + +printf 'untracked\n' >"${TEST_REPOSITORY}/untracked.txt" +assert_equal dirty \ + "$(TAPM_GIT_BRANCH_STATE "$TEST_REPOSITORY" main)" \ + "dirty working tree" +rm -f -- "${TEST_REPOSITORY}/untracked.txt" + +git -C "$TEST_REPOSITORY" switch -q -c feature +assert_equal wrong-branch \ + "$(TAPM_GIT_BRANCH_STATE "$TEST_REPOSITORY" main)" \ + "wrong checked-out branch" + +git -C "$TEST_REPOSITORY" switch -q main +git -C "$TEST_REPOSITORY" update-ref refs/remotes/origin/main "$commit_b" +assert_success "fast-forward merge" \ + TAPM_GIT_FAST_FORWARD "$TEST_REPOSITORY" main +assert_equal "$commit_b" \ + "$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" \ + "fast-forward destination" + +finish_tests diff --git a/tests/test-ha-status.sh b/tests/test-ha-status.sh new file mode 100644 index 0000000..bec801c --- /dev/null +++ b/tests/test-ha-status.sh @@ -0,0 +1,32 @@ +#!/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/ha-status.inc" + +STATUS_FILE="$(mktemp /tmp/tapm-ha-status.XXXXXX)" +cleanup_ha_status_test() { + [[ "$STATUS_FILE" == /tmp/tapm-ha-status.* ]] && rm -f -- "$STATUS_FILE" +} +trap cleanup_ha_status_test EXIT + +printf '%s\n' \ + '{"node_status":{"pve1":"online","pve2":"maintenance mode"}}' \ + >"$STATUS_FILE" +assert_success "maintenance node detected in HA manager state" \ + TAPM_HA_NODE_IN_MAINTENANCE pve2 "$STATUS_FILE" +assert_failure "active node is not reported as maintenance" \ + TAPM_HA_NODE_IN_MAINTENANCE pve1 "$STATUS_FILE" + +printf '%s\n' \ + '{"nodes":[{"node":"pve3","state":"maintenance"}]}' \ + >"$STATUS_FILE" +assert_success "structured HA node state detected" \ + TAPM_HA_NODE_IN_MAINTENANCE pve3 "$STATUS_FILE" + +printf '%s\n' 'not-json' >"$STATUS_FILE" +assert_failure "malformed HA state is rejected" \ + TAPM_HA_NODE_IN_MAINTENANCE pve1 "$STATUS_FILE" + +finish_tests diff --git a/tests/test-header-info.sh b/tests/test-header-info.sh new file mode 100644 index 0000000..585b853 --- /dev/null +++ b/tests/test-header-info.sh @@ -0,0 +1,44 @@ +#!/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 + +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/header-info.inc" + +pveversion() { + printf '%s\n' 'pve-manager/9.2.4/5e5ae681198514d4 (running kernel: 7.0.14-5-pve)' +} + +cat >"${test_dir}/corosync.conf" <<'EOF' +totem { + version: 2 + cluster_name: production-cluster +} +EOF + +TAPM_LOAD_HEADER_INFO "${test_dir}/corosync.conf" +assert_equal '9.2.4' "$TAPM_HEADER_PVE_VERSION" \ + "header extracts the concise Proxmox VE version" +assert_equal 'production-cluster' "$TAPM_HEADER_CLUSTER" \ + "header reads the local cluster name" + +pveversion() { + printf '%s\n' 'pve-manager/10.0.0/changed' +} +TAPM_LOAD_HEADER_INFO "${test_dir}/corosync.conf" +assert_equal '9.2.4' "$TAPM_HEADER_PVE_VERSION" \ + "header information is cached between menu redraws" + +TAPM_HEADER_INFO_LOADED=0 +TAPM_HEADER_PVE_VERSION='Unavailable' +TAPM_HEADER_CLUSTER='Standalone' +TAPM_LOAD_HEADER_INFO "${test_dir}/missing.conf" +assert_equal '10.0.0' "$TAPM_HEADER_PVE_VERSION" \ + "header refresh reads the current Proxmox VE version" +assert_equal 'Standalone' "$TAPM_HEADER_CLUSTER" \ + "host without corosync configuration is shown as standalone" + +finish_tests diff --git a/tests/test-iso-nfs.sh b/tests/test-iso-nfs.sh new file mode 100755 index 0000000..924761c --- /dev/null +++ b/tests/test-iso-nfs.sh @@ -0,0 +1,75 @@ +#!/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/deploy-iso-nfs-lxc.sh" + +assert_success "LXC creation uses the compatible cpuunits option" \ + grep -q -- '--cpuunits 100' "${TEST_ROOT}/inc/deploy-iso-nfs-lxc.sh" +assert_failure "misspelled cpunits option is absent" \ + grep -q -- '--cpunits' "${TEST_ROOT}/inc/deploy-iso-nfs-lxc.sh" + +assert_success "valid storage ID" TAPM_ISO_NFS_VALID_ID PVE-Shared-Storage +assert_failure "storage ID cannot start with a number" TAPM_ISO_NFS_VALID_ID 1-storage +assert_failure "storage ID rejects spaces" TAPM_ISO_NFS_VALID_ID 'shared storage' + +assert_success "valid CTID" TAPM_ISO_NFS_VALID_CTID 210 +assert_failure "reserved two-digit CTID" TAPM_ISO_NFS_VALID_CTID 99 +assert_failure "CTID rejects text" TAPM_ISO_NFS_VALID_CTID ct210 + +assert_success "valid hostname" TAPM_ISO_NFS_VALID_HOSTNAME PVE-Shared-Storage +assert_success "valid FQDN hostname" TAPM_ISO_NFS_VALID_HOSTNAME iso-nfs.example.net +assert_failure "hostname rejects spaces" TAPM_ISO_NFS_VALID_HOSTNAME 'ISO Storage' + +assert_success "valid IPv4 CIDR" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.45/16 +assert_failure "IPv4 octet out of range" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.256/24 +assert_failure "IPv4 prefix out of range" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.45/33 +assert_failure "IPv4 prefix required" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.45 + +pvesm() { + [[ "${1:-}" == status ]] || return 1 + printf '%s\n' \ + 'Name Type Status Total Used Available %' \ + 'local-lvm lvmthin active 100 10 90 10%' \ + 'iSCSI-Datastore1 lvm active 200 20 180 10%' \ + 'iSCSI-Datastore1-2 lvm active 200 20 180 10%' \ + 'offline-store dir inactive 100 0 100 0%' +} + +SELECT_MENU() { + local title="$1" + + assert_equal "Root filesystem storage" "$title" "storage menu title" + assert_equal \ + 'iSCSI-Datastore1-2 (lvm) — default' \ + "${labels[0]}" \ + "default storage placed first" + assert_equal 3 "${#labels[@]}" "only active storages offered" + MENU_SELECTION="${values[0]}" +} + +test_default_storage_selection() { + local selected_storage='' + + TAPM_ISO_NFS_SELECT_STORAGE selected_storage \ + "Root filesystem storage" "iSCSI-Datastore1-2" || return 1 + assert_equal iSCSI-Datastore1-2 "$selected_storage" \ + "pressing Enter retains default storage" +} + +assert_success "default storage menu selection" test_default_storage_selection + +pveam() { + [[ "${1:-}" == list && "${2:-}" == local ]] || return 1 + printf '%s\n' \ + 'NAME VOLID FORMAT TYPE SIZE VMID' \ + 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst 0 0 0 0 0' \ + 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst 0 0 0 0 0' +} +assert_equal \ + 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst' \ + "$(TAPM_ISO_NFS_LOCAL_TEMPLATE local)" \ + "newest local Debian template avoids catalog refresh" + +finish_tests diff --git a/tests/test-post-install.sh b/tests/test-post-install.sh new file mode 100644 index 0000000..58f8cff --- /dev/null +++ b/tests/test-post-install.sh @@ -0,0 +1,173 @@ +#!/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/post-install.inc" + +assert_equal 9 \ + "$(printf '%s\n' 'pve-manager/9.0.3/0255bb4e9600f70c (running kernel: 6.14.8-2-pve)' | + sed -n 's/.*pve-manager\/\([0-9][0-9]*\).*/\1/p')" \ + "Proxmox major version extraction" + +POST_TEST_ROOT="$(mktemp -d /tmp/tapm-post-install.XXXXXX)" +TAPM_HOST_ROOT="$POST_TEST_ROOT" + +cleanup_post_install_tests() { + if [[ "$POST_TEST_ROOT" == /tmp/tapm-post-install.* && + -d "$POST_TEST_ROOT" ]]; then + rm -rf -- "$POST_TEST_ROOT" + fi +} +trap cleanup_post_install_tests EXIT + +mkdir -p \ + "${POST_TEST_ROOT}/etc/apt/apt.conf.d" \ + "${POST_TEST_ROOT}/etc/systemd" \ + "${POST_TEST_ROOT}/etc/logrotate.d" \ + "${POST_TEST_ROOT}/etc/sysctl.d" \ + "${POST_TEST_ROOT}/etc/network" \ + "${POST_TEST_ROOT}/etc" \ + "${POST_TEST_ROOT}/bin" + +printf 'Acquire::Languages "none";\n' \ + >"${POST_TEST_ROOT}/etc/apt/apt.conf.d/99-disable-translations" +assert_success "ProxMenux APT language setting recognized" \ + TAPM_POST_EXACT_APT_LANGUAGES + +printf 'Acquire::Languages "en";\n' \ + >"${POST_TEST_ROOT}/etc/apt/apt.conf.d/99-disable-translations" +assert_failure "custom APT language setting preserved" \ + TAPM_POST_EXACT_APT_LANGUAGES + +cat >"${POST_TEST_ROOT}/etc/systemd/journald.conf" <<'EOF' +[Journal] +Storage=persistent +Seal=no +SystemMaxUse=64M +RuntimeMaxUse=60M +MaxLevelStore=info +EOF +assert_success "ProxMenux journald replacement recognized" \ + TAPM_POST_PROXMENUX_JOURNALD + +cat >"${POST_TEST_ROOT}/etc/logrotate.conf" <<'EOF' +# ProxMenux optimized configuration (Log2RAM-friendly) +daily +rotate 7 +size 10M +copytruncate +include /etc/logrotate.d +EOF +assert_success "ProxMenux logrotate replacement recognized" \ + TAPM_POST_PROXMENUX_LOGROTATE + +cat >"${POST_TEST_ROOT}/bin/gzip" <<'EOF' +#!/bin/sh +GZIP="-1" +exec /usr/bin/pigz "$@" +EOF +cat >"${POST_TEST_ROOT}/bin/gzip.original" <<'EOF' +#!/bin/sh +if [ "${1:-}" = "--version" ]; then + printf 'gzip 1.13\n' +else + cat +fi +EOF +cp "${POST_TEST_ROOT}/bin/gzip" "${POST_TEST_ROOT}/bin/pigzwrapper" +chmod 0755 \ + "${POST_TEST_ROOT}/bin/gzip" \ + "${POST_TEST_ROOT}/bin/gzip.original" \ + "${POST_TEST_ROOT}/bin/pigzwrapper" +assert_success "ProxMenux gzip wrapper recognized" \ + TAPM_POST_PROXMENUX_GZIP_WRAPPER +assert_success "ProxMenux gzip wrapper repaired from valid original" \ + TAPM_POST_REPAIR_GZIP +assert_failure "repaired gzip is no longer recognized as wrapper" \ + TAPM_POST_PROXMENUX_GZIP_WRAPPER +assert_failure "obsolete gzip original removed after verification" \ + test -e "${POST_TEST_ROOT}/bin/gzip.original" + +assert_success "kernel panic profile written" TAPM_POST_CONFIGURE_PANIC +assert_success "inotify profile written" TAPM_POST_CONFIGURE_LIMITS +assert_success "memory profile written" TAPM_POST_CONFIGURE_MEMORY +assert_success "network profile written" TAPM_POST_CONFIGURE_NETWORK +assert_success "BBR profile written" TAPM_POST_CONFIGURE_BBR +assert_equal 30 \ + "$(awk '/kernel.panic =/ {print $3}' \ + "${POST_TEST_ROOT}/etc/sysctl.d/99-ta-proxmenu-kernel-panic.conf")" \ + "kernel panic delay" +assert_equal 524288 \ + "$(awk '/max_user_watches/ {print $3}' \ + "${POST_TEST_ROOT}/etc/sysctl.d/99-ta-proxmenu-limits.conf")" \ + "inotify watch limit" +assert_equal 10 \ + "$(awk '/vm.swappiness/ {print $3}' \ + "${POST_TEST_ROOT}/etc/sysctl.d/99-ta-proxmenu-memory.conf")" \ + "host swappiness" + +cat >"${POST_TEST_ROOT}/etc/vzdump.conf" <<'EOF' +# existing setting +bwlimit: 50000 +pigz: 4 +pigz: 8 +EOF +assert_success "vzdump key replacement" \ + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf pigz 1 +assert_equal 1 \ + "$(awk -F: '/^pigz:/ {gsub(/[[:space:]]/, "", $2); print $2}' \ + "${POST_TEST_ROOT}/etc/vzdump.conf")" \ + "vzdump pigz setting is unique" +assert_success "vzdump key removal" \ + TAPM_POST_REMOVE_COLON_KEY /etc/vzdump.conf bwlimit +assert_failure "vzdump bandwidth setting removed" \ + grep -q '^bwlimit:' "${POST_TEST_ROOT}/etc/vzdump.conf" + +printf 'deb should-be-backed-up\n' \ + >"${POST_TEST_ROOT}/etc/apt/sources.list" +TAPM_POST_BACKUP_BASE='/var/backups/ta-proxmenu/post-install' +test_post_backup() { + TAPM_POST_BACKUP >/dev/null +} +assert_success "host configuration backup created" test_post_backup +printf 'changed\n' >"${POST_TEST_ROOT}/etc/apt/sources.list" +assert_success "host configuration backup restored" \ + TAPM_POST_RESTORE_BACKUP "$TAPM_POST_LAST_BACKUP" +assert_equal 'deb should-be-backed-up' \ + "$(cat "${POST_TEST_ROOT}/etc/apt/sources.list")" \ + "restored APT sources" + +assert_success "APT source layout enforced in fixture root" \ + TAPM_POST_ENSURE_APT_LAYOUT +assert_equal 0 \ + "$(wc -c <"${POST_TEST_ROOT}/etc/apt/sources.list" | tr -d ' ')" \ + "main APT sources list is empty" + +mkdir -p \ + "${POST_TEST_ROOT}/etc/security/limits.d" \ + "${POST_TEST_ROOT}/usr/local/share/proxmenux" \ + "${POST_TEST_ROOT}/usr/local/bin" +cat >"${POST_TEST_ROOT}/etc/sysctl.d/99-network.conf" <<'EOF' +# Custom administrator network profile +net.ipv4.ip_forward = 1 +EOF +cat >"${POST_TEST_ROOT}/etc/security/limits.d/99-limits.conf" <<'EOF' +# ProxMenux configuration +* soft nofile 1048576 +EOF +cat >"${POST_TEST_ROOT}/usr/local/bin/menu" <<'EOF' +#!/bin/sh +exec /usr/local/share/proxmenux/menu.sh +EOF +assert_success "recognized ProxMenux settings cleaned" \ + TAPM_POST_CLEAN_PROXMENUX_SETTINGS +assert_success "custom similarly named network profile retained" \ + test -f "${POST_TEST_ROOT}/etc/sysctl.d/99-network.conf" +assert_failure "recognized ProxMenux limits profile removed" \ + test -e "${POST_TEST_ROOT}/etc/security/limits.d/99-limits.conf" +assert_success "ProxMenux application removed" TAPM_POST_REMOVE_PROXMENUX_APP +assert_failure "ProxMenux menu launcher removed" \ + test -e "${POST_TEST_ROOT}/usr/local/bin/menu" + +finish_tests diff --git a/tests/test-pulse-standalone.sh b/tests/test-pulse-standalone.sh new file mode 100755 index 0000000..d699255 --- /dev/null +++ b/tests/test-pulse-standalone.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +TAPM_PULSE_STANDALONE_NO_MAIN=1 +source "${TEST_ROOT}/install-pulse.sh" + +assert_success "default V2 source branch accepted" \ + TAPM_PULSE_VALID_SOURCE_BRANCH V2 +assert_success "nested release branch accepted" \ + TAPM_PULSE_VALID_SOURCE_BRANCH releases/pulse-6 +assert_failure "empty source branch rejected" \ + TAPM_PULSE_VALID_SOURCE_BRANCH '' +assert_failure "source branch traversal rejected" \ + TAPM_PULSE_VALID_SOURCE_BRANCH '../main' +assert_success "HTTPS source base accepted" \ + TAPM_PULSE_VALID_SOURCE_BASE \ + https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2 +assert_failure "HTTP source base rejected" \ + TAPM_PULSE_VALID_SOURCE_BASE \ + http://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2 + +finish_tests diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh new file mode 100644 index 0000000..df18b83 --- /dev/null +++ b/tests/test-pulse.sh @@ -0,0 +1,217 @@ +#!/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/deploy-pulse-lxc.sh" + +assert_success "stable Pulse release" TAPM_PULSE_VALID_RELEASE v6.1.1 +assert_success "prerelease Pulse release" TAPM_PULSE_VALID_RELEASE v6.2.0-rc.1 +assert_failure "Pulse release requires v prefix" TAPM_PULSE_VALID_RELEASE 6.1.1 +assert_failure "Pulse release rejects URL content" \ + TAPM_PULSE_VALID_RELEASE 'v6.1.1/../../latest' + +assert_equal amd64 "$(TAPM_PULSE_ARCH x86_64)" "x86 architecture mapping" +assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping" +assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64 + +assert_equal 2 "$(TAPM_PULSE_BACKOFF_NEXT 1 8)" "Pulse backoff doubles" +assert_equal 8 "$(TAPM_PULSE_BACKOFF_NEXT 4 8)" "Pulse backoff reaches cap" +assert_equal 8 "$(TAPM_PULSE_BACKOFF_NEXT 8 8)" "Pulse backoff remains capped" + +test_bridge_selection_assignment() { + local bridge='' + + TAPM_PULSE_SET_BRIDGE_FROM_SELECTION bridge 'bridge:vmbr0' || return 1 + assert_equal vmbr0 "$bridge" "selected Pulse bridge assigned to caller" +} + +assert_success "Pulse bridge selection assignment" test_bridge_selection_assignment +assert_failure "empty Pulse bridge selection rejected" \ + TAPM_PULSE_SET_BRIDGE_FROM_SELECTION selected_bridge 'bridge:' + +bootstrap_token='0123456789abcdef0123456789abcdef0123456789abcdef' +bootstrap_output="║ Token: ${bootstrap_token} ║" +assert_equal "$bootstrap_token" \ + "$(TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT "$bootstrap_output")" \ + "Pulse bootstrap token parsed" +assert_failure "malformed Pulse bootstrap output rejected" \ + TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT 'Token: not-a-token' + +agent_token='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +assert_equal "$agent_token" \ + "$(TAPM_PULSE_TOKEN_FROM_RESPONSE "{\"token\":\"${agent_token}\"}")" \ + "Pulse agent token parsed" +assert_failure "missing Pulse agent token rejected" \ + TAPM_PULSE_TOKEN_FROM_RESPONSE '{"command":"install"}' +assert_failure "Pulse agent token containing whitespace rejected" \ + TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"invalid token"}' +assert_failure "non-hex Pulse agent token rejected" \ + TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"not-a-token"}' +assert_success "registered Pulse agent response accepted" \ + TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE \ + '{"agent":{"id":"agent-123","hostname":"pve1"}}' +assert_failure "missing Pulse agent ID rejected" \ + TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE '{"agent":{"hostname":"pve1"}}' +assert_equal '{"type":"pve","enableCommands":true}' \ + "$(TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON)" \ + "Pulse PVE unified-agent enrollment requested with commands enabled" + +setup_token='0123456789abcdef0123456789abcdef' +setup_host='https://pve1.example.test:8006' +setup_old_url='https://pulse.example.test/api/setup-script?backup_perms=true' +setup_response="$( + SETUP_TOKEN="$setup_token" SETUP_HOST="$setup_host" SETUP_OLD_URL="$setup_old_url" \ + python3 -c ' +import json, os, sys, time +old_url = os.environ["SETUP_OLD_URL"] +token = os.environ["SETUP_TOKEN"] +json.dump({ + "type": "pve", + "host": os.environ["SETUP_HOST"], + "url": old_url, + "downloadURL": old_url + "&setup_token=" + token, + "scriptFileName": "pulse-setup-pve.sh", + "command": old_url + " PULSE_SETUP_TOKEN=" + token + " if", + "commandWithEnv": old_url + " PULSE_SETUP_TOKEN=" + token + " if", + "commandWithoutEnv": old_url + " if", + "expires": int(time.time()) + 300, + "setupToken": token, + "tokenHint": token[:3] + "..." + token[-3:], +}, sys.stdout) +' +)" +normalized_setup="$( + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + "$setup_response" 'http://10.10.2.30:7655' +)" +assert_equal "$setup_token" \ + "$(SETUP_RESPONSE="$normalized_setup" python3 -c 'import json, os; print(json.loads(os.environ["SETUP_RESPONSE"])["setupToken"])')" \ + "Pulse v6.1.1 compatibility preserves server-issued setup token" +assert_equal \ + 'http://10.10.2.30:7655/api/setup-script?host=https%3A%2F%2Fpve1.example.test%3A8006&pulse_url=http%3A%2F%2F10.10.2.30%3A7655&type=pve' \ + "$(SETUP_RESPONSE="$normalized_setup" python3 -c 'import json, os; print(json.loads(os.environ["SETUP_RESPONSE"])["url"])')" \ + "Pulse v6.1.1 compatibility normalizes setup artifact URL" +assert_failure "Pulse v6.1.1 compatibility rejects missing setup token" \ + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + '{"type":"pve","host":"https://pve1:8006"}' 'http://10.10.2.30:7655' + +test_pve_token_output_assignment() { + local token_output='' + local token_status=99 + + pveum() { + printf '%s\n' \ + '{"full-tokenid":"pulse-monitor@pve!pulse-test","value":"secret-value"}' + } + TAPM_PULSE_CREATE_PVE_AUTO_REGISTER_TOKEN \ + 'pulse-test' token_output token_status || return 1 + unset -f pveum + + assert_equal \ + '{"full-tokenid":"pulse-monitor@pve!pulse-test","value":"secret-value"}' \ + "$token_output" \ + "Pulse receives pveum token output" + assert_equal 0 "$token_status" "Pulse receives pveum token status" +} + +assert_success "Pulse v6.1.1 pveum output-shadowing workaround" \ + test_pve_token_output_assignment + +nodes_json='[ + {"node":"pve3","status":"offline"}, + {"node":"pve2","status":"online"}, + {"node":"pve1","status":"online"} +]' +assert_equal $'pve1\npve2' \ + "$(TAPM_PULSE_ONLINE_NODES_FROM_JSON "$nodes_json")" \ + "online Pulse nodes selected and sorted" +assert_equal 'pve3' \ + "$(TAPM_PULSE_OFFLINE_NODES_FROM_JSON "$nodes_json")" \ + "offline Pulse nodes reported" + +assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210 +assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 +ctid_resources='[ + {"type":"qemu","vmid":200}, + {"type":"lxc","vmid":"201"}, + {"type":"storage","storage":"local"} +]' +assert_equal 202 \ + "$(TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES "$ctid_resources" 200)" \ + "first available Pulse CTID starts at 200 and skips occupied IDs" +assert_equal 200 \ + "$(TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES '[]' 200)" \ + "Pulse CTID 200 selected when available" +assert_failure "invalid Pulse CTID resource data rejected" \ + TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES '{}' 200 +assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor +assert_failure "invalid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME 'pulse monitor' +assert_success "valid positive integer" TAPM_PULSE_VALID_POSITIVE_INTEGER 1024 +assert_failure "zero is not positive" TAPM_PULSE_VALID_POSITIVE_INTEGER 0 +assert_success "zero is a valid nonnegative integer" \ + TAPM_PULSE_VALID_NONNEGATIVE_INTEGER 0 +assert_failure "negative integer rejected" TAPM_PULSE_VALID_NONNEGATIVE_INTEGER -1 +assert_success "valid Pulse port" TAPM_PULSE_VALID_PORT 7655 +assert_failure "Pulse port too high" TAPM_PULSE_VALID_PORT 65536 +assert_success "valid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.50/24 +assert_failure "invalid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.500/24 +assert_success "valid Pulse gateway" TAPM_PULSE_VALID_IPV4 10.10.0.1 +assert_failure "gateway CIDR rejected" TAPM_PULSE_VALID_IPV4 10.10.0.1/16 +assert_success "blank optional Pulse address accepted" \ + TAPM_PULSE_VALID_OPTIONAL_IPV4_CIDR '' +assert_success "valid Pulse VLAN" TAPM_PULSE_VALID_OPTIONAL_VLAN 4094 +assert_failure "Pulse VLAN zero rejected" TAPM_PULSE_VALID_OPTIONAL_VLAN 0 +assert_success "blank Pulse VLAN accepted" TAPM_PULSE_VALID_OPTIONAL_VLAN '' + +assert_success "12-character Pulse admin password accepted" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD '123456789012' +assert_success "72-byte Pulse admin password accepted" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD \ + '123456789012345678901234567890123456789012345678901234567890123456789012' +assert_failure "short Pulse admin password rejected" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD '12345678901' +assert_failure "multibyte Pulse admin password still requires 12 characters" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD 'éééééé' +assert_failure "Pulse admin password above bcrypt limit rejected" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD \ + '1234567890123456789012345678901234567890123456789012345678901234567890123' + +resources='[ + {"type":"lxc","name":"pulse-a","tags":"tapm;pulse"}, + {"type":"qemu","name":"unrelated"} +]' +assert_success "tagged Pulse LXC detected" TAPM_PULSE_RESOURCE_INSTALLED "$resources" +assert_success "legacy Pulse hostname detected" \ + TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"lxc","name":"Pulse"}]' +assert_success "untagged default Pulse hostname detected" \ + TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"lxc","name":"Pulse-Monitor"}]' +assert_failure "unrelated resource not detected" \ + TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"qemu","name":"pulse"}]' + +test_pulse_config_detection() { + local fixture_root status + + fixture_root="$(mktemp -d /tmp/tapm-pulse-configs.XXXXXX)" || return 1 + mkdir -p "${fixture_root}/pve1/lxc" || return 1 + printf '%s\n' \ + 'arch: amd64' \ + 'hostname: Pulse-Monitor' \ + 'memory: 2048' >"${fixture_root}/pve1/lxc/200.conf" + TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS "$fixture_root" + status=$? + rm -rf -- "$fixture_root" + return "$status" +} + +assert_success "Pulse LXC detected from shared Proxmox configuration" \ + test_pulse_config_detection +assert_failure "missing Pulse configuration directory is not installed" \ + TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS /does/not/exist + +ha_status=$'quorum OK\nmaster pve1 (active, Sat Jul 25 12:00:00 2026)\nlrm pve1 (active, Sat Jul 25 12:00:00 2026)' +assert_success "active Proxmox HA detected" TAPM_PULSE_HA_STATUS_ENABLED "$ha_status" +assert_failure "inactive Proxmox HA rejected" \ + TAPM_PULSE_HA_STATUS_ENABLED $'quorum OK\nmaster pve1 (idle)' + +finish_tests diff --git a/tests/test-rmm.sh b/tests/test-rmm.sh new file mode 100644 index 0000000..020616a --- /dev/null +++ b/tests/test-rmm.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=../inc/rmm.inc +source "${TEST_ROOT}/inc/rmm.inc" + +example_token='a81581f5-2e8c-465a-9eff-a094a75d5bbf' +example_url="https://prod.setup.itsupport247.net/linux/BareboneAgent/64/TA_Green_Bay-Technology_Arch_Corporate_Linux_Server_ITSPlatform_TKN${example_token}/RUN/setup" + +actual_token="$(TAPM_RMM_TOKEN_FROM_URL "$example_url")" +if [[ "$actual_token" != "$example_token" ]]; then + printf 'FAIL: extracted %q, want %q\n' "$actual_token" "$example_token" >&2 + exit 1 +fi + +if TAPM_RMM_TOKEN_FROM_URL 'https://prod.setup.itsupport247.net/linux/setup' >/dev/null; then + printf 'FAIL: accepted an RMM URL without a token\n' >&2 + exit 1 +fi + +if TAPM_RMM_TOKEN_FROM_URL \ + 'https://prod.setup.itsupport247.net/TKNnot-a-token/RUN/setup' >/dev/null; then + printf 'FAIL: accepted a malformed RMM token\n' >&2 + exit 1 +fi + +printf 'PASS: RMM token extraction\n' diff --git a/tests/test-runtime-config.sh b/tests/test-runtime-config.sh new file mode 100644 index 0000000..4a1f36c --- /dev/null +++ b/tests/test-runtime-config.sh @@ -0,0 +1,45 @@ +#!/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_CONFIG_FILE="${test_dir}/etc/config.env" +TAPM_CONFIG_TEST_STDIN=1 +source "${TEST_ROOT}/inc/runtime-config.inc" + +if ! printf '%s\n%s\n' \ + 'https://tapm.example.com' \ + 'git.example.com' | + TAPM_ENSURE_RUNTIME_CONFIG >/dev/null; then + printf 'FAIL: configuration wizard failed\n' >&2 + exit 1 +fi + +expected=$'TAPM_BROKER_URL=https://tapm.example.com\nGITEA_DOMAIN=git.example.com' +actual="$(cat "$TAPM_CONFIG_FILE")" +if [[ "$actual" != "$expected" ]]; then + printf 'FAIL: unexpected configuration contents\n' >&2 + exit 1 +fi +if stat -c '%a' "$TAPM_CONFIG_FILE" >/dev/null 2>&1; then + config_mode="$(stat -c '%a' "$TAPM_CONFIG_FILE")" +else + config_mode="$(stat -f '%Lp' "$TAPM_CONFIG_FILE")" +fi +if [[ "$config_mode" != 600 ]]; then + printf 'FAIL: configuration mode is not 600\n' >&2 + exit 1 +fi + +unset TAPM_BROKER_URL GITEA_DOMAIN GITEA_URL +TAPM_LOAD_RUNTIME_CONFIG +if [[ "$TAPM_BROKER_URL" != 'https://tapm.example.com' || + "$GITEA_DOMAIN" != 'git.example.com' || + "$GITEA_URL" != 'https://git.example.com' ]]; then + printf 'FAIL: saved configuration did not reload\n' >&2 + exit 1 +fi + +printf 'PASS: runtime configuration wizard\n' diff --git a/tests/test-secure-input.sh b/tests/test-secure-input.sh new file mode 100644 index 0000000..a4b1b6d --- /dev/null +++ b/tests/test-secure-input.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=../inc/secure-input.inc +source "${TEST_ROOT}/inc/secure-input.inc" + +masked_value='' +output_file="$(mktemp)" +trap 'rm -f "$output_file"' EXIT + +TAPM_READ_MASKED masked_value >"$output_file" <<'EOF' +TAPM-secret +EOF + +if [[ "$masked_value" != 'TAPM-secret' ]]; then + printf 'FAIL: masked input did not preserve the entered value\n' >&2 + exit 1 +fi +if [[ "$(cat "$output_file")" != '***********' ]]; then + printf 'FAIL: masked input did not display one marker per character\n' >&2 + exit 1 +fi + +printf 'PASS: masked secret input\n' diff --git a/tests/test-self-contained-runtime.sh b/tests/test-self-contained-runtime.sh new file mode 100755 index 0000000..023663c --- /dev/null +++ b/tests/test-self-contained-runtime.sh @@ -0,0 +1,22 @@ +#!/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}/colors.inc" + +assert_equal '\e[96m' "${idsCL[LightCyan]}" \ + "bundled foreground colors are available" +assert_equal '\e[103m' "${idsBG[LightYellow]}" \ + "bundled background colors are available" +assert_equal '\e[1m' "${idsST[Bold]}" \ + "bundled text styles are available" +assert_success "EXIT1 is bundled in TA-ProxMenu defaults" \ + grep -q '^EXIT1()' "${TEST_ROOT}/defaults.inc" +assert_success "ENTER2CONTINUE is bundled in TA-ProxMenu defaults" \ + grep -q '^ENTER2CONTINUE()' "${TEST_ROOT}/defaults.inc" +assert_failure "runtime launchers do not reference external iDS defaults" \ + grep -E -q '/opt/idssys/defaults|iDS-Defaults' \ + "${TEST_ROOT}/run.sh" "${TEST_ROOT}/proxmenu-scripts.sh" + +finish_tests diff --git a/tests/test-virtio.sh b/tests/test-virtio.sh new file mode 100755 index 0000000..d991261 --- /dev/null +++ b/tests/test-virtio.sh @@ -0,0 +1,43 @@ +#!/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/virtio-helpers.inc" + +assert_equal virtio-win.iso \ + "$(TAPM_VIRTIO_FILENAME_FROM_URL \ + 'https://fedorapeople.org/stable/virtio-win.iso?download=1')" \ + "stable source filename" +assert_equal virtio-win-0.1.285.iso \ + "$(TAPM_VIRTIO_FILENAME_FROM_URL \ + 'https://fedorapeople.org/archive/virtio-win-0.1.285.iso')" \ + "versioned source filename" +assert_failure "unexpected source filename rejected" \ + TAPM_VIRTIO_FILENAME_FROM_URL \ + 'https://example.invalid/virtio-win-latest.iso' + +assert_equal virtio-win-latest-0.1.285.iso \ + "$(TAPM_VIRTIO_LABELED_FILENAME virtio-win-0.1.285.iso latest)" \ + "stable local filename" +assert_equal virtio-win-server-2016-0.1.240.iso \ + "$(TAPM_VIRTIO_LABELED_FILENAME \ + virtio-win-0.1.240.iso server-2016)" \ + "Server 2016 local filename" +assert_equal virtio-win-server-2008r2-0.1.172.iso \ + "$(TAPM_VIRTIO_LABELED_FILENAME \ + virtio-win-0.1.172.iso server-2008r2)" \ + "Server 2008 R2 local filename" +assert_failure "unsafe local label rejected" \ + TAPM_VIRTIO_LABELED_FILENAME virtio-win-0.1.285.iso '../latest' + +assert_success "fresh VirtIO cache accepted" \ + TAPM_VIRTIO_CACHE_VALID 1000 1500 86400 virtio-win-0.1.285.iso +assert_failure "expired VirtIO cache rejected" \ + TAPM_VIRTIO_CACHE_VALID 1000 87400 86400 virtio-win-0.1.285.iso +assert_failure "future VirtIO cache rejected" \ + TAPM_VIRTIO_CACHE_VALID 2000 1000 86400 virtio-win-0.1.285.iso +assert_failure "unsafe VirtIO cache filename rejected" \ + TAPM_VIRTIO_CACHE_VALID 1000 1500 86400 ../../installer + +finish_tests diff --git a/tests/testlib.sh b/tests/testlib.sh new file mode 100644 index 0000000..a339cf8 --- /dev/null +++ b/tests/testlib.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +TEST_ASSERTIONS=0 +TEST_FAILURES=0 + +test_fail() { + printf 'FAIL: %s\n' "$1" >&2 + (( TEST_FAILURES += 1 )) +} + +assert_equal() { + local expected="$1" + local actual="$2" + local label="$3" + + (( TEST_ASSERTIONS += 1 )) + if [[ "$actual" != "$expected" ]]; then + test_fail "${label}: expected '${expected}', received '${actual}'" + fi +} + +assert_success() { + local label="$1" + shift + + (( TEST_ASSERTIONS += 1 )) + "$@" || test_fail "${label}: expected success" +} + +assert_failure() { + local label="$1" + shift + + (( TEST_ASSERTIONS += 1 )) + if "$@"; then + test_fail "${label}: expected failure" + fi +} + +finish_tests() { + if (( TEST_FAILURES > 0 )); then + printf '%d assertion(s), %d failure(s)\n' \ + "$TEST_ASSERTIONS" "$TEST_FAILURES" >&2 + return 1 + fi + + printf '%d assertion(s) passed\n' "$TEST_ASSERTIONS" +}