From aedb059f01fa1061722b630c0c011f967e598004 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sun, 8 Nov 2026 19:43:55 +0100 Subject: [PATCH] Recheck the restart marker on login while a restart hint is shown The restart hint from the Updates and restart status check stayed until the template ran again, so a host kept showing "Restart required" after it had been restarted. While a saved host shows that hint, the next login rereads only the restart marker (two existence tests, no sudo, no writes). Exactly "reboot=no" lowers the hint; exactly "reboot=yes" keeps it for the next login. Any other answer or none keeps the hint and stops the rereads until the next full check, since a forced command may have answered. Hosts without a hint are never asked. The write only applies while the stored check is still the one that was read, so a newer check is never overwritten. The restart item in the overview also offers to open the check again as a draft for the hosts concerned. --- app/host_platform.py | 87 +++++++++ app/profile_manager.py | 28 +++ app/socket_events.py | 9 + docs/automation.md | 2 +- static/js/app.js | 5 + static/js/i18n.js | 6 + static/js/overview-workspace.js | 9 + tests/e2e/overview-filters.spec.js | 23 +++ tests/integration/test_automation_openssh.py | 14 ++ tests/test_host_platform.py | 188 +++++++++++++++++++ 10 files changed, 370 insertions(+), 1 deletion(-) diff --git a/app/host_platform.py b/app/host_platform.py index 93b1df74..09239acd 100644 --- a/app/host_platform.py +++ b/app/host_platform.py @@ -80,6 +80,19 @@ ''' _MAX_PACKAGE_COUNT = 100000 +# The restart marker test of MAINTENANCE_COMMAND alone: no package manager, +# no sudo, no writes. It runs only where that marker was seen before, so its +# absence means a restart on any system. See recheck_reboot_hint. +REBOOT_HINT_COMMAND = r'''LC_ALL=C +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export LC_ALL PATH +if [ -e /var/run/reboot-required ] || [ -e /run/reboot-required ]; then printf 'reboot=yes\n'; +else printf 'reboot=no\n'; fi +: +''' +# Matches the overview, which stops showing facts older than a week. +REBOOT_HINT_MAX_AGE_SECONDS = 7 * 24 * 3600 + def parse_maintenance(text, *, max_bytes=1024): """Return {'reboot_required', 'updates', 'security_updates'}; unknown values stay None.""" @@ -273,6 +286,80 @@ def services_needed(profile, now=None): return not 0 <= current - checked < SERVICE_REFRESH_SECONDS +def reboot_hint_needs_check(profile, now=None): + """True only while the overview shows a restart hint from a check the user ran.""" + facts = profile.get('detected_maintenance') if isinstance(profile, dict) else None + if not isinstance(facts, dict) or facts.get('reboot_required') is not True or facts.get('recheck_stopped'): + return False + checked = facts.get('checked_at') + if isinstance(checked, bool) or not isinstance(checked, (int, float)): + return False + age = (time.time() if now is None else now) - checked + return -24 * 3600 <= age <= REBOOT_HINT_MAX_AGE_SECONDS + + +def parse_reboot_hint(text, *, max_bytes=64): + """Accept exactly the one line REBOOT_HINT_COMMAND prints; banners or extra lines are not an answer.""" + if not isinstance(text, str) or len(text.encode('utf-8')) > max_bytes: + raise ValueError('Invalid restart marker answer') + answer = {'reboot=yes': True, 'reboot=no': False}.get(text.strip()) + if answer is None: + raise ValueError('Invalid restart marker answer') + return answer + + +def collect_reboot_hint(session_id, *, cancel_event=None): + from .session_insights import collect_linux_stats + return collect_linux_stats( + session_id, timeout=1.5, max_bytes=64, + command=REBOOT_HINT_COMMAND, parser=parse_reboot_hint, + allow_partial=False, cancel_event=cancel_event, + ) + + +def recheck_reboot_hint(session_id, user_id, publish, *, cancel_event=None): + """Reread only the restart marker on login while a restart hint is shown. + + Hosts without a hint are never asked anything. The hint exists only after + the user ran or scheduled the Updates and restart status check, which read + the same marker over the same login, so this repeats a read the user + already chose. Exactly "reboot=no" lowers the hint, exactly "reboot=yes" + keeps it for the next login. Any other answer may come from a forced + command (authorized_keys command=, ForceCommand) that the server runs in + place of ours, so the recheck stops for this hint until the next full + check. All failures leave the PTY alone. + """ + from . import ssh_manager, profile_manager + try: + with ssh_manager.sessions_lock: + session = ssh_manager.sessions.get(session_id) + if not session or session.get('user_id') != user_id or not session.get('connected'): + return + expected = endpoint(session) + profile_id = session.get('profile_id') + if expected is None or not profile_id or (cancel_event is not None and cancel_event.is_set()): + return + profile = profile_manager.get_profile(user_id, profile_id) + if not profile or endpoint(profile) != expected or not reboot_hint_needs_check(profile): + return + checked_at = profile['detected_maintenance']['checked_at'] + answer, error = collect_reboot_hint(session_id, cancel_event=cancel_event) + # Busy means another sample held the session; nothing reached the host. + if error == 'busy' or answer is True or (cancel_event is not None and cancel_event.is_set()): + return + with ssh_manager.sessions_lock: + current = ssh_manager.sessions.get(session_id) + if current is not session or not current.get('connected') or endpoint(current) != expected: + return + if answer is False and not error: + if profile_manager.settle_reboot_hint(user_id, profile_id, expected, checked_at, cleared=True): + publish({'profile_id': profile_id}) + return + profile_manager.settle_reboot_hint(user_id, profile_id, expected, checked_at, cleared=False) + except Exception: + return + + def detect_connected_platform(session_id, user_id, publish, *, cancel_event=None): """Optional once-per-host work; all failures leave the PTY alone.""" from . import ssh_manager, profile_manager diff --git a/app/profile_manager.py b/app/profile_manager.py index 5a013458..e92854f3 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -1011,6 +1011,34 @@ def cache_detected_facts(user_id, profile_id, expected_endpoint, *, platform=Non return False +@user_operation +def settle_reboot_hint(user_id, profile_id, expected_endpoint, checked_at, *, cleared): + """Lower a restart hint, or stop rechecking it, if it is still the one that was read. + + Only the hint from the check stamped checked_at changes; a newer full + check replaces detected_maintenance and starts over. Update counts and + the check time stay as that check reported them. + """ + if expected_endpoint is None or isinstance(checked_at, bool) or not isinstance(checked_at, (int, float)): + return False + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'profiles:{user_id}'): + profiles, error = _load_profiles_for_write(user_id) + if error: + return False + for profile in profiles: + if profile.get('id') != profile_id or endpoint(profile) != expected_endpoint: + continue + facts = profile.get('detected_maintenance') + if (not isinstance(facts, dict) or facts.get('reboot_required') is not True + or facts.get('recheck_stopped') or facts.get('checked_at') != checked_at): + return False + change = {'reboot_required': False} if cleared else {'recheck_stopped': True} + profile['detected_maintenance'] = {**facts, **change} + return save_profiles(user_id, profiles, previous_count=len(profiles)) + return False + + @user_operation def cache_detected_platform(user_id, profile_id, expected_endpoint, platform): """Merge only platform facts into the current owned profile under its lock.""" diff --git a/app/socket_events.py b/app/socket_events.py index 897bb7f3..fb1748d6 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -1745,6 +1745,15 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box): ) except Exception: pass + try: + from .host_platform import recheck_reboot_hint + recheck_reboot_hint( + session_id, current_user.id, + lambda payload: socketio.emit('host_maintenance', payload, to=request.sid), + cancel_event=cancellation, + ) + except Exception: + pass except StorageCorruptionError as error: if not cancellation.is_set(): emit('ssh_error', _storage_error_payload( diff --git a/docs/automation.md b/docs/automation.md index d85575e1..5fc0ac89 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -45,7 +45,7 @@ For finished runs on several hosts, **Compare hosts** groups the hosts per step ### Keep restart and update status current -WebSSH never checks updates on its own. The **Updates and restart status** template reads whether a restart is required (`/var/run/reboot-required`, any Linux) and, on Debian and Ubuntu, how many updates and security updates the local package lists show (a simulated `apt-get -s upgrade`). It uses no sudo, refreshes nothing, takes no lock and writes no file, also as root: apt's planner log and binary caches are switched off. `apt-check` and `dnf` are not used because both write caches or logs even when only reading, so Fedora and other systems report only the restart marker. The host controls this output, so the overview shows the values as reported and never acts on them. Counts lag behind the repositories until the host refreshes its lists. Run the template yourself or approve a schedule, for example daily. The overview takes the result only from that exact, unchanged step without sudo, with exit status 0 and untruncated output, and only for hosts whose address, port, user and route still match. When security updates are pending and Automation is enabled, the overview offers **Open upgrade workflow** for the Debian or Ubuntu hosts concerned; it opens the upgrade template as a draft with those hosts selected. +WebSSH never checks updates on its own. The **Updates and restart status** template reads whether a restart is required (`/var/run/reboot-required`, any Linux) and, on Debian and Ubuntu, how many updates and security updates the local package lists show (a simulated `apt-get -s upgrade`). It uses no sudo, refreshes nothing, takes no lock and writes no file, also as root: apt's planner log and binary caches are switched off. `apt-check` and `dnf` are not used because both write caches or logs even when only reading, so Fedora and other systems report only the restart marker. The host controls this output, so the overview shows the values as reported and never acts on them. Counts lag behind the repositories until the host refreshes its lists. Run the template yourself or approve a schedule, for example daily. The overview takes the result only from that exact, unchanged step without sudo, with exit status 0 and untruncated output, and only for hosts whose address, port, user and route still match. When security updates are pending and Automation is enabled, the overview offers **Open upgrade workflow** for the Debian or Ubuntu hosts concerned; it opens the upgrade template as a draft with those hosts selected. While the overview shows a restart hint for a saved host, each login to that host rereads only the restart marker (two existence tests, no sudo, no package manager, no writes). When the marker is gone, the hint disappears; the update counts stay until the template runs again. Any answer other than exactly `reboot=yes` or `reboot=no`, or none, keeps the hint and stops these rereads until the next full check, because a forced command (`command=` in `authorized_keys`, `ForceCommand`) may have answered in place of the test. Hosts without a restart hint are never asked. **Check restart status again** opens the template as a draft for the hosts concerned. Expand a step in **Runs** to see its exit code, duration, execution reason and captured standard/error output. New immediate and scheduled runs retain bounded output; older records explicitly indicate that output was not recorded. Output becomes available when the step ends. A confirmed zero exit status means the command reported success, not that WebSSH independently verified the intended change. diff --git a/static/js/app.js b/static/js/app.js index df89117f..2cfa8e26 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1696,6 +1696,11 @@ if (data.profile_id) ProfileManager.loadProfiles(); }); + // The login recheck found the restart marker gone; reload the saved facts. + socket.on('host_maintenance', data => { + if (typeof data?.profile_id === 'string' && data.profile_id) ProfileManager.loadProfiles(); + }); + socket.on('ssh_connected', (data) => { const requestId = typeof data?.client_request_id === 'string' diff --git a/static/js/i18n.js b/static/js/i18n.js index 783e2af9..8d337c02 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -795,6 +795,7 @@ const translations = { "overviewWorkspace.chipUpdatesSecurity": "{count} updates, {security} security", "overviewWorkspace.maintenanceChecked": "From the host's package cache, checked {time}", "overviewWorkspace.openUpgrade": "Open upgrade workflow for {count} hosts", + "overviewWorkspace.openRecheck": "Check restart status again for {count} hosts", "overviewWorkspace.jumpMissing": "Jump host missing for {name}", "overviewWorkspace.runFailed": "Run failed: {name}", "overviewWorkspace.scheduleReview": "Schedule needs review: {name}", @@ -3215,6 +3216,7 @@ const translations = { "overviewWorkspace.chipUpdatesSecurity": "{count} bản cập nhật, {security} bảo mật", "overviewWorkspace.maintenanceChecked": "Theo bộ nhớ đệm gói của máy chủ, kiểm tra lúc {time}", "overviewWorkspace.openUpgrade": "Mở quy trình nâng cấp cho {count} máy chủ", + "overviewWorkspace.openRecheck": "Kiểm tra lại trạng thái khởi động lại cho {count} máy chủ", "overviewWorkspace.jumpMissing": "Thiếu máy chủ trung chuyển cho {name}", "overviewWorkspace.runFailed": "Lần chạy thất bại: {name}", "overviewWorkspace.scheduleReview": "Lịch cần xem lại: {name}", @@ -5639,6 +5641,7 @@ const translations = { "overviewWorkspace.chipUpdatesSecurity": "{count} Updates, {security} Sicherheit", "overviewWorkspace.maintenanceChecked": "Laut Paket-Cache des Hosts, geprüft {time}", "overviewWorkspace.openUpgrade": "Upgrade-Workflow für {count} Hosts öffnen", + "overviewWorkspace.openRecheck": "Neustart-Status für {count} Hosts erneut prüfen", "overviewWorkspace.jumpMissing": "Jump Host fehlt für {name}", "overviewWorkspace.runFailed": "Lauf fehlgeschlagen: {name}", "overviewWorkspace.scheduleReview": "Zeitplan prüfen: {name}", @@ -8058,6 +8061,7 @@ const translations = { "overviewWorkspace.chipUpdatesSecurity": "{count} mises à jour, {security} de sécurité", "overviewWorkspace.maintenanceChecked": "D'après le cache de paquets de l'hôte, vérifié le {time}", "overviewWorkspace.openUpgrade": "Ouvrir le flux de mise à niveau pour {count} hôtes", + "overviewWorkspace.openRecheck": "Vérifier à nouveau l’état de redémarrage pour {count} hôtes", "overviewWorkspace.jumpMissing": "Hôte de rebond manquant pour {name}", "overviewWorkspace.runFailed": "Échec de l’exécution : {name}", "overviewWorkspace.scheduleReview": "Planification à vérifier : {name}", @@ -10477,6 +10481,7 @@ const translations = { "overviewWorkspace.chipUpdatesSecurity": "{count} actualizaciones, {security} de seguridad", "overviewWorkspace.maintenanceChecked": "Según la caché de paquetes del host, comprobado {time}", "overviewWorkspace.openUpgrade": "Abrir el flujo de actualización para {count} hosts", + "overviewWorkspace.openRecheck": "Volver a comprobar el estado de reinicio de {count} hosts", "overviewWorkspace.jumpMissing": "Falta el host de salto de {name}", "overviewWorkspace.runFailed": "Ejecución fallida: {name}", "overviewWorkspace.scheduleReview": "Revisa la programación: {name}", @@ -12895,6 +12900,7 @@ const translations = { "overviewWorkspace.chipUpdatesSecurity": "{count} 个更新,{security} 个安全更新", "overviewWorkspace.maintenanceChecked": "来自主机的软件包缓存,检查于 {time}", "overviewWorkspace.openUpgrade": "为 {count} 台主机打开升级工作流", + "overviewWorkspace.openRecheck": "重新检查 {count} 台主机的重启状态", "overviewWorkspace.jumpMissing": "{name} 缺少跳板机", "overviewWorkspace.runFailed": "运行失败:{name}", "overviewWorkspace.scheduleReview": "计划需要检查:{name}", diff --git a/static/js/overview-workspace.js b/static/js/overview-workspace.js index b9f4e32b..8affd1e9 100644 --- a/static/js/overview-workspace.js +++ b/static/js/overview-workspace.js @@ -546,6 +546,15 @@ open.append(icon('system_update_alt')); entry.append(open); } + // A restart hint only changes when the host is asked again; this opens that check as a draft. + if (item.kind === 'reboot' && win.WebSSHAutomation?.openTemplate && state.automationEnabled) { + const label = tw('openRecheck', 'Check restart status again for {count} hosts', {count: item.hostIds.length}); + const recheck = button('od-dismiss', label, () => win.WebSSHAutomation.openTemplate('maintenance', item.hostIds)); + recheck.title = label; + recheck.dataset.key = `recheck:${item.key}`; + recheck.append(icon('refresh')); + entry.append(recheck); + } entry.append(dismiss); return entry; })); diff --git a/tests/e2e/overview-filters.spec.js b/tests/e2e/overview-filters.spec.js index 74127435..bc9fcf24 100644 --- a/tests/e2e/overview-filters.spec.js +++ b/tests/e2e/overview-filters.spec.js @@ -200,3 +200,26 @@ test('pending security updates open the matching upgrade workflow as a draft wit expect(writes).toEqual([]); expect(await sshAttempts(page)).toEqual([]); }); + +test('a restart hint offers to check the same hosts again, as a draft and without contacting them', async ({page}) => { + const {ids} = await openWithReadiness(page); + await expect.poll(() => page.evaluate(() => window.WebSSHAutomation?.getRunSnapshot?.().enabled === true)).toBe(true); + const affected = ids.slice(-2); + await page.evaluate(hostIds => { + const checked = Math.floor(Date.now() / 1000); + ProfileManager.profiles.forEach(profile => { + if (hostIds.includes(profile.id)) profile.detected_maintenance = {reboot_required: true, checked_at: checked}; + }); + window.dispatchEvent(new Event('profile-list-change')); + }, affected); + const writes = []; + page.on('request', request => { if (request.method() !== 'GET' && request.url().includes('/api/automation/')) writes.push(request.url()); }); + const recheck = page.locator('#odAttention [data-key^="recheck:"]'); + await expect(recheck).toHaveAttribute('aria-label', 'Check restart status again for 2 hosts'); + await recheck.click(); + await expect(page.locator('#runbookName')).toHaveValue('Updates and restart status'); + await expect.poll(() => page.locator('#targetHosts input:checked').evaluateAll(inputs => inputs.map(input => input.value).sort())) + .toEqual([...affected].sort()); + expect(writes).toEqual([]); + expect(await sshAttempts(page)).toEqual([]); +}); diff --git a/tests/integration/test_automation_openssh.py b/tests/integration/test_automation_openssh.py index 2ba47578..36e0dd1a 100644 --- a/tests/integration/test_automation_openssh.py +++ b/tests/integration/test_automation_openssh.py @@ -147,3 +147,17 @@ def test_real_openssh_maintenance_probe_feeds_the_saved_host(peer): assert _record_maintenance_facts(uid, saved, step, result, nullcontext) stored = profile_manager.get_profile(uid, saved['id'])['detected_maintenance'] assert stored['reboot_required'] == facts['reboot_required'] and stored['checked_at'] > 0 + + +def test_real_openssh_login_recheck_reads_the_restart_marker(peer): + """The login recheck command runs unprivileged on Debian OpenSSH and answers yes or no.""" + from app.automation_transport import execute_step + from app.host_platform import REBOOT_HINT_COMMAND, parse_reboot_hint + uid, profile = peer + trust(uid) + step = dict(name='Restart marker', command=REBOOT_HINT_COMMAND, timeout=10, sudo=False) + with connect_profile(uid, profile, PASSWORD, lambda: False) as transport: + result = execute_step(transport, step, lambda: False) + assert result['status'] == 'succeeded' and result['exit_code'] == 0, result + assert result['stderr'] == '' + assert parse_reboot_hint(result['stdout']) in (True, False) diff --git a/tests/test_host_platform.py b/tests/test_host_platform.py index 86423dbc..9a46c8b0 100644 --- a/tests/test_host_platform.py +++ b/tests/test_host_platform.py @@ -379,3 +379,191 @@ def test_an_empty_automation_probe_keeps_the_last_known_facts(app): assert _record_maintenance_facts(user_id, profile, step, {**done, 'stdout': 'reboot=yes\n'}, nullcontext) assert not _record_maintenance_facts(user_id, profile, step, {**done, 'stdout': ''}, nullcontext) assert profile_manager.get_profile(user_id, profile['id'])['detected_maintenance']['reboot_required'] is True + + +def _restart_hint_session(app, monkeypatch, name, *, checked_at=None, reboot_required=True): + import time + from app import ssh_manager, profile_manager + from tests.test_profile_manager import create_user + user_id = create_user(app) + with app.app_context(): + profile, _ = profile_manager.upsert_profile(user_id, dict(name=name, host=f'{name}.example', port=22, + username='deploy', auth_type='password')) + assert profile_manager.cache_detected_facts( + user_id, profile['id'], host_platform.endpoint(profile), + maintenance={'reboot_required': reboot_required, 'updates': 3, 'security_updates': 1}, + now=time.time() if checked_at is None else checked_at) + monkeypatch.setitem(ssh_manager.sessions, name, dict(profile, connected=True, user_id=user_id, profile_id=profile['id'])) + return user_id, profile + + +def _fake_host(monkeypatch, output, during=None): + """A host answering output; None means a timeout, 'busy' a held collector.""" + from app import session_insights + commands = [] + + def collect(session_id, **kwargs): + if output == 'busy': + return None, 'busy' + commands.append(kwargs.get('command')) + if during: + during() + if output is None: + return None, 'transient' + try: + return kwargs['parser'](output, max_bytes=kwargs['max_bytes']), None + except ValueError: + return None, 'unsupported' + + monkeypatch.setattr(session_insights, 'collect_linux_stats', collect) + return commands + + +def _hint(app, user_id, profile): + from app import profile_manager + with app.app_context(): + return profile_manager.get_profile(user_id, profile['id'])['detected_maintenance'] + + +def test_the_login_recheck_reads_only_the_restart_marker(): + command = host_platform.REBOOT_HINT_COMMAND + marker = "if [ -e /var/run/reboot-required ] || [ -e /run/reboot-required ]; then printf 'reboot=yes\\n';" + # The same marker test as the check the user already runs, nothing else. + assert marker in host_platform.MAINTENANCE_COMMAND and marker in command + for forbidden in ('sudo', 'apt', 'dnf', 'timeout', '>', 'rm ', 'touch', 'eval', '. /'): + assert forbidden not in command + + +def test_only_the_exact_one_line_answer_counts(): + assert host_platform.parse_reboot_hint('reboot=yes\n') is True + assert host_platform.parse_reboot_hint('reboot=no\n') is False + for text in ('', 'reboot=maybe\n', 'Welcome\nreboot=no\n', 'reboot=no\napt=3\n', 'x' * 65, None): + with pytest.raises(ValueError): + host_platform.parse_reboot_hint(text) + + +def test_a_gone_marker_on_login_lowers_only_the_restart_hint(app, monkeypatch): + user_id, profile = _restart_hint_session(app, monkeypatch, 'rebooted') + commands = _fake_host(monkeypatch, 'reboot=no\n') + published = [] + with app.app_context(): + host_platform.recheck_reboot_hint('rebooted', user_id, published.append) + saved = _hint(app, user_id, profile) + assert commands == [host_platform.REBOOT_HINT_COMMAND] + assert published == [{'profile_id': profile['id']}] + assert saved['reboot_required'] is False + assert (saved['updates'], saved['security_updates']) == (3, 1) + + +def test_a_present_marker_keeps_the_hint_and_asks_again_next_login(app, monkeypatch): + user_id, profile = _restart_hint_session(app, monkeypatch, 'pending') + commands = _fake_host(monkeypatch, 'reboot=yes\n') + with app.app_context(): + host_platform.recheck_reboot_hint('pending', user_id, pytest.fail) + host_platform.recheck_reboot_hint('pending', user_id, pytest.fail) + assert len(commands) == 2 + saved = _hint(app, user_id, profile) + assert saved['reboot_required'] is True and 'recheck_stopped' not in saved + + +@pytest.mark.parametrize('output', [None, '', 'reboot=maybe\n', 'Welcome to the jump box\nreboot=no\n', 'x' * 2000]) +def test_an_unclear_answer_keeps_the_hint_and_stops_asking(app, monkeypatch, output): + """A forced command can answer in place of ours; it must not run again on every login.""" + user_id, profile = _restart_hint_session(app, monkeypatch, 'forced') + commands = _fake_host(monkeypatch, output) + with app.app_context(): + host_platform.recheck_reboot_hint('forced', user_id, pytest.fail) + host_platform.recheck_reboot_hint('forced', user_id, pytest.fail) + assert len(commands) == 1 + saved = _hint(app, user_id, profile) + assert saved['reboot_required'] is True and saved['recheck_stopped'] is True + + +def test_a_new_full_check_starts_the_recheck_over(app, monkeypatch): + from app import profile_manager + user_id, profile = _restart_hint_session(app, monkeypatch, 'renewed') + _fake_host(monkeypatch, '') + with app.app_context(): + host_platform.recheck_reboot_hint('renewed', user_id, pytest.fail) + assert profile_manager.cache_detected_facts(user_id, profile['id'], host_platform.endpoint(profile), + maintenance={'reboot_required': True}) + assert 'recheck_stopped' not in _hint(app, user_id, profile) + commands = _fake_host(monkeypatch, 'reboot=no\n') + with app.app_context(): + host_platform.recheck_reboot_hint('renewed', user_id, lambda payload: None) + assert len(commands) == 1 and _hint(app, user_id, profile)['reboot_required'] is False + + +def test_a_busy_session_sends_nothing_and_keeps_asking(app, monkeypatch): + user_id, profile = _restart_hint_session(app, monkeypatch, 'busy') + _fake_host(monkeypatch, 'busy') + with app.app_context(): + host_platform.recheck_reboot_hint('busy', user_id, pytest.fail) + assert 'recheck_stopped' not in _hint(app, user_id, profile) + + +def test_a_newer_check_during_the_recheck_is_never_overwritten(app, monkeypatch): + from app import profile_manager + user_id, profile = _restart_hint_session(app, monkeypatch, 'raced') + + def newer_check(): + later = _hint(app, user_id, profile)['checked_at'] + 5 + with app.app_context(): + assert profile_manager.cache_detected_facts(user_id, profile['id'], host_platform.endpoint(profile), + maintenance={'reboot_required': True, 'updates': 9}, now=later) + + _fake_host(monkeypatch, 'reboot=no\n', during=newer_check) + with app.app_context(): + host_platform.recheck_reboot_hint('raced', user_id, pytest.fail) + saved = _hint(app, user_id, profile) + assert saved['reboot_required'] is True and saved['updates'] == 9 + + +@pytest.mark.parametrize('change', ['closed', 'replaced', 'moved', 'cancelled']) +def test_a_session_that_changed_during_the_recheck_writes_nothing(app, monkeypatch, change): + import threading + from app import ssh_manager + user_id, profile = _restart_hint_session(app, monkeypatch, 'changing') + cancel = threading.Event() + + def mutate(): + if change == 'closed': + ssh_manager.sessions['changing']['connected'] = False + elif change == 'replaced': + ssh_manager.sessions['changing'] = dict(ssh_manager.sessions['changing']) + elif change == 'moved': + ssh_manager.sessions['changing']['host'] = 'elsewhere.example' + else: + cancel.set() + + _fake_host(monkeypatch, 'reboot=no\n', during=mutate) + with app.app_context(): + host_platform.recheck_reboot_hint('changing', user_id, pytest.fail, cancel_event=cancel) + saved = _hint(app, user_id, profile) + assert saved['reboot_required'] is True and 'recheck_stopped' not in saved + + +@pytest.mark.parametrize('case', ['no-hint', 'stale', 'stopped', 'other-user', 'moved', 'cancelled', 'quick-connection']) +def test_hosts_without_a_current_restart_hint_are_never_asked(app, monkeypatch, case): + import threading + from app import ssh_manager, profile_manager + hint = case != 'no-hint' + user_id, profile = _restart_hint_session(app, monkeypatch, 'quiet', reboot_required=True if hint else False, + checked_at=1_000 if case == 'stale' else None) + if case == 'stopped': + with app.app_context(): + assert profile_manager.settle_reboot_hint(user_id, profile['id'], host_platform.endpoint(profile), + _hint(app, user_id, profile)['checked_at'], cleared=False) + session = ssh_manager.sessions['quiet'] + if case == 'moved': + session['host'] = 'elsewhere.example' + if case == 'quick-connection': + session['profile_id'] = None + cancel = threading.Event() + if case == 'cancelled': + cancel.set() + commands = _fake_host(monkeypatch, 'reboot=no\n') + with app.app_context(): + host_platform.recheck_reboot_hint('quiet', user_id + 1 if case == 'other-user' else user_id, + lambda payload: None, cancel_event=cancel) + assert commands == []