From d19400a732c8667c96145bea0c47f285f9f98628 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 17:48:04 -0500 Subject: [PATCH] update --- README.md | 18 +- defaults.inc | 2 +- inc/evacuate-proxmox-node.sh | 822 ++++++++++++++++++++++++++++------- proxmenu-scripts.sh | 647 +++++++++++++++++++++++---- run.sh | 75 ++++ 5 files changed, 1314 insertions(+), 250 deletions(-) mode change 100755 => 100644 inc/evacuate-proxmox-node.sh diff --git a/README.md b/README.md index 2da264a..a06711c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ interactive menu. Update availability is checked in the background and cached; updates are installed only when explicitly selected or requested with `tapm update`. +Use `tapm V2` to switch an installed copy to the published V2 branch for +testing, and `tapm main` to switch it back. The command refuses to switch when +local changes, local-only commits, or diverged branch history would be at risk. + The required iDSSYS Defaults repository is handled separately: it is automatically refreshed before launch when its last successful check is more than four hours old. If the remote is unavailable, the installed copy is used. @@ -34,7 +38,7 @@ The menu script also supports these direct actions: ```text pulse Install Pulse monitoring rmm Install the ConnectWise RMM agent -omsa Install Dell OpenManage Server Administrator +omsa Install legacy Dell OMSA on supported PowerEdge x30/x40 hosts glances Install Glances acronis Install the Acronis agent post-install Run the ProxMenux post-install configuration @@ -66,3 +70,15 @@ portal without changing ProxMenu. The Keepalived deployment additionally requires a healthy, quorate Proxmox cluster and passwordless root SSH between cluster nodes. + +The legacy Dell OMSA installer is limited to supported PowerEdge x30/x40 +systems running Proxmox VE 9 on Debian 13 (Trixie), amd64. + +CPU compatibility detection previews cluster-wide QEMU VM and template +changes before applying the ProxCLMC recommendation through the Proxmox CLI. +Running VMs are not restarted automatically. + +Maintenance evacuation leaves HA-managed guests under Proxmox HA control. +Remaining shared-storage guests are routed to online, non-maintenance nodes +with the required storage, while local-storage guests are gracefully shut +down. HA node-affinity preferences are honored when an eligible node exists. diff --git a/defaults.inc b/defaults.inc index ba32347..7039e48 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-30' +VERS='2026.7.25-34' noupdate=' ' diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh old mode 100755 new mode 100644 index 287ab9f..3afc05d --- a/inc/evacuate-proxmox-node.sh +++ b/inc/evacuate-proxmox-node.sh @@ -1,18 +1,21 @@ #!/usr/bin/env bash -set -u +set -u -o pipefail # Evacuate non-HA guests after the local Proxmox node enters HA maintenance. -# Guests using shared storage are migrated to one operator-selected node. -# Guests with local storage remain on this node and are gracefully shut down. +# 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="$(mktemp -d)" +MIGRATION_LOG_DIR='' +PREFERRED_TARGET='' +HA_RULES_FILE="${HA_RULES_FILE:-/etc/pve/ha/rules.cfg}" -trap 'rm -rf "$MIGRATION_LOG_DIR"' EXIT +declare -a MIGRATION_NODES=() +declare -A NODE_STORAGE_CACHE=() log() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*" @@ -27,18 +30,24 @@ die() { exit 1 } -command -v pvesh >/dev/null 2>&1 || die "pvesh is required." -command -v ha-manager >/dev/null 2>&1 || die "ha-manager is required." -command -v python3 >/dev/null 2>&1 || die "python3 is required." +load_lines() { + local destination_name="$1" + local output + local -n destination_ref="$destination_name" + shift -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." + 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 | @@ -55,6 +64,7 @@ for guest in json.load(sys.stdin): guest.get("type", ""), guest.get("status", "unknown"), str(guest.get("name", "")).replace("\x1f", " "), + int(guest.get("maxmem") or 0), sep="\x1f", ) ' "$LOCAL_NODE" @@ -75,6 +85,16 @@ for resource in json.load(sys.stdin): ' } +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 ' @@ -89,6 +109,14 @@ for node in json.load(sys.stdin): ' "$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 ' @@ -101,6 +129,74 @@ for storage in json.load(sys.stdin): ' } +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" @@ -125,6 +221,7 @@ 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 @@ -137,41 +234,221 @@ for key, raw_value in config.items(): continue storage = volume.split(":", 1)[0] + required_storages.add(storage) if storage not in shared: local_reasons.append(f"{key}={storage}") -if local_reasons: - print("local\x1f" + ", ".join(local_reasons)) -else: - print("shared\x1f") +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 -a nodes local choice local index - mapfile -t nodes < <(get_online_nodes) - (( ${#nodes[@]} > 0 )) || die "No other online cluster node is available." + (( ${#MIGRATION_NODES[@]} > 0 )) || + die "No other online, non-maintenance cluster node is available." - printf '\nAvailable migration targets:\n\n' - for index in "${!nodes[@]}"; do - printf ' %d) %s\n' "$((index + 1))" "${nodes[$index]}" + 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 migration target [1-${#nodes[@]}]: " choice + read -r -p "Select preferred migration target [1-${#MIGRATION_NODES[@]}]: " choice if [[ "$choice" =~ ^[0-9]+$ ]] && - (( choice >= 1 && choice <= ${#nodes[@]} )); then - TARGET_NODE="${nodes[$((choice - 1))]}" + (( 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 @@ -183,8 +460,14 @@ wait_for_ha_evacuation() { log "Waiting for HA-managed guests to leave ${LOCAL_NODE}." while true; do - mapfile -t local_guests < <(get_local_guests) - mapfile -t ha_ids < <(get_ha_guest_ids) + 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 @@ -214,23 +497,55 @@ 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" "$TARGET_NODE" --online + qm migrate "$vmid" "$destination" --online else - qm migrate "$vmid" "$TARGET_NODE" + qm migrate "$vmid" "$destination" fi else if [[ "$status" == "running" ]]; then - pct migrate "$vmid" "$TARGET_NODE" --restart 1 \ + pct migrate "$vmid" "$destination" --restart 1 \ --timeout "$SHUTDOWN_TIMEOUT" else - pct migrate "$vmid" "$TARGET_NODE" + 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 @@ -240,17 +555,26 @@ wait_for_migration_batch() { 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 <<< "$guest" + 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"; then - log "Migration completed for ${guest_type} ${vmid} (${name:-unnamed})." + 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})." + warn "Migration failed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." migration_failures+=("$guest") fi @@ -286,15 +610,27 @@ guest_status() { 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 ha-manager status | - grep -F "$LOCAL_NODE" | - grep -q "maintenance mode"; then + if node_in_maintenance "$LOCAL_NODE"; then printf '\n' return fi @@ -308,132 +644,312 @@ wait_for_maintenance_mode() { done } -wait_for_maintenance_mode +preflight() { + local command -wait_for_ha_evacuation || - die "Resolve the remaining HA guests before continuing the evacuation." - -select_target_node - -mapfile -t shared_storages < <(get_shared_storages) -shared_csv="$(IFS=,; echo "${shared_storages[*]}")" - -mapfile -t guests < <(get_local_guests) -if (( ${#guests[@]} == 0 )); then - log "No guests remain on ${LOCAL_NODE}." - exit 0 -fi - -declare -a shared_guests=() -declare -a local_guests=() - -for guest in "${guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name <<< "$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 <<< "$storage_result" - - if [[ "$scope" == "shared" ]]; then - shared_guests+=("$guest") - else - local_guests+=("${guest}"$'\x1f'"${reason:-local storage}") - fi -done - -printf '\nEvacuation plan for %s:\n' "$LOCAL_NODE" -printf ' Migration target: %s\n' "$TARGET_NODE" -printf ' Shared-storage guests to migrate: %d\n' "${#shared_guests[@]}" -printf ' Local-storage guests to retain: %d\n' "${#local_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 <<< "$guest" - printf ' %-6s %-5s %-8s %s\n' "$vmid" "$guest_type" "$status" "$name" + for command in pvesh ha-manager python3 qm pct; do + command -v "$command" >/dev/null 2>&1 || + die "${command} is required." 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 reason <<< "$guest" - printf ' %-6s %-5s %-8s %-24s %s\n' \ - "$vmid" "$guest_type" "$status" "$name" "$reason" + 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 -fi -printf '\n' -read -r -p "Proceed with shared-storage guest migration? [y/N] " answer -[[ "$answer" =~ ^[Yy]$ ]] || { echo "Evacuation cancelled."; exit 0; } + if (( ${#shared_guests[@]} > 0 )); then + refresh_migration_nodes || + die "Could not build a safe migration-target list." + select_target_node -declare -a migration_failures=() -declare -a batch_pids=() -declare -a batch_guests=() -declare -a batch_logs=() + 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" -for guest in "${shared_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" - log "Starting migration for ${guest_type} ${vmid} (${name:-unnamed}) to ${TARGET_NODE}." - - log_file="${MIGRATION_LOG_DIR}/${guest_type}-${vmid}.log" - migrate_guest "$vmid" "$guest_type" "$status" >"$log_file" 2>&1 & - batch_pids+=("$!") - batch_guests+=("$guest") - batch_logs+=("$log_file") - - if (( ${#batch_pids[@]} >= MAX_PARALLEL_MIGRATIONS )); then - wait_for_migration_batch + 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 -done -(( ${#batch_pids[@]} == 0 )) || wait_for_migration_batch + 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[@]}" -declare -a running_local_guests=() -for guest in "${local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - status="$(guest_status "$vmid" "$guest_type")" - if [[ "$status" == "running" ]]; then - running_local_guests+=( - "${vmid}"$'\x1f'"${guest_type}"$'\x1f'"${status}"$'\x1f'"${name}"$'\x1f'"${reason}" - ) + 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 -done -if (( ${#running_local_guests[@]} > 0 )); then - printf '\nThe following local-storage guests remain running:\n' - for guest in "${running_local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - printf ' %-6s %-5s %-24s %s\n' "$vmid" "$guest_type" "$name" "$reason" + 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 "Gracefully shut down these local-storage guests? [y/N] " answer - if [[ "$answer" =~ ^[Yy]$ ]]; then - for guest in "${running_local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - log "Shutting down ${guest_type} ${vmid} (${name:-unnamed})." - shutdown_guest "$vmid" "$guest_type" || - warn "Graceful shutdown failed for ${guest_type} ${vmid}; it was not force-stopped." + 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 + warn "${guest_type} ${vmid} became HA-managed; TAPM will not migrate it manually." + migration_skipped+=("$guest") + continue + 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 - else - warn "Local-storage guests were left running." 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 - -mapfile -t final_guests < <(get_local_guests) - -printf '\nEvacuation summary:\n' -printf ' Migration failures: %d\n' "${#migration_failures[@]}" -printf ' Guests still assigned to %s: %d\n' "$LOCAL_NODE" "${#final_guests[@]}" - -if (( ${#migration_failures[@]} > 0 )); then - printf '\nFailed migrations (left unchanged; not shut down automatically):\n' - printf ' %s\n' "${migration_failures[@]}" -fi - -if (( ${#final_guests[@]} > 0 )); then - printf '\nGuests still assigned to %s:\n' "$LOCAL_NODE" - printf ' %s\n' "${final_guests[@]}" -fi - -printf '\nNo guest was force-stopped.\n' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 3739d7f..f8bb34f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -18,6 +18,7 @@ FINISH_ACTION() { FINISH_FAILED_ACTION() { (( ACTION_REQUESTED == 1 )) && exit 1 ENTER2CONTINUE + return 1 } declare -a TAPM_TEMP_DIRS=() @@ -89,6 +90,22 @@ TAPM_DOWNLOAD_HTTPS() { fi } +TAPM_DOWNLOAD_SHA256() { + local url="$1" + local destination="$2" + local expected_sha256="${3,,}" + local label="${4:-Installer}" + local actual_sha256 + + TAPM_DOWNLOAD_HTTPS "$url" "$destination" "$label" || return 1 + actual_sha256="$(sha256sum "$destination" | cut -d' ' -f1)" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + rm -f -- "$destination" + echo -e "${idsCL[LightRed]}${label} checksum did not match; the file was removed.${idsCL[Default]}" + return 1 + fi +} + TAPM_PACKAGE_INSTALLED() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q '^install ok installed$' @@ -108,6 +125,23 @@ TAPM_WAIT_FOR_SERVICE() { return 1 } +TAPM_WAIT_FOR_TCP_PORT() { + local port="$1" + local attempts="${2:-30}" + local attempt=0 + + while (( attempt < attempts )); do + if ss -H -lnt 2>/dev/null | + awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit !found }'; then + return 0 + fi + sleep 1 + ((attempt++)) + done + + return 1 +} + trap TAPM_CLEAN_ALL_TEMP_DIRS EXIT TAPM_CLEAR_AUTHORIZATION() { @@ -286,13 +320,68 @@ INSTALL_PROXMENUX() { } PROXMENUX_POST_INSTALL() { - PMFLDR='/usr/local/share/proxmenux/scripts/post_install' - [ ! -f "${PMFLDR}/customizable_post_install.sh" ] && INSTALL_PROXMENUX - bash "${PMFLDR}/customizable_post_install.sh" - - touch /opt/.PROXMENUX_POST_INSTALL - [ -s /etc/apt/sources.list ] && cat /dev/null > /etc/apt/sources.list - + local backup_dir='/var/backups/ta-proxmenu' + local backup_file='' + local post_install_dir='/usr/local/share/proxmenux/scripts/post_install' + local post_install_script="${post_install_dir}/customizable_post_install.sh" + local timestamp + + if [[ ! -f "$post_install_script" ]]; then + INSTALL_PROXMENUX + [[ -f "$post_install_script" ]] || return 1 + fi + + if ! bash "$post_install_script"; then + echo -e "${idsCL[LightRed]}The ProxMenux post-install script failed. A completion marker was not created.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ -e /etc/apt/sources.list && ! -f /etc/apt/sources.list ]]; then + echo -e "${idsCL[LightRed]}/etc/apt/sources.list is not a regular file and was not changed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ -s /etc/apt/sources.list ]]; then + timestamp="$(date +%Y%m%d-%H%M%S)" + if ! install -d -m 0700 "$backup_dir" || + ! backup_file="$(mktemp "${backup_dir}/sources.list.proxmenux.${timestamp}.XXXXXX")" || + ! install -m 0600 /etc/apt/sources.list "$backup_file"; then + [[ -n "$backup_file" ]] && rm -f -- "$backup_file" + echo -e "${idsCL[LightRed]}Could not back up /etc/apt/sources.list; it was not cleared.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + + if ! : >/etc/apt/sources.list || + ! chmod 0644 /etc/apt/sources.list; then + echo -e "${idsCL[LightRed]}Could not enforce an empty /etc/apt/sources.list.${idsCL[Default]}" + [[ -n "$backup_file" ]] && + echo "Backup retained at: ${backup_file}" + FINISH_FAILED_ACTION + return + fi + + if ! apt-get update; then + echo -e "${idsCL[LightRed]}APT repository validation failed after ProxMenux post-install.${idsCL[Default]}" + [[ -n "$backup_file" ]] && + echo "Removed sources were retained at: ${backup_file}" + FINISH_FAILED_ACTION + return + fi + + if ! touch /opt/.PROXMENUX_POST_INSTALL; then + echo -e "${idsCL[LightRed]}Post-install succeeded, but the completion marker could not be created.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}ProxMenux post-install completed and APT sources were validated.${idsCL[Default]}" + [[ -n "$backup_file" ]] && + echo "ProxMenux additions to sources.list were saved at: ${backup_file}" + FINISH_ACTION } INSTALL_GLANCES() { @@ -501,48 +590,193 @@ INSTALL_S1() { FINISH_ACTION } +TAPM_SYSTEM_PRODUCT_NAME() { + local product_name='' + + if [[ -r /sys/class/dmi/id/product_name ]]; then + read -r product_name /dev/null 2>&1; then + product_name="$(dmidecode -s system-product-name 2>/dev/null)" + fi + + printf '%s\n' "$product_name" +} + +TAPM_OMSA_SUPPORTED_HARDWARE() { + local product_name + + product_name="$(TAPM_SYSTEM_PRODUCT_NAME)" + [[ "$product_name" =~ PowerEdge[[:space:]]+[[:alpha:]]+[0-9](3|4)[0-9][[:alnum:]-]* ]] +} + +TAPM_OMSA_SUPPORTED_PLATFORM() { + local architecture + local codename + local os_version + local pve_version + + architecture="$(dpkg --print-architecture 2>/dev/null)" + os_version="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}")" + codename="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_CODENAME:-}")" + pve_version="$(pveversion 2>/dev/null | head -n 1)" + + [[ "$architecture" == "amd64" && + "$os_version" == "13" && + "$codename" == "trixie" && + "$pve_version" == pve-manager/9.* ]] +} + INSTALL_OMSA() { - echo - mkdir -p /tmp/omsa - cd /tmp/omsa || return 1 - apt install -y gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 - mkdir -p /etc/apt/keyrings - wget -qO - https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc | gpg --dearmor -o /etc/apt/keyrings/linux.dell.com.gpg - chmod +r /etc/apt/keyrings/linux.dell.com.gpg - echo "deb [signed-by=/etc/apt/keyrings/linux.dell.com.gpg] http://linux.dell.com/repo/community/openmanage/11000/jammy jammy main" > /etc/apt/sources.list.d/linux.dell.com.list - apt update - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman1t64_2.6.5-0ubuntu16_amd64.deb - # wget -c http://http.us.debian.org/debian/pool/main/libx/libxml2/libxml2-16_2.15.1+dfsg-2+b1_amd64.deb - wget -c http://http.us.debian.org/debian/pool/main/libx/libxml2/libxml2-16_2.15.2+dfsg-0.1_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-sfcc/libcimcclient0_2.2.8-0ubuntu2_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/openwsman_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/multiverse/c/cim-schema/cim-schema_2.48.0-0ubuntu1_all.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-sfc-common/libsfcutil0_1.0.1-0ubuntu4_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/multiverse/s/sblim-sfcb/sfcb_1.4.9-0ubuntu7_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-cmpi-devel/libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb - wget -c http://ftp.us.debian.org/debian/pool/main/o/openssl/libssl1.1_1.1.1w-0+deb11u1_amd64.deb - dpkg -i libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb - dpkg -i libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libxml2-16_2.15.2+dfsg-0.1_amd64.deb - dpkg -i libwsman1t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libcimcclient0_2.2.8-0ubuntu2_amd64.deb - dpkg -i openwsman_2.6.5-0ubuntu16_amd64.deb - dpkg -i cim-schema_2.48.0-0ubuntu1_all.deb - dpkg -i libsfcutil0_1.0.1-0ubuntu4_amd64.deb - dpkg -i sfcb_1.4.9-0ubuntu7_amd64.deb - dpkg -i libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb - dpkg -i libssl1.1_1.1.1w-0+deb11u1_amd64.deb - apt install -y srvadmin-all - /opt/dell/srvadmin/sbin/srvadmin-services.sh start - rm -rf -- /tmp/omsa - - echo -e "\n${idsCL[Green]}Dell OMSA has been installed${idsCL[Default]}" - echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" - FINISH_ACTION + local base_url='https://archive.ubuntu.com/ubuntu/pool' + local dell_key + local dell_keyring + local dell_source + local index + local product_name + local temp_dir + local -a filenames=( + 'libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb' + 'libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb' + 'libxml2-16_2.15.2+dfsg-0.1_amd64.deb' + 'libwsman1t64_2.6.5-0ubuntu16_amd64.deb' + 'libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb' + 'libcimcclient0_2.2.8-0ubuntu2_amd64.deb' + 'openwsman_2.6.5-0ubuntu16_amd64.deb' + 'cim-schema_2.48.0-0ubuntu1_all.deb' + 'libsfcutil0_1.0.1-0ubuntu4_amd64.deb' + 'sfcb_1.4.9-0ubuntu7_amd64.deb' + 'libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb' + 'libssl1.1_1.1.1w-0+deb11u1_amd64.deb' + ) + local -a urls=( + "${base_url}/universe/o/openwsman/${filenames[0]}" + "${base_url}/universe/o/openwsman/${filenames[1]}" + 'https://snapshot.debian.org/file/32f51d914435fd29ce31af7ba17525f6276ea58d' + "${base_url}/universe/o/openwsman/${filenames[3]}" + "${base_url}/universe/o/openwsman/${filenames[4]}" + "${base_url}/universe/s/sblim-sfcc/${filenames[5]}" + "${base_url}/universe/o/openwsman/${filenames[6]}" + "${base_url}/multiverse/c/cim-schema/${filenames[7]}" + "${base_url}/universe/s/sblim-sfc-common/${filenames[8]}" + "${base_url}/multiverse/s/sblim-sfcb/${filenames[9]}" + "${base_url}/universe/s/sblim-cmpi-devel/${filenames[10]}" + "https://deb.debian.org/debian/pool/main/o/openssl/${filenames[11]}" + ) + local -a checksums=( + '42fdd34722ac1304427f80c4176ee781d057f05669d485f0ee1da4d87df7488c' + '6d1855a2e8263e9a578b4c0bb7a963a6d99dc8d2ef41e287725799dcad0c6cb3' + '8571682a07f329bb462569502b57aced4866e5b95c2db3ec7e5414a5b3bbdc14' + '61b91e8f234c5f2f87b4bce3534bc2f2304d50cd2fd95f426f12fc73d80e27b4' + '5d3f948ab605b4973b399f53bd62bddd70eb01161769a0d39a811399fe7c2daf' + '14b9ac374f88bd44e57395e87faa76d99d02e242c813fe30083d5bbfafec5870' + '62b30fcf41dae0c1d841f67a46049b7dfa4dfffe314e4226a776eb134605b7fc' + 'a87d16d41e81092c7ada43824a97cf79fab18c4a3722ef6f0476ad697a3d9ab7' + 'ba890cf5f2359befd3da1e5763672ef8b03d5424fa4e3d1ef21c9d52884af247' + '3eb5dce0a873f8eb77174fdd5e02ac55a989f7a73bd5ef8c8aae501d225d7524' + '284acfbb6d675496046ee46e6d5ea6c70ceafa3781cf1eece04f612cbadf117c' + 'aadf8b4b197335645b230c2839b4517aa444fd2e8f434e5438c48a18857988f7' + ) + local -a package_paths=() + + echo + product_name="$(TAPM_SYSTEM_PRODUCT_NAME)" + if ! TAPM_OMSA_SUPPORTED_HARDWARE; then + echo -e "${idsCL[LightRed]}Dell OMSA legacy installation is limited to PowerEdge x30/x40 systems.${idsCL[Default]}" + echo "Detected system: ${product_name:-Unknown}" + FINISH_FAILED_ACTION + return + fi + if ! TAPM_OMSA_SUPPORTED_PLATFORM; then + echo -e "${idsCL[LightRed]}This legacy OMSA package set requires PVE 9 on Debian 13 (Trixie), amd64.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "${idsCL[LightYellow]}Installing legacy Dell OMSA 11.0 compatibility packages on ${product_name}.${idsCL[Default]}" + + if ! TAPM_CREATE_TEMP_DIR omsa; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + dell_key="${temp_dir}/dell-openmanage.asc" + dell_keyring="${temp_dir}/linux.dell.com.gpg" + dell_source="${temp_dir}/linux.dell.com.list" + + if ! TAPM_DOWNLOAD_SHA256 \ + 'https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc' \ + "$dell_key" \ + '92f9622bf300f1fc8a4ef12d8e5efef6511b089c63c15e635cfc7429499e86d4' \ + 'Dell OpenManage signing key'; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + + for index in "${!filenames[@]}"; do + package_paths+=("${temp_dir}/${filenames[$index]}") + if ! TAPM_DOWNLOAD_SHA256 "${urls[$index]}" "${package_paths[$index]}" \ + "${checksums[$index]}" "${filenames[$index]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + done + + if ! apt-get update || + ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 || + ! gpg --batch --yes --dearmor --output "$dell_keyring" "$dell_key"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Unable to prepare the Dell OMSA repository.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! printf '%s\n' \ + 'deb [signed-by=/etc/apt/keyrings/linux.dell.com.gpg] https://linux.dell.com/repo/community/openmanage/11000/jammy jammy main' \ + >"$dell_source" || + ! mkdir -p /etc/apt/keyrings || + ! install -m 0644 "$dell_keyring" /etc/apt/keyrings/linux.dell.com.gpg || + ! install -m 0644 "$dell_source" /etc/apt/sources.list.d/linux.dell.com.list; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Unable to write the Dell OMSA repository configuration.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! apt-get update; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The Dell OMSA repository could not be refreshed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! dpkg -i "${package_paths[@]}"; then + if ! DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y || + ! dpkg -i "${package_paths[@]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The legacy OMSA compatibility packages could not be installed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y srvadmin-all || + ! TAPM_PACKAGE_INSTALLED srvadmin-all || + [[ ! -x /opt/dell/srvadmin/sbin/srvadmin-services.sh ]] || + ! /opt/dell/srvadmin/sbin/srvadmin-services.sh start || + ! TAPM_WAIT_FOR_TCP_PORT 1311 30; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Dell OMSA installation failed or port 1311 did not become available.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "\n${idsCL[Green]}Dell OMSA has been installed and verified.${idsCL[Default]}" + echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" + FINISH_ACTION } DOWNLOAD_VIRTIO() { @@ -558,36 +792,198 @@ DOWNLOAD_VIRTIO() { FINISH_ACTION } -DETECT_CPU(){ - # if [ ! -f /etc/apt/sources.list.d/proxlb.list ]; then - # echo "deb https://repo.gyptazy.com/stable /" > /etc/apt/sources.list.d/proxlb.list - # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg - # apt-get update - # fi - if [ ! -f /etc/apt/sources.list.d/gyptazy.list ]; then - curl https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key -o /etc/apt/keyrings/gyptazy.asc - echo "deb [signed-by=/etc/apt/keyrings/gyptazy.asc] https://packages.gyptazy.com/api/packages/gyptazy/debian trixie main" | sudo tee -a /etc/apt/sources.list.d/gyptazy.list - apt update - fi - if [ "$(dpkg -l | awk '/proxclmc/ {print }'|wc -l)" -eq 0 ]; then - apt -y install proxclmc - fi +TAPM_INSTALL_PROXCLMC() { + local key_url='https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key' + local keyring='/etc/apt/keyrings/gyptazy.asc' + local repository_file='/etc/apt/sources.list.d/gyptazy.list' + local repository_line='deb [signed-by=/etc/apt/keyrings/gyptazy.asc] https://packages.gyptazy.com/api/packages/gyptazy/debian trixie main' + local temp_dir + local temp_key + TAPM_PACKAGE_INSTALLED proxclmc && command -v proxclmc >/dev/null 2>&1 && + return 0 + + TAPM_CREATE_TEMP_DIR proxclmc || return 1 + temp_dir="$TAPM_TEMP_DIR" + temp_key="${temp_dir}/gyptazy.asc" + + TAPM_DOWNLOAD_HTTPS "$key_url" "$temp_key" "ProxCLMC repository key" || + return 1 + if ! command -v gpg >/dev/null 2>&1 || + ! gpg --batch --show-keys "$temp_key" >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}The downloaded ProxCLMC repository key is not a valid OpenPGP key.${idsCL[Default]}" + return 1 + fi + + if ! install -d -m 0755 /etc/apt/keyrings || + ! install -m 0644 "$temp_key" "$keyring" || + ! printf '%s\n' "$repository_line" >"$repository_file" || + ! chmod 0644 "$repository_file"; then + echo -e "${idsCL[LightRed]}Could not configure the ProxCLMC package repository.${idsCL[Default]}" + return 1 + fi + + if ! apt-get update || + ! apt-get install -y proxclmc || + ! command -v proxclmc >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}ProxCLMC could not be installed.${idsCL[Default]}" + return 1 + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" +} + +TAPM_CLUSTER_QEMU_GUESTS() { + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for guest in json.load(sys.stdin): + if guest.get("type") != "qemu": + continue + print( + guest.get("vmid", ""), + str(guest.get("name", "")) + .replace("\x1f", " ") + .replace("\r", " ") + .replace("\n", " "), + guest.get("node", ""), + "yes" if guest.get("template") in (1, True, "1") else "no", + sep="\x1f", + ) +' +} + +TAPM_QEMU_CPU_MODEL() { + local vmid="$1" + local config_output + local cpu_model + + config_output="$(qm config "$vmid" 2>/dev/null)" || return 1 + cpu_model="$( + awk -F': ' '$1 == "cpu" { print $2; exit }' <<< "$config_output" + )" + [[ -n "$cpu_model" ]] || cpu_model='kvm64' + printf '%s\n' "$cpu_model" +} + +DETECT_CPU() { + local answer + local cpu_model + local current_cpu + local guest + local guest_output + local name + local node + local template + local vmid + local -a changes=() + local -a failures=() + local -a successes=() + + for command in apt-get curl gpg install pvesh python3 qm; do + if ! command -v "$command" >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}${command} is required for CPU compatibility detection.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + done + + if ! TAPM_INSTALL_PROXCLMC; then + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[LightCyan]}Analyzing CPU compatibility across the cluster...${idsCL[Default]}" + cpu_model="$(proxclmc --list-only 2>/dev/null)" || { + echo -e "${idsCL[LightRed]}ProxCLMC could not determine a compatible CPU model.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + cpu_model="${cpu_model//$'\r'/}" + cpu_model="${cpu_model//$'\n'/}" + if [[ ! "$cpu_model" =~ ^x86-64-v(1|2-AES|3|4)$ ]]; then + echo -e "${idsCL[LightRed]}ProxCLMC returned an unexpected CPU model: ${cpu_model:-empty}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + guest_output="$(TAPM_CLUSTER_QEMU_GUESTS)" || { + echo -e "${idsCL[LightRed]}Could not read cluster QEMU resources.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + if [[ -z "$guest_output" ]]; then + echo -e "\n${idsCL[LightCyan]}No QEMU VMs or templates were found in the cluster.${idsCL[Default]}" + FINISH_ACTION + return + fi + + while IFS=$'\x1f' read -r vmid name node template; do + [[ -n "$vmid" ]] || continue + current_cpu="$(TAPM_QEMU_CPU_MODEL "$vmid")" || { + echo -e "${idsCL[LightRed]}Could not read the CPU configuration for VMID ${vmid}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + [[ "$current_cpu" == "$cpu_model" ]] && continue + changes+=( + "${vmid}"$'\x1f'"${name}"$'\x1f'"${node}"$'\x1f'"${template}"$'\x1f'"${current_cpu}" + ) + done <<< "$guest_output" + + if (( ${#changes[@]} == 0 )); then + echo -e "\n${idsCL[Green]}All QEMU VMs and templates already use ${cpu_model}.${idsCL[Default]}" + FINISH_ACTION + return + fi + + printf '\nRecommended cluster CPU model: %s\n' "$cpu_model" + printf '\n%-7s %-28s %-20s %-10s %-24s %s\n' \ + 'VMID' 'NAME' 'NODE' 'TEMPLATE' 'CURRENT CPU' 'PROPOSED CPU' + printf '%-7s %-28s %-20s %-10s %-24s %s\n' \ + '-------' '----------------------------' '--------------------' \ + '----------' '------------------------' '----------------' + for guest in "${changes[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + printf '%-7s %-28.28s %-20.20s %-10s %-24.24s %s\n' \ + "$vmid" "${name:-unnamed}" "$node" "$template" "$current_cpu" "$cpu_model" + done + + echo -en "\n${idsCL[LightCyan]}Apply this CPU model to ${#changes[@]} VM(s) and template(s) (y/N)?${idsCL[Default]} " + read -r -n 1 answer echo - proxclmc - echo - echo -en "${idsCL[LightCyan]}Would you like to set '${idsCL[LightGreen]}cpu: $(proxclmc --list-only)${idsCL[LightCyan]}' on all VMs (y/N)?${idsCL[Default]} " - read -n 1 choice - case "$choice" in - [Yy]) - sed -i "/cpu:/c cpu: $(proxclmc --list-only)" /etc/pve/nodes/*/qemu-server/*.conf - echo - echo -e "\n${idsCL[Green]}All VM's have been reconfigured\n${idsCL[LightCyan]}This will require the VM's to be powered off and then turned back on in order to take effect${idsCL[Default]}" - FINISH_ACTION - ;; - *) echo;; - esac - + [[ "$answer" =~ ^[Yy]$ ]] || return + + for guest in "${changes[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + if qm set "$vmid" --cpu "$cpu_model" && + [[ "$(TAPM_QEMU_CPU_MODEL "$vmid")" == "$cpu_model" ]]; then + successes+=("$guest") + else + failures+=("$guest") + fi + done + + printf '\nCPU model update summary:\n' + printf ' Successful: %d\n' "${#successes[@]}" + printf ' Failed: %d\n' "${#failures[@]}" + + if (( ${#failures[@]} > 0 )); then + printf '\nFailed VM/template updates:\n' + for guest in "${failures[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + printf ' %s (%s) on %s\n' "$vmid" "${name:-unnamed}" "$node" + done + echo -e "\n${idsCL[LightRed]}One or more CPU model updates failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}The CPU model was updated to ${cpu_model}.${idsCL[Default]}" + echo -e "${idsCL[LightCyan]}Running VMs must be fully shut down and started again before the new CPU model takes effect.${idsCL[Default]}" + FINISH_ACTION } RESTART_SERVICE_GROUP() { @@ -674,26 +1070,86 @@ RESTART_PVE_SERVICES() { pvedaemon pveproxy pvestatd pvescheduler } -MAINTENANCE_MODE(){ +HA_NODE_IN_MAINTENANCE() { + local node="$1" + local ha_status + + ha_status="$(ha-manager status 2>/dev/null)" || return 2 + grep -F "lrm ${node} " <<< "$ha_status" | + grep -q "maintenance mode" +} + +WAIT_FOR_HA_MAINTENANCE_STATE() { + local node="$1" + local expected_state="$2" + local attempts="${3:-30}" + local attempt=0 + local active=0 + local state_result + + while (( attempt < attempts )); do + active=0 + HA_NODE_IN_MAINTENANCE "$node" + state_result=$? + [[ "$state_result" -eq 0 ]] && active=1 + if [[ "$state_result" -gt 1 ]]; then + sleep 1 + ((attempt++)) + continue + fi + if [[ "$expected_state" == "enabled" && "$active" -eq 1 ]] || + [[ "$expected_state" == "disabled" && "$active" -eq 0 ]]; then + return 0 + fi + sleep 1 + ((attempt++)) + done + return 1 +} + +MAINTENANCE_MODE() { + local choice local local_node + local maintenance_state local_node="$(hostname -s)" - if ha-manager status | grep -F "$local_node" | grep -q "maintenance mode"; then + HA_NODE_IN_MAINTENANCE "$local_node" + maintenance_state=$? + if [[ "$maintenance_state" -gt 1 ]]; then + echo -e "\n${idsCL[LightRed]}Could not read HA maintenance status for ${local_node}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ "$maintenance_state" -eq 0 ]]; then echo -en "${idsCL[LightCyan]}Take the local host out of maintenance mode (Y/n)?${idsCL[Default]} " else echo -en "${idsCL[LightCyan]}Put the local host into maintenance mode (Y/n)?${idsCL[Default]} " fi - read -n 1 choice + read -r -n 1 choice case "$choice" in - [Nn]) echo;; - *) echo - if ha-manager status | grep -F "$local_node" | grep -q "maintenance mode"; then - ha-manager crm-command node-maintenance disable "$local_node" + [Nn]) echo;; + *) echo + if [[ "$maintenance_state" -eq 0 ]]; then + if ! ha-manager crm-command node-maintenance disable "$local_node" || + ! WAIT_FOR_HA_MAINTENANCE_STATE "$local_node" disabled; then + echo -e "\n${idsCL[LightRed]}Failed to take ${local_node} out of HA maintenance mode.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi echo -e "\n${idsCL[Green]}This host will be taken out of maintenance mode${idsCL[Default]}\n" else - ha-manager crm-command node-maintenance enable "$local_node" + if ! ha-manager crm-command node-maintenance enable "$local_node"; then + echo -e "\n${idsCL[LightRed]}Failed to request HA maintenance mode for ${local_node}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi echo -e "\n${idsCL[Green]}This host will be entered into maintenance mode${idsCL[Default]}\n" - bash /opt/idssys/ta-proxmenu/inc/evacuate-proxmox-node.sh + if ! bash /opt/idssys/ta-proxmenu/inc/evacuate-proxmox-node.sh; then + echo -e "\n${idsCL[LightRed]}Evacuation did not complete. ${local_node} remains in HA maintenance mode.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi fi FINISH_ACTION ;; @@ -991,19 +1447,15 @@ SHOW_ABOUT() { HOST_SETUP_MENU() { local -a labels - local -a values=( - "post_install" - "cpu" - "virtio" - "glances" - "omsa" - ) + local -a values while true; do labels=("Run ProxMenux post-install configuration") + values=("post_install") [ -f /opt/.PROXMENUX_POST_INSTALL ] && labels[0]="Run ProxMenux post-install configuration (previously run)" labels+=("Detect CPU model for live migrations") + values+=("cpu") if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then labels+=("VirtIO drivers (${VIRTIO_FILE} already downloaded)") @@ -1012,14 +1464,19 @@ HOST_SETUP_MENU() { else labels+=("Download current VirtIO drivers") fi + values+=("virtio") command -v glances >/dev/null 2>&1 && labels+=("Glances CLI monitor (installed)") || labels+=("Install Glances CLI monitor") + values+=("glances") - dpkg-query -W -f='${Status}' srvadmin-all 2>/dev/null | grep -q "install ok installed" && - labels+=("Dell OpenManage Server Administrator (installed)") || - labels+=("Install Dell OpenManage Server Administrator") + if TAPM_OMSA_SUPPORTED_HARDWARE; then + TAPM_PACKAGE_INSTALLED srvadmin-all && + labels+=("Dell OMSA - Legacy Dell Hosts (installed)") || + labels+=("Install Dell OMSA - Legacy Dell Hosts") + values+=("omsa") + fi SELECT_MENU "Host Setup" labels values case "$MENU_SELECTION" in diff --git a/run.sh b/run.sh index ff7aaf6..48869cd 100755 --- a/run.sh +++ b/run.sh @@ -156,6 +156,77 @@ UPDATE_REPOSITORY() { esac } +SWITCH_TAPM_BRANCH() { + local target_branch="$1" + local repository='/opt/idssys/ta-proxmenu' + local current_branch + local target_state='' + + if ! git check-ref-format --branch "$target_branch" >/dev/null 2>&1; then + echo -e "${idsCL[Red]}'${target_branch}' is not a valid Git branch name.${idsCL[Default]}" + return 1 + fi + if [[ ! -d "${repository}/.git" ]]; then + echo -e "${idsCL[Red]}TA-ProxMenu is not a Git repository.${idsCL[Default]}" + return 1 + fi + + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + echo -e "${idsCL[Red]}TA-ProxMenu is in a detached HEAD state; branch switching was refused.${idsCL[Default]}" + return 1 + fi + if [[ "$target_branch" == "$current_branch" ]]; then + echo -e "${idsCL[Green]}TA-ProxMenu is already using '${target_branch}'.${idsCL[Default]}" + return 0 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + echo -e "${idsCL[LightYellow]}TA-ProxMenu has local changes; branch switching was refused to preserve them.${idsCL[Default]}" + git -C "$repository" status --short + return 1 + fi + + echo -e "${idsCL[LightCyan]}Fetching TA-ProxMenu branch '${target_branch}'...${idsCL[Default]}" + if ! TAPM_GIT_FETCH_BRANCH "$repository" "$target_branch" 30 >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Could not fetch branch '${target_branch}' from origin.${idsCL[Default]}" + return 1 + fi + + if git -C "$repository" show-ref --verify --quiet "refs/heads/${target_branch}"; then + target_state="$( + TAPM_GIT_RELATION "$repository" "refs/heads/${target_branch}" \ + "refs/remotes/origin/${target_branch}" + )" || { + echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}" + return 1 + } + case "$target_state" in + ahead) + echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has commits not on origin; switching was refused.${idsCL[Default]}" + return 1 + ;; + diverged) + echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has diverged from origin; switching was refused.${idsCL[Default]}" + return 1 + ;; + esac + + git -C "$repository" switch "$target_branch" >/dev/null || return 1 + else + git -C "$repository" switch --create "$target_branch" \ + --track "origin/${target_branch}" >/dev/null || return 1 + fi + + if [[ "$target_state" == "behind" ]] && + ! TAPM_GIT_FAST_FORWARD "$repository" "$target_branch" >/dev/null; then + echo -e "${idsCL[Red]}Could not fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}" + return 1 + fi + + rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true + echo -e "${idsCL[Green]}TA-ProxMenu is now using branch '${target_branch}'.${idsCL[Default]}" +} + INSTALL_UPDATES() { local current_branch local defaults_warning=0 @@ -199,6 +270,10 @@ case "${1:-}" in INSTALL_UPDATES exit $? ;; + main|V[0-9]*) + SWITCH_TAPM_BRANCH "$1" + exit $? + ;; tapm) exit 0 ;;