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