main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
252 lines
9.0 KiB
Python
252 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Backfill appfingerprint column for existing EndpointApplication records.
|
|
|
|
Run this ONCE after deploying the 0020_app_delta_fingerprints migration and
|
|
before deploying the updated C# agent that sends fingerprints. This populates
|
|
the fingerprint column for existing records so the delta logic can function
|
|
correctly during the transition period.
|
|
|
|
Usage:
|
|
flask run-script scripts/backfill_app_fingerprints.py
|
|
|
|
or:
|
|
python scripts/backfill_app_fingerprints.py
|
|
"""
|
|
|
|
import sys
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
# Add project root to Python path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# Load .env before anything reads config: a plain `python scripts/...` run
|
|
# otherwise falls back to the hardcoded root@localhost URL and could write the
|
|
# wrong database.
|
|
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
|
|
|
from shopdb.extensions import db
|
|
from plugins.endpoints.models import EndpointApplication
|
|
|
|
|
|
def compute_fingerprint(displayname: str, displayversion: str, publisher: str) -> str:
|
|
"""Compute SHA256 fingerprint from app metadata.
|
|
|
|
This must match the agent's fingerprint algorithm exactly:
|
|
SHA256(displayname|displayversion|publisher)
|
|
|
|
Args:
|
|
displayname: Application display name
|
|
displayversion: Application version (may be None)
|
|
publisher: Application publisher (may be None)
|
|
|
|
Returns:
|
|
64-character lowercase hex SHA256 hash
|
|
"""
|
|
# Normalize None to empty string (matches agent behavior)
|
|
name = displayname or ''
|
|
version = displayversion or ''
|
|
pub = publisher or ''
|
|
|
|
data = f"{name}|{version}|{pub}"
|
|
return hashlib.sha256(data.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def verify_fingerprint_algorithm():
|
|
"""Verify backend fingerprint algorithm matches expected behavior.
|
|
|
|
Fixed test vectors: SHA256 of the literal 'name|version|publisher' strings,
|
|
which is what the agent's ComputeApplicationFingerprint hashes. Any
|
|
mismatch means compute_fingerprint drifted from the agent, which would make
|
|
the delta logic delete and re-add every app on every check-in.
|
|
"""
|
|
print("Verifying fingerprint algorithm...")
|
|
|
|
test_cases = [
|
|
('Google Chrome', '117.0.5938.132', 'Google LLC',
|
|
'a22de9ae7e641954872208b21aa52ac948898b78bcc8d7de5746af3a324f49ae'),
|
|
('Microsoft Office', '16.0.16827.20166', 'Microsoft Corporation',
|
|
'a180bfd16f34c52c94d01317044f7d3112bd9b88c6be570ee5cca4eff9199ae4'),
|
|
('Test App No Version', None, 'Test Publisher',
|
|
'aae1e046f90b39da80b2356713d8e6dc8579746d378ce7bc515800a4fbb85cda'),
|
|
('Test App Empty Strings', '', '',
|
|
'1a29cd2e3bd856ac3143d2675376b92eedf1563f5e8eb78f235d252943580ceb'),
|
|
]
|
|
|
|
failures = []
|
|
for name, version, publisher, expected in test_cases:
|
|
computed = compute_fingerprint(name, version, publisher)
|
|
if computed == expected:
|
|
print(f" OK {name}: {computed[:16]}...")
|
|
else:
|
|
print(f" FAIL MISMATCH: {name}")
|
|
print(f" Expected: {expected}")
|
|
print(f" Got: {computed}")
|
|
failures.append(name)
|
|
|
|
if failures:
|
|
raise ValueError(
|
|
"Fingerprint algorithm verification FAILED for: "
|
|
+ ", ".join(failures)
|
|
+ ". Backend algorithm does not match the agent; the delta logic "
|
|
"would delete+re-add all apps on every check-in."
|
|
)
|
|
|
|
print("OK Fingerprint algorithm verified\n")
|
|
return True
|
|
|
|
|
|
def _fingerprint_batch(apps):
|
|
"""Assign fingerprints to one batch, removing duplicate (assetid, fingerprint) rows.
|
|
|
|
The unique index on (assetid, appfingerprint) means two rows for the same
|
|
install on one asset cannot both carry the fingerprint. Of each duplicate
|
|
set the lowest endpointapplicationid is kept (fingerprinted rows already in
|
|
the table included) and the rest are deleted.
|
|
|
|
Returns the number of duplicate rows deleted.
|
|
"""
|
|
duplicates = 0
|
|
claimed = {}
|
|
for app in sorted(apps, key=lambda row: row.endpointapplicationid):
|
|
fingerprint = compute_fingerprint(app.displayname, app.displayversion,
|
|
app.publisher)
|
|
key = (app.assetid, fingerprint)
|
|
|
|
holder = claimed.get(key)
|
|
if holder is None:
|
|
holder = (EndpointApplication.query
|
|
.filter(EndpointApplication.assetid == app.assetid,
|
|
EndpointApplication.appfingerprint == fingerprint,
|
|
EndpointApplication.endpointapplicationid
|
|
!= app.endpointapplicationid)
|
|
.first())
|
|
|
|
if holder is None:
|
|
app.appfingerprint = fingerprint
|
|
claimed[key] = app
|
|
elif holder.endpointapplicationid < app.endpointapplicationid:
|
|
db.session.delete(app)
|
|
duplicates += 1
|
|
else:
|
|
# An already-fingerprinted row with a HIGHER id: drop it and let the
|
|
# older row carry the fingerprint. Flush the delete first so the
|
|
# unique index is free before the update lands.
|
|
db.session.delete(holder)
|
|
db.session.flush()
|
|
duplicates += 1
|
|
app.appfingerprint = fingerprint
|
|
claimed[key] = app
|
|
return duplicates
|
|
|
|
|
|
def backfill_fingerprints(batch_size: int = 1000, dry_run: bool = False):
|
|
"""Backfill fingerprints for existing EndpointApplication records.
|
|
|
|
Each batch commits on its own. A batch that fails is rolled back and the run
|
|
stops, so nothing half-applied is left in the session.
|
|
|
|
Args:
|
|
batch_size: Number of records to update per commit
|
|
dry_run: If True, show what would be done without making changes
|
|
|
|
Returns:
|
|
dict with processed, duplicatesremoved and failed counts.
|
|
"""
|
|
verify_fingerprint_algorithm()
|
|
|
|
summary = {'processed': 0, 'duplicatesremoved': 0, 'failed': 0}
|
|
|
|
total = EndpointApplication.query.filter(
|
|
EndpointApplication.appfingerprint.is_(None)
|
|
).count()
|
|
|
|
if total == 0:
|
|
print("No records need backfilling (all have fingerprints).")
|
|
return summary
|
|
|
|
print(f"Found {total:,} records without fingerprints")
|
|
|
|
if dry_run:
|
|
print("\nDRY RUN MODE - showing sample records:")
|
|
sample = EndpointApplication.query.filter(
|
|
EndpointApplication.appfingerprint.is_(None)
|
|
).limit(5).all()
|
|
|
|
for app in sample:
|
|
fp = compute_fingerprint(app.displayname, app.displayversion, app.publisher)
|
|
print(f" {app.displayname:50s} -> {fp[:16]}...")
|
|
|
|
print(f"\nWould backfill {total:,} records in batches of {batch_size}")
|
|
return summary
|
|
|
|
print(f"Backfilling in batches of {batch_size}...")
|
|
|
|
batch = 0
|
|
while True:
|
|
apps = (EndpointApplication.query
|
|
.filter(EndpointApplication.appfingerprint.is_(None))
|
|
.order_by(EndpointApplication.endpointapplicationid)
|
|
.limit(batch_size)
|
|
.all())
|
|
|
|
if not apps:
|
|
break
|
|
|
|
batch += 1
|
|
try:
|
|
duplicates = _fingerprint_batch(apps)
|
|
db.session.commit()
|
|
except Exception as error:
|
|
db.session.rollback()
|
|
summary['failed'] = len(apps)
|
|
print(f" Batch {batch} FAILED and was rolled back: {error}")
|
|
break
|
|
|
|
summary['processed'] += len(apps)
|
|
summary['duplicatesremoved'] += duplicates
|
|
print(f" Batch {batch}: {summary['processed']:,} / {total:,} "
|
|
f"({summary['processed'] * 100 // total}%), "
|
|
f"{duplicates} duplicate(s) removed")
|
|
|
|
print(f"\nBackfill finished. Processed {summary['processed']:,} records; "
|
|
f"removed {summary['duplicatesremoved']:,} duplicate row(s).")
|
|
if summary['failed']:
|
|
print("A batch failed; fix the error above and re-run (completed batches "
|
|
"are kept, the run resumes from the remaining null rows).")
|
|
return summary
|
|
|
|
print("\nNext steps:")
|
|
print("1. Deploy updated C# agent that computes and sends fingerprints")
|
|
print("2. Monitor collector warnings for agents without fingerprint support")
|
|
print("3. After all agents updated, consider making appfingerprint NOT NULL")
|
|
return summary
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description='Backfill appfingerprint column')
|
|
parser.add_argument('--dry-run', action='store_true',
|
|
help='Show what would be done without making changes')
|
|
parser.add_argument('--batch-size', type=int, default=1000,
|
|
help='Number of records per batch (default: 1000)')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Need Flask app context for database access
|
|
try:
|
|
from shopdb import create_app
|
|
app = create_app()
|
|
with app.app_context():
|
|
result = backfill_fingerprints(
|
|
batch_size=args.batch_size,
|
|
dry_run=args.dry_run
|
|
)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
if result['failed']:
|
|
sys.exit(1)
|