feat: add POST /sync endpoint + daily n8n drift check workflow

Adds extension sync that queries each customer's EspoCRM to compare
actually-installed extensions with what the platform DB tracks.
Updates platform DB on version mismatches and reports drifts to Mattermost.

n8n workflow ID: NXxBHm3qhWL3yHza (daily at 07:00)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:26:01 +00:00
parent f028299693
commit 36812af6e7
2 changed files with 260 additions and 0 deletions
+91
View File
@@ -8,6 +8,7 @@ Pipeline endpoints (called by n8n):
POST /publish — Download from Gitea, build ZIP, publish to Composer Registry + Release
POST /deploy — Deploy extension to a specific customer via EspoCRM API
POST /deploy-all — Deploy to all licensed customers
POST /sync — Sync installed extensions from EspoCRM servers with platform DB
Dashboard API:
GET /api/stats
@@ -357,6 +358,81 @@ def deploy_to_customer(customer, module, version):
return {'status': 'ok', 'ext_id': ext_id}
# --- Sync logic ---
def sync_customer_extensions(customer):
"""Query a customer's EspoCRM to see what's really installed, compare with platform DB."""
espo_url = customer['espocrm_url'].rstrip('/')
api_key = customer['espocrm_api_key']
cid = customer['id']
# 1. Query EspoCRM for installed extensions
headers = {'Espo-Authorization': api_key}
req = urllib.request.Request(f'{espo_url}/api/v1/Extension?select=name,version&maxSize=200',
headers=headers)
try:
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=30)
data = json.loads(resp.read())
server_exts = {e['name']: e.get('version', '') for e in data.get('list', [])}
except Exception as e:
return {'customer': customer['name'], 'status': 'error',
'message': f'Cannot reach EspoCRM: {str(e)[:200]}'}
# 2. Get what platform thinks is installed
platform_exts = query("""
SELECT e.name, ce.installed_version, ce.license_status
FROM customer_extensions ce JOIN extensions e ON ce.extension_id = e.id
WHERE ce.customer_id = %s AND ce.license_status = 'active'""", (cid,))
platform_map = {r['name']: r['installed_version'] for r in platform_exts}
# 3. Compare
drifts = []
updated = 0
# Check each platform-tracked extension
for ext_name, platform_ver in platform_map.items():
server_ver = server_exts.get(ext_name)
if server_ver is None:
drifts.append({'extension': ext_name, 'type': 'missing_on_server',
'platform': platform_ver, 'server': None})
elif server_ver != platform_ver:
drifts.append({'extension': ext_name, 'type': 'version_mismatch',
'platform': platform_ver, 'server': server_ver})
# Update platform DB to reflect reality
query("""UPDATE customer_extensions SET installed_version = %s, updated_at = NOW()
FROM extensions e WHERE customer_extensions.extension_id = e.id
AND customer_extensions.customer_id = %s AND e.name = %s""",
(server_ver, cid, ext_name), fetch=None)
updated += 1
# Check for extensions on server not tracked by platform
for ext_name, server_ver in server_exts.items():
if ext_name not in platform_map and ext_name in MODULES:
drifts.append({'extension': ext_name, 'type': 'extra_on_server',
'platform': None, 'server': server_ver})
return {
'customer': customer['name'], 'customer_id': cid, 'status': 'ok',
'server_count': len(server_exts), 'platform_count': len(platform_map),
'drifts': drifts, 'drift_count': len(drifts), 'updated': updated
}
def sync_all_customers():
"""Run sync for all active customers."""
customers = query("""SELECT id, name, espocrm_url, espocrm_api_key
FROM customers WHERE is_active AND espocrm_api_key IS NOT NULL""")
results = []
total_drifts = 0
for c in customers:
r = sync_customer_extensions(c)
results.append(r)
total_drifts += r.get('drift_count', 0)
return {
'status': 'ok', 'customers_checked': len(results),
'total_drifts': total_drifts, 'results': results
}
# --- HTTP Handler ---
class APIHandler(BaseHTTPRequestHandler):
@@ -392,6 +468,8 @@ class APIHandler(BaseHTTPRequestHandler):
self._assign_extension(int(re.search(r'/api/customers/(\d+)/extensions', path).group(1)), body)
elif path == '/api/deploy':
self._handle_deploy(body)
elif path == '/sync':
self._handle_sync(body)
else:
self._respond(404, {'error': 'Not found'})
@@ -544,6 +622,19 @@ class APIHandler(BaseHTTPRequestHandler):
self._respond(200, {'status': 'ok', 'module': module, 'version': version,
'deployed_to': len(results), 'results': results})
# --- Sync ---
def _handle_sync(self, body):
cid = body.get('customer_id')
if cid:
customer = query("SELECT id, name, espocrm_url, espocrm_api_key FROM customers WHERE id = %s AND is_active", (cid,), fetch='one')
if not customer:
self._respond(404, {'error': 'Customer not found'}); return
result = sync_customer_extensions(customer)
self._respond(200, result)
else:
result = sync_all_customers()
self._respond(200, result)
# --- Helpers ---
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))