menu update

This commit is contained in:
David Schroeder
2026-09-24 19:22:04 -05:00
parent cd4d505064
commit 6f4c95e0f2
11 changed files with 938 additions and 89 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/perl
# Internal bridge to Proxmox's notification and cluster configuration libraries.
use strict;
use warnings;
use JSON;
use PVE::Cluster;
use PVE::DataCenterConfig;
use PVE::INotify;
use PVE::Notify;
use PVE::Tools;
my $json = JSON->new->canonical;
my $policy_file = '/etc/pve/priv/tapm-update-policy.json';
my $receipts_file = '/etc/pve/priv/tapm-update-receipts.json';
sub read_json {
my ($path) = @_;
return {} if !-e $path;
return $json->decode(PVE::Tools::file_get_contents($path));
}
sub snapshot {
PVE::Cluster::cfs_update();
my $dc = PVE::Cluster::cfs_read_file('datacenter.cfg');
return { policy => read_json($policy_file), notify => $dc->{notify} // {} };
}
sub locked {
my ($code) = @_;
my $result;
PVE::Cluster::cfs_lock_domain('tapm-update-notifications', 10, sub {
$result = $code->();
});
die $@ if $@;
return $result;
}
my $result;
my $ok = eval {
die "root required" if $> != 0;
PVE::INotify::inotify_init();
my $input = do { local $/; <STDIN> };
my $request = $json->decode($input);
my $action = $request->{action} // '';
if ($action eq 'read') {
$result = snapshot();
} elsif ($action eq 'apply') {
$result = locked(sub {
my $current = snapshot();
die "configuration changed" if $json->encode($current) ne $json->encode($request->{expected});
my $policy = $request->{policy};
die "invalid mode" if ($policy->{mode} // '') !~ /\A(?:all|version|disabled)\z/;
die "invalid owner" if ($policy->{owner} // '') !~ /\A[A-Za-z0-9][A-Za-z0-9.-]{0,62}\z/;
my $response;
PVE::Cluster::cfs_lock_file('datacenter.cfg', 10, sub {
my $dc = PVE::Cluster::cfs_read_file('datacenter.cfg');
die "notification settings changed" if $json->encode($dc->{notify} // {}) ne $json->encode($current->{notify});
# Preserve all unrelated notification options and datacenter settings.
my $before = $json->decode($json->encode($dc));
PVE::Tools::file_set_contents('/etc/pve/priv/tapm-update-policy.previous.json', $json->encode($current));
$dc->{notify} //= {};
$dc->{notify}->{'package-updates'} = $policy->{mode} eq 'all' ? 'always' : 'never';
PVE::Cluster::cfs_write_file('datacenter.cfg', $dc);
eval { PVE::Tools::file_set_contents($policy_file, $json->encode($policy)); };
if (my $err = $@) {
PVE::Cluster::cfs_write_file('datacenter.cfg', $before);
die $err;
}
$response = { applied => JSON::true };
});
die $@ if $@;
return $response;
});
} elsif ($action eq 'send') {
$result = locked(sub {
my $current = snapshot();
my $policy = $current->{policy};
# An old scheduler must not send after a policy/owner change.
return { sent => JSON::false } if ($policy->{mode} // '') ne 'version'
|| ($policy->{owner} // '') ne PVE::INotify::nodename()
|| ($policy->{generation} // '') ne ($request->{generation} // '')
|| ($current->{notify}->{'package-updates'} // '') ne 'never';
my $node = $request->{node};
die "invalid node" if ($node // '') !~ /\A[A-Za-z0-9][A-Za-z0-9.-]{0,62}\z/;
for my $key (qw(installed available)) {
die "invalid version" if ($request->{$key} // '') !~ /\A[0-9][A-Za-z0-9.+:~\-]{0,100}\z/;
}
my $receipts = read_json($receipts_file);
# Keep history so repository switches do not re-announce an old candidate.
return { sent => JSON::false } if $receipts->{$node}->{$request->{available}};
my $data = { hostname => $node, installed => $request->{installed}, available => $request->{available} };
PVE::Notify::info('tapm-pve-version', $data, { type => 'package-updates', hostname => $node });
$receipts->{$node}->{$request->{available}} = time();
PVE::Tools::file_set_contents($receipts_file, $json->encode($receipts));
return { sent => JSON::true };
});
} else {
die "unknown operation";
}
1;
};
if (!$ok) {
# Do not expose package names, credentials or raw backend errors in output.
print STDERR "Proxmox notification operation failed; verify cluster quorum, configuration and notification delivery.\n";
exit 1;
}
print $json->encode($result), "\n";
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Interactive entry points; backend operations run only after selection.
TAPM_NOTIFICATION_ACTION() {
python3 /opt/idssys/ta-proxmenu/inc/notifications.py "$@"
local result=$?
ENTER2CONTINUE
return "$result"
}
TAPM_UPDATE_NOTIFICATION_MENU() {
local -a labels=(
"All available package updates"
"Proxmox VE version updates only"
"Disable update notifications"
)
local -a values=(all version disabled)
while true; do
SELECT_MENU "Update Notification Policy" labels values
case "$MENU_SELECTION" in
all|version|disabled) TAPM_NOTIFICATION_ACTION apply "$MENU_SELECTION" ;;
back) return ;;
quit) EXIT1; exit 0 ;;
esac
done
}
TAPM_NOTIFICATIONS_MENU() {
local -a labels=(
"Update notification policy"
"Review notification settings and destinations"
"Send a test notification"
)
local -a values=(policy delivery test)
while true; do
SELECT_MENU "Proxmox Notifications" labels values
case "$MENU_SELECTION" in
policy) TAPM_UPDATE_NOTIFICATION_MENU ;;
delivery) TAPM_NOTIFICATION_ACTION delivery ;;
test) TAPM_NOTIFICATION_ACTION test ;;
back) return ;;
quit) EXIT1; exit 0 ;;
esac
done
}
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""TAPM notification policy UI and scheduled Proxmox VE version checks."""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import uuid
HERE = Path(__file__).resolve().parent
UNIT_DIR = Path('/etc/systemd/system')
TEMPLATE_DIR = Path('/etc/pve/notification-templates/default')
STATE_DIR = Path('/var/lib/ta-proxmenu/notifications')
SERVICE = 'tapm-version-notifications'
LABELS = {
'all': 'All available package updates',
'version': 'Proxmox VE version updates only',
'disabled': 'Disable update notifications',
}
NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}\Z')
VERSION = re.compile(r'[0-9][A-Za-z0-9.+:~\-]{0,100}\Z')
class NotificationError(Exception):
pass
def command(args, payload=None, timeout=30):
try:
result = subprocess.run(args, input=payload, capture_output=True,
text=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as exc:
raise NotificationError('The requested notification operation could not be completed.') from exc
if result.returncode:
raise NotificationError('The requested notification operation failed. Check Proxmox configuration and connectivity.')
return result.stdout
def api(method, path, **params):
args = ['pvesh', method, path, '--output-format', 'json']
for key, value in params.items():
args += ['--' + key.replace('_', '-'), str(value)]
raw = command(args)
try:
return json.loads(raw) if raw.strip() else None
except ValueError as exc:
raise NotificationError('Proxmox returned an unreadable response.') from exc
def bridge(action, **params):
raw = command(['perl', str(HERE / 'notifications-bridge.pl')],
json.dumps(dict(action=action, **params)), timeout=75)
try:
return json.loads(raw)
except ValueError as exc:
raise NotificationError('Notification configuration could not be read.') from exc
def local_node():
name = command(['hostname', '-s']).strip()
if not NAME.fullmatch(name):
raise NotificationError('The host name is not valid.')
return name
def effective_mode(snapshot):
policy = snapshot.get('policy', {})
native = snapshot.get('notify', {}).get('package-updates', 'auto')
if policy.get('mode') == 'version' and native == 'never':
return 'version'
return {'always': 'all', 'never': 'disabled'}.get(native, 'auto')
def current_status():
snapshot = bridge('read')
mode = effective_mode(snapshot)
print('Update notification policy: ' + LABELS.get(mode, 'Proxmox default (subscription-dependent)'))
print('Scope: this Proxmox datacenter (all nodes when clustered).')
if mode == 'version':
owner = snapshot['policy'].get('owner', 'unknown')
print('Hourly version checker host: ' + owner)
if owner == local_node():
try:
command(['systemctl', 'is-enabled', SERVICE + '.timer'])
command(['systemctl', 'is-active', SERVICE + '.timer'])
print('Version checker timer: enabled and active')
except NotificationError:
print('Version checker timer: needs attention; reapply the version-only policy.')
else:
print('Timer status must be checked on that host; checks pause while it is offline.')
return snapshot
def atomic_write(path, content):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp')
try:
with temporary.open('x') as stream:
stream.write(content)
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def prepare_checker():
# Separate unit: never disable or replace Proxmox's daily update service.
service = f'''[Unit]
Description=TAPM Proxmox VE version notifications
After=pve-cluster.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 {HERE}/notifications.py run
TimeoutStartSec=15min
UMask=0077
'''
timer = '''[Unit]
Description=Check for Proxmox VE version updates hourly
[Timer]
OnCalendar=hourly
RandomizedDelaySec=5min
Persistent=true
[Install]
WantedBy=timers.target
'''
atomic_write(UNIT_DIR / (SERVICE + '.service'), service)
atomic_write(UNIT_DIR / (SERVICE + '.timer'), timer)
templates = {
'subject.txt': 'Proxmox VE version update available on {{hostname}}\n',
'body.txt': 'Host: {{hostname}}\nInstalled Proxmox VE version: {{installed}}\nAvailable Proxmox VE version: {{available}}\n\nA newer version is available from this host\'s configured repositories.\n',
'body.html': '<p>Host: {{hostname}}</p><p>Installed Proxmox VE version: {{installed}}<br>Available Proxmox VE version: {{available}}</p><p>A newer version is available from this host&#39;s configured repositories.</p>\n',
}
for suffix, content in templates.items():
atomic_write(TEMPLATE_DIR / ('tapm-pve-version-' + suffix + '.hbs'), content)
command(['systemctl', 'daemon-reload'])
command(['systemctl', 'enable', '--now', SERVICE + '.timer'])
command(['systemctl', 'is-active', SERVICE + '.timer'])
def apply_policy(mode):
expected = current_status()
owner = local_node()
print('\nProposed policy: ' + LABELS[mode])
print('This changes update alerts across this datacenter. Other notification types retain their settings.')
print('Normal Proxmox repository checks remain enabled. No packages will be upgraded.')
if mode == 'version':
print(f'An hourly checker on {owner} will inspect online nodes using their existing repository indexes.')
print('Checks pause while this host is offline; reapply here to repair, or on another host to move the checker.')
print('Only new Proxmox VE versions trigger alerts, including patch releases. Kernel-only updates do not.')
print('Existing update notification routing and destinations are used. No post-upgrade alerts are sent.')
elif mode == 'all':
print('The native Proxmox daily update job sends alerts through existing notification routing.')
if input('Apply this policy (type yes to continue)? ').strip().lower() != 'yes':
print('No changes made.')
return
if mode == 'version':
prepare_checker()
policy = dict(mode=mode, owner=owner, generation=uuid.uuid4().hex)
bridge('apply', expected=expected, policy=policy)
print('Update notification policy applied: ' + LABELS[mode])
if mode != 'version' and (UNIT_DIR / (SERVICE + '.timer')).exists():
try:
command(['systemctl', 'disable', '--now', SERVICE + '.timer'])
except NotificationError:
print('The local timer could not be stopped. It will remain inactive in this notification mode.')
print('Previous policy saved in /etc/pve/priv/tapm-update-policy.previous.json.')
def candidate_update(rows):
if not isinstance(rows, list):
raise NotificationError('The update list could not be read.')
for row in rows:
if not isinstance(row, dict) or row.get('Package') != 'pve-manager':
continue
installed, available = row.get('OldVersion', ''), row.get('Version', '')
if not isinstance(installed, str) or not isinstance(available, str) or not VERSION.fullmatch(installed) or not VERSION.fullmatch(available):
raise NotificationError('The Proxmox VE versions could not be read.')
# Use Debian version ordering, including epochs, revisions and tildes.
comparison = subprocess.run(['dpkg', '--compare-versions', available, 'gt', installed],
capture_output=True, timeout=10)
if comparison.returncode == 0:
return installed, available
if comparison.returncode != 1:
raise NotificationError('The Proxmox VE versions could not be compared.')
return None
def check_versions():
snapshot = bridge('read')
if effective_mode(snapshot) != 'version' or snapshot['policy'].get('owner') != local_node():
return 0
nodes = api('get', '/nodes')
if not isinstance(nodes, list) or not nodes:
raise NotificationError('The Proxmox node list could not be read.')
failures = 0
for node in nodes:
name = node.get('node', '') if isinstance(node, dict) else ''
if not NAME.fullmatch(name):
failures += 1
continue
if node.get('status') != 'online':
print(f'{name}: offline; version check deferred.')
failures += 1
continue
try:
versions = candidate_update(api('get', f'/nodes/{name}/apt/update'))
if not versions:
continue
installed, available = versions
result = bridge('send', generation=snapshot['policy']['generation'],
node=name, installed=installed, available=available)
if result.get('sent'):
print(f'{name}: Proxmox VE {installed} -> {available}; notification submitted to configured routing.')
except (NotificationError, OSError, subprocess.TimeoutExpired):
print(f'{name}: version check or notification failed; will retry on the next check.')
failures += 1
return 1 if failures else 0
def delivery_status():
current_status()
targets = api('get', '/cluster/notifications/targets')
matchers = api('get', '/cluster/notifications/matchers')
print('\nNotification destinations:')
for target in targets:
state = 'disabled' if target.get('disable') else 'enabled'
print(f" {target['name']} ({target['type']}, {state})")
if not targets:
print(' None configured.')
print('\nRouting rules:')
for matcher in matchers:
state = 'disabled' if matcher.get('disable') else 'enabled'
print(f" {matcher['name']} ({state})")
for key in ('target', 'match-field', 'match-severity', 'match-calendar', 'mode', 'invert-match'):
if key in matcher:
print(f" {key}: {matcher[key]}")
print('\nConfigure recipients, delivery credentials and routing in the Proxmox Web UI: Datacenter > Notifications.')
print('Version-only alerts use the existing system-update routing rules.')
def test_delivery():
targets = [t for t in api('get', '/cluster/notifications/targets') if not t.get('disable')]
if not targets:
print('No enabled notification destinations. Configure one in Datacenter > Notifications first.')
return
for index, target in enumerate(targets, 1):
print(f" {index}. {target['name']} ({target['type']})")
choice = input('Destination number (Enter to cancel): ').strip()
if not choice:
return
if not choice.isdigit() or not 1 <= int(choice) <= len(targets):
raise NotificationError('Choose a destination from the list.')
target = targets[int(choice) - 1]['name']
if not NAME.fullmatch(target):
raise NotificationError('The destination name is not valid.')
if input(f'Send a test notification to {target} (type yes to continue)? ').strip().lower() != 'yes':
return
api('create', f'/cluster/notifications/targets/{target}/test')
print('Test notification submitted. Confirm receipt at the destination; this tests delivery, not routing rules.')
def main():
parser = argparse.ArgumentParser(description='Proxmox notification settings')
parser.add_argument('action', choices=['status', 'delivery', 'test', 'apply', 'run'])
parser.add_argument('mode', nargs='?', choices=list(LABELS))
args = parser.parse_args()
if os.geteuid() != 0:
print('Run this action as root on a Proxmox host.', file=sys.stderr)
return 1
try:
if args.action == 'status':
current_status()
elif args.action == 'delivery':
delivery_status()
elif args.action == 'test':
test_delivery()
elif args.action == 'apply':
if args.mode is None:
raise NotificationError('Select an update notification policy.')
apply_policy(args.mode)
else:
STATE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
with (STATE_DIR / 'check.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return 0
return check_versions()
except (EOFError, KeyboardInterrupt):
print('\nCancelled.')
return 1
except (NotificationError, OSError, ValueError, KeyError, TypeError) as exc:
print(str(exc) if isinstance(exc, NotificationError) else 'Notification operation failed; verify configuration and permissions.', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+22 -14
View File
@@ -67,34 +67,42 @@ TAPM_UI_MENU_META() {
TAPM_UI_KIND=task
TAPM_UI_HINT=''
case "$title:$value" in
'Main Menu:'*|'Host Setup:post_install'|'Host Setup:virtio'|\
'Cluster & Maintenance:services'|'TA-ProxMenu Management:branches'|\
'TAPM Host Configuration:customize'|'TAPM Host Configuration:vzdump'|\
'TAPM Host Configuration:restore'|'Configure vzdump Performance:restore')
'Main Menu:'*|'Host Configuration & Recovery:post_install'|'Storage, ISOs & Backups:virtio'|\
'Host Configuration & Recovery:services'|'TAPM Management:branches'|\
'TAPM Host Profile:customize'|'Storage, ISOs & Backups:vzdump'|\
'TAPM Host Profile:restore'|'Proxmox Backup Settings:restore'|\
'Monitoring & Agents:notifications'|'Proxmox Notifications:policy')
TAPM_UI_KIND=submenu ;;
'Select ISO Storage:'*|'Restore TAPM Host Configuration Backup:'*|\
'Git Branch Management:'*|'Configure vzdump Performance:'*)
'Switch TAPM Git Branch:'*|'Proxmox Backup Settings:'*|\
'Update Notification Policy:'*)
TAPM_UI_KIND=choice ;;
'Customize TAPM Host Profile:'*)
[[ "$value" == apply ]] || TAPM_UI_KIND=choice ;;
'TA-ProxMenu Management:install_update')
'TAPM Management:install_update')
# The update banner is printed after the user confirms installation.
TAPM_UI_KIND=choice ;;
esac
case "$value" in back|quit) TAPM_UI_KIND=navigation ;; esac
case "$title:$value" in
'Cluster & Maintenance:maintenance')
TAPM_UI_HINT='Applies to this host; entering maintenance evacuates guests.' ;;
'Cluster & Maintenance:keepalived')
'Cluster & High Availability:maintenance')
TAPM_UI_HINT='This host: eligible guests migrate; local-storage guests shut down gracefully.' ;;
'Cluster & High Availability:keepalived')
TAPM_UI_HINT='Deploy or reconfigure Keepalived across cluster hosts.' ;;
'Cluster & Maintenance:iso_nfs')
'Storage, ISOs & Backups:iso_nfs')
TAPM_UI_HINT='Create an LXC NFS server and register shared ISO storage.' ;;
'Cluster & Maintenance:orphan_disks')
'Storage, ISOs & Backups:orphan_disks')
TAPM_UI_HINT='Read-only cluster scan with optional CSV export.' ;;
'Host Setup:cpu')
'Cluster & High Availability:cpu')
TAPM_UI_HINT='Analyze migration compatibility; review before applying a CPU model.' ;;
'TA-ProxMenu Management:install_update')
TAPM_UI_HINT="Update status: ${UPDATE_STATUS:-unknown}" ;;
'Host Configuration & Recovery:services')
TAPM_UI_HINT='Restart Proxmox management services on this host.' ;;
'Storage, ISOs & Backups:vzdump')
TAPM_UI_HINT='Set backup bandwidth and I/O priority on this host.' ;;
'Proxmox Notifications:policy')
TAPM_UI_HINT='Choose all updates, Proxmox VE version updates only, or no update alerts.' ;;
'TAPM Management:install_update')
TAPM_UI_HINT='Update TAPM on online cluster nodes, or on this standalone host.' ;;
esac
}