feat(pbs): post-install menu for Proxmox Backup Server

- Open the post-install menu from the PBS menu
- List only the customizable options that apply to PBS
- Offer the PBS no-subscription repository through the native repository API
- Upgrade PBS with the package list shown before applying it
- Remove and restore the subscription notice on the PBS web interface
- Install Figurine on PBS without the Proxmox VE aliases
- Offer the Helper-Scripts PBS post-install and microcode scripts
- Tune firewall bridges after the systemd udev rules in the automated post-install
- Add PBS repository policy tests and syntax checks
- Document the PBS differences on the post-install page
- Use backups in the Spanish PBS update message
This commit is contained in:
MacRimi
2026-10-17 18:13:44 +02:00
parent ce1a3de719
commit 98ae1f3626
34 changed files with 1976 additions and 227 deletions
+10 -3
View File
@@ -283,6 +283,10 @@ reload_services() {
systemctl is-active --quiet pvedaemon 2>/dev/null && {
systemctl reload pvedaemon 2>/dev/null || true
}
# Proxmox Backup Server serves the same toolkit from its own proxy.
systemctl is-active --quiet proxmox-backup-proxy 2>/dev/null && {
systemctl reload proxmox-backup-proxy 2>/dev/null || systemctl restart proxmox-backup-proxy 2>/dev/null || true
}
}
main() {
@@ -316,9 +320,12 @@ EOFAPT
# Main function to remove subscription banner
remove_subscription_banner_v3() {
local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")"
if command -v pveversion >/dev/null 2>&1; then
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")"
else
msg_info "$(translate "Detected Proxmox Backup Server") - $(translate "applying minimal banner patch")"
fi
+45
View File
@@ -62,3 +62,48 @@ ensure_repositories() {
fi
return 0
}
# The no-subscription switch of a Proxmox Backup Server: the same policy and
# API as on PVE, offered only when the host has no active subscription.
ensure_pbs_repositories() {
local suite decision refresh_status
if [[ "$(pmx_platform_field supported)" != true ]]; then
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
return 1
fi
suite=$(pmx_platform_field codename)
decision=$(repository_policy plan "$suite" pbs) || return 1
case "$decision" in
preserve) return 0 ;;
offer) ;;
*) msg_error "$(translate 'Repository policy returned an unexpected result.')"; return 1 ;;
esac
if ! declare -F hybrid_yesno >/dev/null || { [[ ! -t 0 ]] && ! { declare -F is_web_mode >/dev/null && is_web_mode; }; }; then
msg_error "$(translate 'No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.')"
return 1
fi
if ! hybrid_yesno "$(translate 'Proxmox repository')" \
"$(translate 'This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.')" 14 90; then
msg_error "$(translate 'Repository switch declined; no APT source changed.')"
return 1
fi
decision=$(repository_policy apply "$suite" pbs) || return 1
[[ "$decision" == changed || "$decision" == preserve ]] || return 1
if [[ "$decision" == changed ]]; then
if declare -F pmx_record_execution >/dev/null; then
local journal_function="${PMX_JOURNAL_FUNCTION:-}" journal_version="${PMX_JOURNAL_VERSION:-}"
pmx_journal_context "ensure_pbs_repositories" "" "${PMX_JOURNAL_SOURCE:-}"
pmx_record_execution "Switch to the PBS no-subscription repository" \
"proxmox-backup-debug api set /nodes/localhost/apt/repositories --handle no-subscription"
PMX_JOURNAL_FUNCTION="$journal_function" PMX_JOURNAL_VERSION="$journal_version"
fi
if apt-get update; then
return 0
else
refresh_status=$?
msg_error "$(translate 'Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.')"
return "$refresh_status"
fi
fi
return 0
}
+55 -28
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Proxmox repository policy via its own parsed APT repository API.
"""Proxmox VE and Proxmox Backup Server repository policy via their own parsed APT
repository API.
Only `notfound` or `expired` permits an offered switch. Never print raw subscription or
repository API responses: they can contain subscription keys or credentials.
@@ -12,6 +13,19 @@ import sys
ENDPOINT = '/nodes/localhost/apt/repositories'
PVE_CHANNELS = {'pve-enterprise', 'pve-no-subscription', 'pve-test', 'pvetest'}
# Proxmox VE and Proxmox Backup Server share the repository API; each has its
# own command line, channels and place in the web interface.
PRODUCTS = {
'pve': {'api': ['pvesh'], 'name': 'Proxmox VE', 'short': 'PVE',
'channels': PVE_CHANNELS, 'enterprise': 'pve-enterprise', 'public': 'pve-no-subscription',
'test': {'pve-no-subscription', 'pve-test', 'pvetest'},
'ui': 'Node > Updates > Repositories', 'ceph': True},
'pbs': {'api': ['proxmox-backup-debug', 'api'], 'name': 'Proxmox Backup Server', 'short': 'PBS',
'channels': {'pbs-enterprise', 'pbs-no-subscription', 'pbs-test', 'pbstest'},
'enterprise': 'pbs-enterprise', 'public': 'pbs-no-subscription',
'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'},
'ui': 'Administration > Repositories', 'ceph': False},
}
def run(args):
@@ -21,7 +35,16 @@ def run(args):
raise ValueError(f'Unable to query or change Proxmox repository state ({args[0]}). Check the service and retry.') from exc
def subscription_status():
def subscription_status(product='pve'):
if product == 'pbs':
# Only the status field is read; the answer also carries the server ID.
try:
status = json.loads(run(['proxmox-backup-manager', 'subscription', 'get', '--output-format', 'json'])).get('status')
except (ValueError, AttributeError):
status = None
if status not in ('active', 'notfound', 'expired'):
raise ValueError('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.')
return status
# pvesubscription's CLI get printer emits sorted "key: value" lines,
# including a secret key and server ID. Only parse the exact status line.
output = run(['pvesubscription', 'get'])
@@ -31,9 +54,10 @@ def subscription_status():
return statuses[0]
def repository_state(suite):
def repository_state(suite, product='pve'):
p = PRODUCTS[product]
try:
data = json.loads(run(['pvesh', 'get', ENDPOINT, '--output-format', 'json']))
data = json.loads(run([*p['api'], 'get', ENDPOINT, '--output-format', 'json']))
if not isinstance(data, dict) or not isinstance(data['files'], list) or not isinstance(data['errors'], list) or not isinstance(data['digest'], str) or not isinstance(data['standard-repos'], list):
raise ValueError()
if data['errors']:
@@ -86,13 +110,14 @@ def repository_state(suite):
entries.append((file['path'], index, row))
return data, entries
except (KeyError, TypeError, ValueError, IndexError) as exc:
raise ValueError('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in Node > Updates > Repositories; no repository changed.') from exc
raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc
def evaluate(suite, apply=False):
def evaluate(suite, apply=False, product='pve'):
if suite not in ('bookworm', 'trixie'):
raise ValueError('Unsupported Proxmox suite; no repository changed.')
data, entries = repository_state(suite)
p = PRODUCTS[product]
data, entries = repository_state(suite, product)
pve = []
ceph = []
debian = False
@@ -102,40 +127,40 @@ def evaluate(suite, apply=False):
if not row['Enabled'] or 'deb' not in row['Types']:
continue
components = set(row['Components'])
if components & PVE_CHANNELS:
if components & p['channels']:
if suite not in row['Suites']:
wrong_pve.append(f"{' '.join(sorted(components & PVE_CHANNELS))} in {path} (suite {' '.join(row['Suites'])})")
wrong_pve.append(f"{' '.join(sorted(components & p['channels']))} in {path} (suite {' '.join(row['Suites'])})")
pve.append((path, index, row))
if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS:
if 'main' in components and suite in row['Suites'] and not components & p['channels']:
debian = True
if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']):
if p['ceph'] and 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']):
if suite not in row['Suites']:
wrong_ceph.append(f"{path} (suite {' '.join(row['Suites'])})")
ceph.append((path, index, row))
# Name the stale sources, never their URIs: those can carry credentials.
if wrong_pve:
raise ValueError(f"This host runs Proxmox VE on {suite}, but these Proxmox VE repositories are for another suite: "
f"{'; '.join(wrong_pve)}. Disable or remove them in Node > Updates > Repositories and run the update again; no repository changed.")
raise ValueError(f"This host runs {p['name']} on {suite}, but these {p['name']} repositories are for another suite: "
f"{'; '.join(wrong_pve)}. Disable or remove them in {p['ui']} and run the update again; no repository changed.")
if wrong_ceph:
raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: "
f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.")
if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve):
if any(set(row['Components']) & p['test'] for _, _, row in pve):
return 'preserve'
# Entitlement lookup is only needed when considering a switch. Existing
# public/test channels are the operator's choice, even if lookup fails.
status = subscription_status() # fail closed before any write
status = subscription_status(product) # fail closed before any write
if status == 'active':
if not pve:
raise ValueError('Host has an active subscription but no active PVE repository. Configure its Enterprise source in Node > Updates > Repositories; no repository changed.')
raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.")
return 'preserve'
if not debian:
raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.')
# No active PVE source or only inaccessible Enterprise. A mixed stanza
# No active product source or only inaccessible Enterprise. A mixed stanza
# cannot be disabled without also disabling an unrelated component.
disable = [item for item in pve if 'pve-enterprise' in item[2]['Components']] + ceph
disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph
for _, _, row in disable:
expected = {'pve-enterprise'} if 'pve-enterprise' in row['Components'] else {'enterprise'}
expected = {p['enterprise']} if p['enterprise'] in row['Components'] else {'enterprise'}
if set(row['Components']) != expected:
raise ValueError('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.')
handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription']
@@ -149,9 +174,9 @@ def evaluate(suite, apply=False):
try:
for path, index, original in disable:
next(file for file in expected if file['path'] == path)['repositories'][index]['Enabled'] = False
run(['pvesh', 'create', ENDPOINT, '--path', path, '--index', str(index),
run([*p['api'], 'create', ENDPOINT, '--path', path, '--index', str(index),
'--enabled', '0', '--digest', data['digest']])
refreshed, current = repository_state(suite)
refreshed, current = repository_state(suite, product)
# Per-file digests change when Proxmox serializes the disabled entry.
inventory = lambda files: sorted(
({key: value for key, value in file.items() if key != 'digest'} for file in files),
@@ -159,23 +184,25 @@ def evaluate(suite, apply=False):
if inventory(refreshed['files']) != inventory(expected):
raise ValueError('Could not verify the expected repository inventory after Enterprise disable; possible concurrent edit. Inspect the Proxmox repository UI before retrying.')
data = refreshed
run(['pvesh', 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']])
_, current = repository_state(suite)
run([*p['api'], 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']])
_, current = repository_state(suite, product)
if not any(row['Enabled'] and 'deb' in row['Types'] and suite in row['Suites']
and 'pve-no-subscription' in row['Components'] for _, _, row in current) \
and p['public'] in row['Components'] for _, _, row in current) \
or any(p == path and i == index and row['Enabled']
for path, index, _ in disable for p, i, row in current):
raise ValueError('Could not verify the switched repositories; inspect the Proxmox repository UI before retrying.')
except ValueError as exc:
raise ValueError('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect Node > Updates > Repositories before retrying; no automatic rollback was attempted.') from exc
raise ValueError(f"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {p['ui']} before retrying; no automatic rollback was attempted.") from exc
return 'changed'
def main():
try:
if len(sys.argv) != 3 or sys.argv[1] not in ('plan', 'apply'):
raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie')
print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply'))
if len(sys.argv) not in (3, 4) or sys.argv[1] not in ('plan', 'apply') \
or (len(sys.argv) == 4 and sys.argv[3] not in PRODUCTS):
raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie [pve|pbs]')
product = sys.argv[3] if len(sys.argv) == 4 else 'pve'
print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply', product=product))
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
+188
View File
@@ -0,0 +1,188 @@
#!/bin/bash
# ==========================================================
# Proxmox Backup Server Update Script (post-install flows)
# ==========================================================
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"
fi
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
fi
source "$LOCAL_SCRIPTS/global/common-functions.sh"
source "$LOCAL_SCRIPTS/global/repository-functions.sh"
load_language
initialize_cache
update_pbs() {
local FUNC_VERSION="1.0"
pmx_journal_context "update_pbs" "$FUNC_VERSION"
if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
return 1
fi
local start_time
start_time=$(date +%s)
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
local OS_CODENAME
OS_CODENAME=$(pmx_platform_field codename)
{
echo "=== ProxMenux — Proxmox Backup Server update ==="
echo "Started: $(date -Iseconds)"
echo "Host: $(hostname)"
echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)"
} > "$log_file"
if ! ensure_pbs_repositories; then
msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")"
rm -f "$screen_capture"
return 1
fi
local available_space
available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
if [ "$available_space" -lt 1024 ]; then
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
return 1
fi
local update_output update_exit_code
update_output=$(apt-get -o APT::Update::Error-Mode=any update 2>&1)
update_exit_code=$?
{
echo
echo "--- apt-get update (exit $update_exit_code) ---"
printf '%s\n' "$update_output"
} >> "$log_file"
if [ $update_exit_code -ne 0 ]; then
msg_error "$(translate "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:") $log_file"
return 1
fi
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
local current_version available_version
current_version=$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)
available_version=$(apt-cache policy proxmox-backup-server 2>/dev/null | awk '/Candidate:/ {print $2; exit}')
local upgradable upgradable_raw upgradable_list security_updates
upgradable_raw=$($APT_ENV apt list --upgradable 2>/dev/null \
| sed '1d' \
| sed '/^\s*$/d')
upgradable=$(printf '%s' "$upgradable_raw" | grep -c . )
security_updates=$(printf '%s\n' "$upgradable_raw" | grep -ci '\-security')
upgradable_list=$(printf '%s\n' "$upgradable_raw" | pmx_format_upgradable)
{
echo
echo "--- Packages to upgrade ($upgradable) ---"
[ "$upgradable" -gt 0 ] && printf '%s\n' "$upgradable_list"
} >> "$log_file"
local menu_text
menu_text="$(translate "System Update Information")\n\n"
menu_text+="$(translate "Current PBS Version"): $current_version\n"
if [ -n "$available_version" ] && [ "$available_version" != "$current_version" ]; then
menu_text+="$(translate "Available PBS Version"): $available_version\n"
fi
menu_text+="\n$(translate "Package Updates Available"): $upgradable\n"
menu_text+="$(translate "Security Updates"): $security_updates\n\n"
if [ "$upgradable" -eq 0 ]; then
menu_text+="$(translate "System is already up to date")"
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
clear
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
cat "$screen_capture"
msg_ok "$(translate "System is already up to date. No update needed.")"
echo -e "\nSystem is already up to date." >> "$log_file"
rm -f "$screen_capture"
return 0
fi
menu_text+="$(translate "Packages to be upgraded"):\n$upgradable_list\n\n"
menu_text+="$(translate "Do you want to proceed with the system update?")"
local proceed=0
whiptail --title "$(translate "Proxmox Backup Server Update")" --scrolltext --yesno "$menu_text" 24 78 || proceed=1
clear
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
cat "$screen_capture"
if [ $proceed -ne 0 ]; then
msg_info2 "$(translate "Update cancelled by user")"
rm -f "$screen_capture"
return 0
fi
pmx_record_execution "Upgrade Proxmox Backup Server packages" "apt -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade"
local dpkg_mark upgrade_exit_code
dpkg_mark=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "\n--- apt full-upgrade ---" >> "$log_file"
DEBIAN_FRONTEND=noninteractive apt -y \
-o Dpkg::Options::='--force-confdef' \
-o Dpkg::Options::='--force-confold' \
full-upgrade 2> >(tee -a "$log_file" >&2)
upgrade_exit_code=$?
echo -e
{
echo
echo "--- Packages changed (exit $upgrade_exit_code) ---"
pmx_dpkg_changes_since "$dpkg_mark"
} >> "$log_file"
clear
show_proxmenux_logo
msg_title "$(translate "$SCRIPT_TITLE")"
cat "$screen_capture"
rm -f "$screen_capture"
if [ $upgrade_exit_code -ne 0 ]; then
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
echo "Finished: $(date -Iseconds) — upgrade failed (exit $upgrade_exit_code)" >> "$log_file"
return 1
fi
pmx_record_execution "Remove unused packages" "apt-get -y autoremove"
apt-get -y autoremove > /dev/null 2>&1 || true
apt-get -y autoclean > /dev/null 2>&1 || true
msg_ok "$(translate "Cleanup finished")"
local end_time duration minutes seconds
end_time=$(date +%s)
duration=$((end_time - start_time))
minutes=$((duration / 60))
seconds=$((duration % 60))
echo -e "${TAB}${BGN}$(translate "====== PBS UPDATE COMPLETED ======")${CL}"
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
echo -e "${TAB}${GN}🗄️ $(translate "Proxmox Backup Server")${CL}: ${BL}$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null) (Debian $OS_CODENAME)${CL}"
{
echo
echo "Finished: $(date -Iseconds) — ${minutes}m ${seconds}s"
echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)"
} >> "$log_file"
if [ -f /var/run/reboot-required ] || [ "$(uname -r)" != "$(ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's|/boot/vmlinuz-||' | sort -V | tail -1)" ]; then
msg_warn "$(translate "A newer kernel is installed. Reboot before the next backup window to use it.")"
fi
msg_ok "$(translate "Proxmox Backup Server update completed.")"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
update_pbs
fi