740866eb1c
manifest.description is for composer / dev consumption (often a long English text). The admin catalog needs short Hebrew labels. Introduces optional manifest.displayLabel field — only that field is synced into extensions.description (the column the admin UI reads). When displayLabel is missing the DB row is left untouched, so curated labels never get clobbered by long English manifest descriptions. Adds POST /admin/set-labels for bulk backfilling labels in one call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1130 lines
54 KiB
Python
1130 lines
54 KiB
Python
#!/usr/bin/env python3
|
|
"""Extension Platform API Server.
|
|
|
|
Runs on Coolify. Connects to PostgreSQL via Docker internal network.
|
|
Builds extensions from Gitea (no local files needed).
|
|
|
|
Pipeline endpoints (called by n8n):
|
|
POST /publish — Download from Gitea, build ZIP, publish to Composer Registry + Release
|
|
POST /release-deleted — Remove a deleted release from DB; deactivate extension if no versions remain
|
|
POST /deploy — Deploy extension to a specific customer via EspoCRM API
|
|
POST /deploy-all — Deploy to all licensed customers
|
|
POST /sync — Sync installed extensions from EspoCRM servers with platform DB
|
|
POST /sync-registry — Discover extensions from Gitea org and sync with DB
|
|
|
|
Dashboard API:
|
|
GET /api/stats
|
|
GET /api/extensions
|
|
GET /api/extensions/:id/versions
|
|
GET /api/customers
|
|
POST /api/customers
|
|
PUT /api/customers/:id
|
|
GET /api/customers/:id
|
|
POST /api/customers/:id/extensions
|
|
DELETE /api/customers/:id/extensions/:ext_id
|
|
GET /api/deployments
|
|
"""
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
import json, os, re, ssl, mimetypes, tempfile, shutil, zipfile, io
|
|
import base64, urllib.request, urllib.error, subprocess
|
|
import psycopg2, psycopg2.extras
|
|
|
|
# --- Config from environment ---
|
|
DB_HOST = os.environ.get('DB_HOST', 'pss404gosg8oo0o8k40o4wco')
|
|
DB_PORT = int(os.environ.get('DB_PORT', '5432'))
|
|
DB_NAME = os.environ.get('DB_NAME', 'extension_platform')
|
|
DB_USER = os.environ.get('DB_USER', 'postgres')
|
|
DB_PASS = os.environ.get('DB_PASS', '9cugHhKR8bCFXzm')
|
|
|
|
GITEA_URL = os.environ.get('GITEA_URL', 'https://gitea.dev.marcus-law.co.il')
|
|
GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '90acb3ebc0f98edfb7a373ef9902bde1cc8eff96')
|
|
GITEA_ORG = os.environ.get('GITEA_ORG', 'espocrm-extensions')
|
|
|
|
DIST_DIR = os.environ.get('DIST_DIR', '/app/dashboard/dist')
|
|
|
|
# Repos in the Gitea org that are NOT extensions (tools, base images, etc.)
|
|
GITEA_NON_EXTENSION_REPOS = {'espocrm-base', 'espo-core', 'espo-tools', 'extension-dashboard', 'extension-platform'}
|
|
|
|
def _pascal_to_kebab(name):
|
|
"""Convert PascalCase to kebab-case: LegalAssistance -> legal-assistance"""
|
|
return re.sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
|
|
|
|
# Module name mapping — loaded from DB on startup, synced from Gitea via /sync-registry
|
|
MODULES = {}
|
|
|
|
# Extension dependency tree (domain knowledge, not auto-discoverable)
|
|
DEPENDENCIES = {
|
|
'HebrewLanguage': [],
|
|
'KlearBranding': ['HebrewLanguage'],
|
|
'EmailDefaults': ['HebrewLanguage'],
|
|
'LocalDocuments': ['HebrewLanguage'],
|
|
'GoogleIntegration': ['HebrewLanguage'],
|
|
'NextCloudIntegration': ['HebrewLanguage'],
|
|
'NetworkStorageIntegration': ['HebrewLanguage'],
|
|
'SmsIntegration': ['HebrewLanguage'],
|
|
'WhatsAppIntegration': ['HebrewLanguage'],
|
|
'GreenInvoiceBilling': ['HebrewLanguage'],
|
|
'LegalCrm': ['HebrewLanguage'],
|
|
'NetHamishpat': ['HebrewLanguage', 'LegalCrm'],
|
|
'DigitalSignature': ['HebrewLanguage', 'LegalCrm'],
|
|
'SmartAssistant': ['HebrewLanguage', 'LegalCrm'],
|
|
'LegalAssistance': ['HebrewLanguage', 'LegalCrm'],
|
|
'DataMigration': [],
|
|
}
|
|
|
|
def _compute_install_order():
|
|
"""Compute install order from DEPENDENCIES via topological sort. Extensions not in DEPENDENCIES are appended at the end."""
|
|
order = []
|
|
visited = set()
|
|
def visit(name):
|
|
if name in visited:
|
|
return
|
|
visited.add(name)
|
|
for dep in DEPENDENCIES.get(name, []):
|
|
visit(dep)
|
|
order.append(name)
|
|
for name in DEPENDENCIES:
|
|
visit(name)
|
|
# Append any MODULES not in DEPENDENCIES (no deps assumed)
|
|
for name in sorted(MODULES):
|
|
if name not in visited:
|
|
order.append(name)
|
|
return order
|
|
|
|
def _load_modules_from_db():
|
|
"""Load MODULES dict from the extensions table."""
|
|
global MODULES
|
|
try:
|
|
rows = query("SELECT name, composer_name FROM extensions WHERE is_active")
|
|
if rows:
|
|
MODULES = {
|
|
r['name']: r['composer_name'].split('/')[-1] if r.get('composer_name') else _pascal_to_kebab(r['name'])
|
|
for r in rows
|
|
}
|
|
print(f"[init] Loaded {len(MODULES)} modules from database")
|
|
except Exception as e:
|
|
print(f"[init] Could not load modules from DB: {e}")
|
|
|
|
# Static fallback used only if DB is not available on startup
|
|
_MODULES_FALLBACK = {
|
|
"DataMigration": "data-migration", "DigitalSignature": "digital-signature",
|
|
"EmailDefaults": "email-defaults", "GoogleIntegration": "google-integration",
|
|
"GreenInvoiceBilling": "green-invoice-billing", "HebrewLanguage": "hebrew-language",
|
|
"KlearBranding": "klear-branding", "LegalAssistance": "legal-assistance",
|
|
"LegalCrm": "legal-crm", "LocalDocuments": "local-documents",
|
|
"NetHamishpat": "net-hamishpat", "NetworkStorageIntegration": "network-storage-integration",
|
|
"NextCloudIntegration": "nextcloud-integration", "SmartAssistant": "smart-assistant",
|
|
"SmsIntegration": "sms-integration", "WhatsAppIntegration": "whatsapp-integration",
|
|
}
|
|
|
|
|
|
def sync_extension_registry():
|
|
"""Discover extensions from Gitea org and sync with database.
|
|
Adds new extensions, reactivates returned ones, deactivates removed ones."""
|
|
repos = gitea_api('GET', f'/orgs/{GITEA_ORG}/repos?limit=100')
|
|
if isinstance(repos, dict) and repos.get('error'):
|
|
return {'status': 'error', 'message': f'Failed to list Gitea repos: {repos.get("message", "")}'}
|
|
|
|
# Filter to extension repos: PascalCase names not in the skip list
|
|
gitea_extensions = {}
|
|
for repo in repos:
|
|
name = repo['name']
|
|
if name in GITEA_NON_EXTENSION_REPOS or not name[0].isupper():
|
|
continue
|
|
gitea_extensions[name] = _pascal_to_kebab(name)
|
|
|
|
# Get current DB state
|
|
db_extensions = query("SELECT name, is_active FROM extensions")
|
|
db_names = {r['name'] for r in db_extensions}
|
|
db_active = {r['name'] for r in db_extensions if r['is_active']}
|
|
|
|
added, reactivated, deactivated = [], [], []
|
|
|
|
# Extensions deactivated via /release-deleted (zero versions) must stay deactivated even if the repo still exists.
|
|
versions_count = {r['name']: r['cnt'] for r in query(
|
|
"SELECT e.name, COUNT(ev.id) AS cnt FROM extensions e LEFT JOIN extension_versions ev ON ev.extension_id = e.id GROUP BY e.name"
|
|
)}
|
|
|
|
for name, kebab in gitea_extensions.items():
|
|
composer_name = f'{GITEA_ORG}/{kebab}'
|
|
if name not in db_names:
|
|
query("INSERT INTO extensions (name, composer_name, is_active) VALUES (%s, %s, true)",
|
|
(name, composer_name), fetch=None)
|
|
added.append(name)
|
|
elif name not in db_active and versions_count.get(name, 0) > 0:
|
|
query("UPDATE extensions SET is_active = true, updated_at = NOW() WHERE name = %s",
|
|
(name,), fetch=None)
|
|
reactivated.append(name)
|
|
|
|
for name in db_active:
|
|
if name not in gitea_extensions:
|
|
query("UPDATE extensions SET is_active = false, updated_at = NOW() WHERE name = %s",
|
|
(name,), fetch=None)
|
|
deactivated.append(name)
|
|
|
|
# Fix latest_version: set to the highest version from extension_versions
|
|
query("""UPDATE extensions e SET latest_version = sub.max_ver, updated_at = NOW()
|
|
FROM (SELECT extension_id, version as max_ver FROM extension_versions ev1
|
|
WHERE NOT EXISTS (SELECT 1 FROM extension_versions ev2
|
|
WHERE ev2.extension_id = ev1.extension_id
|
|
AND string_to_array(ev2.version, '.')::int[] > string_to_array(ev1.version, '.')::int[])
|
|
) sub WHERE e.id = sub.extension_id AND (e.latest_version IS NULL OR e.latest_version != sub.max_ver)""",
|
|
fetch=None)
|
|
|
|
# Sync catalog display label from each extension's manifest.json (main branch).
|
|
# Source: optional `displayLabel` field. `manifest.description` is intentionally NOT used —
|
|
# it's for composer/dev consumption and is often long English; the catalog needs short Hebrew.
|
|
description_updates = []
|
|
for name in gitea_extensions:
|
|
if _sync_display_label_from_manifest(name):
|
|
description_updates.append(name)
|
|
|
|
# Reload MODULES from DB
|
|
_load_modules_from_db()
|
|
|
|
return {
|
|
'status': 'ok',
|
|
'gitea_extensions': len(gitea_extensions),
|
|
'added': added, 'reactivated': reactivated, 'deactivated': deactivated,
|
|
'description_updates': description_updates,
|
|
'active_modules': sorted(MODULES.keys())
|
|
}
|
|
|
|
|
|
def _sync_display_label_from_manifest(module):
|
|
"""Fetch manifest.json from the extension's Gitea main branch and update extensions.description
|
|
from manifest.displayLabel (the short Hebrew catalog label). Leaves DB untouched if displayLabel
|
|
is missing — `manifest.description` is intentionally NOT consulted (it's for composer/dev use
|
|
and is often a long English text unsuitable for the admin UI).
|
|
Returns True if the row was changed."""
|
|
resp = gitea_api('GET', f'/repos/{GITEA_ORG}/{module}/contents/manifest.json')
|
|
if not isinstance(resp, dict) or not resp.get('content'):
|
|
return False
|
|
try:
|
|
manifest = json.loads(base64.b64decode(resp['content']))
|
|
except Exception:
|
|
return False
|
|
label = (manifest.get('displayLabel') or '').strip()
|
|
if not label:
|
|
return False
|
|
current = query("SELECT description FROM extensions WHERE name = %s", (module,), fetch='one')
|
|
if current and (current.get('description') or '').strip() == label:
|
|
return False
|
|
query("UPDATE extensions SET description = %s, updated_at = NOW() WHERE name = %s",
|
|
(label, module), fetch=None)
|
|
return True
|
|
|
|
SSL_CTX = ssl.create_default_context()
|
|
SSL_CTX.check_hostname = False
|
|
SSL_CTX.verify_mode = ssl.CERT_NONE
|
|
|
|
|
|
# --- DB helpers ---
|
|
def get_db():
|
|
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS)
|
|
|
|
def query(sql, params=None, fetch='all'):
|
|
conn = get_db()
|
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
cur.execute(sql, params)
|
|
if fetch == 'all':
|
|
result = [dict(r) for r in cur.fetchall()]
|
|
elif fetch == 'one':
|
|
row = cur.fetchone()
|
|
result = dict(row) if row else None
|
|
else:
|
|
result = None
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
# --- Gitea helpers ---
|
|
def gitea_api(method, path, data=None, raw=False):
|
|
url = f"{GITEA_URL}/api/v1{path}"
|
|
headers = {'Authorization': f'token {GITEA_TOKEN}'}
|
|
if data and not raw:
|
|
headers['Content-Type'] = 'application/json'
|
|
data = json.dumps(data).encode()
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
|
body = resp.read()
|
|
if raw:
|
|
return body
|
|
return json.loads(body) if body else {}
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode()
|
|
return {'error': True, 'code': e.code, 'message': body[:300]}
|
|
|
|
|
|
def gitea_upload(url, file_data, filename):
|
|
"""Upload file to Gitea (Composer registry or release asset)."""
|
|
boundary = '----FormBoundary' + os.urandom(8).hex()
|
|
body = (
|
|
f'--{boundary}\r\n'
|
|
f'Content-Disposition: form-data; name="package"; filename="{filename}"\r\n'
|
|
f'Content-Type: application/zip\r\n\r\n'
|
|
).encode() + file_data + f'\r\n--{boundary}--\r\n'.encode()
|
|
headers = {
|
|
'Authorization': f'token {GITEA_TOKEN}',
|
|
'Content-Type': f'multipart/form-data; boundary={boundary}'
|
|
}
|
|
req = urllib.request.Request(url, data=body, headers=headers, method='PUT')
|
|
try:
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
|
return {'status': 'ok', 'code': resp.getcode()}
|
|
except urllib.error.HTTPError as e:
|
|
code = e.code
|
|
msg = e.read().decode()[:200]
|
|
if code == 409:
|
|
return {'status': 'exists', 'code': 409, 'message': 'already exists'}
|
|
return {'status': 'error', 'code': code, 'message': msg}
|
|
|
|
|
|
# --- Build logic (from Gitea, no local files) ---
|
|
def build_and_publish(module, version):
|
|
"""Download extension from Gitea, inject composer.json, publish to registry + release."""
|
|
if module not in MODULES:
|
|
return {'status': 'error', 'message': f'Unknown module: {module}'}
|
|
|
|
kebab = MODULES[module]
|
|
tag = f'v{version}'
|
|
composer_name = f'{GITEA_ORG}/{kebab}'
|
|
|
|
# 1. Download tag archive from Gitea
|
|
archive_url = f"{GITEA_URL}/api/v1/repos/{GITEA_ORG}/{module}/archive/{tag}.zip"
|
|
headers = {'Authorization': f'token {GITEA_TOKEN}'}
|
|
req = urllib.request.Request(archive_url, headers=headers)
|
|
try:
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
|
archive_data = resp.read()
|
|
except Exception as e:
|
|
return {'status': 'error', 'step': 'download', 'message': str(e)}
|
|
|
|
# 2. Extract and read manifest.json
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
archive_path = os.path.join(tmpdir, 'archive.zip')
|
|
with open(archive_path, 'wb') as f:
|
|
f.write(archive_data)
|
|
|
|
with zipfile.ZipFile(archive_path) as zf:
|
|
# Gitea archives have a top-level directory
|
|
top_dirs = set(n.split('/')[0] for n in zf.namelist() if '/' in n)
|
|
prefix = list(top_dirs)[0] + '/' if len(top_dirs) == 1 else ''
|
|
|
|
# Read manifest
|
|
manifest_path = f'{prefix}manifest.json'
|
|
try:
|
|
manifest = json.loads(zf.read(manifest_path))
|
|
except KeyError:
|
|
return {'status': 'error', 'step': 'manifest', 'message': f'manifest.json not found (tried {manifest_path})'}
|
|
|
|
# Validate version
|
|
if manifest.get('version') != version:
|
|
return {'status': 'error', 'step': 'version',
|
|
'message': f"Mismatch: tag={version}, manifest={manifest.get('version')}"}
|
|
|
|
# 3. Generate composer.json
|
|
composer = {
|
|
"name": composer_name,
|
|
"description": manifest.get('description', ''),
|
|
"version": version,
|
|
"type": "espocrm-extension",
|
|
"license": "proprietary",
|
|
"authors": [{"name": manifest.get('author', 'klear')}],
|
|
"require": {"php": manifest.get('php', ['>=8.1'])[0] if isinstance(manifest.get('php'), list) else '>=8.1'},
|
|
"extra": {
|
|
"espocrm-module": module,
|
|
"espocrm-min-version": manifest.get('acceptableVersions', ['>=8.0.0'])[0] if isinstance(manifest.get('acceptableVersions'), list) else '>=8.0.0'
|
|
}
|
|
}
|
|
|
|
# 4. Build extension ZIP with composer.json at root
|
|
ext_zip_path = os.path.join(tmpdir, f'{module}-{version}.zip')
|
|
with zipfile.ZipFile(ext_zip_path, 'w', zipfile.ZIP_DEFLATED) as ext_zip:
|
|
ext_zip.writestr('composer.json', json.dumps(composer, indent=2))
|
|
|
|
# Re-read archive and copy contents without the top-level dir
|
|
with zipfile.ZipFile(archive_path) as src_zip:
|
|
for item in src_zip.namelist():
|
|
if item.endswith('/'):
|
|
continue
|
|
# Strip top-level directory
|
|
rel_path = item[len(prefix):] if prefix and item.startswith(prefix) else item
|
|
if not rel_path or rel_path.startswith('.git'):
|
|
continue
|
|
ext_zip.writestr(rel_path, src_zip.read(item))
|
|
|
|
with open(ext_zip_path, 'rb') as f:
|
|
ext_zip_data = f.read()
|
|
|
|
# 5. Publish to Gitea Composer Registry
|
|
registry_url = f"{GITEA_URL}/api/packages/{GITEA_ORG}/composer"
|
|
reg_result = gitea_upload(registry_url, ext_zip_data, f'{module}-{version}.zip')
|
|
reg_status = 'published' if reg_result.get('code') == 201 else 'exists' if reg_result.get('code') == 409 else f"error:{reg_result.get('code')}"
|
|
|
|
# 6. Create Gitea Release + upload asset
|
|
release_result = gitea_api('POST', f'/repos/{GITEA_ORG}/{module}/releases', {
|
|
'tag_name': tag, 'name': tag, 'body': f'Release {version}', 'draft': False, 'prerelease': False
|
|
})
|
|
release_id = release_result.get('id')
|
|
asset_status = 'skipped'
|
|
# If release already exists, fetch its ID and delete old ZIP assets
|
|
if not release_id or release_result.get('error'):
|
|
existing = gitea_api('GET', f'/repos/{GITEA_ORG}/{module}/releases/tags/{tag}')
|
|
release_id = existing.get('id')
|
|
if release_id:
|
|
for old_asset in existing.get('assets', []):
|
|
if old_asset['name'].endswith('.zip'):
|
|
gitea_api('DELETE', f'/repos/{GITEA_ORG}/{module}/releases/{release_id}/assets/{old_asset["id"]}')
|
|
if release_id:
|
|
# Upload asset using multipart
|
|
boundary = '----FormBoundary7MA4YWxkTrZu0gW'
|
|
body = (
|
|
f'--{boundary}\r\n'
|
|
f'Content-Disposition: form-data; name="attachment"; filename="{module}-{version}.zip"\r\n'
|
|
f'Content-Type: application/zip\r\n\r\n'
|
|
).encode() + ext_zip_data + f'\r\n--{boundary}--\r\n'.encode()
|
|
|
|
asset_url = f"{GITEA_URL}/api/v1/repos/{GITEA_ORG}/{module}/releases/{release_id}/assets?name={module}-{version}.zip"
|
|
headers = {
|
|
'Authorization': f'token {GITEA_TOKEN}',
|
|
'Content-Type': f'multipart/form-data; boundary={boundary}'
|
|
}
|
|
req = urllib.request.Request(asset_url, data=body, headers=headers, method='POST')
|
|
try:
|
|
urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
|
asset_status = 'uploaded'
|
|
except urllib.error.HTTPError as e:
|
|
asset_status = f'error:{e.code}'
|
|
else:
|
|
asset_status = f'release_failed:{release_result.get("message","")[:100]}'
|
|
|
|
# 7. Update DB
|
|
db_ok = False
|
|
try:
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute("""INSERT INTO extension_versions (extension_id, version, zip_filename, git_tag)
|
|
SELECT id, %s, %s, %s FROM extensions WHERE name = %s
|
|
ON CONFLICT (extension_id, version) DO NOTHING""",
|
|
(version, f'{module}-{version}.zip', tag, module))
|
|
cur.execute("""UPDATE extensions SET latest_version = %s, updated_at = NOW()
|
|
WHERE name = %s AND (latest_version IS NULL OR string_to_array(latest_version, '.')::int[] < string_to_array(%s, '.')::int[])""",
|
|
(version, module, version))
|
|
# Sync catalog display label from manifest.displayLabel (short Hebrew). Skip manifest.description
|
|
# — it's for composer/dev use, often long English, and unsuitable for the admin UI.
|
|
manifest_label = (manifest.get('displayLabel') or '').strip()
|
|
if manifest_label:
|
|
cur.execute("UPDATE extensions SET description = %s, updated_at = NOW() WHERE name = %s",
|
|
(manifest_label, module))
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
db_ok = True
|
|
except Exception as e:
|
|
pass
|
|
|
|
return {
|
|
'status': 'ok', 'module': module, 'version': version,
|
|
'composer_name': composer_name,
|
|
'registry': reg_status, 'release_id': release_id, 'asset': asset_status,
|
|
'db_updated': db_ok, 'zip_size': len(ext_zip_data)
|
|
}
|
|
|
|
|
|
# --- 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={'Espo-Authorization': api_key, '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."""
|
|
espo_url = customer['espocrm_url'].rstrip('/')
|
|
api_key = customer['espocrm_api_key']
|
|
|
|
# Download ZIP from Gitea release
|
|
tag = f'v{version}'
|
|
releases = gitea_api('GET', f'/repos/{GITEA_ORG}/{module}/releases/tags/{tag}')
|
|
if releases.get('error'):
|
|
return {'status': 'error', 'step': 'find_release', 'message': releases.get('message','')}
|
|
|
|
zip_url = None
|
|
for asset in releases.get('assets', []):
|
|
if asset['name'].endswith('.zip'):
|
|
zip_url = asset['browser_download_url']
|
|
# Don't break - take the last ZIP (newest upload) in case of duplicates
|
|
|
|
if not zip_url:
|
|
return {'status': 'error', 'step': 'find_zip', 'message': 'No ZIP asset in release'}
|
|
|
|
# Download ZIP
|
|
req = urllib.request.Request(zip_url, headers={'Authorization': f'token {GITEA_TOKEN}'})
|
|
try:
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
|
zip_data = resp.read()
|
|
except Exception as e:
|
|
return {'status': 'error', 'step': 'download_zip', 'message': str(e)}
|
|
|
|
zip_base64 = base64.b64encode(zip_data).decode()
|
|
headers = {'Espo-Authorization': api_key, 'Content-Type': 'application/json'}
|
|
|
|
# Upload to EspoCRM
|
|
upload_data = json.dumps({'file': f'data:application/zip;base64,{zip_base64}'}).encode()
|
|
req = urllib.request.Request(f'{espo_url}/api/v1/Extension/action/upload',
|
|
data=upload_data, headers=headers, method='POST')
|
|
try:
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=60)
|
|
ext_id = json.loads(resp.read()).get('id')
|
|
except urllib.error.HTTPError as e:
|
|
return {'status': 'error', 'step': 'upload', 'code': e.code, 'message': e.read().decode()[:200]}
|
|
except Exception as e:
|
|
return {'status': 'error', 'step': 'upload', 'message': str(e)}
|
|
|
|
# Install
|
|
req2 = urllib.request.Request(f'{espo_url}/api/v1/Extension/action/install',
|
|
data=json.dumps({'id': ext_id}).encode(), headers=headers, method='POST')
|
|
try:
|
|
urllib.request.urlopen(req2, context=SSL_CTX, timeout=120)
|
|
except urllib.error.HTTPError as e:
|
|
return {'status': 'error', 'step': 'install', 'code': e.code, 'message': e.read().decode()[:200]}
|
|
except Exception as e:
|
|
return {'status': 'error', 'step': 'install', 'message': str(e)}
|
|
|
|
# Rebuild
|
|
req3 = urllib.request.Request(f'{espo_url}/api/v1/Admin/rebuild',
|
|
data=b'true', headers=headers, method='POST')
|
|
try:
|
|
urllib.request.urlopen(req3, context=SSL_CTX, timeout=60)
|
|
except:
|
|
pass
|
|
|
|
# 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 ---
|
|
def sync_customer_extensions(customer):
|
|
"""Query a customer's EspoCRM to see what's really installed, compare with platform DB."""
|
|
espo_url = customer['espocrm_url'].rstrip('/')
|
|
api_key = customer['espocrm_api_key']
|
|
cid = customer['id']
|
|
|
|
# 0. Ensure all active extensions are assigned to this customer
|
|
query("""INSERT INTO customer_extensions (customer_id, extension_id, auto_update, license_status)
|
|
SELECT %s, id, true, 'active' FROM extensions WHERE is_active
|
|
ON CONFLICT (customer_id, extension_id) DO NOTHING""", (cid,), fetch=None)
|
|
|
|
# 1. Query EspoCRM for installed extensions
|
|
headers = {'Espo-Authorization': api_key}
|
|
req = urllib.request.Request(f'{espo_url}/api/v1/Extension?select=name,version&maxSize=200',
|
|
headers=headers)
|
|
try:
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=30)
|
|
data = json.loads(resp.read())
|
|
server_exts = {e['name']: e.get('version', '') for e in data.get('list', [])}
|
|
except Exception as e:
|
|
return {'customer': customer['name'], 'status': 'error',
|
|
'message': f'Cannot reach EspoCRM: {str(e)[:200]}'}
|
|
|
|
# 2. Get what platform thinks is installed
|
|
platform_exts = query("""
|
|
SELECT e.name, ce.installed_version, ce.license_status
|
|
FROM customer_extensions ce JOIN extensions e ON ce.extension_id = e.id
|
|
WHERE ce.customer_id = %s AND ce.license_status = 'active'""", (cid,))
|
|
platform_map = {r['name']: r['installed_version'] for r in platform_exts}
|
|
|
|
# 3. Compare
|
|
drifts = []
|
|
updated = 0
|
|
|
|
# Check each platform-tracked extension
|
|
for ext_name, platform_ver in platform_map.items():
|
|
server_ver = server_exts.get(ext_name)
|
|
if server_ver is None:
|
|
drifts.append({'extension': ext_name, 'type': 'missing_on_server',
|
|
'platform': platform_ver, 'server': None})
|
|
if platform_ver is not None:
|
|
query("""UPDATE customer_extensions SET installed_version = NULL, installed_at = NULL, updated_at = NOW()
|
|
FROM extensions e WHERE customer_extensions.extension_id = e.id
|
|
AND customer_extensions.customer_id = %s AND e.name = %s""",
|
|
(cid, ext_name), fetch=None)
|
|
updated += 1
|
|
elif server_ver != platform_ver:
|
|
drifts.append({'extension': ext_name, 'type': 'version_mismatch',
|
|
'platform': platform_ver, 'server': server_ver})
|
|
# Update platform DB to reflect reality
|
|
query("""UPDATE customer_extensions SET installed_version = %s, updated_at = NOW()
|
|
FROM extensions e WHERE customer_extensions.extension_id = e.id
|
|
AND customer_extensions.customer_id = %s AND e.name = %s""",
|
|
(server_ver, cid, ext_name), fetch=None)
|
|
updated += 1
|
|
|
|
# Check for extensions on server not tracked by platform
|
|
for ext_name, server_ver in server_exts.items():
|
|
if ext_name not in platform_map and ext_name in MODULES:
|
|
drifts.append({'extension': ext_name, 'type': 'extra_on_server',
|
|
'platform': None, 'server': server_ver})
|
|
|
|
return {
|
|
'customer': customer['name'], 'customer_id': cid, 'status': 'ok',
|
|
'server_count': len(server_exts), 'platform_count': len(platform_map),
|
|
'drifts': drifts, 'drift_count': len(drifts), 'updated': updated
|
|
}
|
|
|
|
|
|
def health_check_all():
|
|
"""Run comprehensive health checks on all extensions and customers."""
|
|
issues = []
|
|
|
|
# 1. Check Gitea release integrity - manifest version matches tag
|
|
extensions = query("SELECT name, latest_version FROM extensions WHERE is_active")
|
|
for ext in extensions:
|
|
module = ext['name']
|
|
version = ext['latest_version']
|
|
if not version:
|
|
continue
|
|
tag = f'v{version}'
|
|
release = gitea_api('GET', f'/repos/{GITEA_ORG}/{module}/releases/tags/{tag}')
|
|
if release.get('error'):
|
|
issues.append({'check': 'gitea_release', 'extension': module, 'severity': 'error',
|
|
'message': f'Release {tag} not found on Gitea'})
|
|
continue
|
|
|
|
zip_assets = [a for a in release.get('assets', []) if a['name'].endswith('.zip')]
|
|
if not zip_assets:
|
|
issues.append({'check': 'gitea_release', 'extension': module, 'severity': 'error',
|
|
'message': f'No ZIP asset in release {tag}'})
|
|
continue
|
|
|
|
# Check for duplicate ZIP assets
|
|
if len(zip_assets) > 1:
|
|
issues.append({'check': 'duplicate_assets', 'extension': module, 'severity': 'warning',
|
|
'message': f'{len(zip_assets)} ZIP assets in release {tag} — should be 1'})
|
|
|
|
# Download last ZIP and verify manifest version matches tag
|
|
last_zip_url = zip_assets[-1]['browser_download_url']
|
|
try:
|
|
req = urllib.request.Request(last_zip_url, headers={'Authorization': f'token {GITEA_TOKEN}'})
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=30)
|
|
zip_data = resp.read()
|
|
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
manifest_files = [n for n in zf.namelist() if n.endswith('manifest.json')]
|
|
if manifest_files:
|
|
manifest = json.loads(zf.read(manifest_files[0]))
|
|
manifest_ver = manifest.get('version', '')
|
|
if manifest_ver != version:
|
|
issues.append({'check': 'version_mismatch', 'extension': module, 'severity': 'critical',
|
|
'message': f'ZIP manifest says {manifest_ver} but release tag is {tag} — wrong ZIP!'})
|
|
else:
|
|
issues.append({'check': 'manifest_missing', 'extension': module, 'severity': 'error',
|
|
'message': f'No manifest.json found inside ZIP for {tag}'})
|
|
except Exception as e:
|
|
issues.append({'check': 'zip_verify', 'extension': module, 'severity': 'error',
|
|
'message': f'Failed to verify ZIP: {str(e)[:150]}'})
|
|
|
|
# 2. Check EspoCRM API health for each customer
|
|
customers = query("""SELECT id, name, espocrm_url, espocrm_api_key
|
|
FROM customers WHERE is_active AND espocrm_api_key IS NOT NULL""")
|
|
customer_results = []
|
|
for c in customers:
|
|
espo_url = c['espocrm_url'].rstrip('/')
|
|
headers = {'Espo-Authorization': c['espocrm_api_key']}
|
|
cr = {'customer': c['name'], 'url': espo_url, 'api_reachable': False, 'extensions_loaded': False}
|
|
|
|
# API health
|
|
try:
|
|
req = urllib.request.Request(f'{espo_url}/api/v1/App/user', headers=headers)
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=15)
|
|
data = json.loads(resp.read())
|
|
cr['api_reachable'] = True
|
|
cr['espo_version'] = data.get('version', 'unknown')
|
|
except Exception as e:
|
|
issues.append({'check': 'api_health', 'extension': None, 'severity': 'critical',
|
|
'message': f'{c["name"]}: EspoCRM API unreachable — {str(e)[:150]}'})
|
|
customer_results.append(cr)
|
|
continue
|
|
|
|
# Check extensions are loaded
|
|
try:
|
|
req = urllib.request.Request(f'{espo_url}/api/v1/Extension?select=name,version&maxSize=200', headers=headers)
|
|
resp = urllib.request.urlopen(req, context=SSL_CTX, timeout=15)
|
|
data = json.loads(resp.read())
|
|
server_exts = {e['name']: e.get('version', '') for e in data.get('list', [])}
|
|
cr['extensions_loaded'] = True
|
|
cr['extension_count'] = len(server_exts)
|
|
|
|
# Check installed extensions actually match what platform expects
|
|
platform_exts = query("""SELECT e.name, ce.installed_version FROM customer_extensions ce
|
|
JOIN extensions e ON ce.extension_id = e.id
|
|
WHERE ce.customer_id = %s AND ce.installed_version IS NOT NULL""", (c['id'],))
|
|
for pe in platform_exts:
|
|
server_ver = server_exts.get(pe['name'])
|
|
if server_ver and server_ver != pe['installed_version']:
|
|
issues.append({'check': 'install_integrity', 'extension': pe['name'], 'severity': 'warning',
|
|
'message': f'{c["name"]}: platform says {pe["installed_version"]} but server has {server_ver}'})
|
|
except Exception as e:
|
|
issues.append({'check': 'ext_query', 'extension': None, 'severity': 'error',
|
|
'message': f'{c["name"]}: failed to query extensions — {str(e)[:150]}'})
|
|
|
|
customer_results.append(cr)
|
|
|
|
# 3. Check for stale deployments (latest deploy per extension+customer says ok but version doesn't match)
|
|
stale = query("""SELECT latest.version as deployed_version, e.name as extension, c.name as customer,
|
|
ce.installed_version, latest.started_at
|
|
FROM (
|
|
SELECT DISTINCT ON (customer_id, extension_id)
|
|
customer_id, extension_id, version, status, started_at
|
|
FROM deployment_log
|
|
WHERE started_at > NOW() - INTERVAL '24 hours'
|
|
ORDER BY customer_id, extension_id, started_at DESC
|
|
) latest
|
|
JOIN extensions e ON latest.extension_id = e.id
|
|
JOIN customers c ON latest.customer_id = c.id
|
|
LEFT JOIN customer_extensions ce ON ce.customer_id = latest.customer_id AND ce.extension_id = latest.extension_id
|
|
WHERE latest.status = 'ok'
|
|
AND (ce.installed_version IS NULL OR ce.installed_version != latest.version)""")
|
|
for s in stale:
|
|
issues.append({'check': 'stale_deploy', 'extension': s['extension'], 'severity': 'warning',
|
|
'message': f'{s["customer"]}: deployed {s["deployed_version"]} but installed shows {s["installed_version"] or "NULL"}'})
|
|
|
|
critical = len([i for i in issues if i['severity'] == 'critical'])
|
|
errors = len([i for i in issues if i['severity'] == 'error'])
|
|
warnings = len([i for i in issues if i['severity'] == 'warning'])
|
|
|
|
return {
|
|
'status': 'critical' if critical else 'error' if errors else 'warning' if warnings else 'ok',
|
|
'total_issues': len(issues),
|
|
'critical': critical, 'errors': errors, 'warnings': warnings,
|
|
'issues': issues,
|
|
'customers': customer_results
|
|
}
|
|
|
|
|
|
def sync_all_customers():
|
|
"""Run sync for all active customers."""
|
|
customers = query("""SELECT id, name, espocrm_url, espocrm_api_key
|
|
FROM customers WHERE is_active AND espocrm_api_key IS NOT NULL""")
|
|
results = []
|
|
total_drifts = 0
|
|
for c in customers:
|
|
r = sync_customer_extensions(c)
|
|
results.append(r)
|
|
total_drifts += r.get('drift_count', 0)
|
|
return {
|
|
'status': 'ok', 'customers_checked': len(results),
|
|
'total_drifts': total_drifts, 'results': results
|
|
}
|
|
|
|
|
|
# --- HTTP Handler ---
|
|
class APIHandler(BaseHTTPRequestHandler):
|
|
|
|
def do_GET(self):
|
|
path = self.path.split('?')[0]
|
|
if path == '/api/extensions':
|
|
self._get_extensions()
|
|
elif re.match(r'/api/extensions/\d+/versions', path):
|
|
self._get_extension_versions(int(re.search(r'/api/extensions/(\d+)/versions', path).group(1)))
|
|
elif path == '/api/customers':
|
|
self._get_customers()
|
|
elif re.match(r'/api/customers/\d+$', path):
|
|
self._get_customer(int(re.search(r'/api/customers/(\d+)', path).group(1)))
|
|
elif path == '/api/deployments':
|
|
self._get_deployments()
|
|
elif path == '/api/stats':
|
|
self._get_stats()
|
|
elif path == '/api/health':
|
|
self._respond(200, {'status': 'ok'})
|
|
else:
|
|
self._serve_static(path)
|
|
|
|
def do_POST(self):
|
|
body = self._read_body()
|
|
path = self.path.split('?')[0]
|
|
if path == '/publish':
|
|
self._handle_publish(body)
|
|
elif path == '/release-deleted':
|
|
self._handle_release_deleted(body)
|
|
elif path == '/admin/set-labels':
|
|
self._handle_admin_set_labels(body)
|
|
elif path == '/deploy':
|
|
self._handle_deploy(body)
|
|
elif path == '/deploy-all':
|
|
self._handle_deploy_all(body)
|
|
elif path == '/api/customers':
|
|
self._create_customer(body)
|
|
elif re.match(r'/api/customers/\d+/extensions', path):
|
|
self._assign_extension(int(re.search(r'/api/customers/(\d+)/extensions', path).group(1)), body)
|
|
elif path == '/api/deploy':
|
|
self._handle_deploy(body)
|
|
elif path == '/sync':
|
|
self._handle_sync(body)
|
|
elif path == '/health-check':
|
|
self._handle_health_check()
|
|
elif path == '/sync-registry':
|
|
self._handle_sync_registry()
|
|
else:
|
|
self._respond(404, {'error': 'Not found'})
|
|
|
|
def do_PUT(self):
|
|
body = self._read_body()
|
|
path = self.path.split('?')[0]
|
|
if re.match(r'/api/customers/\d+$', path):
|
|
self._update_customer(int(re.search(r'/api/customers/(\d+)', path).group(1)), body)
|
|
else:
|
|
self._respond(404, {'error': 'Not found'})
|
|
|
|
def do_DELETE(self):
|
|
path = self.path.split('?')[0]
|
|
if re.match(r'/api/customers/\d+/extensions/\d+$', path):
|
|
m = re.search(r'/api/customers/(\d+)/extensions/(\d+)', path)
|
|
self._remove_extension(int(m.group(1)), int(m.group(2)))
|
|
elif re.match(r'/api/customers/\d+$', path):
|
|
cid = int(re.search(r'/api/customers/(\d+)', path).group(1))
|
|
self._delete_customer(cid)
|
|
else:
|
|
self._respond(404, {'error': 'Not found'})
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self._cors_headers()
|
|
self.end_headers()
|
|
|
|
# --- Dashboard API ---
|
|
def _get_extensions(self):
|
|
self._respond(200, query("""
|
|
SELECT e.*, (SELECT COUNT(*) FROM extension_versions ev WHERE ev.extension_id = e.id) as version_count,
|
|
(SELECT COUNT(*) FROM customer_extensions ce WHERE ce.extension_id = e.id AND ce.license_status = 'active') as customer_count
|
|
FROM extensions e WHERE e.is_active ORDER BY e.name"""))
|
|
|
|
def _get_extension_versions(self, ext_id):
|
|
self._respond(200, query("SELECT * FROM extension_versions WHERE extension_id = %s ORDER BY released_at DESC", (ext_id,)))
|
|
|
|
def _get_customers(self):
|
|
rows = query("""SELECT c.id, c.name, c.email, c.espocrm_url, c.is_active, c.notes, c.created_at, c.updated_at,
|
|
c.coolify_url, c.coolify_app_uuid,
|
|
(SELECT COUNT(*) FROM customer_extensions ce WHERE ce.customer_id = c.id AND ce.license_status = 'active') as extension_count,
|
|
(SELECT COUNT(*) FROM customer_extensions ce WHERE ce.customer_id = c.id AND ce.license_status = 'active' AND ce.installed_version IS NOT NULL) as installed_count,
|
|
(SELECT MAX(dl.started_at) FROM deployment_log dl WHERE dl.customer_id = c.id) as last_deploy
|
|
FROM customers c ORDER BY c.name""")
|
|
self._respond(200, rows)
|
|
|
|
def _get_customer(self, cid):
|
|
customer = query("SELECT id, name, email, espocrm_url, is_active, notes, coolify_url, coolify_app_uuid, created_at, updated_at FROM customers WHERE id = %s", (cid,), fetch='one')
|
|
if not customer:
|
|
self._respond(404, {'error': 'Not found'}); return
|
|
customer['extensions'] = query("""
|
|
SELECT e.id, e.name, e.composer_name, e.latest_version, ce.installed_version, ce.license_status, ce.auto_update, ce.installed_at
|
|
FROM customer_extensions ce JOIN extensions e ON ce.extension_id = e.id WHERE ce.customer_id = %s AND e.is_active ORDER BY e.name""", (cid,))
|
|
customer['dependencies'] = DEPENDENCIES
|
|
customer['install_order'] = _compute_install_order()
|
|
self._respond(200, customer)
|
|
|
|
def _create_customer(self, body):
|
|
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,
|
|
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'),
|
|
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')
|
|
for ext in all_exts:
|
|
query("""INSERT INTO customer_extensions (customer_id, extension_id, auto_update, license_status)
|
|
VALUES (%s, %s, true, 'active') ON CONFLICT DO NOTHING""",
|
|
(cid, ext['id']), fetch=None)
|
|
self._respond(201, {'id': cid, 'name': body['name']})
|
|
|
|
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','ssh_host','ssh_user','ssh_key','docker_container']:
|
|
if col in body:
|
|
fields.append(f"{col} = %s"); values.append(body[col])
|
|
if not fields:
|
|
self._respond(400, {'error': 'No fields'}); return
|
|
fields.append("updated_at = NOW()"); values.append(cid)
|
|
query(f"UPDATE customers SET {', '.join(fields)} WHERE id = %s", values, fetch=None)
|
|
self._respond(200, {'status': 'ok'})
|
|
|
|
def _assign_extension(self, cid, body):
|
|
if not body.get('extension_id'):
|
|
self._respond(400, {'error': 'extension_id required'}); return
|
|
query("""INSERT INTO customer_extensions (customer_id, extension_id, auto_update, license_status)
|
|
VALUES (%s, %s, %s, %s) ON CONFLICT (customer_id, extension_id) DO UPDATE
|
|
SET license_status = EXCLUDED.license_status, auto_update = EXCLUDED.auto_update, updated_at = NOW()""",
|
|
(cid, body['extension_id'], body.get('auto_update', True), body.get('license_status', 'active')), fetch=None)
|
|
self._respond(201, {'status': 'ok'})
|
|
|
|
def _delete_customer(self, cid):
|
|
query("DELETE FROM customer_extensions WHERE customer_id = %s", (cid,), fetch=None)
|
|
query("DELETE FROM deployment_log WHERE customer_id = %s", (cid,), fetch=None)
|
|
query("DELETE FROM customers WHERE id = %s", (cid,), fetch=None)
|
|
self._respond(200, {'status': 'ok'})
|
|
|
|
def _remove_extension(self, cid, ext_id):
|
|
query("DELETE FROM customer_extensions WHERE customer_id = %s AND extension_id = %s", (cid, ext_id), fetch=None)
|
|
self._respond(200, {'status': 'ok'})
|
|
|
|
def _get_deployments(self):
|
|
qs = self.path.split('?')[1] if '?' in self.path else ''
|
|
params = dict(p.split('=') for p in qs.split('&') if '=' in p) if qs else {}
|
|
limit = min(int(params.get('limit', 50)), 200)
|
|
self._respond(200, query("""SELECT dl.*, c.name as customer_name, e.name as extension_name
|
|
FROM deployment_log dl JOIN customers c ON dl.customer_id = c.id JOIN extensions e ON dl.extension_id = e.id
|
|
ORDER BY dl.started_at DESC LIMIT %s""", (limit,)))
|
|
|
|
def _get_stats(self):
|
|
self._respond(200, query("""SELECT
|
|
(SELECT COUNT(*) FROM extensions WHERE is_active) as total_extensions,
|
|
(SELECT COUNT(*) FROM customers WHERE is_active) as total_customers,
|
|
(SELECT COUNT(*) FROM customer_extensions WHERE license_status = 'active') as total_licenses,
|
|
(SELECT COUNT(*) FROM deployment_log WHERE status = 'ok') as successful_deploys,
|
|
(SELECT COUNT(*) FROM deployment_log WHERE status != 'ok') as failed_deploys,
|
|
(SELECT MAX(started_at) FROM deployment_log) as last_deploy""", fetch='one'))
|
|
|
|
# --- Pipeline ---
|
|
def _handle_publish(self, body):
|
|
module, version = body.get('module',''), body.get('version','')
|
|
if not module or not version:
|
|
self._respond(400, {'error': 'module and version required'}); return
|
|
result = build_and_publish(module, version)
|
|
self._respond(200, result)
|
|
|
|
def _handle_admin_set_labels(self, body):
|
|
"""Bulk-set extensions.description (the catalog display label) for one or many extensions.
|
|
Body: { "labels": { "ModuleName": "תווית קצרה", ... } }"""
|
|
labels = body.get('labels', {})
|
|
if not isinstance(labels, dict) or not labels:
|
|
self._respond(400, {'error': 'labels dict required'}); return
|
|
updated, skipped = [], []
|
|
for name, label in labels.items():
|
|
if not isinstance(label, str):
|
|
skipped.append({'name': name, 'reason': 'label must be string'}); continue
|
|
ext = query("SELECT id FROM extensions WHERE name = %s", (name,), fetch='one')
|
|
if not ext:
|
|
skipped.append({'name': name, 'reason': 'not found'}); continue
|
|
query("UPDATE extensions SET description = %s, updated_at = NOW() WHERE id = %s",
|
|
(label.strip(), ext['id']), fetch=None)
|
|
updated.append(name)
|
|
self._respond(200, {'status': 'ok', 'updated': updated, 'skipped': skipped})
|
|
|
|
def _handle_release_deleted(self, body):
|
|
"""Triggered by Gitea release-deleted webhook (via n8n).
|
|
Removes the version row, recomputes latest_version, and deactivates the extension if no versions remain.
|
|
Body: { module: str, version: str (optional — if omitted, only recomputes/deactivates) }"""
|
|
module = body.get('module', '')
|
|
version = body.get('version', '')
|
|
if not module:
|
|
self._respond(400, {'error': 'module required'}); return
|
|
ext = query("SELECT id FROM extensions WHERE name = %s", (module,), fetch='one')
|
|
if not ext:
|
|
self._respond(404, {'error': f'Extension {module} not found'}); return
|
|
ext_id = ext['id']
|
|
if version:
|
|
query("DELETE FROM extension_versions WHERE extension_id = %s AND version = %s",
|
|
(ext_id, version), fetch=None)
|
|
remaining = query("SELECT COUNT(*) AS cnt FROM extension_versions WHERE extension_id = %s",
|
|
(ext_id,), fetch='one')['cnt']
|
|
if remaining == 0:
|
|
query("UPDATE extensions SET is_active = false, latest_version = NULL, updated_at = NOW() WHERE id = %s",
|
|
(ext_id,), fetch=None)
|
|
self._respond(200, {'status': 'ok', 'module': module, 'version': version,
|
|
'action': 'deactivated', 'remaining_versions': 0})
|
|
return
|
|
query("""UPDATE extensions e SET latest_version = sub.max_ver, updated_at = NOW()
|
|
FROM (SELECT extension_id, version as max_ver FROM extension_versions ev1
|
|
WHERE ev1.extension_id = %s
|
|
AND NOT EXISTS (SELECT 1 FROM extension_versions ev2
|
|
WHERE ev2.extension_id = ev1.extension_id
|
|
AND string_to_array(ev2.version, '.')::int[] > string_to_array(ev1.version, '.')::int[])
|
|
) sub WHERE e.id = sub.extension_id""", (ext_id,), fetch=None)
|
|
self._respond(200, {'status': 'ok', 'module': module, 'version': version,
|
|
'action': 'version_removed', 'remaining_versions': remaining})
|
|
|
|
def _handle_deploy(self, body):
|
|
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, 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)
|
|
# Log
|
|
query("""INSERT INTO deployment_log (customer_id, extension_id, version, status, trigger_type, error_message)
|
|
SELECT %s, e.id, %s, %s, 'manual', %s FROM extensions e WHERE e.name = %s""",
|
|
(cid, version, result['status'], result.get('message',''), module), fetch=None)
|
|
if result['status'] == 'ok':
|
|
query("""UPDATE customer_extensions SET installed_version = %s, installed_at = NOW()
|
|
FROM extensions e WHERE customer_extensions.extension_id = e.id
|
|
AND customer_extensions.customer_id = %s AND e.name = %s""", (version, cid, module), fetch=None)
|
|
self._respond(200, {**result, 'customer': customer['name'], 'module': module, 'version': version})
|
|
|
|
def _handle_deploy_all(self, body):
|
|
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,
|
|
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 = []
|
|
for c in customers:
|
|
r = deploy_to_customer(c, module, version)
|
|
results.append({**r, 'customer': c['name']})
|
|
query("""INSERT INTO deployment_log (customer_id, extension_id, version, status, trigger_type, error_message)
|
|
SELECT %s, e.id, %s, %s, 'auto', %s FROM extensions e WHERE e.name = %s""",
|
|
(c['id'], version, r['status'], r.get('message',''), module), fetch=None)
|
|
if r['status'] == 'ok':
|
|
query("""UPDATE customer_extensions SET installed_version = %s, installed_at = NOW()
|
|
FROM extensions e WHERE customer_extensions.extension_id = e.id
|
|
AND customer_extensions.customer_id = %s AND e.name = %s""", (version, c['id'], module), fetch=None)
|
|
self._respond(200, {'status': 'ok', 'module': module, 'version': version,
|
|
'deployed_to': len(results), 'results': results})
|
|
|
|
# --- Sync ---
|
|
def _handle_sync(self, body):
|
|
cid = body.get('customer_id')
|
|
if cid:
|
|
customer = query("SELECT id, name, espocrm_url, espocrm_api_key FROM customers WHERE id = %s AND is_active", (cid,), fetch='one')
|
|
if not customer:
|
|
self._respond(404, {'error': 'Customer not found'}); return
|
|
result = sync_customer_extensions(customer)
|
|
self._respond(200, result)
|
|
else:
|
|
result = sync_all_customers()
|
|
self._respond(200, result)
|
|
|
|
# --- Health Check ---
|
|
def _handle_health_check(self):
|
|
result = health_check_all()
|
|
self._respond(200, result)
|
|
|
|
# --- Registry Sync ---
|
|
def _handle_sync_registry(self):
|
|
result = sync_extension_registry()
|
|
self._respond(200, result)
|
|
|
|
# --- Helpers ---
|
|
def _read_body(self):
|
|
length = int(self.headers.get('Content-Length', 0))
|
|
return json.loads(self.rfile.read(length)) if length > 0 else {}
|
|
|
|
def _serve_static(self, path):
|
|
if path == '/':
|
|
path = '/index.html'
|
|
file_path = os.path.join(DIST_DIR, path.lstrip('/'))
|
|
if not os.path.isfile(file_path):
|
|
# Only fallback to index.html for routes, not for asset files
|
|
if path.startswith('/assets/') or '.' in path.split('/')[-1]:
|
|
self._respond(404, {'error': 'Not found'}); return
|
|
file_path = os.path.join(DIST_DIR, 'index.html')
|
|
try:
|
|
with open(file_path, 'rb') as f:
|
|
content = f.read()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', mimetypes.guess_type(file_path)[0] or 'application/octet-stream')
|
|
self.send_header('Content-Length', len(content))
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
except:
|
|
self._respond(404, {'error': 'Not found'})
|
|
|
|
def _respond(self, code, data):
|
|
self.send_response(code)
|
|
self._cors_headers()
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(data, default=str).encode())
|
|
|
|
def _cors_headers(self):
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
|
|
def log_message(self, fmt, *args):
|
|
print(f"[api] {args[0]}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# Sync extensions from Gitea and load into DB on every startup
|
|
try:
|
|
result = sync_extension_registry()
|
|
if result['status'] == 'ok':
|
|
changes = result['added'] + result['reactivated'] + result['deactivated']
|
|
if changes:
|
|
print(f"[init] Registry synced — added: {result['added']}, reactivated: {result['reactivated']}, deactivated: {result['deactivated']}")
|
|
else:
|
|
print(f"[init] Registry synced — no changes ({len(MODULES)} modules)")
|
|
else:
|
|
print(f"[init] Registry sync failed: {result.get('message', '')}")
|
|
_load_modules_from_db()
|
|
except Exception as e:
|
|
print(f"[init] Registry sync error: {e}")
|
|
_load_modules_from_db()
|
|
if not MODULES:
|
|
MODULES.update(_MODULES_FALLBACK)
|
|
print(f"[init] Using static fallback: {len(MODULES)} modules")
|
|
port = int(os.environ.get('PORT', '8080'))
|
|
server = HTTPServer(('0.0.0.0', port), APIHandler)
|
|
print(f"Extension Platform API on port {port}")
|
|
server.serve_forever()
|