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:
@@ -65,6 +65,104 @@ class ComputersPlugin(BasePlugin):
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Computers plugin initialized (v{self.meta.version})")
|
||||
|
||||
# -- ADR-006 collector contract -----------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
"""Schema for the PC collector payload (matched by hostname)."""
|
||||
return {
|
||||
'identityfield': 'hostname',
|
||||
'fields': {
|
||||
'hostname': {'type': 'string', 'required': True},
|
||||
'serialnumber': {'type': 'string'},
|
||||
'currentuser': {'type': 'string'},
|
||||
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
||||
'ipaddress': {'type': 'string'},
|
||||
'installedsoftware': {
|
||||
'type': 'array',
|
||||
'items': {'name': 'string', 'version': 'string'},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
||||
from datetime import datetime
|
||||
from shopdb.core.models import (
|
||||
Asset, AssetType, Application, Communication, CommunicationType,
|
||||
)
|
||||
|
||||
warnings = []
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
|
||||
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||
if not comp:
|
||||
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
|
||||
.filter(Asset.assetnumber.ilike(hostname)).first())
|
||||
|
||||
action = 'updated'
|
||||
if not comp:
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
action = 'created'
|
||||
|
||||
comp.lastreporteddate = datetime.utcnow()
|
||||
if payload.get('lastboottime'):
|
||||
try:
|
||||
comp.lastboottime = datetime.fromisoformat(
|
||||
payload['lastboottime'].replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
warnings.append('lastboottime not parseable')
|
||||
if payload.get('currentuser'):
|
||||
comp.loggedinuser = payload['currentuser']
|
||||
if payload.get('serialnumber') and comp.asset:
|
||||
comp.asset.serialnumber = payload['serialnumber']
|
||||
|
||||
if payload.get('ipaddress'):
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=comp.assetid, isprimary=True).first()
|
||||
if primary:
|
||||
primary.ipaddress = payload['ipaddress']
|
||||
elif ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=comp.assetid, comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=payload['ipaddress'], isprimary=True))
|
||||
|
||||
for app_data in payload.get('installedsoftware', []) or []:
|
||||
name = app_data.get('name')
|
||||
if not name:
|
||||
continue
|
||||
app = Application.query.filter(Application.appname.ilike(name)).first()
|
||||
if not app:
|
||||
warnings.append(f'unknown application: {name}')
|
||||
continue
|
||||
installed = ComputerInstalledApp.query.filter_by(
|
||||
computerid=comp.computerid, appid=app.appid).first()
|
||||
version = app_data.get('version')
|
||||
if installed:
|
||||
installed.installedversion = version
|
||||
installed.isactive = True
|
||||
else:
|
||||
db.session.add(ComputerInstalledApp(
|
||||
computerid=comp.computerid, appid=app.appid,
|
||||
installedversion=version))
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'action': action,
|
||||
'assetid': comp.assetid,
|
||||
'identityvalue': hostname,
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
|
||||
Reference in New Issue
Block a user