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:
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user