Add GE-Enforce observed-state reporting: receipt + self-heal from PCs
PCs now report enforcement results back to shopdb, closing the desired-vs-observed loop. - POST /api/geenforce/report (geenforce.report service token): each cycle a PC posts the published version it applied, install/skip/fail/filtered counts, and per-entry outcomes. - Two tables: manifestenforcementreports (latest-per-host + history: applied version, enforcer version, counts, derived status ok/selfhealed/failed) and manifestenforcementresults (per entry: action installed/skipped/failed, selfhealed flag, exit code, warning/error message). - RECEIVED: reports carry the applied version; the admin view derives receivedlatest by comparing it to the scope's current published version, so the fleet view shows which PCs picked up an update. - SELF-HEAL: per-entry action captures drift correction (installed when it should already be present) vs skipped (already good) vs failed, with messages. - Admin reads: GET /reports (fleet compliance rollup) and GET /reports/<id> (per-entry detail). New geenforce.report permission. - Tables added to the (undeployed) 0001 baseline; geenforce.post_report is a service-token endpoint so it is exempt from the JWT authz sweep, like the collector blueprint. 8 reporting tests; full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
152
tests/test_plugins/test_geenforce_reporting.py
Normal file
152
tests/test_plugins/test_geenforce_reporting.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""GE-Enforce observed-state reporting: PCs POST enforcement results.
|
||||
|
||||
Covers the report ingest (applied version + per-entry self-heal outcomes), the
|
||||
latest-per-host upsert, received-latest derivation (did the PC pick up the newest
|
||||
published manifest?), status derivation (ok / selfhealed / failed), and the admin
|
||||
fleet-compliance views.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', SCOPE)
|
||||
version = service.publish_scope(scopename, 'runtime', notes='v')
|
||||
service.db.session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def _token(client, auth_headers, scopes):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': scopes},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_report_recorded_and_listed(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'enforcerversion': '2.6',
|
||||
'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0},
|
||||
'results': [
|
||||
{'name': 'Alpha', 'action': 'installed', 'selfhealed': True},
|
||||
{'name': 'Beta', 'action': 'skipped'},
|
||||
],
|
||||
}, headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['status'] == 'selfhealed'
|
||||
|
||||
listing = client.get('/api/geenforce/reports', headers=auth_headers)
|
||||
rows = listing.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row['hostname'] == 'WJCMM01'
|
||||
assert row['appliedversion'] == 1
|
||||
assert row['latestversion'] == 1
|
||||
assert row['receivedlatest'] is True
|
||||
assert row['installed'] == 1 and row['skipped'] == 1
|
||||
|
||||
|
||||
def test_received_latest_flips_when_new_version_published(client, db, app,
|
||||
auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'counts': {}},
|
||||
headers={'X-API-Key': secret})
|
||||
# Publish a newer version; the PC is now behind.
|
||||
with app.app_context():
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2')
|
||||
service.db.session.commit()
|
||||
|
||||
row = client.get('/api/geenforce/reports?hostname=WJCMM02',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['appliedversion'] == 1
|
||||
assert row['latestversion'] == 2
|
||||
assert row['receivedlatest'] is False
|
||||
|
||||
|
||||
def test_latest_report_upserts_per_host(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for installed in (0, 1):
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1,
|
||||
'counts': {'installed': installed}},
|
||||
headers={'X-API-Key': secret})
|
||||
rows = client.get('/api/geenforce/reports?hostname=WJCMM03',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert len(rows) == 1 # only the latest is current
|
||||
assert rows[0]['installed'] == 1
|
||||
|
||||
|
||||
def test_status_failed_when_failures(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm',
|
||||
'counts': {'failed': 1},
|
||||
'results': [{'name': 'Alpha', 'action': 'failed',
|
||||
'exitcode': 1603,
|
||||
'message': 'MSI 1603'}]},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.get_json()['data']['status'] == 'failed'
|
||||
|
||||
|
||||
def test_report_detail_shows_per_entry_results(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
post = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [
|
||||
{'name': 'Alpha', 'action': 'installed',
|
||||
'selfhealed': True},
|
||||
{'name': 'Beta', 'action': 'failed',
|
||||
'exitcode': 1, 'message': 'boom'}]},
|
||||
headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
results = {r['entryname']: r for r in detail['results']}
|
||||
assert results['Alpha']['selfhealed'] is True
|
||||
assert results['Beta']['action'] == 'failed'
|
||||
assert results['Beta']['message'] == 'boom'
|
||||
|
||||
|
||||
def test_report_requires_report_scope(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
# A fetch-only token cannot report.
|
||||
secret = _token(client, auth_headers, ['geenforce.fetch'])
|
||||
resp = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM06'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_report_unauthenticated_rejected(client, db, app):
|
||||
resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_report_missing_hostname_rejected(client, db, app, auth_headers):
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user