Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0bbfb3447 | |||
| 8bd60e2f52 | |||
| dd7a66e1e4 | |||
| 3bea3baa88 | |||
| 247c4ddace | |||
| c48e89af6c | |||
| 72f6be5b43 | |||
| 741e2ad9e8 | |||
| 8418cbed56 | |||
| eca9bd2be2 | |||
| 8f8002f33e | |||
| 9b2e704629 | |||
| 3ea33a17d8 | |||
| 8c73cb7a53 | |||
| dcccb8d837 | |||
| ebddd792b2 | |||
| 825a35924d | |||
| b3b092afa4 | |||
| 916916384f | |||
| ffa89f9911 | |||
| 4c6bd2320f | |||
| 53a6e91218 | |||
| aa0e74ae8c | |||
| c51e1ab1de | |||
| 0e209b0085 | |||
| 8754ba47d7 | |||
| 6156d315d8 | |||
| eb42068196 | |||
| 53504cd167 | |||
| baa7846a15 | |||
| 70f5c09cec | |||
| e0214033ec | |||
| 6b5cbe928d | |||
| eb88282cb1 | |||
| 55babe1ba2 | |||
| 854309c3a1 | |||
| d8d27e5966 | |||
| fd23ec525a | |||
| 2b8b2c8f6f | |||
| bb44ca81f7 | |||
| f1ed0bb12d | |||
| 747e984da6 | |||
| 76af8116da | |||
| 7102550f4e | |||
| f2da5b3062 | |||
| 67e109d923 | |||
| e12f5137bd | |||
| 7a2f968670 | |||
| 6bed7068c7 | |||
| 808546846c | |||
| 9cc1c64c64 | |||
| d360b571fb | |||
| d19400a732 | |||
| bd005495ce | |||
| b324d31273 | |||
| 601457f9d0 | |||
| 8696de1559 | |||
| 2f4f45f64f | |||
| dc1cf6a863 | |||
| 707cd24d8d | |||
| 377be371c5 | |||
| bb781e648c | |||
| 22da8f43d2 | |||
| bdd69fbfff | |||
| ec038e9088 | |||
| 376d029fc8 | |||
| cc83cce667 | |||
| cd01e190ef | |||
| f2a6bce8f8 | |||
| dc48f1a8af | |||
| f13ce07397 | |||
| f06f3d88e0 | |||
| 5cb4bf2902 | |||
| 89ceeaa324 | |||
| 7efe765854 |
@@ -1,8 +1,199 @@
|
||||
# TA-Proxmenu - Proxmox Scripts for TA Use
|
||||
# TA-ProxMenu
|
||||
|
||||
bash <(curl -sL https://go.scity.us/install-tapm)
|
||||
Interactive installation and configuration helpers used when deploying Proxmox
|
||||
VE environments.
|
||||
|
||||
To update an installed main-branch copy, switch it to V2, and immediately load
|
||||
the V2 menu:
|
||||
## Install
|
||||
|
||||
tapm V2
|
||||
Run as `root` on a Proxmox VE host:
|
||||
|
||||
```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
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+24
@@ -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
|
||||
}
|
||||
@@ -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>&-
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 ))
|
||||
}
|
||||
Executable
+255
@@ -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
|
||||
+2679
-342
File diff suppressed because it is too large
Load Diff
@@ -1,151 +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
|
||||
|
||||
SWITCH_TO_V2() {
|
||||
UPDATE_REPOSITORY() {
|
||||
local repository="$1"
|
||||
local branch="$2"
|
||||
local label="$3"
|
||||
local current_branch
|
||||
local state
|
||||
|
||||
if [[ ! -d "${repository}/.git" ]]; then
|
||||
echo -e "${idsCL[Red]}${label} 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]}${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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
SWITCH_TAPM_BRANCH() {
|
||||
local target_branch="$1"
|
||||
local repository='/opt/idssys/ta-proxmenu'
|
||||
local current_branch
|
||||
local head_commit
|
||||
local local_commit
|
||||
local main_commit
|
||||
local remote_commit
|
||||
local target_state=''
|
||||
|
||||
if ! git check-ref-format --branch "$target_branch" >/dev/null 2>&1; then
|
||||
echo -e "${idsCL[Red]}'${target_branch}' is not a valid Git branch name.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "${repository}/.git" ]]; then
|
||||
echo -e "${idsCL[Red]}TA-ProxMenu is not a Git repository.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)"
|
||||
if [[ "$current_branch" == "V2" ]]; then
|
||||
exec /opt/idssys/ta-proxmenu/run.sh
|
||||
if [[ -z "$current_branch" ]]; then
|
||||
echo -e "${idsCL[Red]}TA-ProxMenu is in a detached HEAD state; branch switching was refused.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$(git -C "$repository" status --porcelain --untracked-files=normal)" ]]; then
|
||||
if [[ "$target_branch" == "$current_branch" ]]; then
|
||||
echo -e "${idsCL[Green]}TA-ProxMenu is already using '${target_branch}'.${idsCL[Default]}"
|
||||
return 0
|
||||
fi
|
||||
if TAPM_GIT_WORKTREE_DIRTY "$repository"; then
|
||||
echo -e "${idsCL[LightYellow]}TA-ProxMenu has local changes; branch switching was refused to preserve them.${idsCL[Default]}"
|
||||
git -C "$repository" status --short
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${idsCL[LightCyan]}Fetching TA-ProxMenu branch 'V2'...${idsCL[Default]}"
|
||||
if ! timeout 30 git -C "$repository" fetch --prune origin \
|
||||
'+refs/heads/main:refs/remotes/origin/main' \
|
||||
'+refs/heads/V2:refs/remotes/origin/V2' >/dev/null 2>&1; then
|
||||
echo -e "${idsCL[Red]}Could not fetch branch 'V2' from origin.${idsCL[Default]}"
|
||||
echo -e "${idsCL[LightCyan]}Fetching TA-ProxMenu branch '${target_branch}'...${idsCL[Default]}"
|
||||
if ! TAPM_GIT_FETCH_BRANCH "$repository" "$target_branch" 30 >/dev/null 2>&1; then
|
||||
echo -e "${idsCL[Red]}Could not fetch branch '${target_branch}' from origin.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$current_branch" ]]; then
|
||||
head_commit="$(git -C "$repository" rev-parse --verify 'HEAD^{commit}' 2>/dev/null)"
|
||||
main_commit="$(git -C "$repository" rev-parse --verify \
|
||||
'refs/remotes/origin/main^{commit}' 2>/dev/null)"
|
||||
if [[ -z "$head_commit" || "$head_commit" != "$main_commit" ]]; then
|
||||
echo -e "${idsCL[Red]}TA-ProxMenu has an unrecognized detached HEAD; branch switching was refused.${idsCL[Default]}"
|
||||
if git -C "$repository" show-ref --verify --quiet "refs/heads/${target_branch}"; then
|
||||
target_state="$(
|
||||
TAPM_GIT_RELATION "$repository" "refs/heads/${target_branch}" \
|
||||
"refs/remotes/origin/${target_branch}"
|
||||
)" || {
|
||||
echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
echo -e "${idsCL[LightCyan]}Recovered the legacy updater's detached main checkout.${idsCL[Default]}"
|
||||
fi
|
||||
}
|
||||
case "$target_state" in
|
||||
ahead)
|
||||
echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has commits not on origin; switching was refused.${idsCL[Default]}"
|
||||
return 1
|
||||
;;
|
||||
diverged)
|
||||
echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has diverged from origin; switching was refused.${idsCL[Default]}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
remote_commit="$(git -C "$repository" rev-parse --verify \
|
||||
'refs/remotes/origin/V2^{commit}' 2>/dev/null)" || {
|
||||
echo -e "${idsCL[Red]}Origin does not provide a usable V2 branch.${idsCL[Default]}"
|
||||
return 1
|
||||
}
|
||||
|
||||
if git -C "$repository" show-ref --verify --quiet refs/heads/V2; then
|
||||
local_commit="$(git -C "$repository" rev-parse --verify \
|
||||
'refs/heads/V2^{commit}' 2>/dev/null)" || return 1
|
||||
|
||||
if [[ "$local_commit" != "$remote_commit" ]] &&
|
||||
! git -C "$repository" merge-base --is-ancestor \
|
||||
"$local_commit" "$remote_commit"; then
|
||||
echo -e "${idsCL[LightYellow]}Local branch 'V2' has local-only or diverged commits; switching was refused.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
git -C "$repository" switch V2 >/dev/null || return 1
|
||||
if [[ "$local_commit" != "$remote_commit" ]] &&
|
||||
! git -C "$repository" merge --ff-only \
|
||||
refs/remotes/origin/V2 >/dev/null; then
|
||||
echo -e "${idsCL[Red]}Could not fast-forward V2; no commits were discarded.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
git -C "$repository" switch "$target_branch" >/dev/null || return 1
|
||||
else
|
||||
git -C "$repository" switch --create V2 \
|
||||
--track origin/V2 >/dev/null || return 1
|
||||
git -C "$repository" switch --create "$target_branch" \
|
||||
--track "origin/${target_branch}" >/dev/null || return 1
|
||||
fi
|
||||
|
||||
if [[ "$target_state" == "behind" ]] &&
|
||||
! TAPM_GIT_FAST_FORWARD "$repository" "$target_branch" >/dev/null; then
|
||||
echo -e "${idsCL[Red]}Could not fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true
|
||||
echo -e "${idsCL[Green]}TA-ProxMenu is now using branch 'V2'. Loading the V2 menu...${idsCL[Default]}"
|
||||
echo -e "${idsCL[Green]}TA-ProxMenu is now using branch '${target_branch}'. Loading its menu...${idsCL[Default]}"
|
||||
exec /opt/idssys/ta-proxmenu/run.sh
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "V2" ]]; then
|
||||
SWITCH_TO_V2
|
||||
exit $?
|
||||
fi
|
||||
INSTALL_LOCAL_UPDATES() {
|
||||
local current_branch
|
||||
local update_failed=0
|
||||
|
||||
if [[ "${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
|
||||
echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}"
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true
|
||||
|
||||
else
|
||||
echo -e "${idsCL[Red]}Could not connect to 'git.scity.us' for updates${idsCL[Default]}"
|
||||
fi
|
||||
fi
|
||||
if (( update_failed == 0 )); then
|
||||
source /opt/idssys/ta-proxmenu/defaults.inc
|
||||
echo -e "${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "${1}" != "tapm" ] && [ "${1}" != "update" ] && [ "${1}" != "u" ]; then
|
||||
/opt/idssys/ta-proxmenu/proxmenu-scripts.sh $1 $2 $3 $4
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
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
@@ -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'
|
||||
@@ -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
|
||||
@@ -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'
|
||||
Executable
+101
@@ -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
|
||||
@@ -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'
|
||||
Executable
+82
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+75
@@ -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
|
||||
@@ -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
|
||||
Executable
+24
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
Executable
+22
@@ -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
|
||||
Executable
+43
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user