feat: clear PHP opcache after extension deploy

Add multi-tier opcache clearing after extension install:
- Tier 1: HTTP call to opcache-clear.php endpoint
- Tier 2: SSH + docker exec apachectl graceful
- Tier 3: Log warning

Add ssh_host, ssh_user, ssh_key, docker_container fields
to customers table for SSH fallback.

Fixes production issue where new PHP files weren't loaded
after extension upgrade due to stale opcache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 11:51:33 +00:00
parent f8026afed4
commit 08995b9027
+68 -8
View File
@@ -24,7 +24,7 @@ Dashboard API:
""" """
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
import json, os, re, ssl, mimetypes, tempfile, shutil, zipfile, io import json, os, re, ssl, mimetypes, tempfile, shutil, zipfile, io
import base64, urllib.request, urllib.error import base64, urllib.request, urllib.error, subprocess
import psycopg2, psycopg2.extras import psycopg2, psycopg2.extras
# --- Config from environment --- # --- Config from environment ---
@@ -304,6 +304,59 @@ def build_and_publish(module, version):
} }
# --- OPcache clear ---
def _clear_opcache(customer, espo_url, api_key):
"""Clear PHP opcache after extension install. Tries HTTP endpoint first, SSH fallback."""
# Tier 1: HTTP-based opcache_reset via opcache-clear.php
try:
oc_req = urllib.request.Request(
f'{espo_url}/opcache-clear.php',
headers={'X-Api-Key': api_key},
method='GET',
)
oc_resp = urllib.request.urlopen(oc_req, context=SSL_CTX, timeout=10)
result = json.loads(oc_resp.read())
if result.get('success'):
return 'http'
except Exception:
pass
# Tier 2: SSH + docker exec apachectl graceful
ssh_host = customer.get('ssh_host')
container = customer.get('docker_container')
if ssh_host and container:
ssh_user = customer.get('ssh_user', 'root')
ssh_key = customer.get('ssh_key')
key_file = None
try:
ssh_cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=5']
if ssh_key:
key_file = tempfile.NamedTemporaryFile(mode='w', suffix='.key', delete=False)
key_file.write(ssh_key)
key_file.close()
os.chmod(key_file.name, 0o600)
ssh_cmd.extend(['-i', key_file.name])
ssh_cmd.extend([
f'{ssh_user}@{ssh_host}',
f'sudo docker exec {container} apachectl graceful',
])
proc = subprocess.run(ssh_cmd, timeout=15, capture_output=True)
if proc.returncode == 0:
return 'ssh'
except Exception:
pass
finally:
if key_file:
try:
os.unlink(key_file.name)
except OSError:
pass
# Tier 3: warning
print(f'[WARN] Could not clear opcache for {customer.get("name", "?")} — new PHP files may not load immediately')
return 'failed'
# --- Deploy logic --- # --- Deploy logic ---
def deploy_to_customer(customer, module, version): def deploy_to_customer(customer, module, version):
"""Deploy extension to a customer via EspoCRM API. Downloads ZIP from Gitea.""" """Deploy extension to a customer via EspoCRM API. Downloads ZIP from Gitea."""
@@ -366,7 +419,10 @@ def deploy_to_customer(customer, module, version):
except: except:
pass pass
return {'status': 'ok', 'ext_id': ext_id} # Clear PHP opcache (new PHP files won't load until opcache refreshes)
opcache_status = _clear_opcache(customer, espo_url, api_key)
return {'status': 'ok', 'ext_id': ext_id, 'opcache': opcache_status}
# --- Sync logic --- # --- Sync logic ---
@@ -683,10 +739,13 @@ class APIHandler(BaseHTTPRequestHandler):
for f in ['name', 'espocrm_url', 'espocrm_api_key']: for f in ['name', 'espocrm_url', 'espocrm_api_key']:
if not body.get(f): if not body.get(f):
self._respond(400, {'error': f'{f} required'}); return self._respond(400, {'error': f'{f} required'}); return
row = query("""INSERT INTO customers (name, email, espocrm_url, espocrm_api_key, coolify_url, coolify_token, coolify_app_uuid, notes) row = query("""INSERT INTO customers (name, email, espocrm_url, espocrm_api_key,
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""", coolify_url, coolify_token, coolify_app_uuid, notes,
ssh_host, ssh_user, ssh_key, docker_container)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
(body['name'], body.get('email'), body['espocrm_url'], body['espocrm_api_key'], (body['name'], body.get('email'), body['espocrm_url'], body['espocrm_api_key'],
body.get('coolify_url'), body.get('coolify_token'), body.get('coolify_app_uuid'), body.get('notes')), fetch='one') body.get('coolify_url'), body.get('coolify_token'), body.get('coolify_app_uuid'), body.get('notes'),
body.get('ssh_host'), body.get('ssh_user'), body.get('ssh_key'), body.get('docker_container')), fetch='one')
cid = row['id'] cid = row['id']
# Auto-assign all active extensions to new customer # Auto-assign all active extensions to new customer
all_exts = query("SELECT id FROM extensions WHERE is_active", fetch='all') all_exts = query("SELECT id FROM extensions WHERE is_active", fetch='all')
@@ -698,7 +757,7 @@ class APIHandler(BaseHTTPRequestHandler):
def _update_customer(self, cid, body): def _update_customer(self, cid, body):
fields, values = [], [] fields, values = [], []
for col in ['name','email','espocrm_url','espocrm_api_key','coolify_url','coolify_token','coolify_app_uuid','is_active','notes']: for col in ['name','email','espocrm_url','espocrm_api_key','coolify_url','coolify_token','coolify_app_uuid','is_active','notes','ssh_host','ssh_user','ssh_key','docker_container']:
if col in body: if col in body:
fields.append(f"{col} = %s"); values.append(body[col]) fields.append(f"{col} = %s"); values.append(body[col])
if not fields: if not fields:
@@ -755,7 +814,7 @@ class APIHandler(BaseHTTPRequestHandler):
cid, module, version = body.get('customer_id'), body.get('module',''), body.get('version','') cid, module, version = body.get('customer_id'), body.get('module',''), body.get('version','')
if not all([cid, module, version]): if not all([cid, module, version]):
self._respond(400, {'error': 'customer_id, module, version required'}); return self._respond(400, {'error': 'customer_id, module, version required'}); return
customer = query("SELECT id, name, espocrm_url, espocrm_api_key FROM customers WHERE id = %s AND is_active", (cid,), fetch='one') customer = query("SELECT id, name, espocrm_url, espocrm_api_key, ssh_host, ssh_user, ssh_key, docker_container FROM customers WHERE id = %s AND is_active", (cid,), fetch='one')
if not customer: if not customer:
self._respond(404, {'error': 'Customer not found'}); return self._respond(404, {'error': 'Customer not found'}); return
result = deploy_to_customer(customer, module, version) result = deploy_to_customer(customer, module, version)
@@ -773,7 +832,8 @@ class APIHandler(BaseHTTPRequestHandler):
module, version = body.get('module',''), body.get('version','') module, version = body.get('module',''), body.get('version','')
if not module or not version: if not module or not version:
self._respond(400, {'error': 'module and version required'}); return self._respond(400, {'error': 'module and version required'}); return
customers = query("""SELECT c.id, c.name, c.espocrm_url, c.espocrm_api_key FROM customers c customers = query("""SELECT c.id, c.name, c.espocrm_url, c.espocrm_api_key,
c.ssh_host, c.ssh_user, c.ssh_key, c.docker_container FROM customers c
JOIN customer_extensions ce ON c.id = ce.customer_id JOIN extensions e ON ce.extension_id = e.id JOIN customer_extensions ce ON c.id = ce.customer_id JOIN extensions e ON ce.extension_id = e.id
WHERE e.name = %s AND ce.auto_update AND ce.license_status = 'active' AND c.is_active""", (module,)) WHERE e.name = %s AND ce.auto_update AND ce.license_status = 'active' AND c.is_active""", (module,))
results = [] results = []