Add ADR-006 generic plugin collector contract
Implements the plugin collector contract from ADR-006 so any plugin can accept idempotent inventory ingest, not just PCs. - base.py: add apply_collector_payload hook (companion to get_collector_schema), raises NotImplementedError by default for plugins that declare a schema but do not implement the upsert. - collector.py: generic POST /api/collector/<plugin> dispatch with per-plugin API key (COLLECTOR_API_KEY_<PLUGINNAME> with COLLECTOR_API_KEY fallback), schema-driven identity validation, idempotent upsert, ADR-006 response contract (status, action, assetid, identityvalue, warnings), audit log. JWT-protected GET /api/collector/_schemas lists registered schemas. Legacy /pc, /apps, /heartbeat, /bulk kept for back-compat. - computers plugin: implements get_collector_schema (identityfield hostname) and apply_collector_payload (create-or-update Asset+Computer, serialnumber, loggedinuser, lastboottime, primary IP communication, installed apps). - tests: 7 collector-contract tests (auth, 404, validation, create/idempotent update, per-plugin key precedence, JWT schema listing). A single dynamic dispatch route is used instead of per-plugin blueprint registration, avoiding Flask's register-blueprint-after-first-request error. 144 tests pass, naming/style check green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,119 @@ def _parse_boot(value):
|
||||
return None
|
||||
|
||||
|
||||
def _plugin_api_key(pluginname):
|
||||
"""Per-plugin collector key, falling back to the shared key (ADR-006).
|
||||
|
||||
Looks up COLLECTOR_API_KEY_<PLUGINNAME> (uppercased) first so each
|
||||
collector can carry its own credential, then COLLECTOR_API_KEY.
|
||||
"""
|
||||
per_plugin = current_app.config.get(
|
||||
f'COLLECTOR_API_KEY_{pluginname.upper()}')
|
||||
return per_plugin or current_app.config.get('COLLECTOR_API_KEY')
|
||||
|
||||
|
||||
def _collector_plugins():
|
||||
"""Map of pluginname -> (plugin, schema) for plugins accepting collector input."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
result = {}
|
||||
if not pm:
|
||||
return result
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
schema = plugin.get_collector_schema()
|
||||
except Exception:
|
||||
schema = None
|
||||
if schema:
|
||||
result[name] = (plugin, schema)
|
||||
return result
|
||||
|
||||
|
||||
@collector_bp.route('/<pluginname>', methods=['POST'])
|
||||
def generic_collect(pluginname):
|
||||
"""Generic collector ingest for any plugin (ADR-006).
|
||||
|
||||
Per-plugin API key auth, schema-driven validation of the identity field,
|
||||
idempotent upsert via the plugin's apply_collector_payload. Returns the
|
||||
ADR-006 response contract: status, action, assetid, identityvalue, warnings.
|
||||
"""
|
||||
from shopdb.core.models import AuditLog
|
||||
|
||||
plugins = _collector_plugins()
|
||||
if pluginname not in plugins:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'No collector registered for plugin {pluginname}',
|
||||
http_code=404)
|
||||
|
||||
plugin, schema = plugins[pluginname]
|
||||
|
||||
expected_key = _plugin_api_key(pluginname)
|
||||
if not expected_key:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Collector API key not configured', http_code=500)
|
||||
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
||||
if api_key != expected_key:
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not payload or not isinstance(payload, dict):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
identityfield = schema.get('identityfield')
|
||||
identityvalue = (payload.get(identityfield) or '').strip() if identityfield else ''
|
||||
if identityfield and not identityvalue:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'{identityfield} is required')
|
||||
|
||||
try:
|
||||
outcome = plugin.apply_collector_payload(payload)
|
||||
except NotImplementedError:
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
f'Plugin {pluginname} does not implement apply_collector_payload',
|
||||
http_code=500)
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception('Collector upsert failed for %s', pluginname)
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||
|
||||
action = outcome.get('action', 'noop')
|
||||
AuditLog.log(
|
||||
action if action in ('created', 'updated') else 'updated',
|
||||
'Collector',
|
||||
entityid=outcome.get('assetid'),
|
||||
entityname=outcome.get('identityvalue', identityvalue),
|
||||
details=f'collector:{pluginname} action={action}',
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'status': 'ok',
|
||||
'action': action,
|
||||
'assetid': outcome.get('assetid'),
|
||||
'identityvalue': outcome.get('identityvalue', identityvalue),
|
||||
'warnings': outcome.get('warnings', []),
|
||||
}, message=f'{pluginname} collector {action}')
|
||||
|
||||
|
||||
@collector_bp.route('/_schemas', methods=['GET'])
|
||||
def collector_schemas():
|
||||
"""List collector schemas for all enabled plugins (JWT-protected)."""
|
||||
from flask_jwt_extended import verify_jwt_in_request
|
||||
verify_jwt_in_request()
|
||||
|
||||
schemas = {
|
||||
name: schema for name, (plugin, schema) in _collector_plugins().items()
|
||||
}
|
||||
return success_response({'schemas': schemas})
|
||||
|
||||
|
||||
@collector_bp.route('/pc', methods=['POST'])
|
||||
@require_api_key
|
||||
def update_pc_info():
|
||||
@@ -257,6 +370,8 @@ def collector_status():
|
||||
'status': 'ok',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'endpoints': [
|
||||
'POST /api/collector/<plugin>',
|
||||
'GET /api/collector/_schemas',
|
||||
'POST /api/collector/pc',
|
||||
'POST /api/collector/apps',
|
||||
'POST /api/collector/heartbeat',
|
||||
|
||||
@@ -120,6 +120,21 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotently upsert an asset from a validated collector payload.
|
||||
|
||||
Called by the generic /api/collector/<pluginname> endpoint after the
|
||||
payload passed schema validation. Plugins that return a schema from
|
||||
get_collector_schema must implement this. Return a dict with at least:
|
||||
- 'action': 'created' | 'updated' | 'noop'
|
||||
- 'assetid': the affected asset id (or None)
|
||||
- 'warnings': list[str]
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.meta.name} declares a collector schema but does not "
|
||||
f"implement apply_collector_payload"
|
||||
)
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed via CLI."""
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user