geenforce: judge silence on both clocks, not just the server's
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

The fleet table has two time columns and staleness only looked at one. A PC can
go quiet in either direction: silent to the server, or still posting while its
own cycle has stopped advancing. Only the first was caught, so a machine whose
Last check-in had not moved since the previous morning still showed 'ok'.

The two are tested independently rather than by taking the newer of them.
receivedat is the server's own record and cannot be argued with; lastcheckin is
asserted by the client, so a wrong clock there must not be able to vouch for a
PC the server has not actually heard from. Checking them separately means a
future-dated lastcheckin cannot mask real silence.

The tooltip now names both times, since which one stopped says what kind of
failure it is.
This commit is contained in:
cproudlock
2026-08-13 09:53:56 -04:00
parent 962979d483
commit 1e884dc02a
3 changed files with 70 additions and 6 deletions

View File

@@ -285,3 +285,54 @@ def test_zero_disables_the_stale_check(client, db, app, auth_headers):
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is False
assert row['staleafterminutes'] == 0
def test_a_pc_still_posting_with_a_stalled_checkin_is_stale(client, db, app,
auth_headers):
"""The other way round: the server hears from it, but its own cycle stopped.
receivedat stays current while the PC's self-reported lastcheckin does not
advance. The fleet table has a column for each, and either going quiet is a
problem worth seeing.
"""
from datetime import timedelta
from plugins.geenforce.service import _utcnow
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
client.post('/api/geenforce/report', json={
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'enforcerversion': '2.6',
'lastcheckin': (_utcnow() - timedelta(hours=22)).isoformat() + 'Z',
'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
'results': [{'name': 'Alpha', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['status'] == 'ok', 'the cycle itself reported clean'
assert row['isstale'] is True, 'but it has not checked in for 22 hours'
def test_a_future_checkin_cannot_mask_server_side_silence(client, db, app,
auth_headers):
"""lastcheckin is client-asserted, so it must not be able to vouch for a PC.
Taking the newer of the two timestamps would let a PC with a wrong clock
report itself healthy forever. receivedat is checked on its own.
"""
from datetime import timedelta
from plugins.geenforce.service import _utcnow
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
client.post('/api/geenforce/report', json={
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'enforcerversion': '2.6',
'lastcheckin': (_utcnow() + timedelta(days=3)).isoformat() + 'Z',
'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
'results': [{'name': 'Alpha', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
_age_report(db, 'WJCMM01', minutes=60 * 26)
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is True