update to V2

This commit is contained in:
David Schroeder
2026-08-12 21:50:50 -05:00
parent 873bb17b62
commit 72d61e5785
41 changed files with 9421 additions and 477 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# Cluster discovery and remote update helpers for TA-ProxMenu.
TAPM_COROSYNC_CONFIG="${TAPM_COROSYNC_CONFIG:-/etc/pve/corosync.conf}"
TAPM_CLUSTER_NODES_FROM_JSON() {
python3 -c '
import json, re, sys
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, OSError):
raise SystemExit(1)
if isinstance(payload, dict):
payload = payload.get("data", [])
if not isinstance(payload, list):
raise SystemExit(1)
valid_name = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$")
rows = []
for item in payload:
if not isinstance(item, dict):
continue
name = str(item.get("node", "")).strip()
status = str(item.get("status", "unknown")).strip().lower()
if valid_name.fullmatch(name):
rows.append((name, status))
for name, status in sorted(rows):
print(f"{name}\t{status}")
'
}
TAPM_CLUSTER_NODE_ROWS() {
local node_json
node_json="$(timeout 10 pvesh get /nodes --output-format json 2>/dev/null)" ||
return 1
printf '%s' "$node_json" | TAPM_CLUSTER_NODES_FROM_JSON
}
TAPM_LOCAL_CLUSTER_NODE() {
local local_link=''
local_link="$(readlink /etc/pve/local 2>/dev/null || true)"
if [[ -n "$local_link" ]]; then
basename "$local_link"
else
hostname -s
fi
}
TAPM_UPDATE_REMOTE_NODE() {
local node="$1"
[[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || return 1
timeout 240 ssh \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=2 \
"root@${node}" \
'/opt/idssys/ta-proxmenu/run.sh update --local-only'
}
INSTALL_CLUSTER_UPDATES() {
local index
local local_node
local node
local required_command
local status
local node_rows=''
local cluster_failed=0
local -a cluster_nodes=()
local -a cluster_statuses=()
if [[ ! -s "$TAPM_COROSYNC_CONFIG" ]]; then
INSTALL_LOCAL_UPDATES
return $?
fi
for required_command in pvesh python3 ssh timeout; do
if ! command -v "$required_command" >/dev/null 2>&1; then
echo -e "${idsCL[Red]}Cluster update requires ${required_command}; no nodes were updated.${idsCL[Default]}"
return 1
fi
done
node_rows="$(TAPM_CLUSTER_NODE_ROWS)" || {
echo -e "${idsCL[Red]}Could not retrieve the Proxmox cluster node list; no nodes were updated.${idsCL[Default]}"
return 1
}
while IFS=$'\t' read -r node status; do
[[ -n "$node" ]] || continue
cluster_nodes+=("$node")
cluster_statuses+=("$status")
done <<<"$node_rows"
if (( ${#cluster_nodes[@]} == 0 )); then
echo -e "${idsCL[Red]}The Proxmox API returned no cluster nodes; no nodes were updated.${idsCL[Default]}"
return 1
fi
local_node="$(TAPM_LOCAL_CLUSTER_NODE)"
echo -e "${idsCL[LightCyan]}Updating TA-ProxMenu across ${#cluster_nodes[@]} cluster node(s)...${idsCL[Default]}"
for index in "${!cluster_nodes[@]}"; do
node="${cluster_nodes[$index]}"
status="${cluster_statuses[$index]}"
if [[ "$node" == "$local_node" ]]; then
continue
fi
if [[ "$status" != "online" ]]; then
printf '\n'
echo -e "${idsCL[LightYellow]}Skipping ${node}: node status is ${status}.${idsCL[Default]}"
cluster_failed=1
continue
fi
printf '\n'
echo -e "${idsCL[LightCyan]}Updating remote node ${node}...${idsCL[Default]}"
if TAPM_UPDATE_REMOTE_NODE "$node"; then
echo -e "${idsCL[Green]}Remote node ${node} is updated.${idsCL[Default]}"
else
echo -e "${idsCL[Red]}Remote node ${node} failed to update.${idsCL[Default]}"
cluster_failed=1
fi
done
printf '\n'
echo -e "${idsCL[LightCyan]}Updating local node ${local_node}...${idsCL[Default]}"
if INSTALL_LOCAL_UPDATES; then
echo -e "${idsCL[Green]}Local node ${local_node} is updated.${idsCL[Default]}"
else
echo -e "${idsCL[Red]}Local node ${local_node} failed to update.${idsCL[Default]}"
cluster_failed=1
fi
printf '\n'
if (( cluster_failed == 0 )); then
echo -e "${idsCL[Green]}TA-ProxMenu is updated on all ${#cluster_nodes[@]} cluster node(s).${idsCL[Default]}"
return 0
fi
echo -e "${idsCL[LightYellow]}Cluster update completed with failures; review the node messages above.${idsCL[Default]}"
return 1
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Parse the per-node rows printed by ProxCLMC. Fields are separated with an
# ASCII unit separator so processor descriptions can safely contain spaces.
TAPM_PROXCLMC_HOST_ROWS() {
awk -F '|' '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
{
node = trim($1)
address = trim($2)
level = trim($3)
if (node ~ /^[[:alnum:]_.-]+$/ && level ~ /^x86-64-v(1|2-AES|3|4)$/) {
printf "%s\034%s\034%s\n", node, address, level
}
}
'
}
# Produce a conservative generation label from the processor name exposed by
# Proxmox. Ambiguous products are described as a family instead of being given
# a potentially incorrect codename.
TAPM_CPU_GENERATION_FROM_NAME() {
local cpu_name="${1:-}"
local normalized
local generation=""
local model_number=""
normalized="$(printf '%s' "$cpu_name" | tr '[:upper:]' '[:lower:]')"
if [[ "$normalized" =~ xeon.*e5-[[:digit:]]+[[:space:]]+v([1-4]) ]]; then
generation="${BASH_REMATCH[1]}"
case "$generation" in
1) printf '%s\n' "Sandy Bridge-EP" ;;
2) printf '%s\n' "Ivy Bridge-EP" ;;
3) printf '%s\n' "Haswell-EP" ;;
4) printf '%s\n' "Broadwell-EP" ;;
esac
return 0
fi
if [[ "$normalized" =~ xeon.*(bronze|silver|gold|platinum)[[:space:]]+([[:digit:]]{4}) ]]; then
model_number="${BASH_REMATCH[2]}"
generation="${model_number:1:1}"
case "$generation" in
1) printf '%s\n' "1st Gen Xeon Scalable (Skylake-SP)" ;;
2) printf '%s\n' "2nd Gen Xeon Scalable (Cascade Lake)" ;;
3) printf '%s\n' "3rd Gen Xeon Scalable (Ice Lake)" ;;
4) printf '%s\n' "4th Gen Xeon Scalable (Sapphire Rapids)" ;;
5) printf '%s\n' "5th Gen Xeon Scalable (Emerald Rapids)" ;;
*) printf '%s\n' "Intel Xeon Scalable family" ;;
esac
return 0
fi
if [[ "$normalized" =~ epyc[[:space:]]+([[:digit:]]{4}) ]]; then
model_number="${BASH_REMATCH[1]}"
generation="${model_number:3:1}"
case "$generation" in
1) printf '%s\n' "1st Gen EPYC (Naples / Zen)" ;;
2) printf '%s\n' "2nd Gen EPYC (Rome / Zen 2)" ;;
3) printf '%s\n' "3rd Gen EPYC (Milan / Zen 3)" ;;
4) printf '%s\n' "4th Gen EPYC (Zen 4 family)" ;;
5) printf '%s\n' "5th Gen EPYC (Zen 5 family)" ;;
*) printf '%s\n' "AMD EPYC family" ;;
esac
return 0
fi
if [[ "$normalized" =~ core.*i[3579]-([[:digit:]]{4,5}) ]]; then
model_number="${BASH_REMATCH[1]}"
if (( ${#model_number} == 4 )); then
generation="${model_number:0:1}"
else
generation="${model_number:0:2}"
fi
printf '%s\n' "Intel Core Gen ${generation}"
return 0
fi
if [[ "$normalized" =~ ryzen.*[[:space:]]([[:digit:]]{4,5}) ]]; then
model_number="${BASH_REMATCH[1]}"
printf '%s\n' "AMD Ryzen ${model_number:0:1}000 family"
return 0
fi
case "$normalized" in
*intel*) printf '%s\n' "Intel generation unknown" ;;
*amd*) printf '%s\n' "AMD generation unknown" ;;
*) printf '%s\n' "Generation unknown" ;;
esac
}
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env bash
# Deploy a dedicated LXC NFS server and register it as cluster ISO storage.
TAPM_ISO_NFS_VALID_ID() {
[[ "${1:-}" =~ ^[A-Za-z][A-Za-z0-9_-]{0,31}$ ]]
}
TAPM_ISO_NFS_VALID_CTID() {
[[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]]
}
TAPM_ISO_NFS_VALID_HOSTNAME() {
[[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]]
}
TAPM_ISO_NFS_VALID_IPV4_CIDR() {
local value="${1:-}"
local address prefix octet
local -a octets
[[ "$value" == */* ]] || return 1
address="${value%/*}"
prefix="${value#*/}"
[[ "$prefix" =~ ^[0-9]+$ ]] && (( prefix <= 32 )) || return 1
IFS=. read -r -a octets <<<"$address"
(( ${#octets[@]} == 4 )) || return 1
for octet in "${octets[@]}"; do
[[ "$octet" =~ ^[0-9]+$ ]] && (( 10#$octet <= 255 )) || return 1
done
}
TAPM_ISO_NFS_PROMPT() {
local variable="$1"
local label="$2"
local default_value="${3:-}"
local value
if [[ -n "$default_value" ]]; then
read -r -p " ${label} [${default_value}]: " value
printf -v "$variable" '%s' "${value:-$default_value}"
else
read -r -p " ${label}: " value
printf -v "$variable" '%s' "$value"
fi
}
TAPM_ISO_NFS_FAIL() {
echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}"
return 1
}
TAPM_ISO_NFS_LOCAL_TEMPLATE() {
local storage="$1"
pveam list "$storage" 2>/dev/null |
awk 'NR > 1 && $1 ~ /:vztmpl\/debian-(13|12)-standard_/ { print $1 }' |
sort -V |
tail -1
}
TAPM_ISO_NFS_SELECT_STORAGE() {
local variable="$1"
local label="$2"
local default_value="$3"
local storage type status index
local default_found=0
local -a storage_ids=()
local -a storage_types=()
local -a labels=()
local -a values=()
while read -r storage type status _; do
[[ "$storage" != 'Name' && "$status" == 'active' ]] || continue
storage_ids+=("$storage")
storage_types+=("$type")
[[ "$storage" == "$default_value" ]] && default_found=1
done < <(pvesm status --content rootdir --enabled 1 2>/dev/null)
if (( ${#storage_ids[@]} == 0 )); then
TAPM_ISO_NFS_FAIL "No active, enabled storage supports LXC volumes."
return 1
fi
# Put the suggested storage first so pressing ENTER retains the default.
if (( default_found == 1 )); then
for index in "${!storage_ids[@]}"; do
[[ "${storage_ids[$index]}" == "$default_value" ]] || continue
labels+=("${storage_ids[$index]} (${storage_types[$index]}) — default")
values+=("storage:${storage_ids[$index]}")
break
done
fi
for index in "${!storage_ids[@]}"; do
[[ $default_found == 1 && "${storage_ids[$index]}" == "$default_value" ]] &&
continue
labels+=("${storage_ids[$index]} (${storage_types[$index]})")
values+=("storage:${storage_ids[$index]}")
done
SELECT_MENU "$label" labels values
case "$MENU_SELECTION" in
storage:*) printf -v "$variable" '%s' "${MENU_SELECTION#storage:}";;
quit) EXIT1; exit 0;;
*) return 1;;
esac
}
TAPM_DEPLOY_ISO_NFS_LXC() {
local ctid default_ctid hostname address_cidr server_ip gateway bridge
local client_cidr root_storage data_storage root_size data_size
local pve_storage_id template_storage template_name template_path
local default_root_storage default_template_storage choice test_file
local container_config
echo
echo -e "${idsCL[LightCyan]}Shared ISO storage using an LXC NFS server${idsCL[Default]}"
echo
echo " This creates a privileged Debian LXC with an unconfined AppArmor profile,"
echo " allocates a dedicated mp0 data volume, exports it to the cluster, and"
echo " registers it in storage.cfg. This reduced isolation is required for the"
echo " kernel NFS service; do not run unrelated or untrusted software in this LXC."
echo " The container's host must be online for ISO storage to remain available."
echo
[[ $EUID -eq 0 ]] ||
{ TAPM_ISO_NFS_FAIL "Run this action as root on a Proxmox VE host."; return 1; }
for command in pct pvesm pveam pvesh ha-manager; do
command -v "$command" >/dev/null 2>&1 ||
{ TAPM_ISO_NFS_FAIL "Required Proxmox command '${command}' was not found."; return 1; }
done
default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)"
TAPM_ISO_NFS_PROMPT ctid "Container ID" "$default_ctid"
TAPM_ISO_NFS_VALID_CTID "$ctid" ||
{ TAPM_ISO_NFS_FAIL "The container ID is invalid."; return 1; }
if pct status "$ctid" >/dev/null 2>&1; then
TAPM_ISO_NFS_FAIL "Container ${ctid} already exists; no changes were made."
return 1
fi
TAPM_ISO_NFS_PROMPT hostname "Container hostname" "PVE-Shared-Storage"
TAPM_ISO_NFS_VALID_HOSTNAME "$hostname" ||
{ TAPM_ISO_NFS_FAIL "The hostname is invalid."; return 1; }
TAPM_ISO_NFS_PROMPT address_cidr "Static IPv4 address with prefix (example: 10.20.30.10/24)"
TAPM_ISO_NFS_VALID_IPV4_CIDR "$address_cidr" ||
{ TAPM_ISO_NFS_FAIL "A valid static IPv4 address and prefix are required."; return 1; }
server_ip="${address_cidr%/*}"
TAPM_ISO_NFS_PROMPT gateway "IPv4 gateway"
TAPM_ISO_NFS_VALID_IPV4_CIDR "${gateway}/32" ||
{ TAPM_ISO_NFS_FAIL "A valid IPv4 gateway is required."; return 1; }
TAPM_ISO_NFS_PROMPT bridge "Proxmox bridge" "vmbr0"
ip link show "$bridge" >/dev/null 2>&1 ||
{ TAPM_ISO_NFS_FAIL "Bridge '${bridge}' does not exist on this host."; return 1; }
client_cidr="$(
python3 -c 'import ipaddress,sys; print(ipaddress.ip_interface(sys.argv[1]).network)' \
"$address_cidr" 2>/dev/null
)" || client_cidr=''
TAPM_ISO_NFS_PROMPT client_cidr "CIDR allowed to mount the export" "$client_cidr"
TAPM_ISO_NFS_VALID_IPV4_CIDR "$client_cidr" ||
{ TAPM_ISO_NFS_FAIL "The allowed client CIDR is invalid."; return 1; }
default_root_storage="$(
pvesm status --content rootdir 2>/dev/null |
awk 'NR > 1 && $3 == "active" { print $1; exit }'
)"
[[ -n "$default_root_storage" ]] ||
{ TAPM_ISO_NFS_FAIL "No active storage supports LXC volumes."; return 1; }
TAPM_ISO_NFS_SELECT_STORAGE root_storage "Root filesystem storage" "$default_root_storage" || return 1
TAPM_ISO_NFS_SELECT_STORAGE data_storage "Dedicated ISO volume storage" "$root_storage" || return 1
TAPM_ISO_NFS_PROMPT root_size "Root filesystem size in GiB" "8"
[[ "$root_size" =~ ^[1-9][0-9]*$ ]] ||
{ TAPM_ISO_NFS_FAIL "The root filesystem size must be a positive integer."; return 1; }
TAPM_ISO_NFS_PROMPT data_size "ISO volume size in GiB" "250"
[[ "$data_size" =~ ^[1-9][0-9]*$ ]] ||
{ TAPM_ISO_NFS_FAIL "The ISO volume size must be a positive integer."; return 1; }
TAPM_ISO_NFS_PROMPT pve_storage_id "Proxmox cluster storage ID" "$hostname"
TAPM_ISO_NFS_VALID_ID "$pve_storage_id" ||
{ TAPM_ISO_NFS_FAIL "The Proxmox storage ID is invalid."; return 1; }
if pvesm status 2>/dev/null | awk 'NR > 1 { print $1 }' | grep -Fxq -- "$pve_storage_id"; then
TAPM_ISO_NFS_FAIL "Storage ID '${pve_storage_id}' already exists; no changes were made."
return 1
fi
default_template_storage="$(
pvesm status --content vztmpl 2>/dev/null |
awk 'NR > 1 && $3 == "active" { print $1; exit }'
)"
[[ -n "$default_template_storage" ]] ||
{ TAPM_ISO_NFS_FAIL "No active storage supports container templates."; return 1; }
template_storage="$default_template_storage"
echo
echo " Deployment summary"
echo " LXC: ${ctid} (${hostname}), privileged"
echo " Network: ${address_cidr} via ${gateway} on ${bridge}"
echo " Resources: 2 vCPU, 2048 MiB RAM, 512 MiB swap, 100 CPU units"
echo " Root volume: ${root_storage}:${root_size} GiB"
echo " ISO volume mp0: ${data_storage}:${data_size} GiB -> /srv/iso"
echo " NFS clients: ${client_cidr}"
echo " Cluster storage: ${pve_storage_id}"
echo " HA resource: ct:${ctid} (started)"
echo
read -r -p " Create this container and storage (type yes to continue)? " choice
[[ "$choice" =~ ^[Yy][Ee][Ss]$ ]] || {
echo " Cancelled; no changes were made."
return 0
}
echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}"
template_path="$(TAPM_ISO_NFS_LOCAL_TEMPLATE "$template_storage")"
if [[ -z "$template_path" ]]; then
pveam update ||
{ TAPM_ISO_NFS_FAIL "Could not refresh the template catalog."; return 1; }
template_name="$(
pveam available --section system |
awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' |
sort -V |
tail -1
)"
[[ -n "$template_name" ]] ||
{ TAPM_ISO_NFS_FAIL "No supported Debian 12/13 standard template was found."; return 1; }
template_path="${template_storage}:vztmpl/${template_name}"
pveam download "$template_storage" "$template_name" ||
{ TAPM_ISO_NFS_FAIL "The Debian template download failed."; return 1; }
fi
echo -e "\n${idsCL[LightCyan]}Creating LXC ${ctid}...${idsCL[Default]}"
if ! pct create "$ctid" "$template_path" \
--hostname "$hostname" \
--ostype debian \
--unprivileged 0 \
--features nesting=1 \
--cores 2 \
--cpuunits 100 \
--memory 2048 \
--swap 512 \
--rootfs "${root_storage}:${root_size}" \
--mp0 "${data_storage}:${data_size},mp=/srv/iso,backup=1" \
--net0 "name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},type=veth" \
--onboot 1 \
--startup order=1; then
TAPM_ISO_NFS_FAIL "Container creation failed."
return 1
fi
container_config="/etc/pve/lxc/${ctid}.conf"
if ! grep -q '^lxc\.apparmor\.profile:' "$container_config"; then
printf 'lxc.apparmor.profile: unconfined\n' >>"$container_config" ||
{ TAPM_ISO_NFS_FAIL "Container ${ctid} was created, but its NFS AppArmor setting could not be applied."; return 1; }
fi
pct start "$ctid" ||
{ TAPM_ISO_NFS_FAIL "Container ${ctid} was created but could not be started."; return 1; }
if ! timeout 60 bash -c \
"until pct exec '$ctid' -- test -d /run/systemd/system >/dev/null 2>&1; do sleep 2; done"; then
TAPM_ISO_NFS_FAIL "Container ${ctid} did not become ready within 60 seconds."
return 1
fi
echo -e "\n${idsCL[LightCyan]}Installing and configuring NFS...${idsCL[Default]}"
pct exec "$ctid" -- apt-get update ||
{ TAPM_ISO_NFS_FAIL "Package index refresh failed inside container ${ctid}."; return 1; }
pct exec "$ctid" -- env DEBIAN_FRONTEND=noninteractive \
apt-get install -y nfs-kernel-server ||
{ TAPM_ISO_NFS_FAIL "NFS package installation failed inside container ${ctid}."; return 1; }
pct exec "$ctid" -- install -d -m 0775 /srv/iso/template/iso ||
{ TAPM_ISO_NFS_FAIL "Could not initialize the ISO directory."; return 1; }
pct exec "$ctid" -- install -d -m 0755 /etc/exports.d ||
{ TAPM_ISO_NFS_FAIL "Could not initialize the NFS exports directory."; return 1; }
printf '/srv/iso %s(rw,sync,no_subtree_check,no_root_squash)\n' "$client_cidr" |
pct exec "$ctid" -- tee /etc/exports.d/proxmox-isos.exports >/dev/null ||
{ TAPM_ISO_NFS_FAIL "Could not write the NFS export configuration."; return 1; }
pct exec "$ctid" -- exportfs -ra ||
{ TAPM_ISO_NFS_FAIL "The NFS export configuration was rejected."; return 1; }
pct exec "$ctid" -- systemctl enable --now nfs-server ||
{ TAPM_ISO_NFS_FAIL "The NFS server could not be started."; return 1; }
pct exec "$ctid" -- exportfs -v | grep -Fq "/srv/iso" ||
{ TAPM_ISO_NFS_FAIL "The expected NFS export is not active."; return 1; }
echo -e "\n${idsCL[LightCyan]}Registering cluster storage...${idsCL[Default]}"
if ! pvesm add nfs "$pve_storage_id" \
--server "$server_ip" \
--export /srv/iso \
--content iso \
--options vers=3; then
TAPM_ISO_NFS_FAIL "The LXC is running, but Proxmox could not add the NFS storage."
return 1
fi
if ! timeout 30 pvesm status --storage "$pve_storage_id" |
awk -v id="$pve_storage_id" 'NR > 1 && $1 == id && $3 == "active" { found=1 } END { exit !found }'; then
pvesm remove "$pve_storage_id" >/dev/null 2>&1 || true
TAPM_ISO_NFS_FAIL "The NFS storage did not become active; its cluster entry was removed."
return 1
fi
test_file="/mnt/pve/${pve_storage_id}/template/iso/.tapm-write-test"
if ! touch "$test_file" || ! rm -f -- "$test_file"; then
pvesm remove "$pve_storage_id" >/dev/null 2>&1 || true
TAPM_ISO_NFS_FAIL "The NFS mount was not writable; its cluster entry was removed."
return 1
fi
echo -e "\n${idsCL[LightCyan]}Adding LXC ${ctid} to Proxmox HA...${idsCL[Default]}"
if ! ha-manager add "ct:${ctid}" --state started; then
TAPM_ISO_NFS_FAIL \
"Shared storage '${pve_storage_id}' is active, but LXC ${ctid} could not be added to HA."
return 1
fi
echo
echo -e "${idsCL[Green]}Shared ISO storage '${pve_storage_id}' is active.${idsCL[Default]}"
echo " LXC ${ctid} serves ${data_storage}:${data_size} GiB from ${server_ip}:/srv/iso."
echo " HA now manages ct:${ctid} with requested state 'started'."
echo " Because Proxmox storage configuration is cluster-wide, every cluster node"
echo " can use it when that node can reach ${server_ip} and is allowed by ${client_cidr}."
return 0
}
+1
View File
@@ -345,6 +345,7 @@ remote_exec() {
if [[ "$node" == "$LOCAL_NODE" ]]; then
bash -lc "$cmd"
else
# shellcheck disable=SC2029 # cmd is intentionally expanded into the remote command.
ssh "${SSH_OPTS[@]}" "root@${node}" "$cmd"
fi
}
File diff suppressed because it is too large Load Diff
+964
View File
@@ -0,0 +1,964 @@
#!/usr/bin/env bash
set -u -o pipefail
# Evacuate non-HA guests after the local Proxmox node enters HA maintenance.
# HA-managed guests remain under Proxmox HA control. Non-HA guests using
# shared storage are migrated, while guests using local storage are shut down.
HA_WAIT_SECONDS=300
MAINTENANCE_WAIT_SECONDS=60
MAX_PARALLEL_MIGRATIONS=3
SHUTDOWN_TIMEOUT=180
LOCAL_NODE="$(hostname -s)"
MIGRATION_LOG_DIR=''
PREFERRED_TARGET=''
HA_RULES_FILE="${HA_RULES_FILE:-/etc/pve/ha/rules.cfg}"
declare -a MIGRATION_NODES=()
declare -A NODE_STORAGE_CACHE=()
log() {
printf '\n[%s] %s\n' "$(date '+%F %T')" "$*"
}
warn() {
printf '\nWARNING: %s\n' "$*" >&2
}
die() {
printf '\nERROR: %s\n' "$*" >&2
exit 1
}
load_lines() {
local destination_name="$1"
local output
local -n destination_ref="$destination_name"
shift
output="$("$@")" || return 1
destination_ref=()
# shellcheck disable=SC2034 # mapfile writes through the nameref.
[[ -z "$output" ]] || mapfile -t destination_ref <<< "$output"
}
cleanup() {
if [[ "$MIGRATION_LOG_DIR" == /tmp/ta-proxmenu-evacuation.* &&
-d "$MIGRATION_LOG_DIR" ]]; then
rm -rf -- "$MIGRATION_LOG_DIR"
fi
}
get_local_guests() {
pvesh get /cluster/resources --type vm --output-format json 2>/dev/null |
python3 -c '
import json
import sys
node = sys.argv[1]
for guest in json.load(sys.stdin):
if guest.get("node") != node or guest.get("type") not in ("qemu", "lxc"):
continue
print(
guest.get("vmid", ""),
guest.get("type", ""),
guest.get("status", "unknown"),
str(guest.get("name", "")).replace("\x1f", " "),
int(guest.get("maxmem") or 0),
sep="\x1f",
)
' "$LOCAL_NODE"
}
get_ha_guest_ids() {
pvesh get /cluster/ha/resources --output-format json 2>/dev/null |
python3 -c '
import json
import re
import sys
for resource in json.load(sys.stdin):
sid = str(resource.get("sid") or resource.get("service") or "")
match = re.fullmatch(r"(?:vm|ct):(\d+)", sid)
if match:
print(match.group(1))
'
}
guest_is_ha_managed() {
local vmid="$1"
local ha_id
for ha_id in "${CURRENT_HA_IDS[@]:-}"; do
[[ "$ha_id" == "$vmid" ]] && return 0
done
return 1
}
get_online_nodes() {
pvesh get /nodes --output-format json 2>/dev/null |
python3 -c '
import json
import sys
local_node = sys.argv[1]
for node in json.load(sys.stdin):
name = str(node.get("node", ""))
if name and name != local_node and node.get("status") == "online":
print(name)
' "$LOCAL_NODE"
}
node_in_maintenance() {
local node="$1"
ha-manager status 2>/dev/null |
grep -F "lrm ${node} " |
grep -q "maintenance mode"
}
get_shared_storages() {
pvesh get /storage --output-format json 2>/dev/null |
python3 -c '
import json
import sys
for storage in json.load(sys.stdin):
if storage.get("shared"):
print(storage.get("storage", ""))
'
}
get_node_active_storages() {
local node="$1"
pvesh get "/nodes/${node}/storage" --output-format json 2>/dev/null |
python3 -c '
import json
import sys
for storage in json.load(sys.stdin):
active = storage.get("active")
enabled = storage.get("enabled", 1)
if active in (1, True, "1") and enabled not in (0, False, "0"):
storage_id = str(storage.get("storage", ""))
if storage_id:
print(storage_id)
'
}
refresh_migration_nodes() {
local ha_status
local node
local online_output
local storage_csv
local -a online_nodes=()
local -a usable_nodes=()
online_output="$(get_online_nodes)" || {
warn "Could not refresh online cluster nodes."
return 1
}
[[ -z "$online_output" ]] ||
mapfile -t online_nodes <<< "$online_output"
ha_status="$(ha-manager status 2>/dev/null)" || {
warn "Could not refresh HA node status."
return 1
}
NODE_STORAGE_CACHE=()
for node in "${online_nodes[@]}"; do
if grep -F "lrm ${node} " <<< "$ha_status" |
grep -q "maintenance mode"; then
continue
fi
storage_csv="$(get_node_active_storages "$node" | paste -sd, -)" || {
warn "Could not read storage status from ${node}; it will not be used."
continue
}
NODE_STORAGE_CACHE["$node"]="$storage_csv"
usable_nodes+=("$node")
done
MIGRATION_NODES=("${usable_nodes[@]}")
}
node_has_required_storages() {
local node="$1"
local required_csv="$2"
local available_csv="${NODE_STORAGE_CACHE[$node]-}"
local storage
local -a required_storages=()
[[ -n "$required_csv" ]] || return 0
IFS=',' read -r -a required_storages <<< "$required_csv"
for storage in "${required_storages[@]}"; do
[[ ",${available_csv}," == *",${storage},"* ]] || return 1
done
}
guest_storage_scope() {
local guest_type="$1"
local vmid="$2"
local shared_csv="$3"
pvesh get "/nodes/${LOCAL_NODE}/${guest_type}/${vmid}/config" \
--output-format json 2>/dev/null |
python3 -c '
import json
import re
import sys
guest_type, shared_csv = sys.argv[1:3]
shared = {item for item in shared_csv.split(",") if item}
config = json.load(sys.stdin)
if guest_type == "qemu":
disk_key = re.compile(
r"^(?:ide|sata|scsi|virtio|efidisk|tpmstate|unused)\d+$"
)
else:
disk_key = re.compile(r"^(?:rootfs|mp\d+|unused\d+)$")
local_reasons = []
required_storages = set()
for key, raw_value in config.items():
if not disk_key.match(key) or not isinstance(raw_value, str):
continue
volume = raw_value.split(",", 1)[0]
if volume in ("none", "cdrom") or volume.startswith("none,"):
continue
if volume.startswith("/") or ":" not in volume:
local_reasons.append(f"{key}={volume}")
continue
storage = volume.split(":", 1)[0]
required_storages.add(storage)
if storage not in shared:
local_reasons.append(f"{key}={storage}")
scope = "local" if local_reasons else "shared"
print(
scope,
", ".join(local_reasons),
",".join(sorted(required_storages)),
sep="\x1f",
)
' "$guest_type" "$shared_csv"
}
get_guest_node_affinity() {
local guest_type="$1"
local vmid="$2"
local sid_type='vm'
[[ "$guest_type" == "lxc" ]] && sid_type='ct'
python3 -c '
import os
import sys
sid = sys.argv[1]
path = sys.argv[2]
if not os.path.exists(path):
print("none", "0", "", sep="\x1f")
raise SystemExit
rules = []
current = None
with open(path, encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.rstrip()
if not line or line.lstrip().startswith("#"):
continue
if not line[0].isspace() and ":" in line:
rule_type, rule_id = line.split(":", 1)
current = {
"type": rule_type.strip(),
"id": rule_id.strip(),
}
rules.append(current)
continue
if current is not None and line[0].isspace():
key_value = line.strip().split(None, 1)
if len(key_value) == 2:
current[key_value[0]] = key_value[1].strip()
for rule in rules:
if rule.get("type") != "node-affinity":
continue
if rule.get("disable", "0") in ("1", "yes", "true", "on"):
continue
resources = {
item.strip()
for item in rule.get("resources", "").replace(";", ",").split(",")
if item.strip()
}
if sid not in resources:
continue
nodes = []
for position, item in enumerate(rule.get("nodes", "").split(",")):
item = item.strip()
if not item:
continue
node = item
priority = 0
if ":" in item:
possible_node, possible_priority = item.rsplit(":", 1)
try:
priority = int(possible_priority)
node = possible_node
except ValueError:
pass
nodes.append((node, priority, position))
nodes.sort(key=lambda value: (-value[1], value[2]))
strict = "1" if rule.get("strict", "0") in ("1", "yes", "true", "on") else "0"
print(rule.get("id", "node-affinity"), strict, ",".join(n[0] for n in nodes), sep="\x1f")
raise SystemExit
print("none", "0", "", sep="\x1f")
' "${sid_type}:${vmid}" "$HA_RULES_FILE"
}
select_target_node() {
local choice
local index
(( ${#MIGRATION_NODES[@]} > 0 )) ||
die "No other online, non-maintenance cluster node is available."
printf '\nAvailable preferred migration targets:\n\n'
for index in "${!MIGRATION_NODES[@]}"; do
printf ' %d) %s\n' "$((index + 1))" "${MIGRATION_NODES[$index]}"
done
while true; do
printf '\n'
read -r -p "Select preferred migration target [1-${#MIGRATION_NODES[@]}]: " choice
if [[ "$choice" =~ ^[0-9]+$ ]] &&
(( choice >= 1 && choice <= ${#MIGRATION_NODES[@]} )); then
PREFERRED_TARGET="${MIGRATION_NODES[$((choice - 1))]}"
return
fi
printf 'Invalid selection.\n' >&2
done
}
CHOSEN_DESTINATION=''
CHOSEN_NOTE=''
choose_destination() {
local required_csv="$1"
local policy_nodes_csv="$2"
local policy_strict="$3"
local node
local policy_node
local -a storage_candidates=()
local -a policy_nodes=()
CHOSEN_DESTINATION=''
CHOSEN_NOTE=''
for node in "${MIGRATION_NODES[@]}"; do
if node_has_required_storages "$node" "$required_csv"; then
storage_candidates+=("$node")
fi
done
(( ${#storage_candidates[@]} > 0 )) || return 1
IFS=',' read -r -a policy_nodes <<< "$policy_nodes_csv"
if [[ -z "$policy_nodes_csv" ]]; then
for node in "${storage_candidates[@]}"; do
if [[ "$node" == "$PREFERRED_TARGET" ]]; then
CHOSEN_DESTINATION="$node"
return 0
fi
done
CHOSEN_DESTINATION="${storage_candidates[0]}"
CHOSEN_NOTE="preferred target unavailable for required storage"
return 0
fi
for node in "${storage_candidates[@]}"; do
if [[ "$node" == "$PREFERRED_TARGET" &&
",${policy_nodes_csv}," == *",${node},"* ]]; then
CHOSEN_DESTINATION="$node"
return 0
fi
done
for policy_node in "${policy_nodes[@]}"; do
for node in "${storage_candidates[@]}"; do
if [[ "$node" == "$policy_node" ]]; then
CHOSEN_DESTINATION="$node"
CHOSEN_NOTE="routed to satisfy HA node-affinity preference"
return 0
fi
done
done
if (( ${#storage_candidates[@]} == 1 )); then
CHOSEN_DESTINATION="${storage_candidates[0]}"
CHOSEN_NOTE="only eligible node; HA node-affinity preference overridden"
return 0
fi
if [[ "$policy_strict" == "1" ]]; then
return 1
fi
for node in "${storage_candidates[@]}"; do
if [[ "$node" == "$PREFERRED_TARGET" ]]; then
CHOSEN_DESTINATION="$node"
CHOSEN_NOTE="no preferred HA node was eligible; non-strict fallback used"
return 0
fi
done
CHOSEN_DESTINATION="${storage_candidates[0]}"
CHOSEN_NOTE="no preferred HA node was eligible; non-strict fallback used"
}
get_node_available_memory() {
local node="$1"
pvesh get /nodes --output-format json 2>/dev/null |
python3 -c '
import json
import sys
requested = sys.argv[1]
for node in json.load(sys.stdin):
if str(node.get("node", "")) != requested:
continue
total = int(node.get("maxmem") or 0)
used = int(node.get("mem") or 0)
print(max(0, total - used))
raise SystemExit
raise SystemExit(1)
' "$node"
}
format_bytes() {
local bytes="${1:-0}"
if command -v numfmt >/dev/null 2>&1; then
numfmt --to=iec-i --suffix=B "$bytes"
else
printf '%d MiB' "$((bytes / 1024 / 1024))"
fi
}
wait_for_ha_evacuation() {
local deadline=$((SECONDS + HA_WAIT_SECONDS))
local -a local_guests
local -a ha_ids
local -a remaining
local guest
local guest_status
local guest_type
local guest_vmid
local ha_id
log "Waiting for HA-managed guests to leave ${LOCAL_NODE}."
while true; do
load_lines local_guests get_local_guests || {
warn "Could not refresh guests assigned to ${LOCAL_NODE}."
return 1
}
load_lines ha_ids get_ha_guest_ids || {
warn "Could not refresh HA resources."
return 1
}
remaining=()
for guest in "${local_guests[@]}"; do
IFS=$'\x1f' read -r guest_vmid guest_type guest_status _ <<< "$guest"
[[ "$guest_status" == "running" ]] || continue
for ha_id in "${ha_ids[@]}"; do
if [[ "$guest_vmid" == "$ha_id" ]]; then
remaining+=("$guest")
break
fi
done
done
(( ${#remaining[@]} == 0 )) && return
if (( SECONDS >= deadline )); then
warn "HA evacuation did not finish within ${HA_WAIT_SECONDS} seconds."
printf 'HA-managed guests still assigned to %s:\n' "$LOCAL_NODE" >&2
printf ' %s\n' "${remaining[@]}" >&2
return 1
fi
printf '\r Waiting: %d HA guest(s) remain... ' "${#remaining[@]}"
sleep 5
done
}
migrate_guest() {
local vmid="$1"
local guest_type="$2"
local status="$3"
local destination="$4"
if [[ "$guest_type" == "qemu" ]]; then
if [[ "$status" == "running" ]]; then
qm migrate "$vmid" "$destination" --online
else
qm migrate "$vmid" "$destination"
fi
else
if [[ "$status" == "running" ]]; then
pct migrate "$vmid" "$destination" --restart 1 \
--timeout "$SHUTDOWN_TIMEOUT"
else
pct migrate "$vmid" "$destination"
fi
fi
}
get_guest_node() {
local vmid="$1"
pvesh get /cluster/resources --type vm --output-format json 2>/dev/null |
python3 -c '
import json
import sys
vmid = str(sys.argv[1])
for guest in json.load(sys.stdin):
if str(guest.get("vmid", "")) == vmid:
print(guest.get("node", ""))
raise SystemExit
raise SystemExit(1)
' "$vmid"
}
wait_for_guest_node() {
local vmid="$1"
local destination="$2"
local attempts="${3:-15}"
local attempt=0
while (( attempt < attempts )); do
[[ "$(get_guest_node "$vmid")" == "$destination" ]] && return 0
sleep 1
((attempt++))
done
return 1
}
wait_for_migration_batch() {
local index
local pid
local guest
local log_file
local vmid
local guest_type
local status
local name
local maxmem
local required_storages
local policy_nodes
local policy_strict
local policy_rule
local destination
local route_note
for index in "${!batch_pids[@]}"; do
pid="${batch_pids[$index]}"
guest="${batch_guests[$index]}"
log_file="${batch_logs[$index]}"
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \
policy_nodes policy_strict policy_rule destination route_note <<< "$guest"
if wait "$pid" && wait_for_guest_node "$vmid" "$destination"; then
log "Migration completed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}."
migration_successes+=("$guest")
else
warn "Migration failed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}."
migration_failures+=("$guest")
fi
if [[ -s "$log_file" ]]; then
sed 's/^/ /' "$log_file"
fi
done
batch_pids=()
batch_guests=()
batch_logs=()
}
shutdown_guest() {
local vmid="$1"
local guest_type="$2"
if [[ "$guest_type" == "qemu" ]]; then
qm shutdown "$vmid" --timeout "$SHUTDOWN_TIMEOUT"
else
pct shutdown "$vmid" --timeout "$SHUTDOWN_TIMEOUT"
fi
}
guest_status() {
local vmid="$1"
local guest_type="$2"
if [[ "$guest_type" == "qemu" ]]; then
qm status "$vmid" 2>/dev/null | awk '{ print $2 }'
else
pct status "$vmid" 2>/dev/null | awk '{ print $2 }'
fi
}
wait_for_guest_stopped() {
local vmid="$1"
local guest_type="$2"
local attempts="${3:-15}"
local attempt=0
while (( attempt < attempts )); do
[[ "$(guest_status "$vmid" "$guest_type")" == "stopped" ]] && return 0
sleep 1
((attempt++))
done
return 1
}
wait_for_maintenance_mode() {
local deadline=$((SECONDS + MAINTENANCE_WAIT_SECONDS))
log "Waiting for ${LOCAL_NODE} to enter HA maintenance mode."
while true; do
if node_in_maintenance "$LOCAL_NODE"; then
printf '\n'
return
fi
if (( SECONDS >= deadline )); then
die "${LOCAL_NODE} did not enter HA maintenance mode within ${MAINTENANCE_WAIT_SECONDS} seconds."
fi
printf '\r Waiting for HA maintenance mode... '
sleep 2
done
}
preflight() {
local command
for command in pvesh ha-manager python3 qm pct; do
command -v "$command" >/dev/null 2>&1 ||
die "${command} is required."
done
pvesh get /cluster/resources --type vm --output-format json >/dev/null 2>&1 ||
die "Could not read cluster guest resources."
pvesh get /cluster/ha/resources --output-format json >/dev/null 2>&1 ||
die "Could not read HA resources."
pvesh get /nodes --output-format json >/dev/null 2>&1 ||
die "Could not read cluster node status."
pvesh get /storage --output-format json >/dev/null 2>&1 ||
die "Could not read cluster storage configuration."
ha-manager status >/dev/null 2>&1 ||
die "Could not read HA node status."
}
main() {
local answer
local available_memory
local destination
local guest
local guest_type
local log_file
local maxmem
local name
local policy_nodes
local policy_result
local policy_rule
local policy_strict
local reason
local required_memory
local required_storages
local route_note
local scope
local shared_csv
local status
local storage_result
local vmid
local -a guests=()
local -a shared_storages=()
local -a shared_guests=()
local -a local_guests=()
local -a unroutable_guests=()
local -a migration_failures=()
local -a migration_successes=()
local -a migration_skipped=()
local -a shutdown_failures=()
local -a shutdown_successes=()
local -a batch_pids=()
local -a batch_guests=()
local -a batch_logs=()
local -a final_guests=()
local -a running_final_guests=()
local -a CURRENT_HA_IDS=()
local -A DEST_REQUIRED_MEMORY=()
MIGRATION_LOG_DIR="$(mktemp -d /tmp/ta-proxmenu-evacuation.XXXXXX)" ||
die "Could not create the migration log directory."
chmod 0700 "$MIGRATION_LOG_DIR"
trap cleanup EXIT
preflight
wait_for_maintenance_mode
wait_for_ha_evacuation ||
die "Resolve the remaining HA guests before continuing the evacuation."
load_lines shared_storages get_shared_storages ||
die "Could not read shared-storage configuration."
shared_csv="$(IFS=,; echo "${shared_storages[*]}")"
load_lines guests get_local_guests ||
die "Could not read guests assigned to ${LOCAL_NODE}."
if (( ${#guests[@]} == 0 )); then
log "No guests remain on ${LOCAL_NODE}."
return 0
fi
for guest in "${guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest"
storage_result="$(guest_storage_scope "$guest_type" "$vmid" "$shared_csv")" ||
die "Could not inspect storage for ${guest_type} ${vmid}."
IFS=$'\x1f' read -r scope reason required_storages <<< "$storage_result"
if [[ "$scope" == "shared" ]]; then
shared_guests+=(
"${guest}"$'\x1f'"${required_storages}"
)
else
local_guests+=(
"${guest}"$'\x1f'"${reason:-local storage}"
)
fi
done
if (( ${#shared_guests[@]} > 0 )); then
refresh_migration_nodes ||
die "Could not build a safe migration-target list."
select_target_node
guests=("${shared_guests[@]}")
shared_guests=()
for guest in "${guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages <<< "$guest"
policy_result="$(get_guest_node_affinity "$guest_type" "$vmid")" ||
die "Could not inspect HA node-affinity policy for ${guest_type} ${vmid}."
IFS=$'\x1f' read -r policy_rule policy_strict policy_nodes <<< "$policy_result"
if choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then
destination="$CHOSEN_DESTINATION"
route_note="$CHOSEN_NOTE"
shared_guests+=(
"${guest}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"$'\x1f'"${destination}"$'\x1f'"${route_note}"
)
if [[ "$status" == "running" && "$maxmem" =~ ^[0-9]+$ ]]; then
DEST_REQUIRED_MEMORY["$destination"]="$(( ${DEST_REQUIRED_MEMORY[$destination]:-0} + maxmem ))"
fi
else
unroutable_guests+=(
"${guest}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"
)
fi
done
fi
printf '\nEvacuation plan for %s:\n' "$LOCAL_NODE"
[[ -n "$PREFERRED_TARGET" ]] &&
printf ' Preferred migration target: %s\n' "$PREFERRED_TARGET"
printf ' Shared-storage guests to migrate: %d\n' "${#shared_guests[@]}"
printf ' Local-storage guests to shut down: %d\n' "${#local_guests[@]}"
printf ' Shared guests without a valid route: %d\n' "${#unroutable_guests[@]}"
if (( ${#shared_guests[@]} > 0 )); then
printf '\nShared-storage guests:\n'
for guest in "${shared_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \
policy_nodes policy_strict policy_rule destination route_note <<< "$guest"
printf ' %-6s %-5s %-8s %-24s -> %s' \
"$vmid" "$guest_type" "$status" "$name" "$destination"
[[ -n "$route_note" ]] && printf ' (%s)' "$route_note"
printf '\n'
done
fi
if (( ${#local_guests[@]} > 0 )); then
printf '\nLocal-storage guests (will not migrate):\n'
for guest in "${local_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest"
printf ' %-6s %-5s %-8s %-24s %s\n' \
"$vmid" "$guest_type" "$status" "$name" "$reason"
done
fi
if (( ${#unroutable_guests[@]} > 0 )); then
printf '\nShared guests with no eligible destination:\n'
for guest in "${unroutable_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \
policy_nodes policy_strict policy_rule <<< "$guest"
printf ' %-6s %-5s %-24s storages=%s' \
"$vmid" "$guest_type" "$name" "$required_storages"
[[ "$policy_rule" != "none" ]] &&
printf ' policy=%s strict=%s nodes=%s' \
"$policy_rule" "$policy_strict" "$policy_nodes"
printf '\n'
done
fi
for destination in "${!DEST_REQUIRED_MEMORY[@]}"; do
required_memory="${DEST_REQUIRED_MEMORY[$destination]}"
available_memory="$(get_node_available_memory "$destination" 2>/dev/null || echo 0)"
printf '\nTarget %s memory: %s available; %s configured for incoming running guests.\n' \
"$destination" "$(format_bytes "$available_memory")" \
"$(format_bytes "$required_memory")"
if (( available_memory > 0 && required_memory > available_memory )); then
warn "${destination} has less currently available memory than the incoming guests' configured memory."
fi
done
printf '\n'
read -r -p "Proceed with guest migrations and local-storage guest shutdown? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || {
echo "Evacuation cancelled; ${LOCAL_NODE} remains in maintenance mode."
return 1
}
load_lines CURRENT_HA_IDS get_ha_guest_ids ||
die "Could not safely recheck HA-managed guests."
refresh_migration_nodes ||
die "Could not safely recheck migration targets."
for guest in "${shared_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \
policy_nodes policy_strict policy_rule destination route_note <<< "$guest"
if guest_is_ha_managed "$vmid"; then
status="$(guest_status "$vmid" "$guest_type")"
if [[ "$status" != "stopped" ]]; then
warn "${guest_type} ${vmid} is HA-managed and is not confirmed stopped; TAPM will not migrate it manually."
migration_skipped+=("$guest")
continue
fi
log "${guest_type} ${vmid} is HA-managed but stopped; continuing with offline migration."
fi
if ! choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then
warn "No eligible destination remains for ${guest_type} ${vmid}; migration skipped."
migration_skipped+=("$guest")
continue
fi
if [[ "$destination" != "$CHOSEN_DESTINATION" ]]; then
warn "Destination for ${guest_type} ${vmid} changed from ${destination} to ${CHOSEN_DESTINATION} after recheck."
destination="$CHOSEN_DESTINATION"
route_note="$CHOSEN_NOTE"
guest="${vmid}"$'\x1f'"${guest_type}"$'\x1f'"${status}"$'\x1f'"${name}"$'\x1f'"${maxmem}"$'\x1f'"${required_storages}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"$'\x1f'"${destination}"$'\x1f'"${route_note}"
fi
log "Starting migration for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}."
log_file="${MIGRATION_LOG_DIR}/${guest_type}-${vmid}.log"
migrate_guest "$vmid" "$guest_type" "$status" "$destination" >"$log_file" 2>&1 &
batch_pids+=("$!")
batch_guests+=("$guest")
batch_logs+=("$log_file")
if (( ${#batch_pids[@]} >= MAX_PARALLEL_MIGRATIONS )); then
wait_for_migration_batch
fi
done
(( ${#batch_pids[@]} == 0 )) || wait_for_migration_batch
for guest in "${local_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest"
status="$(guest_status "$vmid" "$guest_type")"
[[ "$status" == "running" ]] || continue
log "Shutting down local-storage ${guest_type} ${vmid} (${name:-unnamed})."
if shutdown_guest "$vmid" "$guest_type" &&
wait_for_guest_stopped "$vmid" "$guest_type"; then
shutdown_successes+=("$guest")
else
warn "Graceful shutdown failed for ${guest_type} ${vmid}; it was not force-stopped."
shutdown_failures+=("$guest")
fi
done
load_lines final_guests get_local_guests ||
die "Could not verify the final guest state on ${LOCAL_NODE}."
for guest in "${final_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest"
[[ "$status" == "running" ]] && running_final_guests+=("$guest")
done
printf '\nEvacuation summary:\n'
printf ' Successful migrations: %d\n' "${#migration_successes[@]}"
printf ' Failed migrations: %d\n' "${#migration_failures[@]}"
printf ' Skipped migrations: %d\n' "${#migration_skipped[@]}"
printf ' Unroutable shared guests: %d\n' "${#unroutable_guests[@]}"
printf ' Local guest shutdowns: %d\n' "${#shutdown_successes[@]}"
printf ' Failed local shutdowns: %d\n' "${#shutdown_failures[@]}"
printf ' Guests still running locally: %d\n' "${#running_final_guests[@]}"
if (( ${#migration_successes[@]} > 0 )); then
printf '\nMigrated guests:\n'
for guest in "${migration_successes[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \
policy_nodes policy_strict policy_rule destination route_note <<< "$guest"
printf ' %-6s %-5s %-24s -> %s\n' \
"$vmid" "$guest_type" "$name" "$destination"
done
fi
if (( ${#migration_failures[@]} > 0 )); then
printf '\nFailed migrations (left unchanged and not force-stopped):\n'
for guest in "${migration_failures[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \
policy_nodes policy_strict policy_rule destination route_note <<< "$guest"
printf ' %-6s %-5s %-24s target=%s\n' \
"$vmid" "$guest_type" "$name" "$destination"
done
fi
if (( ${#shutdown_successes[@]} > 0 )); then
printf '\nLocal-storage guests shut down:\n'
for guest in "${shutdown_successes[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest"
printf ' %-6s %-5s %s\n' "$vmid" "$guest_type" "$name"
done
fi
if (( ${#final_guests[@]} > 0 )); then
printf '\nGuests still assigned to %s:\n' "$LOCAL_NODE"
for guest in "${final_guests[@]}"; do
IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest"
printf ' %-6s %-5s %-8s %s\n' "$vmid" "$guest_type" "$status" "$name"
done
fi
printf '\nNo guest was force-stopped.\n'
if (( ${#migration_failures[@]} > 0 ||
${#migration_skipped[@]} > 0 ||
${#unroutable_guests[@]} > 0 ||
${#shutdown_failures[@]} > 0 ||
${#running_final_guests[@]} > 0 )); then
return 1
fi
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
TAPM_FLEET_STATE_DIR="${TAPM_FLEET_STATE_DIR:-/var/lib/ta-proxmenu}"
TAPM_FLEET_IDENTITY_FILE="${TAPM_FLEET_IDENTITY_FILE:-${TAPM_FLEET_STATE_DIR}/identity.env}"
TAPM_FLEET_INSTALLATION_ID=''
TAPM_FLEET_CREDENTIAL=''
TAPM_FLEET_LAST_VERSION=''
TAPM_FLEET_ENROLLED=0
TAPM_FLEET_REGISTERED=0
TAPM_FLEET_STARTED_AT=0
TAPM_FLEET_VERSION=''
TAPM_FLEET_HOSTNAME=''
TAPM_FLEET_READ_VALUE() {
local key="$1"
local value=''
if [[ -r "$TAPM_FLEET_IDENTITY_FILE" ]]; then
value="$(
sed -n "s/^${key}=//p" "$TAPM_FLEET_IDENTITY_FILE" |
tail -n 1
)"
fi
printf '%s' "$value"
}
TAPM_FLEET_LOAD_IDENTITY() {
TAPM_FLEET_INSTALLATION_ID="$(TAPM_FLEET_READ_VALUE INSTALLATION_ID)"
TAPM_FLEET_CREDENTIAL="$(TAPM_FLEET_READ_VALUE CREDENTIAL)"
TAPM_FLEET_LAST_VERSION="$(TAPM_FLEET_READ_VALUE LAST_VERSION)"
TAPM_FLEET_ENROLLED="$(TAPM_FLEET_READ_VALUE ENROLLED)"
[[ "$TAPM_FLEET_ENROLLED" == 1 ]] || TAPM_FLEET_ENROLLED=0
}
TAPM_FLEET_VALID_IDENTITY() {
[[ "$TAPM_FLEET_INSTALLATION_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ &&
"$TAPM_FLEET_CREDENTIAL" =~ ^[0-9a-f]{64}$ ]]
}
TAPM_FLEET_WRITE_IDENTITY() {
local temporary_file
mkdir -p "$TAPM_FLEET_STATE_DIR" 2>/dev/null || return 1
chmod 0700 "$TAPM_FLEET_STATE_DIR" 2>/dev/null || return 1
temporary_file="$(mktemp "${TAPM_FLEET_IDENTITY_FILE}.tmp.XXXXXX")" || return 1
if ! {
printf 'INSTALLATION_ID=%s\n' "$TAPM_FLEET_INSTALLATION_ID"
printf 'CREDENTIAL=%s\n' "$TAPM_FLEET_CREDENTIAL"
printf 'LAST_VERSION=%s\n' "$TAPM_FLEET_LAST_VERSION"
printf 'ENROLLED=%s\n' "$TAPM_FLEET_ENROLLED"
} >"$temporary_file" ||
! chmod 0600 "$temporary_file" ||
! mv -f "$temporary_file" "$TAPM_FLEET_IDENTITY_FILE"; then
rm -f "$temporary_file"
return 1
fi
}
TAPM_FLEET_ENSURE_IDENTITY() {
TAPM_FLEET_LOAD_IDENTITY
TAPM_FLEET_VALID_IDENTITY && return 0
command -v openssl >/dev/null 2>&1 || return 1
if [[ -r /proc/sys/kernel/random/uuid ]]; then
IFS= read -r TAPM_FLEET_INSTALLATION_ID </proc/sys/kernel/random/uuid
elif command -v uuidgen >/dev/null 2>&1; then
TAPM_FLEET_INSTALLATION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
else
return 1
fi
TAPM_FLEET_CREDENTIAL="$(openssl rand -hex 32)" || return 1
TAPM_FLEET_LAST_VERSION=''
TAPM_FLEET_ENROLLED=0
TAPM_FLEET_WRITE_IDENTITY
}
TAPM_FLEET_COLLECT_METADATA() {
TAPM_FLEET_HOSTNAME="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)"
TAPM_FLEET_GIT_COMMIT="$(git -C "${FOLDER:-/opt/idssys/ta-proxmenu}" rev-parse HEAD 2>/dev/null || true)"
TAPM_FLEET_PVE_VERSION="$(pveversion 2>/dev/null | head -n 1 || true)"
TAPM_FLEET_OS_VERSION="$(
if [[ -r /etc/os-release ]]; then
(
# shellcheck disable=SC1091
source /etc/os-release
printf '%s' "${PRETTY_NAME:-}"
)
fi
)"
TAPM_FLEET_KERNEL_VERSION="$(uname -r 2>/dev/null || true)"
TAPM_FLEET_ARCHITECTURE="$(
dpkg --print-architecture 2>/dev/null ||
uname -m 2>/dev/null ||
true
)"
if [[ -s /etc/pve/corosync.conf ]]; then
TAPM_FLEET_CLUSTERED=true
else
TAPM_FLEET_CLUSTERED=false
fi
}
TAPM_FLEET_JSON() {
local event="$1"
local result="$2"
local error_code="${3:-}"
local duration="${4:-0}"
TAPM_FLEET_EVENT="$event" \
TAPM_FLEET_RESULT="$result" \
TAPM_FLEET_ERROR_CODE="$error_code" \
TAPM_FLEET_DURATION="$duration" \
TAPM_FLEET_INSTALLATION_ID="$TAPM_FLEET_INSTALLATION_ID" \
TAPM_FLEET_HOSTNAME="$TAPM_FLEET_HOSTNAME" \
TAPM_FLEET_CREDENTIAL="$TAPM_FLEET_CREDENTIAL" \
TAPM_FLEET_VERSION="$TAPM_FLEET_VERSION" \
TAPM_FLEET_GIT_COMMIT="$TAPM_FLEET_GIT_COMMIT" \
TAPM_FLEET_PVE_VERSION="$TAPM_FLEET_PVE_VERSION" \
TAPM_FLEET_OS_VERSION="$TAPM_FLEET_OS_VERSION" \
TAPM_FLEET_KERNEL_VERSION="$TAPM_FLEET_KERNEL_VERSION" \
TAPM_FLEET_ARCHITECTURE="$TAPM_FLEET_ARCHITECTURE" \
TAPM_FLEET_CLUSTERED="$TAPM_FLEET_CLUSTERED" \
TAPM_FLEET_INCLUDE_CREDENTIAL="${TAPM_FLEET_INCLUDE_CREDENTIAL:-0}" \
python3 -c '
import json, os, sys
payload = {
"schema_version": 1,
"installation_id": os.environ["TAPM_FLEET_INSTALLATION_ID"],
"hostname": os.environ["TAPM_FLEET_HOSTNAME"],
"event": os.environ["TAPM_FLEET_EVENT"],
"result": os.environ["TAPM_FLEET_RESULT"],
"proxmenu_version": os.environ["TAPM_FLEET_VERSION"],
"git_commit": os.environ["TAPM_FLEET_GIT_COMMIT"],
"pve_version": os.environ["TAPM_FLEET_PVE_VERSION"],
"os_version": os.environ["TAPM_FLEET_OS_VERSION"],
"kernel_version": os.environ["TAPM_FLEET_KERNEL_VERSION"],
"architecture": os.environ["TAPM_FLEET_ARCHITECTURE"],
"clustered": os.environ["TAPM_FLEET_CLUSTERED"] == "true",
"error_code": os.environ["TAPM_FLEET_ERROR_CODE"],
"duration_seconds": int(os.environ["TAPM_FLEET_DURATION"]),
}
if os.environ.get("TAPM_FLEET_INCLUDE_CREDENTIAL") == "1":
payload["credential"] = os.environ["TAPM_FLEET_CREDENTIAL"]
json.dump(payload, sys.stdout, separators=(",", ":"))
'
}
TAPM_FLEET_REGISTER() {
TAPM_FLEET_INCLUDE_CREDENTIAL=1 TAPM_FLEET_JSON installed success |
curl --fail --silent --show-error \
--connect-timeout 3 --max-time 10 \
--header 'Content-Type: application/json' \
--data-binary @- \
"${TAPM_BROKER_URL}/api/v1/hosts/register" \
>/dev/null 2>&1
}
TAPM_FLEET_EVENT_SEND() {
local event="$1"
local result="$2"
local error_code="${3:-}"
local duration="${4:-0}"
local event_payload=''
(( TAPM_FLEET_REGISTERED == 1 )) || return 1
event_payload="$(mktemp "${TMPDIR:-/tmp}/tapm-fleet-event.XXXXXX")" || return 1
chmod 0600 "$event_payload" 2>/dev/null || {
rm -f "$event_payload"
return 1
}
if ! TAPM_FLEET_JSON "$event" "$result" "$error_code" "$duration" >"$event_payload"; then
rm -f "$event_payload"
return 1
fi
if printf 'header = "Content-Type: application/json"\nheader = "Authorization: Bearer %s"\nurl = "%s/api/v1/hosts/events"\n' \
"$TAPM_FLEET_CREDENTIAL" "$TAPM_BROKER_URL" |
curl --fail --silent --show-error \
--connect-timeout 3 --max-time 10 \
--config - --data-binary "@${event_payload}" \
>/dev/null 2>&1; then
rm -f "$event_payload"
return 0
fi
rm -f "$event_payload"
return 1
}
TAPM_FLEET_START() {
TAPM_FLEET_VERSION="$1"
TAPM_FLEET_STARTED_AT="$(date +%s)"
TAPM_FLEET_ENSURE_IDENTITY || return 0
command -v python3 >/dev/null 2>&1 || return 0
command -v curl >/dev/null 2>&1 || return 0
TAPM_FLEET_COLLECT_METADATA
if (( TAPM_FLEET_ENROLLED == 1 )); then
TAPM_FLEET_REGISTERED=1
elif TAPM_FLEET_REGISTER; then
TAPM_FLEET_REGISTERED=1
TAPM_FLEET_ENROLLED=1
TAPM_FLEET_WRITE_IDENTITY || true
fi
}
TAPM_FLEET_FINISH() {
local exit_status="${1:-0}"
local duration=0
local event_sent=0
if [[ "$TAPM_FLEET_STARTED_AT" =~ ^[0-9]+$ ]] &&
(( TAPM_FLEET_STARTED_AT > 0 )); then
duration="$(( $(date +%s) - TAPM_FLEET_STARTED_AT ))"
fi
if (( exit_status == 0 )); then
TAPM_FLEET_EVENT_SEND run_completed success '' "$duration" &&
event_sent=1
else
TAPM_FLEET_EVENT_SEND run_failed failure exit_nonzero "$duration" &&
event_sent=1
fi
if (( event_sent == 1 )) && TAPM_FLEET_VALID_IDENTITY; then
TAPM_FLEET_LAST_VERSION="$TAPM_FLEET_VERSION"
else
# A failed event may mean the broker no longer recognizes this local
# credential. Re-enroll on the next invocation, not during this one.
TAPM_FLEET_ENROLLED=0
fi
TAPM_FLEET_VALID_IDENTITY && TAPM_FLEET_WRITE_IDENTITY || true
return 0
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Shared non-destructive Git update helpers for TA-ProxMenu.
TAPM_GIT_WORKTREE_DIRTY() {
local repository="$1"
[[ -n "$(git -C "$repository" status --porcelain --untracked-files=normal 2>/dev/null)" ]]
}
TAPM_GIT_FETCH_BRANCH() {
local repository="$1"
local branch="$2"
local timeout_seconds="${3:-30}"
timeout "$timeout_seconds" git -C "$repository" fetch --prune origin \
"+refs/heads/${branch}:refs/remotes/origin/${branch}"
}
TAPM_GIT_RELATION() {
local repository="$1"
local local_ref="$2"
local remote_ref="$3"
local local_commit
local remote_commit
local_commit="$(git -C "$repository" rev-parse --verify "${local_ref}^{commit}" 2>/dev/null)" ||
return 1
remote_commit="$(git -C "$repository" rev-parse --verify "${remote_ref}^{commit}" 2>/dev/null)" ||
return 1
if [[ "$local_commit" == "$remote_commit" ]]; then
printf 'current\n'
elif git -C "$repository" merge-base --is-ancestor "$local_commit" "$remote_commit"; then
printf 'behind\n'
elif git -C "$repository" merge-base --is-ancestor "$remote_commit" "$local_commit"; then
printf 'ahead\n'
else
printf 'diverged\n'
fi
}
TAPM_GIT_BRANCH_STATE() {
local repository="$1"
local branch="$2"
local current_branch
[[ -d "${repository}/.git" ]] || {
printf 'unavailable\n'
return 1
}
current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)"
if [[ -z "$current_branch" ]]; then
printf 'detached\n'
return 0
fi
if [[ "$current_branch" != "$branch" ]]; then
printf 'wrong-branch\n'
return 0
fi
if TAPM_GIT_WORKTREE_DIRTY "$repository"; then
printf 'dirty\n'
return 0
fi
TAPM_GIT_RELATION "$repository" HEAD "refs/remotes/origin/${branch}" || {
printf 'unavailable\n'
return 1
}
}
TAPM_GIT_FAST_FORWARD() {
local repository="$1"
local branch="$2"
git -C "$repository" merge --ff-only "refs/remotes/origin/${branch}"
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
TAPM_HA_NODE_IN_MAINTENANCE() {
local node="${1:-}"
local status_file="${2:-/etc/pve/ha/manager_status}"
[[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 2
[[ -r "$status_file" ]] || return 2
TAPM_HA_NODE="$node" python3 - "$status_file" <<'PY'
import json
import os
import sys
try:
with open(sys.argv[1], encoding="utf-8") as status_handle:
status = json.load(status_handle)
except (OSError, TypeError, ValueError):
raise SystemExit(2)
node = os.environ["TAPM_HA_NODE"].strip().lower()
def contains_maintenance(value):
if isinstance(value, str):
return "maintenance" in value.lower()
if isinstance(value, dict):
return any(contains_maintenance(item) for item in value.values())
if isinstance(value, list):
return any(contains_maintenance(item) for item in value)
return False
def node_is_in_maintenance(value):
if isinstance(value, dict):
for key, item in value.items():
if str(key).strip().lower() == node and contains_maintenance(item):
return True
identity = next(
(
value.get(key)
for key in ("node", "name", "id")
if isinstance(value.get(key), str)
),
"",
)
if identity.strip().lower() == node and contains_maintenance(value):
return True
return any(node_is_in_maintenance(item) for item in value.values())
if isinstance(value, list):
return any(node_is_in_maintenance(item) for item in value)
return False
raise SystemExit(0 if node_is_in_maintenance(status) else 1)
PY
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Cached local host details displayed by the interactive menu header.
TAPM_HEADER_INFO_LOADED=0
TAPM_HEADER_PVE_VERSION='Unavailable'
TAPM_HEADER_CLUSTER='Standalone'
TAPM_LOAD_HEADER_INFO() {
local pve_output=''
local first_line=''
local cluster_config="${1:-/etc/pve/corosync.conf}"
local cluster_name=''
local line=''
(( TAPM_HEADER_INFO_LOADED == 0 )) || return 0
TAPM_HEADER_INFO_LOADED=1
if command -v pveversion >/dev/null 2>&1; then
pve_output="$(pveversion 2>/dev/null || true)"
first_line="${pve_output%%$'\n'*}"
if [[ "$first_line" =~ ^pve-manager/([^/[:space:]]+) ]]; then
TAPM_HEADER_PVE_VERSION="${BASH_REMATCH[1]}"
elif [[ -n "$first_line" ]]; then
TAPM_HEADER_PVE_VERSION="${first_line%%[[:space:]]*}"
fi
fi
if [[ -r "$cluster_config" ]]; then
while IFS= read -r line; do
if [[ "$line" =~ ^[[:space:]]*cluster_name:[[:space:]]*([^[:space:]#]+) ]]; then
cluster_name="${BASH_REMATCH[1]}"
break
fi
done <"$cluster_config"
TAPM_HEADER_CLUSTER="${cluster_name:-Clustered}"
fi
}
+1054
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
TAPM_RMM_TOKEN_FROM_URL() {
local url="${1:-}"
local token_pattern
token_pattern='TKN([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})/RUN(/|$)'
if [[ "$url" =~ $token_pattern ]]; then
printf '%s' "${BASH_REMATCH[1],,}"
return 0
fi
return 1
}
TAPM_RMM_ENSURE_SUDO() {
if command -v sudo >/dev/null 2>&1; then
return 0
fi
command -v apt-get >/dev/null 2>&1 || return 1
printf 'The RMM installer requires sudo; installing it now...\n'
apt-get update &&
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends sudo
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
TAPM_CONFIG_FILE="${TAPM_CONFIG_FILE:-/etc/ta-proxmenu/config.env}"
TAPM_CONFIG_READ_VALUE() {
local key="$1"
local value=''
if [[ -r "$TAPM_CONFIG_FILE" ]]; then
value="$(
sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" \
"$TAPM_CONFIG_FILE" |
tail -n 1
)"
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
fi
printf '%s' "$value"
}
TAPM_VALID_BROKER_ORIGIN() {
[[ "${1:-}" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]]
}
TAPM_VALID_GITEA_DOMAIN() {
[[ "${1:-}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]
}
TAPM_LOAD_RUNTIME_CONFIG() {
if [[ -z "${TAPM_BROKER_URL:-}" ]]; then
TAPM_BROKER_URL="$(TAPM_CONFIG_READ_VALUE TAPM_BROKER_URL)"
fi
if [[ -z "${GITEA_DOMAIN:-}" ]]; then
GITEA_DOMAIN="$(TAPM_CONFIG_READ_VALUE GITEA_DOMAIN)"
fi
TAPM_BROKER_URL="${TAPM_BROKER_URL:-}"
TAPM_BROKER_URL="${TAPM_BROKER_URL%/}"
GITEA_DOMAIN="${GITEA_DOMAIN:-}"
if TAPM_VALID_GITEA_DOMAIN "$GITEA_DOMAIN"; then
GITEA_URL="https://${GITEA_DOMAIN}"
else
GITEA_URL=''
fi
}
TAPM_ENSURE_RUNTIME_CONFIG() {
local broker_url
local config_dir
local gitea_domain
local input_device='/dev/tty'
local temporary_file
TAPM_LOAD_RUNTIME_CONFIG
if [[ -r "$TAPM_CONFIG_FILE" ]] &&
TAPM_VALID_BROKER_ORIGIN "$TAPM_BROKER_URL" &&
TAPM_VALID_GITEA_DOMAIN "$GITEA_DOMAIN"; then
return 0
fi
if [[ "${TAPM_CONFIG_TEST_STDIN:-0}" == 1 ]]; then
input_device='/dev/stdin'
exec 3>&2
elif [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
printf 'TA-ProxMenu requires %s with TAPM_BROKER_URL and GITEA_DOMAIN.\n' \
"$TAPM_CONFIG_FILE" >&2
return 1
else
exec 3>/dev/tty
fi
printf '\nTA-ProxMenu V2 requires deployment service configuration.\n' \
>&3
while true; do
printf 'TAPM broker HTTPS origin (example: https://tapm.example.com): ' \
>&3
IFS= read -r broker_url <"$input_device" || return 1
broker_url="${broker_url%/}"
TAPM_VALID_BROKER_ORIGIN "$broker_url" && break
printf 'Enter an HTTPS origin without a path.\n' >&3
done
while true; do
printf 'Git hostname (example: git.example.com): ' >&3
IFS= read -r gitea_domain <"$input_device" || return 1
TAPM_VALID_GITEA_DOMAIN "$gitea_domain" && break
printf 'Enter a hostname without https:// or a path.\n' >&3
done
config_dir="${TAPM_CONFIG_FILE%/*}"
[[ "$config_dir" != "$TAPM_CONFIG_FILE" ]] || config_dir='.'
mkdir -p "$config_dir" || return 1
temporary_file="$(mktemp "${TAPM_CONFIG_FILE}.tmp.XXXXXX")" || return 1
if ! {
printf 'TAPM_BROKER_URL=%s\n' "$broker_url"
printf 'GITEA_DOMAIN=%s\n' "$gitea_domain"
} >"$temporary_file" ||
! chmod 0600 "$temporary_file" ||
! mv -f "$temporary_file" "$TAPM_CONFIG_FILE"; then
rm -f "$temporary_file"
return 1
fi
TAPM_BROKER_URL="$broker_url"
GITEA_DOMAIN="$gitea_domain"
GITEA_URL="https://${GITEA_DOMAIN}"
printf 'Saved TA-ProxMenu configuration to %s.\n\n' "$TAPM_CONFIG_FILE" \
>&3
exec 3>&-
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
TAPM_READ_MASKED() {
local destination="$1"
local character=''
local value=''
while IFS= read -r -s -n 1 character; do
if [[ -z "$character" ]]; then
break
fi
case "$character" in
$'\177' | $'\b')
if [[ -n "$value" ]]; then
value="${value%?}"
printf '\b \b'
fi
;;
*)
value+="$character"
printf '*'
;;
esac
done
printf '\n'
printf -v "$destination" '%s' "$value"
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Pure VirtIO filename helpers shared by TA-ProxMenu and its tests.
TAPM_VIRTIO_FILENAME_FROM_URL() {
local url="${1%%\?*}"
local filename="${url##*/}"
[[ "$filename" =~ ^virtio-win(-0\.1\.[0-9]+)?\.iso$ ]] || return 1
printf '%s\n' "$filename"
}
TAPM_VIRTIO_LABELED_FILENAME() {
local source_filename="$1"
local label="$2"
local version=''
[[ "$label" =~ ^[a-z0-9-]+$ ]] || return 1
if [[ "$source_filename" =~ ^virtio-win-(0\.1\.[0-9]+)\.iso$ ]]; then
version="${BASH_REMATCH[1]}"
elif [[ "$source_filename" != 'virtio-win.iso' ]]; then
return 1
fi
printf 'virtio-win-%s%s.iso\n' "$label" "${version:+-${version}}"
}
TAPM_VIRTIO_CACHE_VALID() {
local checked_at="${1:-}"
local now="${2:-}"
local max_age="${3:-}"
local filename="${4:-}"
[[ "$checked_at" =~ ^[0-9]+$ &&
"$now" =~ ^[0-9]+$ &&
"$max_age" =~ ^[1-9][0-9]*$ &&
"$filename" =~ ^virtio-win(-[0-9]+\.[0-9]+\.[0-9]+)?\.iso$ ]] ||
return 1
(( now >= checked_at && now - checked_at < max_age ))
}