fix: require subscription-aware consent for PVE repository switches

This commit is contained in:
martino
2026-09-29 18:04:12 +02:00
parent eb7cc548fa
commit e57a2f3aae
8 changed files with 618 additions and 231 deletions
+11
View File
@@ -0,0 +1,11 @@
# Repository choice in dependency and safe-update flows
On PVE 8/9, the shared helper checks `pvesubscription get` (only the `status` field, never logging the key) and reads parsed APT sources through Proxmox's `/nodes/localhost/apt/repositories` API. An **active** subscription preserves the configured Enterprise repository and all other sources; if no PVE channel is active, the flow stops for manual repair rather than adding one. An already active PVE no-subscription or test channel is likewise preserved. Repository files may use custom names, `.list` or deb822 `.sources`, and mirrors; presence of a filename alone does not establish an active channel.
A fresh ISO may have Enterprise enabled but **no subscription** (`status: notfound`). Before changing anything, interactive flows ask: **“This host has no subscription, switch to the no-subscription repository?”** Acceptance disables active Enterprise PVE entries with Proxmox's per-entry API and adds Proxmox's standard no-subscription PVE repository. If an Enterprise Ceph source is active, it is disabled too, but **no Ceph replacement is chosen**: the appropriate Ceph release/channel must be selected separately in **Node > Updates > Repositories** if Ceph is used. Unrelated stanzas and Debian sources remain untouched. A mixed-component Enterprise stanza cannot be disabled safely, so the flow stops for manual splitting instead. A missing Debian base source also stops for manual configuration, rather than guessing a mirror.
Declining or running without an interactive terminal/web dialog makes **no source changes**. Unrecognized, expired, invalid, suspended, malformed, or unavailable subscription status is **not** interpreted as no subscription; lookup and repository parser errors stop before changes. The apply step rechecks the subscription and repository inventory and uses the API digest on writes. A failure during a multi-entry API update can leave a partial switch; inspect the repository UI before retrying. General APT error-handling policy is unchanged in this focused patch; stricter index refresh and broader dependency validation remain separate work. The NVIDIA interactive driver installer checks repositories before downloading or removing the working driver, and the noninteractive reinstall propagates refusal.
This policy does **not** change the legacy automated post-install repository updater, which separately advertises and asks for free repositories. No fresh-ISO live integration has been run; fixture tests exercise the policy without touching the host's APT configuration.
Primary references: [Proxmox subscription CLI and status schema](https://github.com/proxmox/pve-manager/blob/master/PVE/CLI/pvesubscription.pm), [Proxmox APT repository API](https://github.com/proxmox/pve-manager/blob/master/PVE/API2/APT.pm), [Proxmox standard repository definitions](https://github.com/proxmox/proxmox-rs/blob/master/proxmox-apt/src/repositories/standard.rs), [Package Repositories](https://pve.proxmox.com/wiki/Package_Repositories).
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Shared repository policy for package-install and safe-update flows.
# Proxmox's own repository API parses .list/.sources and edits individual
# entries. No shell rewriting of operator-maintained APT files.
repository_policy() {
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
}
ensure_repositories() {
local version suite decision
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
case "$version" in
8) suite=bookworm ;;
9) suite=trixie ;;
*) msg_error "$(translate 'Unsupported or unknown Proxmox version; no repository changed.')"; return 1 ;;
esac
# Do not hide diagnostics or open a spinner during user interaction.
decision=$(repository_policy plan "$suite") || 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 subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.')"
return 1
fi
if ! hybrid_yesno "$(translate 'Proxmox repository')" \
"$(translate 'This host has no subscription, switch to the no-subscription repository? The inaccessible Enterprise PVE source will be disabled. Enterprise Ceph sources, if present, will be disabled without choosing a replacement Ceph channel; configure Ceph separately if needed.')" 16 90; then
msg_error "$(translate 'Repository switch declined; no APT source changed.')"
return 1
fi
decision=$(repository_policy apply "$suite") || return 1
[[ "$decision" == changed || "$decision" == preserve ]] || return 1
return 0
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Proxmox repository policy via its own parsed APT repository API.
Only `notfound` permits an offered switch. Never print raw subscription or
repository API responses: they can contain subscription keys or credentials.
"""
import copy
import json
import re
import subprocess
import sys
ENDPOINT = '/nodes/localhost/apt/repositories'
PVE_CHANNELS = {'pve-enterprise', 'pve-no-subscription', 'pve-test', 'pvetest'}
def run(args):
try:
return subprocess.run(args, check=True, capture_output=True, text=True).stdout
except (OSError, subprocess.CalledProcessError) as exc:
raise ValueError(f'Unable to query or change Proxmox repository state ({args[0]}). Check the service and retry.') from exc
def subscription_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'])
statuses = re.findall(r'^status: ([a-z]+)$', output, re.MULTILINE)
if len(statuses) != 1 or statuses[0] not in ('active', 'notfound'):
raise ValueError('Subscription status is not unambiguously active or notfound. Check pvesubscription get locally; no repository changed.')
return statuses[0]
def repository_state(suite):
try:
data = json.loads(run(['pvesh', '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']:
raise ValueError()
entries = []
for file in data['files']:
for index, row in enumerate(file['repositories']):
if not isinstance(row, dict):
raise ValueError()
# Flat repositories (e.g. suite './') legitimately omit this.
row.setdefault('Components', [])
if not isinstance(row['Enabled'], bool) or not all(
isinstance(row[k], list) and all(isinstance(value, str) for value in row[k])
for k in ('Types', 'URIs', 'Suites', 'Components')
):
raise ValueError()
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
def evaluate(suite, apply=False):
if suite not in ('bookworm', 'trixie'):
raise ValueError('Unsupported Proxmox suite; no repository changed.')
status = subscription_status() # fail closed before inventory or any write
data, entries = repository_state(suite)
pve = []
ceph = []
debian = False
for path, index, row in entries:
if not row['Enabled'] or 'deb' not in row['Types']:
continue
components = set(row['Components'])
if components & PVE_CHANNELS:
if suite not in row['Suites']:
raise ValueError('PVE repository suite does not match the installed version; no repository changed.')
pve.append((path, index, row))
if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS:
debian = True
if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']):
if suite not in row['Suites']:
raise ValueError('Enterprise Ceph repository suite does not match this PVE version; correct it in the Proxmox repository UI before switching.')
ceph.append((path, index, row))
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.')
return 'preserve'
if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve):
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
# cannot be disabled without also disabling an unrelated component.
disable = [item for item in pve if 'pve-enterprise' in item[2]['Components']] + ceph
for _, _, row in disable:
expected = {'pve-enterprise'} if 'pve-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']
if len(handles) != 1:
raise ValueError('Proxmox no-subscription standard repository handle unavailable; no repository changed.')
if not apply:
return 'offer'
# Adopt a refreshed digest only after checking the complete expected
# inventory: path/index targets are unsafe if another writer changed it.
expected = copy.deepcopy(data['files'])
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),
'--enabled', '0', '--digest', data['digest']])
refreshed, current = repository_state(suite)
# 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),
key=lambda file: file['path'])
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)
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) \
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
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'))
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+2 -82
View File
@@ -12,88 +12,8 @@
# ========================================================== # Repository policy shared with utility installers.
# Ensure repositories are properly configured source "$(dirname "${BASH_SOURCE[0]}")/repository-functions.sh"
# ==========================================================
ensure_repositories() {
local pve_version need_update=false
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
if [[ -z "$pve_version" ]]; then
msg_error "$(translate 'Unable to detect Proxmox version.')"
return 1
fi
if (( pve_version >= 9 )); then
# ===== PVE 9 (Debian 13 - trixie) =====
# proxmox.sources (no-subscription) - create if missing.
# chmod 0644 explicit on every new .sources file: under the default
# root umask 0027 the redirect would land at 0640, which the PVE 9
# webgui's repository manager treats as unparseable and silently
# hides the source — issue #230.
if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then
cat > /etc/apt/sources.list.d/proxmox.sources <<'EOF'
Enabled: true
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
chmod 0644 /etc/apt/sources.list.d/proxmox.sources
need_update=true
fi
# debian.sources - create if missing
if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then
cat > /etc/apt/sources.list.d/debian.sources <<'EOF'
Types: deb
URIs: http://deb.debian.org/debian/
Suites: trixie trixie-updates
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://security.debian.org/debian-security/
Suites: trixie-security
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
chmod 0644 /etc/apt/sources.list.d/debian.sources
need_update=true
fi
else
# ===== PVE 8 (Debian 12 - bookworm) =====
local sources_file="/etc/apt/sources.list"
# Debian base (create or append minimal lines if missing)
if ! grep -qE 'deb .* bookworm .* main' "$sources_file" 2>/dev/null; then
{
echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware"
echo "deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware"
echo "deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware"
} >> "$sources_file"
need_update=true
fi
# Proxmox no-subscription list (classic) if missing
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
> /etc/apt/sources.list.d/pve-no-subscription.list
need_update=true
fi
fi
# apt-get update only if needed or lists are empty
if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then
msg_info "$(translate 'Updating APT package lists...')"
apt-get update >/dev/null 2>&1 || apt-get update
msg_ok "$(translate 'APT package lists updated')"
fi
return 0
}
# ========================================================== # ==========================================================
+25 -53
View File
@@ -9,36 +9,31 @@
# Description: # Description:
# Update path intended for a Proxmox host ALREADY in # Update path intended for a Proxmox host ALREADY in
# production. Unlike scripts/global/update-pve8.sh and # production. Unlike scripts/global/update-pve8.sh and
# update-pve9_2.sh (invoked by post_install), this variant # update-pve9_2.sh (invoked by post_install), this variant preserves
# NEVER modifies the operator's own configuration: # operator-maintained sources unless an unsubscribed host explicitly chooses
# to switch. Otherwise, the operator's source configuration is preserved:
# #
# - Does NOT disable Enterprise / Ceph repositories # - Does NOT silently disable Enterprise / Ceph repositories
# - Does NOT delete legacy repo files # - Does NOT delete or deduplicate existing repo files
# - Does NOT overwrite proxmox.sources / debian.sources # - Does NOT overwrite proxmox.sources / debian.sources
# when they already exist
# - Does NOT purge alternative NTP services # - Does NOT purge alternative NTP services
# - Does NOT force-install zfsutils / chrony / # - Does NOT force-install zfsutils / chrony /
# proxmox-backup-restore-image # proxmox-backup-restore-image
# - Does NOT write no-firmware-warnings.conf # - Does NOT write no-firmware-warnings.conf
# #
# What it DOES: # What it DOES:
# 1. Sanity checks (disk space, network) # 1. Sanity checks (disk space)
# 2. ensure_repositories() — only when repos are MISSING # 2. ensure_repositories() — check subscription and request consent if needed
# 3. apt-get update, with automatic GPG key import when apt # 3. apt-get update, with automatic GPG key import when apt
# reports NO_PUBKEY (any repo, user's or ours) # reports NO_PUBKEY (any repo, user's or ours)
# 4. cleanup_duplicate_repos() — exact URL+Suite+Component # 4. Detect pending upgrades + security count
# match against proxmox.sources / debian.sources; leaves # 5. Confirmation dialog
# unrelated custom `download.proxmox.com/*` and # 6. apt-get full-upgrade with --force-confdef / --force-confold
# user-authored pve-*.list files alone; backs each file
# up before modifying
# 5. Detect pending upgrades + security count
# 6. Confirmation dialog
# 7. apt-get full-upgrade with --force-confdef / --force-confold
# (never overwrites the operator's edited config files) # (never overwrites the operator's edited config files)
# 8. lvm_repair_check() — refreshes VG metadata when disks # 7. lvm_repair_check() — refreshes VG metadata when disks
# passed through to guest VMs (DSM, TrueNAS, …) come back # passed through to guest VMs (DSM, TrueNAS, …) come back
# with old PV headers # with old PV headers
# 9. apt-get autoremove + autoclean # 8. apt-get autoremove + autoclean
# #
# Reboot detection is handled by the caller (utilities/proxmox_update.sh). # Reboot detection is handled by the caller (utilities/proxmox_update.sh).
# ========================================================== # ==========================================================
@@ -66,6 +61,8 @@ source_install_functions() {
local f="$LOCAL_SCRIPTS/global/utils-install-functions.sh" local f="$LOCAL_SCRIPTS/global/utils-install-functions.sh"
if [[ -f "$f" ]]; then if [[ -f "$f" ]]; then
source "$f" source "$f"
else
return 1
fi fi
} }
@@ -91,8 +88,11 @@ update_pve_safe() {
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt" local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
: > "$screen_capture" : > "$screen_capture"
download_common_functions if ! download_common_functions || ! source_install_functions; then
source_install_functions msg_error "$(translate 'Required update helpers unavailable. Update stopped.')"
rm -f "$screen_capture"
return 1
fi
{ {
msg_info2 "$(translate "Detected: Proxmox VE $pve_version — running safe update path")" msg_info2 "$(translate "Detected: Proxmox VE $pve_version — running safe update path")"
@@ -110,39 +110,14 @@ update_pve_safe() {
return 1 return 1
fi fi
# Reachability check: probe the public Proxmox repository over the # Enterprise hosts and custom mirrors need not reach the public CDN.
# transport apt is most likely to use. Many PVE installs use the # The configured sources, not that hostname, are checked by APT below.
# official HTTP apt URI, while HTTPS may fail before apt ever runs if ! declare -F ensure_repositories >/dev/null 2>&1 || ! ensure_repositories; then
# if the CDN presents a certificate for another Proxmox hostname. msg_error "$(translate 'Repository check failed. Update stopped.')"
# Accept either transport and let apt-get update report repo-specific
# errors in the next step.
_repo_reachable() {
local url attempt
for url in "http://download.proxmox.com/" "https://download.proxmox.com/"; do
for attempt in 1 2; do
if curl -sfI --connect-timeout 5 --max-time 10 -o /dev/null "$url"; then
return 0
fi
[[ $attempt -eq 1 ]] && sleep 1
done
done
return 1
}
if ! _repo_reachable; then
msg_error "$(translate "Cannot reach download.proxmox.com. Check network, proxy or DNS.")"
echo -e
msg_success "$(translate "Press Enter to return to menu...")"
read -r
rm -f "$screen_capture" rm -f "$screen_capture"
return 1 return 1
fi fi
# ── 2. ensure_repositories: adds base Proxmox+Debian repos only if
# they don't already exist. On a configured host this is a no-op. ──
if declare -f ensure_repositories >/dev/null 2>&1; then
ensure_repositories
fi
# ── 3. apt-get update with automatic key recovery ── # ── 3. apt-get update with automatic key recovery ──
local update_output update_exit_code local update_output update_exit_code
update_output=$(apt-get update 2>&1) update_output=$(apt-get update 2>&1)
@@ -191,11 +166,8 @@ update_pve_safe() {
fi fi
fi fi
# ── 4. Precise duplicate cleanup (exact URL+Suite+Component match, # No duplicate-source rewrite here: even a seemingly duplicate entry may
# backs up files before modifying). Skipped if unavailable. ── # be operator-maintained. Only the consented switch above edits sources.
if declare -f cleanup_duplicate_repos >/dev/null 2>&1; then
cleanup_duplicate_repos
fi
# ── 5-6. Detect + confirm ── # ── 5-6. Detect + confirm ──
local current_pve_version available_pve_version upgradable security_updates local current_pve_version available_pve_version upgradable security_updates
+3 -84
View File
@@ -39,95 +39,14 @@ PROXMENUX_UTILS=(
) )
# Ensure APT repositories are configured for the current PVE version. # Shared journal helpers for utility installs.
# Creates missing no-subscription repo entries for PVE8 (bookworm) or PVE9 (trixie).
# Shared journal helpers, so any script sourcing this file records what
# it installs without arranging for it.
if [[ -f "${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/global/pmx_journal.sh" ]]; then if [[ -f "${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/global/pmx_journal.sh" ]]; then
source "${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/global/pmx_journal.sh" source "${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/global/pmx_journal.sh"
fi fi
ensure_repositories() { # Shared subscription and consent policy for all utility callers.
local FUNC_VERSION="1.0" source "$(dirname "${BASH_SOURCE[0]}")/repository-functions.sh"
pmx_journal_context "ensure_repositories" "$FUNC_VERSION"
local pve_version need_update=false
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
if [[ -z "$pve_version" ]]; then
msg_error "Unable to detect Proxmox version."
return 1
fi
if (( pve_version >= 9 )); then
# ===== PVE 9 (Debian 13 - trixie) =====
# Force 0644 (world-readable) on every .sources file we drop.
# Under the default root umask 0027 the redirect would land at
# 0640, which the PVE 9 webgui's repository manager treats as
# unparseable and silently hides the source — issue #230.
if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then
pmx_write_file /etc/apt/sources.list.d/proxmox.sources <<'EOF'
Enabled: true
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
chmod 0644 /etc/apt/sources.list.d/proxmox.sources
need_update=true
fi
if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then
pmx_write_file /etc/apt/sources.list.d/debian.sources <<'EOF'
Types: deb
URIs: http://deb.debian.org/debian/
Suites: trixie trixie-updates
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://security.debian.org/debian-security/
Suites: trixie-security
Components: main contrib non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
chmod 0644 /etc/apt/sources.list.d/debian.sources
need_update=true
fi
else
# ===== PVE 8 (Debian 12 - bookworm) =====
local sources_file="/etc/apt/sources.list"
if ! grep -qE 'deb .* bookworm .* main' "$sources_file" 2>/dev/null; then
{
echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware"
echo "deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware"
echo "deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware"
} | pmx_append_file "$sources_file"
need_update=true
fi
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
| pmx_write_file /etc/apt/sources.list.d/pve-no-subscription.list
need_update=true
fi
fi
if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then
msg_info "$(translate "Updating APT package lists...")"
apt-get update >/dev/null 2>&1 || apt-get update
# Spinner pair: msg_info must be closed before returning.
# Without this the next `msg_info` caller spawns a second
# spinner on top of ours and the original line never gets
# ✓'d — leaving a dangling progress char on screen.
msg_ok "$(translate "APT package lists updated")"
fi
return 0
}
# Install a single package and verify the resulting command is available. # Install a single package and verify the resulting command is available.
+18 -12
View File
@@ -525,21 +525,24 @@ offer_lxc_updates_if_any() {
# ========================================================== # ==========================================================
ensure_repos_and_headers() { ensure_repos_and_headers() {
pmx_journal_context "ensure_repos_and_headers" "1.3" "nvidia_installer.sh" pmx_journal_context "ensure_repos_and_headers" "1.3" "nvidia_installer.sh"
# Bootstrap APT repos FIRST. On a fresh Proxmox install the # Check repositories before opening the headers spinner. A fresh ISO may
# pve-no-subscription / debian repos aren't configured by default # have Enterprise enabled but no subscription; the shared policy asks the
# → `pve-headers-$(uname -r)` and `build-essential` come back as # operator before switching. Refusal stops before driver removal.
# "Unable to locate package" and the NVIDIA install bails out with
# "no cc found". We delegate to the shared helper (same one the
# post-install flow uses), which owns its own spinner pair — that's
# why this block has to run BEFORE we open our own msg_info.
if ! declare -F ensure_repositories >/dev/null 2>&1; then if ! declare -F ensure_repositories >/dev/null 2>&1; then
local _utils_install="$LOCAL_SCRIPTS/global/utils-install-functions.sh" local _utils_install="$LOCAL_SCRIPTS/global/utils-install-functions.sh"
[[ ! -f "$_utils_install" ]] && _utils_install="/usr/local/share/proxmenux/scripts/global/utils-install-functions.sh" [[ ! -f "$_utils_install" ]] && _utils_install="/usr/local/share/proxmenux/scripts/global/utils-install-functions.sh"
# shellcheck source=/dev/null # shellcheck source=/dev/null
[[ -f "$_utils_install" ]] && source "$_utils_install" [[ -f "$_utils_install" ]] && source "$_utils_install"
fi fi
if declare -F ensure_repositories >/dev/null 2>&1; then if ! declare -F ensure_repositories >/dev/null 2>&1; then
ensure_repositories >>"$LOG_FILE" 2>&1 || true msg_error "$(translate 'Repository helper unavailable. NVIDIA installation stopped.')"
return 1
fi
# Keep the consent dialog and diagnostic visible, not just in the log.
ensure_repositories 2>&1 | tee -a "$LOG_FILE"
if (( PIPESTATUS[0] != 0 )); then
msg_error "$(translate 'Repository check failed. NVIDIA installation stopped.')"
return 1
fi fi
# Now own the spinner for the headers + build-tools check. # Now own the spinner for the headers + build-tools check.
@@ -548,7 +551,10 @@ ensure_repos_and_headers() {
local kver local kver
kver=$(uname -r) kver=$(uname -r)
apt-get update -qq >>"$LOG_FILE" 2>&1 if ! apt-get update -qq >>"$LOG_FILE" 2>&1; then
msg_error "$(translate 'APT update failed. Check repository access; NVIDIA installation stopped.')"
return 1
fi
if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \ if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \
! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then ! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then
@@ -2184,7 +2190,7 @@ main() {
# Headers before anything else: the build check below needs them, # Headers before anything else: the build check below needs them,
# and so does DKMS afterwards. # and so does DKMS afterwards.
ensure_repos_and_headers ensure_repos_and_headers || exit 1
installer=$(download_nvidia_installer "$DRIVER_VERSION") installer=$(download_nvidia_installer "$DRIVER_VERSION")
local download_result=$? local download_result=$?
@@ -2442,7 +2448,7 @@ auto_reinstall_from_state() {
# dialogs and confirmations. # dialogs and confirmations.
echo "Reinstalling NVIDIA driver $DRIVER_VERSION non-interactively..." | tee -a "$LOG_FILE" echo "Reinstalling NVIDIA driver $DRIVER_VERSION non-interactively..." | tee -a "$LOG_FILE"
ensure_workdir ensure_workdir
ensure_repos_and_headers >>"$LOG_FILE" 2>&1 ensure_repos_and_headers >>"$LOG_FILE" 2>&1 || return 2
blacklist_nouveau >>"$LOG_FILE" 2>&1 blacklist_nouveau >>"$LOG_FILE" 2>&1
ensure_modules_config >>"$LOG_FILE" 2>&1 ensure_modules_config >>"$LOG_FILE" 2>&1
+381
View File
@@ -0,0 +1,381 @@
"""Fixture-only policy checks: never call host pvesh or edit host APT sources."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
POLICY = ROOT / 'scripts/global/repository_policy.py'
def repo(components, *, uri='https://mirror.example/debian/pve', suite='trixie', enabled=True):
return {'Types': ['deb'], 'URIs': [uri], 'Suites': [suite],
'Components': components.split(), 'Enabled': enabled}
def fixture(*entries):
files = {}
for path, entries_for_file in entries:
files[path] = {'path': path, 'repositories': entries_for_file}
return {'files': list(files.values()), 'errors': [], 'digest': 'fixture-digest',
'standard-repos': [{'handle': 'no-subscription', 'name': 'PVE No-Subscription'}]}
class RepositoryPolicyTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
spec = importlib.util.spec_from_file_location('repository_policy', POLICY)
cls.module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.module)
def setUp(self):
self.calls = []
self.mutate = True
self.after_write = lambda: None
self.writes = 0
self.subscription = 'status: notfound\nmessage: There is no subscription key\n'
self.repositories = fixture(
('/etc/apt/sources.list.d/custom.sources', [repo('pve-enterprise')]),
('/etc/apt/sources.list.d/debian.sources', [repo('main', uri='https://deb.example/debian')]),
)
def run_policy(self, apply=False, suite='trixie'):
def run(args):
self.calls.append(args)
if args[:2] == ['pvesubscription', 'get']:
if isinstance(self.subscription, Exception):
raise ValueError('service unavailable') from self.subscription
return self.subscription
if args[:2] == ['pvesh', 'get']:
return json.dumps(self.repositories)
if args[1] in ('create', 'set'):
self.assertEqual(args[args.index('--digest') + 1], self.repositories['digest'])
self.writes += 1
if self.mutate and args[:2] == ['pvesh', 'create']:
path = args[args.index('--path') + 1]
index = int(args[args.index('--index') + 1])
next(f for f in self.repositories['files'] if f['path'] == path)['repositories'][index]['Enabled'] = False
if self.mutate and args[:2] == ['pvesh', 'set']:
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/proxmox.sources',
'repositories': [repo('pve-no-subscription')]})
self.repositories['digest'] = f'write-{self.writes}'
self.after_write()
return ''
with patch.object(self.module, 'run', side_effect=run):
return self.module.evaluate(suite, apply=apply)
def test_concurrent_inventory_edit_aborts_before_second_mutation(self):
for edit in ('replace', 'reorder', 'unrelated'):
with self.subTest(edit=edit):
self.setUp()
rows = self.repositories['files'][0]['repositories']
rows.extend([repo('enterprise', uri='https://example/ceph-squid'),
repo('custom')])
def external_edit():
if self.writes != 1:
return
if edit == 'replace':
rows[1] = repo('custom-replacement')
elif edit == 'reorder':
rows[1], rows[2] = rows[2], rows[1]
else:
self.repositories['files'][1]['repositories'][0]['Options'] = [
{'Key': 'Signed-By', 'Values': ['/external/keyring.gpg']}]
self.repositories['digest'] = 'external-edit'
self.after_write = external_edit
with self.assertRaises(ValueError):
self.run_policy(apply=True)
self.assertEqual(self.writes, 1)
self.assertTrue(rows[1]['Enabled'])
self.assertTrue(rows[2]['Enabled'])
def test_failed_post_write_readback_reports_partial_or_unknown_state(self):
for after in (1, 2):
with self.subTest(after=after):
self.setUp()
def break_readback():
if self.writes == after:
self.repositories['errors'] = [{'error': 'external parse error'}]
self.after_write = break_readback
with self.assertRaises(ValueError) as error:
self.run_policy(apply=True)
self.assertEqual(self.writes, after)
self.assertNotIn('no repository changed', str(error.exception))
self.assertRegex(str(error.exception), 'partial|unknown')
def test_inventory_comparison_accepts_api_serialization_and_new_digests(self):
self.repositories['files'][0]['repositories'].append(
repo('enterprise', uri='https://example/ceph-squid'))
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/flat.list',
'repositories': [repo('', uri='https://example/flat', suite='./')]})
def serialize():
for file in self.repositories['files']:
file['digest'] = f'file-digest-{self.writes}'
for row in file['repositories']:
if row.get('Components') == []:
del row['Components']
self.repositories['files'].reverse()
self.after_write = serialize
self.assertEqual(self.run_policy(apply=True), 'changed')
self.assertEqual(self.writes, 3)
def test_fresh_iso_requires_consent_before_any_write(self):
self.assertEqual(self.run_policy(), 'offer')
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_consented_switch_disables_enterprise_then_adds_public_pve(self):
self.assertEqual(self.run_policy(apply=True), 'changed')
writes = [c for c in self.calls if c[1] in ('create', 'set')]
self.assertEqual([c[1] for c in writes], ['create', 'set'])
self.assertEqual(writes[0][writes[0].index('--path') + 1], '/etc/apt/sources.list.d/custom.sources')
self.assertEqual(writes[0][writes[0].index('--enabled') + 1], '0')
self.assertEqual(writes[1][writes[1].index('--handle') + 1], 'no-subscription')
def test_api_noop_after_write_does_not_report_success(self):
self.mutate = False
with self.assertRaisesRegex(ValueError, 'verify'):
self.run_policy(apply=True)
def test_flat_repository_without_components_is_preserved(self):
self.subscription = 'status: active\n'
flat = repo('', uri='https://vendor.example/flat', suite='./')
del flat['Components']
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/vendor.list',
'repositories': [flat]})
before = json.dumps(self.repositories)
self.assertEqual(self.run_policy(apply=True), 'preserve')
self.assertEqual(json.dumps(self.repositories), before)
self.assertEqual(self.writes, 0)
def test_malformed_components_fail_closed(self):
self.subscription = 'status: active\n'
for components in (None, 'main', {}, [42]):
with self.subTest(components=components):
self.repositories['files'][1]['repositories'][0]['Components'] = components
with self.assertRaises(ValueError):
self.run_policy(apply=True)
self.assertEqual(self.writes, 0)
def test_active_subscription_preserves_enterprise(self):
self.subscription = 'key: pve1x-SECRET\nstatus: active\nserverid: SECRET\n'
self.assertEqual(self.run_policy(apply=True), 'preserve')
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_active_subscription_without_pve_source_stops_without_add(self):
self.subscription = 'status: active\n'
self.repositories['files'][0]['repositories'][0]['Enabled'] = False
with self.assertRaisesRegex(ValueError, 'active subscription'):
self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_existing_public_or_test_preserved_without_switch(self):
for channel in ('pve-no-subscription', 'pve-test'):
with self.subTest(channel=channel):
self.repositories['files'][0]['repositories'] = [repo(channel)]
self.assertEqual(self.run_policy(), 'preserve')
def test_legacy_pvetest_list_on_pve8_is_preserved(self):
self.repositories = fixture(
('/etc/apt/sources.list.d/operator.list', [repo('pvetest', suite='bookworm')]),
('/etc/apt/sources.list', [repo('main', uri='https://deb.example/debian', suite='bookworm')]),
)
self.assertEqual(self.run_policy(suite='bookworm'), 'preserve')
def test_disabled_enterprise_stanza_is_not_disabled_again(self):
self.repositories['files'][0]['repositories'][0]['Enabled'] = False
self.assertEqual(self.run_policy(apply=True), 'changed')
self.assertFalse(any(c[1] == 'create' for c in self.calls))
def test_unrecognized_and_failed_subscription_cannot_mutate(self):
for response in ('status: unknown\n', 'status: invalid\n', 'status: active\nstatus: notfound\n',
'key: secret\n', RuntimeError('service unavailable')):
with self.subTest(response=response):
self.subscription = response
self.calls = []
with self.assertRaises(ValueError):
self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_multiple_stanzas_disables_only_pve_enterprise_and_ceph_enterprise(self):
self.repositories = fixture(
('/etc/apt/sources.list.d/anything.sources', [
repo('main', uri='https://debian.example/debian'),
repo('pve-enterprise'),
repo('enterprise', uri='https://enterprise.proxmox.com/debian/ceph-squid'),
repo('pve-enterprise', enabled=False),
]),
)
self.assertEqual(self.run_policy(apply=True), 'changed')
writes = [c for c in self.calls if c[1] == 'create']
self.assertEqual([int(c[c.index('--index') + 1]) for c in writes], [1, 2])
def test_mixed_components_block_switch_without_partial_write(self):
self.repositories['files'][0]['repositories'] = [repo('pve-enterprise custom')]
with self.assertRaises(ValueError):
self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_wrong_suite_and_parse_errors_block_before_writes(self):
for mode in ('suite', 'error'):
with self.subTest(mode=mode):
self.repositories = fixture(
('/etc/apt/sources.list.d/custom.list', [repo('pve-enterprise', suite='bookworm')]),
('/etc/apt/sources.list.d/debian.sources', [repo('main', uri='https://deb.example/debian')]),
)
if mode == 'error':
self.repositories['errors'] = [{'path': 'custom.list', 'error': 'invalid'}]
with self.assertRaises(ValueError):
self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_ceph_enterprise_mirror_is_disabled_without_choosing_ceph_channel(self):
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/storage.list',
'repositories': [repo('enterprise', uri='https://mirror.example/ceph-squid')]})
self.assertEqual(self.run_policy(apply=True), 'changed')
writes = [c for c in self.calls if c[1] == 'create']
self.assertEqual(len(writes), 2)
self.assertFalse(any(c[1] == 'set' and 'ceph' in ' '.join(c) for c in self.calls))
def test_wrong_suite_ceph_stops_before_switch(self):
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/ceph.sources',
'repositories': [repo('enterprise', uri='https://enterprise.proxmox.com/debian/ceph-reef', suite='bookworm')]})
with self.assertRaisesRegex(ValueError, 'suite'):
self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
def test_missing_debian_base_blocks_switch(self):
self.repositories['files'].pop()
with self.assertRaises(ValueError):
self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls))
class CallerPropagationTest(unittest.TestCase):
def test_safe_update_refusal_blocks_apt_update(self):
source = (ROOT / 'scripts/global/update-pve-safe.sh').read_text()
body = source.split('update_pve_safe() {', 1)[1].split(
'\nif [[ "${BASH_SOURCE[0]}"', 1)[0]
with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as d:
body = body.replace('/var/log/', f'{d}/').replace(
'/tmp/proxmenux_screen_capture_', f'{d}/proxmenux_screen_capture_')
script = '''
pveversion() { printf 'pve-manager/9.0.0\\n'; }
translate() { printf '%s' "$1"; }
msg_info2() { :; }
msg_error() { :; }
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted on\\n';
printf 'fixture 9999999 1 9999999 1%% /\\n'; }
download_common_functions() { :; }
source_install_functions() { :; }
ensure_repositories() { return 1; }
apt-get() { touch "$TEST_ROOT/apt_called"; }
update_pve_safe() {
''' + body + '\nupdate_pve_safe\n'
result = subprocess.run(['bash', '-c', script], env=dict(os.environ, TEST_ROOT=d),
capture_output=True, text=True, stdin=subprocess.DEVNULL,
timeout=15)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((Path(d) / 'apt_called').exists())
def test_nvidia_refusal_preserves_working_driver_before_uninstall(self):
source = (ROOT / 'scripts/gpu_tpu/nvidia_installer.sh').read_text()
main = 'main() {' + source.split('main() {', 1)[1].split(
'\n# ==========================================================\n# Non-interactive', 1)[0]
with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as d:
script = '''
LOG_FILE="$TEST_ROOT/log"; screen_capture="$TEST_ROOT/screen"
NVIDIA_GPU_PRESENT=true; CURRENT_DRIVER_INSTALLED=true
CURRENT_DRIVER_VERSION=580.1; DRIVER_VERSION=580.2; ACTION=install
for fn in detect_nvidia_gpus detect_driver_status check_gpu_not_in_vm_passthrough \\
check_stale_vfio_config_for_nvidia show_action_menu_if_installed \\
show_install_overview get_system_info show_version_menu hybrid_yesno \\
show_proxmenux_logo msg_title msg_info2 sleep; do
eval "$fn() { :; }"
done
translate() { printf '%s' "$1"; }
ensure_repos_and_headers() { return 1; }
download_nvidia_installer() { touch "$TEST_ROOT/downloaded"; return 1; }
complete_nvidia_uninstall() { touch "$TEST_ROOT/uninstalled"; }
''' + main + '\nmain\n'
result = subprocess.run(['bash', '-c', script],
env=dict(os.environ, TEST_ROOT=d),
capture_output=True, text=True, timeout=15)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((Path(d) / 'downloaded').exists())
self.assertFalse((Path(d) / 'uninstalled').exists())
def test_nvidia_repository_failure_blocks_header_install(self):
source = (ROOT / 'scripts/gpu_tpu/nvidia_installer.sh').read_text()
fn = source.split('ensure_repos_and_headers() {', 1)[1].split(
'\n_nouveau_legacy_file_is_proxmenux_shape()', 1)[0]
with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as d:
script = '''
LOG_FILE="$TEST_ROOT/log"; screen_capture="$TEST_ROOT/screen"
translate() { printf %s "$1"; }
msg_error() { :; }
ensure_repositories() { return 1; }
apt-get() { touch "$TEST_ROOT/apt_called"; }
ensure_repos_and_headers() {
''' + fn + '\nensure_repos_and_headers\n'
result = subprocess.run(['bash', '-c', script], env=dict(os.environ, TEST_ROOT=d),
capture_output=True, text=True, timeout=15)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((Path(d) / 'apt_called').exists())
class ShellFlowTest(unittest.TestCase):
def test_web_consent_applies_only_after_prompt(self):
helper = ROOT / 'scripts/global/repository-functions.sh'
with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as d:
log = Path(d) / 'calls'
script = f'''source "{helper}"
pveversion() {{ echo pve-manager/9.0; }}
translate() {{ printf %s "$1"; }}
msg_error() {{ :; }}
is_web_mode() {{ return 0; }}
hybrid_yesno() {{ printf 'prompt\\n' >> "{log}"; [[ "$2" == *'This host has no subscription, switch to the no-subscription repository?'* ]]; }}
repository_policy() {{ printf '%s\\n' "$1" >> "{log}"; [[ "$1" == plan ]] && echo offer || echo changed; }}
ensure_repositories
'''
result = subprocess.run(['bash', '-c', script], input='', text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(log.read_text().splitlines(), ['plan', 'prompt', 'apply'])
def test_refusal_and_noninteractive_never_apply(self):
helper = ROOT / 'scripts/global/repository-functions.sh'
with tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) as d:
mock = Path(d) / 'policy.py'
mock.write_text('import sys\nfrom pathlib import Path\n'
'Path(sys.argv[1]).open("a").write(sys.argv[2]+"\\n")\n'
'print("offer" if sys.argv[2] == "plan" else "changed")\n')
for web, confirm in ((False, True), (True, False)):
with self.subTest(web=web, confirm=confirm):
log = Path(d) / 'calls'
log.unlink(missing_ok=True)
script = f'''source "{helper}"
pveversion() {{ echo pve-manager/9.0; }}
translate() {{ printf %s "$1"; }}
msg_error() {{ :; }}
msg_info2() {{ :; }}
is_web_mode() {{ {'return 0' if web else 'return 1'}; }}
hybrid_yesno() {{ {'return 0' if confirm else 'return 1'}; }}
repository_policy() {{ python3 "{mock}" "{log}" "$1"; }}
ensure_repositories
'''
result = subprocess.run(['bash', '-c', script], input='', text=True, capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(log.read_text(), 'plan\n')
if __name__ == '__main__':
unittest.main()