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
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
# --- 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 ---
def deploy_to_customer(customer, module, version):
"""Deploy extension to a customer via EspoCRM API. Downloads ZIP from Gitea."""
@@ -366,7 +419,10 @@ def deploy_to_customer(customer, module, version):
except:
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 ---
@@ -683,10 +739,13 @@ class APIHandler(BaseHTTPRequestHandler):
for f in ['name', 'espocrm_url', 'espocrm_api_key']:
if not body.get(f):
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)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
row = query("""INSERT INTO customers (name, email, espocrm_url, espocrm_api_key,
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.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']
# Auto-assign all active extensions to new customer
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):
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:
fields.append(f"{col} = %s"); values.append(body[col])
if not fields:
@@ -755,7 +814,7 @@ class APIHandler(BaseHTTPRequestHandler):
cid, module, version = body.get('customer_id'), body.get('module',''), body.get('version','')
if not all([cid, module, version]):
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:
self._respond(404, {'error': 'Customer not found'}); return
result = deploy_to_customer(customer, module, version)
@@ -773,7 +832,8 @@ class APIHandler(BaseHTTPRequestHandler):
module, version = body.get('module',''), body.get('version','')
if not module or not version:
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
WHERE e.name = %s AND ce.auto_update AND ce.license_status = 'active' AND c.is_active""", (module,))
results = []