Act on the wj architectural review: races, limits, pool and contract gaps
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
This commit is contained in:
165
tests/test_plugins/test_endpoints_app_conflict.py
Normal file
165
tests/test_plugins/test_endpoints_app_conflict.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""A concurrent application insert must not sink the whole check-in.
|
||||
|
||||
The unique index on (assetid, appfingerprint) detects a second writer inserting
|
||||
the same application row; it does not resolve one. Before the savepoint went in,
|
||||
the IntegrityError from the insert flush escaped generic_collect, reached the
|
||||
global handler, rolled back the WHOLE check-in - state, events and hardware -
|
||||
and answered the agent with operator-facing 409 form copy.
|
||||
|
||||
The insert now behaves like the delete-count mismatch beside it: warn, keep the
|
||||
telemetry, and let the next check-in (which reports the full list again) settle
|
||||
the inventory.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from plugins.endpoints.models import (
|
||||
EndpointApplication, EndpointEvent, EndpointState,
|
||||
)
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def _fingerprint(name, version, publisher):
|
||||
data = '{}|{}|{}'.format(name or '', version or '', publisher or '')
|
||||
return hashlib.sha256(data.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _payload(hostname, applications):
|
||||
return {
|
||||
'hostname': hostname,
|
||||
'pendingreboot': True,
|
||||
'applications': applications,
|
||||
'events': [{
|
||||
'eventid': 4625,
|
||||
'eventrecordid': 501,
|
||||
'eventchannel': 'Security',
|
||||
'eventsource': 'Microsoft-Windows-Security-Auditing',
|
||||
'eventmessage': 'Failed logon',
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_insert_conflict_keeps_the_checkin(db, monkeypatch):
|
||||
"""A REAL IntegrityError on the insert flush leaves the check-in intact.
|
||||
|
||||
The first (bulk) insert adds a genuine duplicate row, so the flush raises on
|
||||
the unique index exactly as a concurrent check-in would make it. The
|
||||
reconcile then re-reads and re-inserts, and state plus events still commit.
|
||||
"""
|
||||
assetid = _computer(db, 'APPRACE01')
|
||||
plugin = EndpointsPlugin()
|
||||
|
||||
real_insert = EndpointsPlugin._insert_applications
|
||||
calls = []
|
||||
|
||||
def conflicting_insert(assetid_arg, incoming, fingerprints):
|
||||
rows = real_insert(assetid_arg, incoming, fingerprints)
|
||||
calls.append(list(fingerprints))
|
||||
if len(calls) == 1:
|
||||
# The conflicting row a second writer would have committed.
|
||||
first = rows[0]
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=first.assetid,
|
||||
displayname=first.displayname,
|
||||
displayversion=first.displayversion,
|
||||
publisher=first.publisher,
|
||||
appfingerprint=first.appfingerprint,
|
||||
))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(EndpointsPlugin, '_insert_applications',
|
||||
staticmethod(conflicting_insert))
|
||||
|
||||
applications = [
|
||||
{'displayname': 'Alpha', 'displayversion': '1.0', 'publisher': 'ACME',
|
||||
'fingerprint': _fingerprint('Alpha', '1.0', 'ACME')},
|
||||
{'displayname': 'Beta', 'displayversion': '2.0', 'publisher': 'ACME',
|
||||
'fingerprint': _fingerprint('Beta', '2.0', 'ACME')},
|
||||
]
|
||||
|
||||
result = plugin.apply_collector_payload(_payload('APPRACE01', applications))
|
||||
db.session.commit()
|
||||
|
||||
# The collision was detected and reported rather than raised.
|
||||
assert len(calls) > 1, 'the bulk insert should have collided and reconciled'
|
||||
assert any('application insert conflict' in w for w in result['warnings'])
|
||||
|
||||
# The telemetry the check-in carried is still there.
|
||||
state = EndpointState.query.filter_by(assetid=assetid).first()
|
||||
assert state is not None
|
||||
assert state.pendingreboot is True
|
||||
assert state.lastreportedat is not None
|
||||
assert EndpointEvent.query.filter_by(assetid=assetid).count() == 1
|
||||
|
||||
# And the inventory reconciled rather than being abandoned.
|
||||
stored = {row.appfingerprint for row
|
||||
in EndpointApplication.query.filter_by(assetid=assetid).all()}
|
||||
assert stored == {app['fingerprint'] for app in applications}
|
||||
|
||||
|
||||
def test_reconcile_skips_fingerprints_that_now_exist(db):
|
||||
"""Reconcile inserts only what is missing and counts the rest as skipped."""
|
||||
assetid = _computer(db, 'APPRACE02')
|
||||
plugin = EndpointsPlugin()
|
||||
|
||||
taken = _fingerprint('Alpha', '1.0', 'ACME')
|
||||
missing = _fingerprint('Beta', '2.0', 'ACME')
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid, displayname='Alpha', displayversion='1.0',
|
||||
publisher='ACME', appfingerprint=taken))
|
||||
db.session.commit()
|
||||
|
||||
incoming = {
|
||||
taken: {'displayname': 'Alpha', 'displayversion': '1.0',
|
||||
'publisher': 'ACME'},
|
||||
missing: {'displayname': 'Beta', 'displayversion': '2.0',
|
||||
'publisher': 'ACME'},
|
||||
}
|
||||
warnings = []
|
||||
stored = plugin._reconcile_applications(assetid, incoming,
|
||||
[taken, missing], warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert stored == 1
|
||||
assert warnings == ['application insert conflict: stored 1, skipped 1 '
|
||||
'of 2 new (concurrent update?)']
|
||||
rows = EndpointApplication.query.filter_by(assetid=assetid).all()
|
||||
assert len(rows) == 2
|
||||
assert {r.appfingerprint for r in rows} == {taken, missing}
|
||||
|
||||
|
||||
def test_the_unique_index_is_really_there(db):
|
||||
"""The whole fix rests on the constraint raising. Prove it does."""
|
||||
assetid = _computer(db, 'APPRACE03')
|
||||
fingerprint = _fingerprint('Alpha', '1.0', 'ACME')
|
||||
for _ in range(2):
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid, displayname='Alpha', displayversion='1.0',
|
||||
publisher='ACME', appfingerprint=fingerprint))
|
||||
raised = False
|
||||
try:
|
||||
db.session.flush()
|
||||
except IntegrityError:
|
||||
raised = True
|
||||
db.session.rollback()
|
||||
assert raised, 'endpointapplications has no unique (assetid, appfingerprint)'
|
||||
Reference in New Issue
Block a user