diff --git a/README.md b/README.md index 36e083a..124161d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/defaults.inc b/defaults.inc index 23b005e..ff06370 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.9.22-18' +VERS='2026.9.23-1' noupdate=' ' diff --git a/inc/notifications-bridge.pl b/inc/notifications-bridge.pl new file mode 100644 index 0000000..02b6dbd --- /dev/null +++ b/inc/notifications-bridge.pl @@ -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 $/; }; + 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"; diff --git a/inc/notifications.inc b/inc/notifications.inc new file mode 100644 index 0000000..8ec5d54 --- /dev/null +++ b/inc/notifications.inc @@ -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 +} diff --git a/inc/notifications.py b/inc/notifications.py new file mode 100644 index 0000000..d39c853 --- /dev/null +++ b/inc/notifications.py @@ -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': '

Host: {{hostname}}

Installed Proxmox VE version: {{installed}}
Available Proxmox VE version: {{available}}

A newer version is available from this host's configured repositories.

\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()) diff --git a/inc/ui.inc b/inc/ui.inc index 87fa5b5..e9b5b56 100644 --- a/inc/ui.inc +++ b/inc/ui.inc @@ -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 } diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 1488d77..d66830f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -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 diff --git a/tests/test-menu-layout.py b/tests/test-menu-layout.py new file mode 100644 index 0000000..5d794ac --- /dev/null +++ b/tests/test-menu-layout.py @@ -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() diff --git a/tests/test-notifications.py b/tests/test-notifications.py new file mode 100644 index 0000000..88cc24f --- /dev/null +++ b/tests/test-notifications.py @@ -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() diff --git a/tests/test-python.sh b/tests/test-python.sh new file mode 100644 index 0000000..3c02d97 --- /dev/null +++ b/tests/test-python.sh @@ -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 )) diff --git a/tests/test-ui.py b/tests/test-ui.py index ef7b0d0..32e7980 100644 --- a/tests/test-ui.py +++ b/tests/test-ui.py @@ -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\"")