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:
@@ -339,13 +339,18 @@ PC-type manager:
|
||||
an immutable snapshot (see section 4) and is what the fleet then gets. Show the
|
||||
draft-vs-published diff before publishing. Rollback republishes a prior
|
||||
snapshot.
|
||||
- **Desired vs observed**: the scope page can show, per entry, how many fleet
|
||||
PCs match the expected detection value. CAVEAT: this is NOT free with today's
|
||||
collector - it reports `installedsoftware[]`, not the per-entry manifest
|
||||
status map (`installedVersions` keyed `<scope>/<Name>` that GE-Enforce already
|
||||
computes for status.json). Delivering this feature needs a new collector
|
||||
payload field carrying that map. Worth it (it is the payoff of unifying
|
||||
desired + observed state) but it is a dependency, not existing data.
|
||||
- **Desired vs observed (BUILT: observed-state reporting)**: rather than extend
|
||||
the collector, the plugin has its own reporting path. Each enforcement cycle a
|
||||
PC POSTs `POST /api/geenforce/report` (geenforce.report service token) with the
|
||||
published version it applied, the installed/skipped/failed/filtered counts, and
|
||||
per-entry outcomes. Stored in `manifestenforcementreports` (latest-per-host +
|
||||
history) and `manifestenforcementresults` (per-entry). Two payoffs fall out:
|
||||
RECEIVED - `receivedlatest` compares the applied version to the scope's current
|
||||
published version, so the fleet view shows which PCs picked up an update; and
|
||||
SELF-HEAL - each entry's action (installed = drift corrected, skipped = already
|
||||
good, failed) with any warning/error message. Admin reads: `GET /reports`
|
||||
(fleet compliance) and `GET /reports/<id>` (per-entry detail). This is the
|
||||
observed half that makes the manifest a closed desired-vs-observed loop.
|
||||
|
||||
This is an ADR-010 settings card contributed by the geenforce plugin, so it only
|
||||
appears when the plugin is enabled.
|
||||
|
||||
@@ -19,19 +19,24 @@ from shopdb.api import (
|
||||
service_token_authorized,
|
||||
)
|
||||
|
||||
from ..models import ManifestScope, ManifestPublishedVersion
|
||||
from ..models import (
|
||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||
)
|
||||
from ..serializer import scope_to_manifest
|
||||
from .. import service
|
||||
|
||||
geenforce_bp = Blueprint('geenforce', __name__)
|
||||
|
||||
FETCH_SCOPE = 'geenforce.fetch'
|
||||
REPORT_SCOPE = 'geenforce.report'
|
||||
|
||||
|
||||
def require_fetch_token(f):
|
||||
"""Require a geenforce.fetch service token OR the env bootstrap key."""
|
||||
def _require_service_token(scope):
|
||||
"""Decorator factory: require `scope` service token OR the env bootstrap key."""
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(FETCH_SCOPE):
|
||||
if service_token_authorized(scope):
|
||||
return f(*args, **kwargs)
|
||||
expected = current_app.config.get('GEENFORCE_API_KEY')
|
||||
if expected and request.headers.get('X-API-Key') == expected:
|
||||
@@ -39,6 +44,11 @@ def require_fetch_token(f):
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
return decorated
|
||||
return wrapper
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
|
||||
# -- client-facing endpoint ---------------------------------------------------
|
||||
@@ -78,6 +88,28 @@ def get_manifest():
|
||||
'X-Manifest-Version': str(published.versionnumber)})
|
||||
|
||||
|
||||
# -- client reporting (observed state) ----------------------------------------
|
||||
|
||||
@geenforce_bp.route('/report', methods=['POST'])
|
||||
@require_report_token
|
||||
def post_report():
|
||||
"""Record one PC's enforcement cycle: applied version (did it receive the
|
||||
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not (payload.get('hostname') or '').strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required', http_code=400)
|
||||
try:
|
||||
report = service.record_enforcement_report(payload)
|
||||
db.session.commit()
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
|
||||
http_code=400)
|
||||
return success_response({'reportid': report.reportid,
|
||||
'status': report.status}, message='recorded')
|
||||
|
||||
|
||||
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['GET'])
|
||||
@@ -115,3 +147,92 @@ def preview_scope(scopeid):
|
||||
http_code=404)
|
||||
return success_response({'scopename': scope.scopename,
|
||||
'manifest': scope_to_manifest(scope)})
|
||||
|
||||
|
||||
def _current_published_version(scopename, phase):
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
return None
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
return published.versionnumber if published else None
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_reports():
|
||||
"""Latest enforcement report per PC (fleet compliance view).
|
||||
|
||||
Each row shows the applied vs latest published version (receivedlatest = the
|
||||
PC picked up the update) and the install/skip/fail counts + status.
|
||||
Optional filters: hostname, scopename.
|
||||
"""
|
||||
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
|
||||
hostname = request.args.get('hostname')
|
||||
scopename = request.args.get('scopename')
|
||||
if hostname:
|
||||
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
|
||||
if scopename:
|
||||
query = query.filter_by(scopename=scopename)
|
||||
reports = query.order_by(
|
||||
ManifestEnforcementReport.receivedat.desc()).all()
|
||||
|
||||
latest_cache = {}
|
||||
data = []
|
||||
for report in reports:
|
||||
key = (report.scopename, report.phase)
|
||||
if key not in latest_cache:
|
||||
latest_cache[key] = _current_published_version(*key)
|
||||
latest = latest_cache[key]
|
||||
data.append({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
'latestversion': latest,
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'enforcerversion': report.enforcerversion,
|
||||
'status': report.status,
|
||||
'installed': report.installedcount,
|
||||
'skipped': report.skippedcount,
|
||||
'failed': report.failedcount,
|
||||
'filtered': report.filteredcount,
|
||||
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
|
||||
if report.lastcheckin else None),
|
||||
'receivedat': (report.receivedat.isoformat() + 'Z'
|
||||
if report.receivedat else None),
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_report(reportid):
|
||||
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
||||
report = db.session.get(ManifestEnforcementReport, reportid)
|
||||
if not report:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
|
||||
http_code=404)
|
||||
latest = _current_published_version(report.scopename, report.phase)
|
||||
return success_response({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
'latestversion': latest,
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'status': report.status,
|
||||
'results': [{
|
||||
'entryname': r.entryname,
|
||||
'action': r.action,
|
||||
'selfhealed': r.selfhealed,
|
||||
'exitcode': r.exitcode,
|
||||
'message': r.message,
|
||||
} for r in report.results],
|
||||
})
|
||||
|
||||
@@ -173,6 +173,46 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('payloadid'),
|
||||
)
|
||||
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
op.create_table(
|
||||
'manifestenforcementreports',
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=100), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
sa.Column('phase', sa.String(length=16), nullable=False),
|
||||
sa.Column('appliedversion', sa.Integer(), nullable=True),
|
||||
sa.Column('enforcerversion', sa.String(length=20), nullable=True),
|
||||
sa.Column('installedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('skippedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('failedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('filteredcount', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('lastcheckin', sa.DateTime(), nullable=True),
|
||||
sa.Column('receivedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('reportid'),
|
||||
)
|
||||
op.create_index('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
op.create_index('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
op.create_table(
|
||||
'manifestenforcementresults',
|
||||
sa.Column('resultid', sa.Integer(), nullable=False),
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryname', sa.String(length=128), nullable=False),
|
||||
sa.Column('action', sa.String(length=16), nullable=False),
|
||||
sa.Column('selfhealed', sa.Boolean(), nullable=False),
|
||||
sa.Column('exitcode', sa.Integer(), nullable=True),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['reportid'],
|
||||
['manifestenforcementreports.reportid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('resultid'),
|
||||
)
|
||||
op.create_index('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
op.create_table(
|
||||
'pctypealiases',
|
||||
sa.Column('aliasid', sa.Integer(), nullable=False),
|
||||
@@ -187,6 +227,8 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('pctypealiases')
|
||||
op.drop_table('manifestenforcementresults')
|
||||
op.drop_table('manifestenforcementreports')
|
||||
op.drop_table('manifestpayloads')
|
||||
op.drop_table('manifestpublishedversions')
|
||||
op.drop_table('manifestinusecheckprocesses')
|
||||
|
||||
@@ -10,6 +10,8 @@ from .manifest import (
|
||||
ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion,
|
||||
ManifestPayload,
|
||||
ManifestEnforcementReport,
|
||||
ManifestEnforcementResult,
|
||||
PcTypeAlias,
|
||||
PHASES,
|
||||
ENTRY_TYPES,
|
||||
@@ -30,6 +32,8 @@ __all__ = [
|
||||
'ManifestInUseCheckProcess',
|
||||
'ManifestPublishedVersion',
|
||||
'ManifestPayload',
|
||||
'ManifestEnforcementReport',
|
||||
'ManifestEnforcementResult',
|
||||
'PcTypeAlias',
|
||||
'PHASES',
|
||||
'ENTRY_TYPES',
|
||||
|
||||
@@ -271,6 +271,66 @@ class ManifestPayload(db.Model):
|
||||
uploadedat = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
|
||||
class ManifestEnforcementReport(db.Model):
|
||||
"""One enforcement cycle reported by a PC (observed state).
|
||||
|
||||
Each cycle the client POSTs its result for a scope: the published version it
|
||||
actually applied (so shopdb knows whether the PC RECEIVED the latest update),
|
||||
the enforcer version, and the installed/skipped/failed/filtered counts. The
|
||||
latest report per (hostname, scopename, phase) carries `iscurrent`; older
|
||||
ones are history. Pairs desired state (the manifest) with observed state.
|
||||
"""
|
||||
__tablename__ = 'manifestenforcementreports'
|
||||
|
||||
reportid = db.Column(db.Integer, primary_key=True)
|
||||
hostname = db.Column(db.String(100), nullable=False, index=True)
|
||||
scopename = db.Column(db.String(64), nullable=False)
|
||||
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
||||
# Published version the client actually ran; compare to the scope's current
|
||||
# published version to see whether this PC received the latest manifest.
|
||||
appliedversion = db.Column(db.Integer, nullable=True)
|
||||
enforcerversion = db.Column(db.String(20), nullable=True)
|
||||
installedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
skippedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
failedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
filteredcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
# 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
|
||||
status = db.Column(db.String(16), nullable=False, default='ok')
|
||||
lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time
|
||||
receivedat = db.Column(db.DateTime, nullable=False) # server time
|
||||
iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
results = db.relationship(
|
||||
'ManifestEnforcementResult', back_populates='report',
|
||||
cascade='all, delete-orphan', lazy='selectin')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
|
||||
)
|
||||
|
||||
|
||||
class ManifestEnforcementResult(db.Model):
|
||||
"""One entry's outcome within an enforcement cycle (self-heal detail)."""
|
||||
__tablename__ = 'manifestenforcementresults'
|
||||
|
||||
resultid = db.Column(db.Integer, primary_key=True)
|
||||
reportid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
entryname = db.Column(db.String(128), nullable=False)
|
||||
# 'installed' (action fired - a self-heal when it should already be present),
|
||||
# 'skipped' (detected present), 'failed', 'filtered'.
|
||||
action = db.Column(db.String(16), nullable=False)
|
||||
# True when this install was a drift correction (self-heal), not a first
|
||||
# install. Client-supplied; defaults to whether the action installed.
|
||||
selfhealed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
exitcode = db.Column(db.Integer, nullable=True)
|
||||
message = db.Column(db.Text, nullable=True) # warning / error text
|
||||
|
||||
report = db.relationship('ManifestEnforcementReport', back_populates='results')
|
||||
|
||||
|
||||
class PcTypeAlias(db.Model):
|
||||
"""Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1).
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ from .api import geenforce_bp
|
||||
from .models import (
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, PcTypeAlias,
|
||||
ManifestPublishedVersion, ManifestPayload, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult, PcTypeAlias,
|
||||
)
|
||||
from .filters import ALIAS_GROUPS
|
||||
|
||||
@@ -61,7 +62,8 @@ class GeEnforcePlugin(BasePlugin):
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
||||
ManifestEntryHostname, ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, PcTypeAlias,
|
||||
ManifestPublishedVersion, ManifestPayload,
|
||||
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
@@ -73,6 +75,8 @@ class GeEnforcePlugin(BasePlugin):
|
||||
'geenforce'),
|
||||
('geenforce.fetch', 'Fetch published manifests (client service token)',
|
||||
'geenforce'),
|
||||
('geenforce.report', 'Report enforcement results (client service token)',
|
||||
'geenforce'),
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
|
||||
@@ -14,7 +14,10 @@ from sqlalchemy import func
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import ManifestScope, ManifestPublishedVersion
|
||||
from .models import (
|
||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult,
|
||||
)
|
||||
from .importer import build_entry
|
||||
from .serializer import scope_to_json
|
||||
|
||||
@@ -90,6 +93,76 @@ def rollback_scope(scopename, phase, versionnumber):
|
||||
return versionnumber
|
||||
|
||||
|
||||
def record_enforcement_report(payload):
|
||||
"""Record one PC's enforcement cycle (observed state). Upserts the latest
|
||||
report per (hostname, scopename, phase) and keeps prior ones as history.
|
||||
|
||||
Payload (all but hostname optional):
|
||||
hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin,
|
||||
counts {installed, skipped, failed, filtered},
|
||||
results [{name, action, selfhealed, exitcode, message}]
|
||||
|
||||
Returns the new ManifestEnforcementReport (uncommitted).
|
||||
"""
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
scopename = (payload.get('scopename') or '').strip()
|
||||
phase = (payload.get('phase') or 'runtime').strip()
|
||||
counts = payload.get('counts') or {}
|
||||
results = payload.get('results') or []
|
||||
|
||||
failed = int(counts.get('failed', 0))
|
||||
installed = int(counts.get('installed', 0))
|
||||
# Derive status: any failure wins; else drift-corrected installs = selfhealed.
|
||||
if failed > 0:
|
||||
status = 'failed'
|
||||
elif installed > 0 or any(r.get('selfhealed') for r in results):
|
||||
status = 'selfhealed'
|
||||
else:
|
||||
status = 'ok'
|
||||
|
||||
# Demote the prior current report for this host+scope.
|
||||
ManifestEnforcementReport.query.filter_by(
|
||||
hostname=hostname, scopename=scopename, phase=phase, iscurrent=True
|
||||
).update({'iscurrent': False})
|
||||
|
||||
report = ManifestEnforcementReport(
|
||||
hostname=hostname,
|
||||
scopename=scopename,
|
||||
phase=phase,
|
||||
appliedversion=payload.get('appliedversion'),
|
||||
enforcerversion=payload.get('enforcerversion'),
|
||||
installedcount=installed,
|
||||
skippedcount=int(counts.get('skipped', 0)),
|
||||
failedcount=failed,
|
||||
filteredcount=int(counts.get('filtered', 0)),
|
||||
status=status,
|
||||
lastcheckin=_parse_dt(payload.get('lastcheckin')),
|
||||
receivedat=_utcnow(),
|
||||
iscurrent=True)
|
||||
for item in results:
|
||||
report.results.append(ManifestEnforcementResult(
|
||||
entryname=item.get('name', ''),
|
||||
action=item.get('action', ''),
|
||||
selfhealed=bool(item.get('selfhealed',
|
||||
item.get('action') == 'installed')),
|
||||
exitcode=item.get('exitcode'),
|
||||
message=item.get('message')))
|
||||
db.session.add(report)
|
||||
return report
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace(
|
||||
tzinfo=None)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def export_scope_to_share(scopename, phase, shareroot):
|
||||
"""Write a scope's current published JSON to the share, backing up the old
|
||||
file to _meta/history first. Returns the written path."""
|
||||
|
||||
@@ -46,6 +46,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
'manifestpublishedversions', 'manifestpayloads',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
|
||||
@@ -40,7 +40,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
|
||||
# The apitokens create/update/revoke endpoints are NOT exempt: they now require
|
||||
# the apitokens.create permission, so a role-less member gets the 403 this sweep
|
||||
# asserts (ownership is still enforced inside the handler for non-admins).
|
||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user'}
|
||||
# geenforce.post_report - GE-Enforce client ingest, authenticated by a
|
||||
# geenforce.report managed service token (X-API-Key/Bearer), not JWT. Same
|
||||
# shape as the exempt collector blueprint; the geenforce admin endpoints in
|
||||
# the same blueprint are JWT+permission gated and ARE swept.
|
||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
'geenforce.post_report'}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
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