menu update
This commit is contained in:
@@ -29,7 +29,7 @@ On their first launch after receiving this version, control is handed to the
|
||||
current preloader so the new startup configuration and registration procedures
|
||||
run before the menu opens.
|
||||
|
||||
The TA-ProxMenu Management menu can safely switch TA-ProxMenu between branches published on
|
||||
The TAPM Management menu can safely switch TA-ProxMenu between branches published on
|
||||
its Git origin. Branch switching is refused when the installed repository has
|
||||
local changes or when the destination branch has commits that would be
|
||||
discarded.
|
||||
@@ -49,6 +49,71 @@ The shared menu layout and disk report omit ANSI styling when redirected or when
|
||||
findings by storage and node, and explain approximate sizes without adding CSV
|
||||
columns. CSV content and disk classification are unchanged by the presentation.
|
||||
|
||||
## Menu organization
|
||||
|
||||
- **Host Configuration & Recovery**: TAPM host profile and local Proxmox service recovery.
|
||||
- **Monitoring & Agents**: Pulse, Glances, supported legacy Dell OMSA, RMM,
|
||||
Acronis, SentinelOne, ScreenConnect, and Proxmox notifications.
|
||||
- **Cluster & High Availability**: host maintenance, cluster VM CPU baseline,
|
||||
and the Keepalived floating IP.
|
||||
- **Storage, ISOs & Backups**: shared ISO storage, Windows VirtIO driver ISOs,
|
||||
orphan/unused disk reporting, and local Proxmox backup bandwidth/I/O priority.
|
||||
- **TAPM Management**: TAPM updates, Git branch selection, and installation information.
|
||||
|
||||
Existing direct actions remain compatible. Installed agent entries still run
|
||||
installers; they do not launch the corresponding application. Host configuration
|
||||
backups are explicitly identified to distinguish them from guest backups.
|
||||
|
||||
## Proxmox notifications
|
||||
|
||||
Open **Monitoring & Agents → Proxmox Notifications → Update notification policy**:
|
||||
|
||||
- **All available package updates**: use Proxmox's native daily update alerts,
|
||||
regardless of subscription status, through the existing notification routing.
|
||||
- **Proxmox VE version updates only**: notify when a newer Proxmox VE version,
|
||||
including a patch release, is available from a host's configured repositories.
|
||||
- **Disable update notifications**: suppress native daily update alerts.
|
||||
|
||||
The policy applies to the Proxmox datacenter, including all cluster nodes.
|
||||
Selecting a policy displays its scope and requires confirmation before applying.
|
||||
Other notification types and destination credentials are preserved. Normal
|
||||
Proxmox repository checks remain enabled; this feature never upgrades packages
|
||||
or sends an alert confirming that a host was upgraded.
|
||||
|
||||
Version-only mode installs an hourly systemd timer on the host where the policy
|
||||
is applied. This host checks online cluster nodes through the Proxmox API;
|
||||
standalone hosts check themselves. Repository indexes are refreshed by Proxmox's
|
||||
existing daily update job, not by the TAPM checker. Kernel-only and other package
|
||||
updates do not trigger version-only alerts. Checks pause while the checker host
|
||||
is offline. Reapply the version-only policy on another host to move the checker;
|
||||
old checkers consult the shared policy and stop sending automatically. Newly
|
||||
joined nodes are discovered at the next check.
|
||||
|
||||
Version alerts include the hostname, installed version, and available version.
|
||||
They use the existing system-update routing rules and destinations. Configure
|
||||
recipients and delivery credentials in **Datacenter → Notifications** in the
|
||||
Proxmox Web UI. TAPM can display the destinations/rules and explicitly send a
|
||||
chosen destination a test notification. Testing a destination does not test its
|
||||
routing rules; ensure those rules allow informational system-update events.
|
||||
|
||||
The checker records announced versions per node in the private cluster
|
||||
configuration, so moving the checker preserves its history. Failed sends are
|
||||
retried on subsequent checks. Submission to the notification subsystem is not
|
||||
confirmation of delivery; the configured destination and routing must work.
|
||||
Offline nodes and failed reads are logged and retried without stopping checks
|
||||
for other nodes. No notification policy is changed merely by installing or
|
||||
opening TAPM.
|
||||
|
||||
Operational files:
|
||||
|
||||
- `/etc/pve/priv/tapm-update-policy.json`: shared policy and checker host.
|
||||
- `/etc/pve/priv/tapm-update-policy.previous.json`: previous policy and native
|
||||
notification options, saved before each change for manual recovery.
|
||||
- `/etc/pve/priv/tapm-update-receipts.json`: announced versions per host.
|
||||
- `/etc/pve/notification-templates/default/tapm-pve-version-*.hbs`: version alert templates.
|
||||
- `tapm-version-notifications.timer` and `.service`: checker scheduling; inspect
|
||||
results with `journalctl -u tapm-version-notifications.service`.
|
||||
|
||||
## Direct actions
|
||||
|
||||
The menu script also supports these direct actions:
|
||||
@@ -74,7 +139,7 @@ orphan-disks Report cluster-wide orphan candidates and unused disks (read-only)
|
||||
|
||||
## Orphaned and unused disks
|
||||
|
||||
Open **Cluster & Maintenance → Find orphaned and unused disks [Read-only]**.
|
||||
Open **Storage, ISOs & Backups → Report orphan candidates and unused disks (read-only)**.
|
||||
The menu offers CSV export after displaying the report. Direct commands:
|
||||
|
||||
```bash
|
||||
@@ -224,7 +289,7 @@ Later launches make one best-effort HTTPS call on completion or failure to
|
||||
record the hostname, duration, result, TA-ProxMenu and Git versions,
|
||||
PVE/OS/kernel versions, architecture, and whether the host is clustered. A
|
||||
failed event schedules re-enrollment on the next invocation instead of adding
|
||||
another retry to the current run. No background service is installed, and
|
||||
another retry to the current run. The installation registry does not install a background service, and
|
||||
broker availability never prevents the menu from running.
|
||||
|
||||
Automatic Git update checks and the stable VirtIO release lookup are cached for
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
action="${1:-}"
|
||||
FOLDER='/opt/idssys/ta-proxmenu'
|
||||
VERS='2026.9.22-18'
|
||||
VERS='2026.9.23-1'
|
||||
|
||||
noupdate=' '
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+74
-67
@@ -23,6 +23,7 @@ source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh
|
||||
source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh
|
||||
source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc
|
||||
source /opt/idssys/ta-proxmenu/inc/ui.inc
|
||||
source /opt/idssys/ta-proxmenu/inc/notifications.inc
|
||||
|
||||
ACTION_REQUESTED=0
|
||||
[[ -n "${action:-}" ]] && ACTION_REQUESTED=1
|
||||
@@ -2237,9 +2238,9 @@ TAPM_POST_VZDUMP_APPLY() {
|
||||
TAPM_POST_VZDUMP_MENU() {
|
||||
local -a labels=(
|
||||
"Use Proxmox defaults — recommended"
|
||||
"Maximum throughput — unlimited, ionice 5"
|
||||
"Set a custom bandwidth limit"
|
||||
"Restore a TAPM configuration backup"
|
||||
"Unlimited backup bandwidth (I/O priority 5)"
|
||||
"Set backup bandwidth and I/O priority"
|
||||
"Restore a TAPM host configuration backup"
|
||||
)
|
||||
local -a values=(defaults maximum custom restore)
|
||||
local confirmation
|
||||
@@ -2247,7 +2248,7 @@ TAPM_POST_VZDUMP_MENU() {
|
||||
local bandwidth_kib
|
||||
local ionice
|
||||
|
||||
SELECT_MENU "Configure vzdump Performance" labels values
|
||||
SELECT_MENU "Proxmox Backup Settings" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
defaults)
|
||||
TAPM_POST_VZDUMP_APPLY defaults
|
||||
@@ -2255,7 +2256,7 @@ TAPM_POST_VZDUMP_MENU() {
|
||||
maximum)
|
||||
echo
|
||||
echo -e "${idsCL[LightYellow]}This can saturate shared storage and increase guest latency.${idsCL[Default]}"
|
||||
read -r -p " Enable maximum throughput (type yes to continue)? " confirmation
|
||||
read -r -p " Enable unlimited backup bandwidth (type yes to continue)? " confirmation
|
||||
[[ "${confirmation,,}" == yes ]] &&
|
||||
TAPM_POST_VZDUMP_APPLY maximum
|
||||
;;
|
||||
@@ -2286,7 +2287,7 @@ TAPM_HOST_CONFIGURATION_MENU() {
|
||||
|
||||
while true; do
|
||||
labels=(
|
||||
"Audit current host"
|
||||
"Audit TAPM host profile (read-only)"
|
||||
"Apply/repair recommended TAPM profile"
|
||||
"Customize TAPM profile"
|
||||
)
|
||||
@@ -2296,19 +2297,17 @@ TAPM_HOST_CONFIGURATION_MENU() {
|
||||
values+=(migrate)
|
||||
fi
|
||||
labels+=(
|
||||
"Configure vzdump performance"
|
||||
"Restore a TAPM configuration backup"
|
||||
"Restore a TAPM host configuration backup"
|
||||
"View last configuration report"
|
||||
)
|
||||
values+=(vzdump restore report)
|
||||
values+=(restore report)
|
||||
|
||||
SELECT_MENU "TAPM Host Configuration" labels values
|
||||
SELECT_MENU "TAPM Host Profile" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
audit) TAPM_POST_SHOW_AUDIT ;;
|
||||
apply) TAPM_POST_APPLY_RECOMMENDED ;;
|
||||
customize) TAPM_POST_CUSTOMIZE ;;
|
||||
migrate) TAPM_POST_MIGRATE_PROXMENUX ;;
|
||||
vzdump) TAPM_POST_VZDUMP_MENU ;;
|
||||
restore) TAPM_POST_RESTORE_MENU ;;
|
||||
report) TAPM_POST_SHOW_REPORT ;;
|
||||
back) return ;;
|
||||
@@ -2331,39 +2330,16 @@ SHOW_ABOUT() {
|
||||
|
||||
HOST_SETUP_MENU() {
|
||||
local -a labels
|
||||
local -a values
|
||||
local -a values=("post_install" "services")
|
||||
|
||||
while true; do
|
||||
labels=("TAPM Host Configuration")
|
||||
values=("post_install")
|
||||
labels=("TAPM Host Profile" "Proxmox service recovery")
|
||||
TAPM_POST_PROXMENUX_DETECTED &&
|
||||
labels[0]="TAPM Host Configuration — ProxMenux migration recommended"
|
||||
labels+=("Analyze VM CPU migration compatibility")
|
||||
values+=("cpu")
|
||||
|
||||
# Discover ISO storage only after the VirtIO submenu is selected.
|
||||
labels+=("VirtIO driver downloads")
|
||||
values+=("virtio")
|
||||
|
||||
command -v glances >/dev/null 2>&1 &&
|
||||
labels+=("Glances CLI monitor (installed)") ||
|
||||
labels+=("Install Glances CLI monitor")
|
||||
values+=("glances")
|
||||
|
||||
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
|
||||
labels[0]="TAPM Host Profile — ProxMenux migration recommended"
|
||||
SELECT_MENU "Host Configuration & Recovery" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
post_install) TAPM_HOST_CONFIGURATION_MENU;;
|
||||
cpu) DETECT_CPU;;
|
||||
virtio) VIRTIO_MENU;;
|
||||
glances) INSTALL_GLANCES;;
|
||||
omsa) INSTALL_OMSA;;
|
||||
services) SERVICE_RECOVERY_MENU;;
|
||||
back) return;;
|
||||
quit) EXIT1; exit 0;;
|
||||
esac
|
||||
@@ -2372,11 +2348,12 @@ HOST_SETUP_MENU() {
|
||||
|
||||
MONITORING_MENU() {
|
||||
local -a labels
|
||||
local -a values=("pulse" "rmm" "acronis" "sentinelone" "screenconnect")
|
||||
local -a values
|
||||
local cluster_resources
|
||||
local pulse_status
|
||||
|
||||
while true; do
|
||||
values=("pulse" "rmm" "acronis" "sentinelone" "screenconnect" "glances")
|
||||
TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS
|
||||
pulse_status=$?
|
||||
if (( pulse_status == 2 )); then
|
||||
@@ -2387,37 +2364,48 @@ MONITORING_MENU() {
|
||||
pulse_status=$?
|
||||
fi
|
||||
if (( pulse_status == 0 )); then
|
||||
labels=("Pulse monitoring (installed)")
|
||||
labels=("Deploy Pulse monitoring in an LXC container (installed)")
|
||||
else
|
||||
labels=($'Install \e[36mPulse\e[39m monitoring')
|
||||
labels=("Deploy Pulse monitoring in an LXC container")
|
||||
fi
|
||||
|
||||
if systemctl is-active --quiet ITSPlatform; then
|
||||
labels+=("ConnectWise RMM agent (running)")
|
||||
labels+=("Install ConnectWise RMM agent (running)")
|
||||
else
|
||||
labels+=($'Install \e[36mConnectWise RMM\e[39m agent')
|
||||
fi
|
||||
|
||||
if dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null |
|
||||
grep -q "install ok installed"; then
|
||||
labels+=("Acronis backup agent (installed)")
|
||||
labels+=("Install Acronis backup agent (installed)")
|
||||
else
|
||||
labels+=($'Install \e[36mAcronis\e[39m backup agent')
|
||||
fi
|
||||
|
||||
if dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null |
|
||||
grep -q "install ok installed"; then
|
||||
labels+=("SentinelOne agent (installed)")
|
||||
labels+=("Install SentinelOne endpoint security agent (installed)")
|
||||
else
|
||||
labels+=($'Install \e[36mSentinelOne\e[39m agent')
|
||||
labels+=("Install SentinelOne endpoint security agent")
|
||||
fi
|
||||
|
||||
if systemctl is-active --quiet 'connectwise*'; then
|
||||
labels+=("ScreenConnect agent (running)")
|
||||
labels+=("Install ScreenConnect remote access agent (running)")
|
||||
else
|
||||
labels+=($'Install \e[36mScreenConnect\e[39m agent')
|
||||
labels+=("Install ScreenConnect remote access agent")
|
||||
fi
|
||||
|
||||
command -v glances >/dev/null 2>&1 &&
|
||||
labels+=("Install Glances CLI monitor (installed)") ||
|
||||
labels+=("Install Glances CLI monitor")
|
||||
if TAPM_OMSA_SUPPORTED_HARDWARE; then
|
||||
labels+=("Install Dell OMSA hardware monitoring (legacy hosts)")
|
||||
TAPM_PACKAGE_INSTALLED srvadmin-all && labels[-1]+=" (installed)"
|
||||
values+=("omsa")
|
||||
fi
|
||||
labels+=("Proxmox Notifications")
|
||||
values+=("notifications")
|
||||
|
||||
SELECT_MENU "Monitoring & Agents" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
pulse) INSTALL_PULSE;;
|
||||
@@ -2425,6 +2413,9 @@ MONITORING_MENU() {
|
||||
acronis) INSTALL_ACRONIS;;
|
||||
sentinelone) INSTALL_S1;;
|
||||
screenconnect) INSTALL_SCREENCONNECT;;
|
||||
glances) INSTALL_GLANCES;;
|
||||
omsa) INSTALL_OMSA;;
|
||||
notifications) TAPM_NOTIFICATIONS_MENU;;
|
||||
back) return;;
|
||||
quit) EXIT1; exit 0;;
|
||||
esac
|
||||
@@ -2449,7 +2440,7 @@ TAPM_ORPHAN_DISK_REPORT() {
|
||||
|
||||
CLUSTER_MENU() {
|
||||
local -a labels
|
||||
local -a values=("maintenance" "services" "keepalived" "iso_nfs" "orphan_disks")
|
||||
local -a values=("maintenance" "cpu" "keepalived")
|
||||
local maintenance_status
|
||||
local node
|
||||
|
||||
@@ -2469,23 +2460,37 @@ CLUSTER_MENU() {
|
||||
labels=("Enter maintenance mode and evacuate guests")
|
||||
fi
|
||||
|
||||
labels+=("Proxmox service recovery")
|
||||
labels+=("Review and apply cluster VM CPU baseline")
|
||||
dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" &&
|
||||
labels+=("Deploy or reconfigure Keepalived (installed locally)") ||
|
||||
labels+=("Deploy Keepalived on all cluster hosts")
|
||||
labels+=("Create shared ISO storage")
|
||||
labels+=("Find orphaned and unused disks [Read-only]")
|
||||
labels+=("Configure cluster floating IP (Keepalived installed locally)") ||
|
||||
labels+=("Configure cluster floating IP (Keepalived)")
|
||||
|
||||
SELECT_MENU "Cluster & Maintenance" labels values
|
||||
SELECT_MENU "Cluster & High Availability" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
maintenance) MAINTENANCE_MODE;;
|
||||
services) SERVICE_RECOVERY_MENU;;
|
||||
cpu) DETECT_CPU;;
|
||||
keepalived) INSTALL_KEEPALIVE;;
|
||||
back) return;;
|
||||
quit) EXIT1; exit 0;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
STORAGE_MENU() {
|
||||
local -a labels=(
|
||||
"Deploy shared ISO storage (NFS container)"
|
||||
"Download Windows VirtIO driver ISOs"
|
||||
"Report orphan candidates and unused disks (read-only)"
|
||||
"Configure Proxmox backup bandwidth and I/O priority"
|
||||
)
|
||||
local -a values=("iso_nfs" "virtio" "orphan_disks" "vzdump")
|
||||
while true; do
|
||||
SELECT_MENU "Storage, ISOs & Backups" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
iso_nfs) TAPM_DEPLOY_ISO_NFS_LXC; FINISH_ACTION;;
|
||||
virtio) VIRTIO_MENU;;
|
||||
orphan_disks) TAPM_ORPHAN_DISK_REPORT --interactive;;
|
||||
iso_nfs)
|
||||
TAPM_DEPLOY_ISO_NFS_LXC
|
||||
FINISH_ACTION
|
||||
;;
|
||||
vzdump) TAPM_POST_VZDUMP_MENU;;
|
||||
back) return;;
|
||||
quit) EXIT1; exit 0;;
|
||||
esac
|
||||
@@ -2655,7 +2660,7 @@ BRANCH_MANAGEMENT_MENU() {
|
||||
fi
|
||||
|
||||
while true; do
|
||||
SELECT_MENU "Git Branch Management" labels values
|
||||
SELECT_MENU "Switch TAPM Git Branch" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
branch:*)
|
||||
SWITCH_SCRIPT_BRANCH "${MENU_SELECTION#branch:}"
|
||||
@@ -2695,11 +2700,11 @@ UTILITIES_MENU() {
|
||||
last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)"
|
||||
labels+=(
|
||||
"Check for updates "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m'
|
||||
"Switch Git branch"
|
||||
"Switch TAPM Git branch"
|
||||
"Version and installation information"
|
||||
)
|
||||
|
||||
SELECT_MENU "TA-ProxMenu Management" labels values
|
||||
SELECT_MENU "TAPM Management" labels values
|
||||
case "$MENU_SELECTION" in
|
||||
install_update)
|
||||
if [[ "$UPDATE_STATUS" != "behind" ]]; then
|
||||
@@ -2726,13 +2731,14 @@ UTILITIES_MENU() {
|
||||
|
||||
MAIN_MENU() {
|
||||
local -a labels=(
|
||||
"Host Setup"
|
||||
"Host Configuration & Recovery"
|
||||
"Monitoring & Agents"
|
||||
"Cluster & Maintenance"
|
||||
"TA-ProxMenu Management"
|
||||
"Cluster & High Availability"
|
||||
"Storage, ISOs & Backups"
|
||||
"TAPM Management"
|
||||
"Quit"
|
||||
)
|
||||
local -a values=("host" "monitoring" "cluster" "utilities" "quit")
|
||||
local -a values=("host" "monitoring" "cluster" "storage" "utilities" "quit")
|
||||
|
||||
while true; do
|
||||
SELECT_MENU "Main Menu" labels values 0
|
||||
@@ -2740,6 +2746,7 @@ MAIN_MENU() {
|
||||
host) HOST_SETUP_MENU;;
|
||||
monitoring) MONITORING_MENU;;
|
||||
cluster) CLUSTER_MENU;;
|
||||
storage) STORAGE_MENU;;
|
||||
utilities) UTILITIES_MENU;;
|
||||
quit) EXIT1; exit 0;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check reorganized menu routes, including conditional hardware entries."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = (ROOT / 'proxmenu-scripts.sh').read_text()
|
||||
|
||||
|
||||
def function(name):
|
||||
return name + '() {' + SOURCE.split(name + '() {', 1)[1].split('\n}\n', 1)[0] + '\n}\n'
|
||||
|
||||
|
||||
class MenuLayoutTests(unittest.TestCase):
|
||||
def menu(self, name, selection, dell=False):
|
||||
# Real menu functions, mocked host inspection and terminal selection.
|
||||
script = '''
|
||||
TAPM_POST_PROXMENUX_DETECTED() { return 1; }
|
||||
TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS() { return 0; }
|
||||
TAPM_PACKAGE_INSTALLED() { return 0; }
|
||||
TAPM_OMSA_SUPPORTED_HARDWARE() { [[ "$TEST_DELL" == yes ]]; }
|
||||
TAPM_HA_NODE_IN_MAINTENANCE() { return 1; }
|
||||
systemctl() { return 0; }
|
||||
dpkg-query() { printf 'install ok installed'; }
|
||||
glances() { :; }
|
||||
SELECT_MENU() {
|
||||
local -n actual_labels="$2" actual_values="$3"
|
||||
[[ ${#actual_labels[@]} == ${#actual_values[@]} ]] || exit 40
|
||||
if [[ ${TEST_VISITED:-0} == 1 ]]; then MENU_SELECTION=back; return; fi
|
||||
TEST_VISITED=1
|
||||
printf 'TITLE:%s\\n' "$1"
|
||||
for i in "${!actual_values[@]}"; do
|
||||
printf 'ROW:%s:%s\\n' "${actual_values[$i]}" "${actual_labels[$i]}"
|
||||
done
|
||||
MENU_SELECTION="$TEST_SELECTION"
|
||||
}
|
||||
'''
|
||||
for action in ['SERVICE_RECOVERY_MENU', 'TAPM_HOST_CONFIGURATION_MENU', 'INSTALL_GLANCES',
|
||||
'INSTALL_OMSA', 'TAPM_NOTIFICATIONS_MENU', 'DETECT_CPU', 'INSTALL_KEEPALIVE',
|
||||
'VIRTIO_MENU', 'TAPM_POST_VZDUMP_MENU', 'TAPM_ORPHAN_DISK_REPORT']:
|
||||
script += action + '() { printf "ACTION:' + action + '\\n"; }\n'
|
||||
script += function(name) + '\n' + name
|
||||
result = subprocess.run(['bash', '-c', script], capture_output=True, text=True,
|
||||
env=dict(os.environ, TEST_SELECTION=selection, TEST_DELL='yes' if dell else 'no'))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
rows = dict(re.findall(r'^ROW:([^:]+):(.*)$', result.stdout, re.M))
|
||||
return rows, result.stdout
|
||||
|
||||
def test_local_service_recovery_is_under_host_configuration(self):
|
||||
rows, output = self.menu('HOST_SETUP_MENU', 'services')
|
||||
self.assertEqual(set(rows), {'post_install', 'services'})
|
||||
self.assertIn('ACTION:SERVICE_RECOVERY_MENU', output)
|
||||
|
||||
def test_cluster_cpu_action_moved_with_its_dispatch(self):
|
||||
rows, output = self.menu('CLUSTER_MENU', 'cpu')
|
||||
self.assertEqual(set(rows), {'maintenance', 'cpu', 'keepalived'})
|
||||
self.assertIn('ACTION:DETECT_CPU', output)
|
||||
|
||||
def test_storage_routes_keep_iso_scan_deferred(self):
|
||||
for choice, action in [('virtio', 'VIRTIO_MENU'), ('vzdump', 'TAPM_POST_VZDUMP_MENU'),
|
||||
('orphan_disks', 'TAPM_ORPHAN_DISK_REPORT')]:
|
||||
rows, output = self.menu('STORAGE_MENU', choice)
|
||||
self.assertEqual(set(rows), {'iso_nfs', 'virtio', 'orphan_disks', 'vzdump'})
|
||||
self.assertIn('ACTION:' + action, output)
|
||||
self.assertEqual(output.count('ACTION:'), 1)
|
||||
|
||||
def test_notifications_and_glances_with_optional_dell_entry(self):
|
||||
for dell in [False, True]:
|
||||
rows, output = self.menu('MONITORING_MENU', 'notifications', dell)
|
||||
self.assertEqual('omsa' in rows, dell)
|
||||
self.assertIn('glances', rows)
|
||||
self.assertIn('ACTION:TAPM_NOTIFICATIONS_MENU', output)
|
||||
rows, output = self.menu('MONITORING_MENU', 'glances', dell)
|
||||
self.assertIn('ACTION:INSTALL_GLANCES', output)
|
||||
self.assertTrue(rows['glances'].startswith('Install '))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise notification policies without touching a host or sending messages."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
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]
|
||||
spec = importlib.util.spec_from_file_location('notifications', ROOT / 'inc/notifications.py')
|
||||
n = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(n)
|
||||
|
||||
|
||||
class PolicyTests(unittest.TestCase):
|
||||
def test_external_policy_change_takes_precedence(self):
|
||||
snapshot = dict(policy=dict(mode='version', owner='pve1'), notify={'package-updates': 'always'})
|
||||
self.assertEqual(n.effective_mode(snapshot), 'all')
|
||||
snapshot['notify']['package-updates'] = 'never'
|
||||
self.assertEqual(n.effective_mode(snapshot), 'version')
|
||||
self.assertEqual(n.effective_mode(dict(policy={}, notify={})), 'auto')
|
||||
|
||||
def test_only_newer_ve_versions_qualify(self):
|
||||
rows = [dict(Package='linux-image', OldVersion='1', Version='2'),
|
||||
dict(Package='pve-manager', OldVersion='9.1.9', Version='9.1.10')]
|
||||
with patch.object(n.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||
self.assertEqual(n.candidate_update(rows), ('9.1.9', '9.1.10'))
|
||||
self.assertEqual(run.call_args.args[0], ['dpkg', '--compare-versions', '9.1.10', 'gt', '9.1.9'])
|
||||
with patch.object(n.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1)):
|
||||
self.assertIsNone(n.candidate_update(rows))
|
||||
with patch.object(n.subprocess, 'run') as run:
|
||||
self.assertIsNone(n.candidate_update(rows[:1]))
|
||||
run.assert_not_called()
|
||||
rows[1]['Version'] = 'invalid\nversion'
|
||||
with self.assertRaises(n.NotificationError):
|
||||
n.candidate_update(rows)
|
||||
|
||||
def test_cancel_never_writes_configuration(self):
|
||||
with patch.object(n, 'current_status', return_value={}), patch.object(n, 'local_node', return_value='pve1'), \
|
||||
patch('builtins.input', return_value='no'), patch.object(n, 'prepare_checker') as prepare, \
|
||||
patch.object(n, 'bridge') as bridge, contextlib.redirect_stdout(io.StringIO()):
|
||||
n.apply_policy('version')
|
||||
prepare.assert_not_called()
|
||||
bridge.assert_not_called()
|
||||
|
||||
def test_failed_timer_setup_does_not_suppress_native_alerts(self):
|
||||
with patch.object(n, 'current_status', return_value={}), patch.object(n, 'local_node', return_value='pve1'), \
|
||||
patch('builtins.input', return_value='yes'), patch.object(n, 'bridge') as bridge, \
|
||||
patch.object(n, 'prepare_checker', side_effect=n.NotificationError('failed')), \
|
||||
contextlib.redirect_stdout(io.StringIO()), self.assertRaises(n.NotificationError):
|
||||
n.apply_policy('version')
|
||||
bridge.assert_not_called()
|
||||
|
||||
def test_disabled_or_nonowner_worker_is_inert(self):
|
||||
for mode, owner in [('disabled', 'pve1'), ('all', 'pve1'), ('version', 'pve2')]:
|
||||
snapshot = dict(policy=dict(mode=mode, owner=owner), notify={'package-updates': 'never'})
|
||||
with patch.object(n, 'bridge', return_value=snapshot), patch.object(n, 'local_node', return_value='pve1'), \
|
||||
patch.object(n, 'api') as api:
|
||||
self.assertEqual(n.check_versions(), 0)
|
||||
api.assert_not_called()
|
||||
|
||||
def test_partial_cluster_check_continues_without_upgrade_alerts(self):
|
||||
snapshot = dict(policy=dict(mode='version', owner='pve1', generation='g'), notify={'package-updates': 'never'})
|
||||
responses = [[{'node': 'pve1', 'status': 'online'}, {'node': 'pve2', 'status': 'offline'},
|
||||
{'node': 'pve3', 'status': 'online'}, {'node': 'pve4', 'status': 'online'}],
|
||||
[], n.NotificationError('API down'),
|
||||
[dict(Package='pve-manager', OldVersion='9.1.9', Version='9.1.10')]]
|
||||
output = io.StringIO()
|
||||
with patch.object(n, 'bridge', side_effect=[snapshot, {'sent': True}]) as bridge, \
|
||||
patch.object(n, 'local_node', return_value='pve1'), patch.object(n, 'api', side_effect=responses), \
|
||||
patch.object(n.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0)), \
|
||||
contextlib.redirect_stdout(output):
|
||||
self.assertEqual(n.check_versions(), 1)
|
||||
self.assertEqual(bridge.call_count, 2)
|
||||
self.assertEqual(bridge.call_args.kwargs['node'], 'pve4')
|
||||
self.assertNotIn('pve-manager', output.getvalue())
|
||||
self.assertIn('offline', output.getvalue())
|
||||
self.assertIn('9.1.9 -> 9.1.10', output.getvalue())
|
||||
|
||||
def test_checker_install_does_not_modify_daily_job(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with patch.object(n, 'UNIT_DIR', root / 'units'), patch.object(n, 'TEMPLATE_DIR', root / 'templates'), \
|
||||
patch.object(n, 'command') as command:
|
||||
n.prepare_checker()
|
||||
paths = list(root.rglob('*'))
|
||||
self.assertFalse(any('pve-daily' in p.name for p in paths))
|
||||
for path in paths:
|
||||
if path.is_file():
|
||||
self.assertNotIn('pve-manager', path.read_text())
|
||||
self.assertIn('OnCalendar=hourly', (root / 'units' / (n.SERVICE + '.timer')).read_text())
|
||||
self.assertEqual(command.call_args.args[0], ['systemctl', 'is-active', n.SERVICE + '.timer'])
|
||||
|
||||
def test_delivery_test_requires_explicit_confirmation(self):
|
||||
targets = [{'name': 'smtp-team', 'type': 'smtp'}]
|
||||
with patch.object(n, 'api', return_value=targets) as api, \
|
||||
patch('builtins.input', side_effect=['1', 'no']), contextlib.redirect_stdout(io.StringIO()):
|
||||
n.test_delivery()
|
||||
self.assertEqual(api.call_count, 1)
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
"""Run real bridge code against fake PVE modules and temporary config files."""
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / 'PVE').mkdir()
|
||||
(self.root / 'priv').mkdir()
|
||||
self.dc = self.root / 'dc.json'
|
||||
self.dc.write_text(json.dumps({'notify': {'package-updates': 'auto', 'replication': 'always'}, 'keyboard': 'en-us'}))
|
||||
modules = {
|
||||
'Cluster': '''package PVE::Cluster;
|
||||
use JSON; use PVE::Tools;
|
||||
sub cfs_update {}
|
||||
sub cfs_read_file { JSON::decode_json(PVE::Tools::file_get_contents("$ENV{FIXTURE}/dc.json")) }
|
||||
sub cfs_write_file { PVE::Tools::file_set_contents("$ENV{FIXTURE}/dc.json", JSON::encode_json($_[1])); }
|
||||
sub cfs_lock_domain { eval { $_[2]->(); }; }
|
||||
sub cfs_lock_file { eval { $_[2]->(); }; }
|
||||
1;
|
||||
''',
|
||||
'DataCenterConfig': 'package PVE::DataCenterConfig; 1;',
|
||||
'INotify': 'package PVE::INotify; sub inotify_init {} sub nodename { "pve1" } 1;',
|
||||
'Tools': '''package PVE::Tools;
|
||||
sub file_get_contents { open(my $f, '<', $_[0]) or die $!; local $/; return <$f>; }
|
||||
sub file_set_contents {
|
||||
die "write failure" if $ENV{FAIL_POLICY} && $_[0] =~ /tapm-update-policy.json$/;
|
||||
open(my $f, '>', $_[0]) or die $!; print $f $_[1]; close $f or die $!;
|
||||
}
|
||||
1;
|
||||
''',
|
||||
'Notify': '''package PVE::Notify;
|
||||
use JSON;
|
||||
sub info {
|
||||
die "delivery failure" if $ENV{FAIL_SEND};
|
||||
open(my $f, '>>', "$ENV{FIXTURE}/sent.jsonl") or die $!;
|
||||
print $f JSON::encode_json([@_]), "\\n"; close $f;
|
||||
}
|
||||
1;
|
||||
''',
|
||||
}
|
||||
for module, source in modules.items():
|
||||
(self.root / 'PVE' / (module + '.pm')).write_text(source)
|
||||
source = (ROOT / 'inc/notifications-bridge.pl').read_text()
|
||||
source = source.replace('/etc/pve', str(self.root)).replace('die "root required" if $> != 0;', '')
|
||||
self.script = self.root / 'bridge.pl'
|
||||
self.script.write_text(source)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def call(self, action, env=None, **kwargs):
|
||||
result = subprocess.run(['perl', '-I', str(self.root), str(self.script)],
|
||||
input=json.dumps(dict(action=action, **kwargs)), text=True, capture_output=True,
|
||||
env=dict(os.environ, FIXTURE=str(self.root), **(env or {})))
|
||||
return result
|
||||
|
||||
def read(self):
|
||||
result = self.call('read')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def apply(self, mode='version', **kwargs):
|
||||
return self.call('apply', expected=self.read(),
|
||||
policy=dict(mode=mode, owner='pve1', generation='g'), **kwargs)
|
||||
|
||||
def send(self, version='9.1.10', **kwargs):
|
||||
return self.call('send', generation='g', node='pve2', installed='9.1.9', available=version, **kwargs)
|
||||
|
||||
def test_apply_preserves_unrelated_settings_and_saves_previous_policy(self):
|
||||
for mode, expected in [('version', 'never'), ('all', 'always'), ('disabled', 'never')]:
|
||||
result = self.apply(mode)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
dc = json.loads(self.dc.read_text())
|
||||
self.assertEqual(dc['notify'], {'package-updates': expected, 'replication': 'always'})
|
||||
self.assertEqual(dc['keyboard'], 'en-us')
|
||||
previous = json.loads((self.root / 'priv/tapm-update-policy.previous.json').read_text())
|
||||
self.assertEqual(previous['policy']['mode'], 'all')
|
||||
|
||||
def test_stale_configuration_is_rejected(self):
|
||||
previous = self.read()
|
||||
self.assertEqual(self.apply('all').returncode, 0)
|
||||
result = self.call('apply', expected=previous, policy=dict(mode='disabled', owner='pve1'))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.read()['notify']['package-updates'], 'always')
|
||||
|
||||
def test_failed_policy_save_restores_native_notifications(self):
|
||||
result = self.apply(env={'FAIL_POLICY': '1'})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.read()['notify']['package-updates'], 'auto')
|
||||
|
||||
def test_deduplication_and_generic_message(self):
|
||||
self.assertEqual(self.apply().returncode, 0)
|
||||
for version, expected in [('9.1.10', True), ('9.1.10', False), ('9.1.11', True), ('9.1.10', False)]:
|
||||
result = self.send(version)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)['sent'], expected)
|
||||
messages = (self.root / 'sent.jsonl').read_text()
|
||||
self.assertEqual(len(messages.splitlines()), 2)
|
||||
self.assertNotIn('pve-manager', messages)
|
||||
self.assertEqual(json.loads(messages.splitlines()[0])[2], {'type': 'package-updates', 'hostname': 'pve2'})
|
||||
|
||||
def test_failed_delivery_is_retried_and_policy_change_stops_old_worker(self):
|
||||
self.assertEqual(self.apply().returncode, 0)
|
||||
self.assertNotEqual(self.send(env={'FAIL_SEND': '1'}).returncode, 0)
|
||||
self.assertFalse((self.root / 'priv/tapm-update-receipts.json').exists())
|
||||
self.assertTrue(json.loads(self.send().stdout)['sent'])
|
||||
self.assertEqual(self.apply('disabled').returncode, 0)
|
||||
self.assertFalse(json.loads(self.send('9.1.11').stdout)['sent'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Include the Python behavior suites in the standard validation entry point.
|
||||
set -u -o pipefail
|
||||
TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
failures=0
|
||||
# Hyphenated filenames are not importable by unittest discovery.
|
||||
for test_file in "${TEST_ROOT}"/tests/test-*.py; do
|
||||
if ! PYTHONDONTWRITEBYTECODE=1 python3 "$test_file"; then
|
||||
(( failures += 1 ))
|
||||
fi
|
||||
done
|
||||
(( failures == 0 ))
|
||||
+4
-4
@@ -29,7 +29,7 @@ class UITests(unittest.TestCase):
|
||||
def test_plain_wrapped_menu_and_task(self):
|
||||
output = self.shell('''
|
||||
TAPM_UI_RULE
|
||||
TAPM_UI_MENU_META 'Cluster & Maintenance' orphan_disks
|
||||
TAPM_UI_MENU_META 'Storage, ISOs & Backups' orphan_disks
|
||||
TAPM_UI_MENU_ROW 5 $'Find \\e[36morphaned\\e[39m and unused disks [Read-only]' 1
|
||||
TAPM_UI_TASK 'Find orphaned and unused disks'
|
||||
''', width=40)
|
||||
@@ -39,12 +39,12 @@ TAPM_UI_TASK 'Find orphaned and unused disks'
|
||||
|
||||
def test_submenus_and_choices_do_not_start_tasks(self):
|
||||
cases = [('Main Menu', 'cluster', 'submenu'),
|
||||
('Cluster & Maintenance', 'services', 'submenu'),
|
||||
('Host Configuration & Recovery', 'services', 'submenu'),
|
||||
('Select ISO Storage', 'local', 'choice'),
|
||||
('Customize TAPM Host Profile', 'utilities', 'choice'),
|
||||
('Customize TAPM Host Profile', 'apply', 'task'),
|
||||
('Cluster & Maintenance', 'orphan_disks', 'task'),
|
||||
('Cluster & Maintenance', 'back', 'navigation')]
|
||||
('Storage, ISOs & Backups', 'orphan_disks', 'task'),
|
||||
('Cluster & High Availability', 'back', 'navigation')]
|
||||
for title, value, expected in cases:
|
||||
with self.subTest(title=title, value=value):
|
||||
output = self.shell(f"TAPM_UI_MENU_META '{title}' '{value}'\nprintf '%s' \"$TAPM_UI_KIND\"")
|
||||
|
||||
Reference in New Issue
Block a user