75 Commits
v1 .. V2

Author SHA1 Message Date
David Schroeder c0bbfb3447 update 2026-07-29 17:22:18 -05:00
David Schroeder 8bd60e2f52 update 2026-07-29 17:17:56 -05:00
David Schroeder dd7a66e1e4 update 2026-07-28 22:26:29 -05:00
David Schroeder 3bea3baa88 update 2026-07-28 22:09:13 -05:00
David Schroeder 247c4ddace updateupdate 2026-07-28 21:17:41 -05:00
David Schroeder c48e89af6c update gui 2026-07-28 21:15:50 -05:00
David Schroeder 72f6be5b43 update 2026-07-28 21:12:05 -05:00
David Schroeder 741e2ad9e8 update 2026-07-28 20:41:57 -05:00
David Schroeder 8418cbed56 update 2026-07-28 20:14:57 -05:00
David Schroeder eca9bd2be2 update 2026-07-28 20:02:42 -05:00
David Schroeder 8f8002f33e update gir url 2026-07-28 19:18:02 -05:00
David Schroeder 9b2e704629 Update defaults.inc 2026-07-28 19:05:45 -05:00
David Schroeder 3ea33a17d8 update 2026-07-28 08:49:38 -05:00
David Schroeder 8c73cb7a53 fixed HA VM timeout during mm 2026-07-26 22:01:55 -05:00
David Schroeder dcccb8d837 fix lxc file nas 2026-07-26 20:05:08 -05:00
David Schroeder ebddd792b2 update security 2026-07-26 16:41:16 -05:00
David Schroeder 825a35924d update 2026-07-26 16:06:17 -05:00
David Schroeder b3b092afa4 update 2026-07-26 15:57:00 -05:00
David Schroeder 916916384f update 2026-07-26 15:45:45 -05:00
David Schroeder ffa89f9911 update 2026-07-26 15:37:11 -05:00
David Schroeder 4c6bd2320f update 2026-07-26 01:38:44 -05:00
David Schroeder 53a6e91218 update 2026-07-26 01:20:41 -05:00
David Schroeder aa0e74ae8c update to new post-install script 2026-07-26 01:15:01 -05:00
David Schroeder c51e1ab1de update 2026-07-25 23:57:17 -05:00
David Schroeder 0e209b0085 update 2026-07-25 23:55:40 -05:00
David Schroeder 8754ba47d7 update 2026-07-25 23:53:24 -05:00
David Schroeder 6156d315d8 update 2026-07-25 23:47:42 -05:00
David Schroeder eb42068196 update 2026-07-25 23:44:07 -05:00
David Schroeder 53504cd167 update 2026-07-25 23:41:34 -05:00
David Schroeder baa7846a15 update cpu checks 2026-07-25 23:38:23 -05:00
David Schroeder 70f5c09cec update 2026-07-25 23:29:44 -05:00
David Schroeder e0214033ec update pulse installer, removing host registration 2026-07-25 23:04:29 -05:00
David Schroeder 6b5cbe928d update 2026-07-25 22:45:52 -05:00
David Schroeder eb88282cb1 update 2026-07-25 22:26:10 -05:00
David Schroeder 55babe1ba2 update 2026-07-25 22:05:50 -05:00
David Schroeder 854309c3a1 update pulse installer 2026-07-25 22:02:07 -05:00
David Schroeder d8d27e5966 update pulse installer 2026-07-25 21:35:13 -05:00
David Schroeder fd23ec525a update 2026-07-25 21:26:23 -05:00
David Schroeder 2b8b2c8f6f update 2026-07-25 21:09:23 -05:00
David Schroeder bb44ca81f7 update pulse installer 2026-07-25 20:24:46 -05:00
David Schroeder f1ed0bb12d update 2026-07-25 20:19:28 -05:00
David Schroeder 747e984da6 update 2026-07-25 19:56:08 -05:00
David Schroeder 76af8116da update 2026-07-25 19:46:11 -05:00
David Schroeder 7102550f4e update 2026-07-25 19:35:17 -05:00
David Schroeder f2da5b3062 update 2026-07-25 19:17:09 -05:00
David Schroeder 67e109d923 update 2026-07-25 19:04:05 -05:00
David Schroeder e12f5137bd update 2026-07-25 18:54:17 -05:00
David Schroeder 7a2f968670 update 2026-07-25 18:52:23 -05:00
David Schroeder 6bed7068c7 update 2026-07-25 18:45:18 -05:00
David Schroeder 808546846c update 2026-07-25 18:35:35 -05:00
David Schroeder 9cc1c64c64 Update run.sh 2026-07-25 18:06:03 -05:00
David Schroeder d360b571fb update 2026-07-25 17:57:45 -05:00
David Schroeder d19400a732 update 2026-07-25 17:48:04 -05:00
David Schroeder bd005495ce update 2026-07-25 16:47:09 -05:00
David Schroeder b324d31273 update 2026-07-25 16:39:46 -05:00
David Schroeder 601457f9d0 update 2026-07-25 15:35:51 -05:00
David Schroeder 8696de1559 Update defaults.inc 2026-07-25 15:30:15 -05:00
David Schroeder 2f4f45f64f Update defaults.inc 2026-07-25 15:25:33 -05:00
David Schroeder dc1cf6a863 Update defaults.inc 2026-07-25 15:18:46 -05:00
David Schroeder 707cd24d8d Update defaults.inc 2026-07-25 15:14:27 -05:00
David Schroeder 377be371c5 Update defaults.inc 2026-07-25 15:08:44 -05:00
David Schroeder bb781e648c update 2026-07-25 14:51:42 -05:00
David Schroeder 22da8f43d2 adding deployment site 2026-07-25 13:12:16 -05:00
David Schroeder bdd69fbfff update 2026-07-25 12:08:57 -05:00
David Schroeder ec038e9088 update 2026-07-25 11:48:08 -05:00
David Schroeder 376d029fc8 update 2026-07-25 11:46:04 -05:00
David Schroeder cc83cce667 new menu 2026-07-25 11:30:41 -05:00
David Schroeder cd01e190ef update 2026-07-25 11:21:16 -05:00
David Schroeder f2a6bce8f8 update 2026-07-25 11:12:00 -05:00
David Schroeder dc48f1a8af setup new maintenance mode process 2026-07-25 11:07:14 -05:00
David Schroeder f13ce07397 update 2026-07-25 10:49:24 -05:00
David Schroeder f06f3d88e0 Update defaults.inc 2026-07-25 10:41:00 -05:00
David Schroeder 5cb4bf2902 Update run.sh 2026-07-25 10:40:14 -05:00
David Schroeder 89ceeaa324 Update defaults.inc 2026-07-25 10:37:16 -05:00
David Schroeder 7efe765854 v2.initial upload 2026-07-25 10:18:31 -05:00
40 changed files with 9329 additions and 417 deletions
+198 -2
View File
@@ -1,3 +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.
## Install
Run as `root` on a Proxmox VE host:
```bash
bash <(curl -fsSL https://go.scity.us/install-tapm)
```
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`.
On a clustered Proxmox host, both `tapm update` and the management-menu update
update every online cluster node over Proxmox's root SSH trust, then update the
initiating node. Each node independently performs the same clean-worktree,
branch, and fast-forward safety checks. Offline, unreachable, dirty, ahead, or
diverged nodes are never overwritten and are reported as failures. Standalone
hosts retain the local-only update behavior.
When testing this branch, use `tapm main` to switch the installed copy back to
the published main branch. The command refuses to switch when local changes,
local-only commits, or diverged branch history would be at risk.
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.
## Direct actions
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/taiadmin/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.
Executable
+50
View File
@@ -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"
+3
View File
@@ -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
+28 -16
View File
@@ -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.25-4'
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,5 +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
+138
View File
@@ -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
}
+96
View File
@@ -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
}
+316
View File
@@ -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
}
+1
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+964
View File
@@ -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
+227
View File
@@ -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 </proc/sys/kernel/random/uuid
elif command -v uuidgen >/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
}
+77
View File
@@ -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}"
}
+53
View File
@@ -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
}
+37
View File
@@ -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
}
+1054
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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
}
+111
View File
@@ -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>&-
}
+27
View File
@@ -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"
}
+39
View File
@@ -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 ))
}
+255
View File
@@ -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/taiadmin/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
+2680 -338
View File
File diff suppressed because it is too large Load Diff
+186 -61
View File
@@ -1,71 +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
if [[ "${noupdate}" != *" ${1} "* ]] && [[ "${noupdate}" != *" ${2} "* ]]; then
if curl -m 3 -s --head --request GET https://git.scity.us | grep "HTTP/2 200" > /dev/null; then
if [ "${1}" != "tapm" ]; then
echo -en "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}"
echo ""
udtd=0
fi
UPDATE_REPOSITORY() {
local repository="$1"
local branch="$2"
local label="$3"
local current_branch
local state
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
if [[ ! -d "${repository}/.git" ]]; then
echo -e "${idsCL[Red]}${label} is not a Git repository.${idsCL[Default]}"
return 1
fi
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
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 [[ "$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
else
echo -e "${idsCL[Red]}Could not connect to 'git.scity.us' for updates${idsCL[Default]}"
fi
fi
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
if [ "${1}" != "tapm" ] && [ "${1}" != "update" ] && [ "${1}" != "u" ]; then
/opt/idssys/ta-proxmenu/proxmenu-scripts.sh $1 $2 $3 $4
fi
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
;;
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
}
exit 0
SWITCH_TAPM_BRANCH() {
local target_branch="$1"
local repository='/opt/idssys/ta-proxmenu'
local current_branch
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 [[ -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 [[ "$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 '${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 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
}
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
git -C "$repository" switch "$target_branch" >/dev/null || return 1
else
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 '${target_branch}'. Loading its menu...${idsCL[Default]}"
exec /opt/idssys/ta-proxmenu/run.sh
}
INSTALL_LOCAL_UPDATES() {
local current_branch
local update_failed=0
echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}"
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
rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true
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
return 1
}
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
/opt/idssys/ta-proxmenu/proxmenu-scripts.sh \
"${1:-}" "${2:-}" "${3:-}" "${4:-}"
exit $?
Executable
+83
View File
@@ -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'
+79
View File
@@ -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
+47
View File
@@ -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'
+101
View File
@@ -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
+148
View File
@@ -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'
+82
View File
@@ -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
+32
View File
@@ -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
+44
View File
@@ -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
+75
View File
@@ -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
+173
View File
@@ -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
+24
View File
@@ -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/taiadmin/TA-ProxMenu/raw/branch/V2
assert_failure "HTTP source base rejected" \
TAPM_PULSE_VALID_SOURCE_BASE \
http://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2
finish_tests
+217
View File
@@ -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
+28
View File
@@ -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'
+45
View File
@@ -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'
+25
View File
@@ -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'
+22
View File
@@ -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
+43
View File
@@ -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
+48
View File
@@ -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"
}