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
+68 -3
View File
@@ -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
View File
@@ -3,7 +3,7 @@
action="${1:-}"
FOLDER='/opt/idssys/ta-proxmenu'
VERS='2026.9.22-18'
VERS='2026.9.23-1'
noupdate=' '
+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
}
+74 -67
View File
@@ -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
+82
View File
@@ -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()
+217
View File
@@ -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()
+12
View File
@@ -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
View File
@@ -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\"")