diff --git a/server.py b/server.py index 6a7eac3..be31271 100644 --- a/server.py +++ b/server.py @@ -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 = []