From 962979d483a1141d8fb9ef49f47c95c53f16cb75 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 13 Aug 2026 09:28:37 -0400 Subject: [PATCH] geenforce: a PC that has gone quiet stops reading as healthy A report records how ONE cycle went. Nothing ages it, so a PC that stops reporting keeps the status of its last good cycle: switch a machine off after a clean run and it shows 'ok' indefinitely. One had been offline more than a day and still read 'ok'. Silence is a different fact from the last cycle's outcome, so it is computed separately rather than by rewriting the stored status. receivedat - the server's own clock, not anything a client asserts - is compared against geenforce_reportstaleminutes, default 30, which is roughly two missed cycles at the usual cadence. Set it to 0 to turn the check off. In the table 'stale' takes the badge, because a status from a machine that has not spoken since is not evidence of anything. What it last reported stays in the tooltip with the time it was heard. The stored status is untouched: it is still a true record of that cycle, just not proof the PC is alive. A site whose scope enforces less often than the threshold will read stale while healthy, which is what the setting is for. --- plugins/geenforce/api/routes.py | 36 ++++++- .../frontend/views/EnforcementReports.vue | 15 ++- plugins/geenforce/plugin.py | 20 ++++ .../test_plugins/test_geenforce_reporting.py | 100 ++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index 26a7e12..86b8fb2 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -11,7 +11,7 @@ Two audiences: import ipaddress import os import time -from datetime import datetime +from datetime import datetime, timedelta from functools import wraps from flask import Blueprint, request, Response, send_file, current_app, g @@ -1061,6 +1061,29 @@ def _attach_backup_state(facts, hostnames): if revision.lastseenat else None) +def _report_stale_cutoff(): + """(cutoff datetime, threshold minutes) for 'this PC has gone quiet'. + + A report is a record of one cycle, not a heartbeat with an expiry: nothing + ages it. So a PC that stops reporting keeps the status of its last good + cycle and reads as healthy while it is unplugged. Comparing receivedat - the + SERVER's clock, not anything the client asserts - against a threshold is + what turns silence into a visible state. + + Returns (None, 0) when the check is disabled, so callers skip it entirely. + """ + from shopdb.api import Setting + try: + minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0) + except (TypeError, ValueError): + minutes = 30 + if minutes <= 0: + return None, 0 + # Same clock helper that WROTE receivedat (naive UTC), so the two cannot + # drift into comparing an aware datetime against a naive one. + return service._utcnow() - timedelta(minutes=minutes), minutes + + @geenforce_bp.route('/reports', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') @@ -1084,6 +1107,7 @@ def list_reports(): facts = _asset_facts({report.hostname for report in reports if report.hostname}) + stalecutoff, stalafter = _report_stale_cutoff() latest_cache = {} data = [] for report in reports: @@ -1092,6 +1116,9 @@ def list_reports(): latest_cache[key] = _current_published_version(*key) latest = latest_cache[key] known = facts.get((report.hostname or '').lower(), {}) + isstale = (stalecutoff is not None + and (report.receivedat is None + or report.receivedat < stalecutoff)) data.append({ 'reportid': report.reportid, 'hostname': report.hostname, @@ -1118,7 +1145,14 @@ def list_reports(): 'receivedlatest': (latest is not None and report.appliedversion == latest), 'enforcerversion': report.enforcerversion, + # 'status' is what the PC said about its LAST cycle and is left + # exactly as reported. 'isstale' is the server's own judgement that + # no cycle has been heard from in too long - a PC switched off after + # a clean run reports 'ok' forever, so the two are different facts + # and the table shows both. 'status': report.status, + 'isstale': isstale, + 'staleafterminutes': stalafter, 'installed': report.installedcount, 'skipped': report.skippedcount, 'failed': report.failedcount, diff --git a/plugins/geenforce/frontend/views/EnforcementReports.vue b/plugins/geenforce/frontend/views/EnforcementReports.vue index 93d1d0d..9b4cc46 100644 --- a/plugins/geenforce/frontend/views/EnforcementReports.vue +++ b/plugins/geenforce/frontend/views/EnforcementReports.vue @@ -88,7 +88,14 @@ {{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }} - {{ report.status }} + + + stale + {{ report.status }} + {{ report.installed }} {{ report.skipped }} {{ report.failed }} @@ -183,6 +190,12 @@ async function openDetail(reportid) { function statusClass(status) { return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || '' } + +function staleTitle(report) { + const last = report.receivedat ? formatDate(report.receivedat) : 'never' + return `No report for over ${report.staleafterminutes} minutes.` + + ` Last heard ${last}, reporting "${report.status}".` +} function actionClass(action) { return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger', filtered: '' }[action] || '' diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py index 31a2115..7dc49e2 100644 --- a/plugins/geenforce/plugin.py +++ b/plugins/geenforce/plugin.py @@ -141,6 +141,26 @@ class GeEnforcePlugin(BasePlugin): }, ] + def get_settings_defaults(self) -> List[Dict]: + """How long a PC may go quiet before the fleet table calls it stale.""" + return [ + { + 'key': 'geenforce_reportstaleminutes', + 'value': '30', + 'valuetype': 'integer', + 'category': 'geenforce', + 'description': 'Minutes without an enforcement report before a ' + 'PC is shown as stale. A report records how the ' + 'LAST cycle went, so a PC that stops reporting ' + 'keeps whatever status it last sent - it reads ' + 'as healthy while it is switched off. Set to 0 ' + 'to disable the check.', + # Server-side only: nothing on a PC reads this, so it stays off + # the public settings surface. + 'public': False, + }, + ] + def init_app(self, app: Flask, db_instance) -> None: logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})") diff --git a/tests/test_plugins/test_geenforce_reporting.py b/tests/test_plugins/test_geenforce_reporting.py index 0e322b5..db0da61 100644 --- a/tests/test_plugins/test_geenforce_reporting.py +++ b/tests/test_plugins/test_geenforce_reporting.py @@ -185,3 +185,103 @@ def test_report_missing_hostname_rejected(client, db, app, auth_headers): resp = client.post('/api/geenforce/report', json={'scopename': 'x'}, headers={'X-API-Key': secret}) assert resp.status_code == 400 + + +# -- going quiet ------------------------------------------------------------ +# +# A report records how one cycle went; nothing ages it. A PC switched off after +# a clean run therefore kept 'ok' indefinitely and read as healthy - which is +# how a machine offline for over a day still showed 'ok'. + +def _age_report(db, hostname, minutes): + """Push a stored report's server-side receivedat back in time.""" + from datetime import timedelta + from plugins.geenforce.models import ManifestEnforcementReport + from plugins.geenforce.service import _utcnow + (ManifestEnforcementReport.query + .filter_by(hostname=hostname, iscurrent=True) + .update({'receivedat': _utcnow() - timedelta(minutes=minutes)})) + db.session.commit() + + +def _post_clean_report(client, secret, hostname): + return client.post('/api/geenforce/report', json={ + 'hostname': hostname, 'scopename': 'gea-shopfloor-cmm', + 'appliedversion': 1, 'enforcerversion': '2.6', + 'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0}, + 'results': [{'name': 'Alpha', 'action': 'installed'}], + }, headers={'X-API-Key': secret}) + + +def test_a_pc_reporting_now_is_not_stale(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + _post_clean_report(client, secret, 'WJCMM01') + + row = client.get('/api/geenforce/reports', + headers=auth_headers).get_json()['data'][0] + assert row['status'] == 'ok' + assert row['isstale'] is False + assert row['staleafterminutes'] == 30 + + +def test_a_pc_that_stopped_reporting_goes_stale(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + _post_clean_report(client, secret, 'WJCMM01') + _age_report(db, 'WJCMM01', minutes=60 * 26) # offline over a day + + row = client.get('/api/geenforce/reports', + headers=auth_headers).get_json()['data'][0] + assert row['isstale'] is True + # The REPORTED status is left alone - it is still a true record of the last + # cycle, and the UI shows it in the tooltip behind the stale badge. + assert row['status'] == 'ok' + + +def test_stale_boundary_is_the_configured_threshold(client, db, app, auth_headers): + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + _post_clean_report(client, secret, 'WJCMM01') + + _age_report(db, 'WJCMM01', minutes=29) + row = client.get('/api/geenforce/reports', + headers=auth_headers).get_json()['data'][0] + assert row['isstale'] is False, 'inside the window is not stale' + + _age_report(db, 'WJCMM01', minutes=31) + row = client.get('/api/geenforce/reports', + headers=auth_headers).get_json()['data'][0] + assert row['isstale'] is True, 'past the window is stale' + + +def test_stale_threshold_is_a_setting(client, db, app, auth_headers): + from shopdb.api import Setting + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + _post_clean_report(client, secret, 'WJCMM01') + _age_report(db, 'WJCMM01', minutes=45) + + Setting.set('geenforce_reportstaleminutes', '120', valuetype='integer', + category='geenforce') + db.session.commit() + row = client.get('/api/geenforce/reports', + headers=auth_headers).get_json()['data'][0] + assert row['isstale'] is False + assert row['staleafterminutes'] == 120 + + +def test_zero_disables_the_stale_check(client, db, app, auth_headers): + from shopdb.api import Setting + _seed_and_publish(app) + secret = _token(client, auth_headers, ['geenforce.report']) + _post_clean_report(client, secret, 'WJCMM01') + _age_report(db, 'WJCMM01', minutes=60 * 24 * 7) + + Setting.set('geenforce_reportstaleminutes', '0', valuetype='integer', + category='geenforce') + db.session.commit() + row = client.get('/api/geenforce/reports', + headers=auth_headers).get_json()['data'][0] + assert row['isstale'] is False + assert row['staleafterminutes'] == 0