update to V2
This commit is contained in:
@@ -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 ))
|
||||
}
|
||||
Reference in New Issue
Block a user