admin_panel/admin_panel/api/apps.py

685 lines
20 KiB
Python

import json
import os
import shutil
import subprocess
import tempfile
import time
import uuid
import frappe
import requests
import urllib3
import yaml
from admin_panel.api.servers import _run_doas_command
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
TEMPLATES_DIR = "/home/frappe/frappe-bench/apps/admin_panel/templates/distrobuilder"
META_FILE = os.path.join(TEMPLATES_DIR, "apps_meta.json")
SERVERS_FILE = "/home/frappe/frappe-bench/apps/admin_panel/templates/servers.json"
CERTS_DIR = "/home/frappe/frappe-bench/apps/admin_panel/certs"
CLIENT_CERT = os.path.join(CERTS_DIR, "client.crt")
CLIENT_KEY = os.path.join(CERTS_DIR, "client.key")
def _check_admin_permission():
"""Check if user has admin permission (System Manager or Administrator)."""
if frappe.session.user == "Guest":
frappe.throw("Please login to access this feature", frappe.AuthenticationError)
if frappe.session.user == "Administrator":
return
roles = frappe.get_roles()
if "System Manager" not in roles and "Administrator" not in roles:
frappe.throw(
"You need System Manager or Administrator role to access this feature",
frappe.PermissionError,
)
def _ensure_templates_dir():
"""Ensure templates directory exists."""
if not os.path.exists(TEMPLATES_DIR):
os.makedirs(TEMPLATES_DIR, exist_ok=True)
def _load_meta():
"""Load apps metadata from JSON file."""
_ensure_templates_dir()
if os.path.exists(META_FILE):
with open(META_FILE, "r") as f:
return json.load(f)
return {}
def _save_meta(meta):
"""Save apps metadata to JSON file."""
_ensure_templates_dir()
with open(META_FILE, "w") as f:
json.dump(meta, f, indent=2)
def _load_servers():
"""Load servers config from JSON file."""
if os.path.exists(SERVERS_FILE):
with open(SERVERS_FILE, "r") as f:
return json.load(f)
return {}
def _get_image_server():
"""Get the first configured image server URL."""
servers = _load_servers()
for name, config in servers.items():
if config.get("is_image_server"):
return {"name": name, "url": config.get("url", "")}
# Fallback: return the first server with a URL
for name, config in servers.items():
if config.get("url"):
return {"name": name, "url": config.get("url", "")}
return None
def _incus_api(server_url, method, path, data=None, timeout=30):
"""Make REST API request to an Incus server using panel certificate."""
if not os.path.exists(CLIENT_CERT) or not os.path.exists(CLIENT_KEY):
raise Exception("Panel certificate not configured. Generate one in Servers tab.")
url = f"{server_url}{path}"
r = requests.request(
method,
url,
headers={"Content-Type": "application/json"},
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
json=data,
timeout=timeout,
)
r.raise_for_status()
return r.json()
def _get_server_url(server_name):
"""Get URL for a specific server."""
servers = _load_servers()
config = servers.get(server_name, {})
return config.get("url", "")
def _get_all_images(server_url):
"""Get all images from a server (single request)."""
try:
resp = _incus_api(server_url, "GET", "/1.0/images?recursion=1")
return resp.get("metadata", [])
except Exception:
return []
def _filter_images_by_prefix(all_images, prefix):
"""Filter images that match the given alias prefix."""
images = []
for img in all_images:
aliases = img.get("aliases", [])
for alias in aliases:
alias_name = alias.get("name", "")
if alias_name.startswith(prefix):
version = alias_name[len(prefix):].lstrip(":")
images.append(
{
"alias": alias_name,
"version": version or "latest",
"fingerprint": img.get("fingerprint", ""),
"created_at": img.get("created_at", ""),
"size": img.get("size", 0),
"architecture": img.get("architecture", ""),
}
)
return images
def _format_size(size):
"""Format byte size to human readable string."""
if size <= 0:
return "-"
if size >= 1024 * 1024 * 1024:
return f"{size / (1024 * 1024 * 1024):.1f} GB"
elif size >= 1024 * 1024:
return f"{size / (1024 * 1024):.1f} MB"
else:
return f"{size / 1024:.1f} KB"
@frappe.whitelist()
def get_apps():
"""Get list of all app templates."""
_check_admin_permission()
_ensure_templates_dir()
meta = _load_meta()
# Get list of .yaml files in templates directory
yaml_files = [f for f in os.listdir(TEMPLATES_DIR) if f.endswith(".yaml")]
# Fetch all images ONCE from image server
all_images = []
image_server = _get_image_server()
if image_server and image_server.get("url"):
all_images = _get_all_images(image_server["url"])
apps = []
for yaml_file in yaml_files:
app_name = yaml_file.replace(".yaml", "")
app_meta = meta.get(app_name, {})
# Filter versions from cached images
versions = _filter_images_by_prefix(all_images, app_name)
versions_count = len(versions)
latest_version = "-"
total_size = 0
if versions:
versions.sort(key=lambda x: x.get("created_at", ""), reverse=True)
latest_version = versions[0].get("version", "latest")
total_size = versions[0].get("size", 0)
apps.append(
{
"id": app_name,
"name": app_name,
"description": app_meta.get("description", f"Distrobuilder template: {app_name}"),
"status": app_meta.get("status", "active"),
"versions_count": versions_count,
"latest_version": latest_version,
"currentVersion": latest_version,
"size": _format_size(total_size),
"created": app_meta.get("created", ""),
"lastUpdated": versions[0].get("created_at", "")[:10] if versions else app_meta.get("created", ""),
"sitesCount": 0,
}
)
return apps
@frappe.whitelist()
def get_app_detail(app_name):
"""Get app details including YAML config."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
with open(yaml_path, "r") as f:
yaml_content = f.read()
meta = _load_meta()
app_meta = meta.get(app_name, {})
# Get versions from image server
image_server = _get_image_server()
all_images = []
if image_server and image_server.get("url"):
all_images = _get_all_images(image_server["url"])
versions = _filter_images_by_prefix(all_images, app_name)
versions.sort(key=lambda x: x.get("created_at", ""), reverse=True)
formatted_versions = []
for i, v in enumerate(versions):
formatted_versions.append(
{
"version": v.get("version", "latest"),
"alias": v.get("alias", ""),
"fingerprint": v.get("fingerprint", "")[:12],
"released": v.get("created_at", "")[:10],
"size": _format_size(v.get("size", 0)),
"status": "current" if i == 0 else "active",
"sitesCount": 0,
"changelog": "",
}
)
latest_version = formatted_versions[0].get("version") if formatted_versions else "-"
size = formatted_versions[0].get("size") if formatted_versions else "-"
return {
"id": app_name,
"name": app_name,
"description": app_meta.get("description", f"Distrobuilder template: {app_name}"),
"status": app_meta.get("status", "active"),
"yaml_content": yaml_content,
"versions": formatted_versions,
"currentVersion": latest_version,
"size": size,
"sitesCount": 0,
"lastUpdated": formatted_versions[0].get("released") if formatted_versions else app_meta.get("created", ""),
}
@frappe.whitelist()
def save_app_config(app_name, yaml_content):
"""Save YAML configuration for an app."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
try:
yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
frappe.throw(f"Invalid YAML: {str(e)}")
with open(yaml_path, "w") as f:
f.write(yaml_content)
return {"success": True, "message": f"Configuration for '{app_name}' saved successfully"}
@frappe.whitelist()
def create_app(app_name, description, yaml_content):
"""Create a new app template."""
_check_admin_permission()
_ensure_templates_dir()
if not app_name or not all(c.isalnum() or c in "-_" for c in app_name):
frappe.throw("App name must be alphanumeric (dashes and underscores allowed)")
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' already exists")
try:
yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
frappe.throw(f"Invalid YAML: {str(e)}")
with open(yaml_path, "w") as f:
f.write(yaml_content)
meta = _load_meta()
meta[app_name] = {
"name": app_name,
"description": description or f"Distrobuilder template: {app_name}",
"status": "active",
"created": frappe.utils.today(),
}
_save_meta(meta)
return {"success": True, "message": f"App template '{app_name}' created successfully"}
@frappe.whitelist()
def delete_app(app_name):
"""Delete an app template."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
os.remove(yaml_path)
meta = _load_meta()
if app_name in meta:
del meta[app_name]
_save_meta(meta)
return {"success": True, "message": f"App template '{app_name}' deleted successfully"}
def _update_build(build_id, **kwargs):
"""Update build status in cache."""
cache_key = f"admin_panel:build:{build_id}"
current = frappe.cache.get_value(cache_key) or {}
current.update(kwargs)
frappe.cache.set_value(cache_key, current, expires_in_sec=3600)
def _append_build_log(build_id, *lines):
"""Append log lines to build status."""
cache_key = f"admin_panel:build:{build_id}"
current = frappe.cache.get_value(cache_key) or {}
logs = current.get("logs", [])
logs.extend(lines)
current["logs"] = logs
frappe.cache.set_value(cache_key, current, expires_in_sec=3600)
@frappe.whitelist()
def build_image(app_name, version, target_server=None, release="edge", doas_password=None):
"""Start image build in background. Returns build_id for polling status."""
_check_admin_permission()
template_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(template_path):
return {"success": False, "error": f"Template '{app_name}' not found"}
if not doas_password:
return {"success": False, "error": "Password is required for building images"}
# Determine target server
if not target_server:
image_server = _get_image_server()
if image_server:
target_server = image_server["name"]
else:
return {"success": False, "error": "No image server configured"}
server_url = _get_server_url(target_server)
if not server_url:
return {"success": False, "error": f"Server '{target_server}' URL not found"}
if not os.path.exists(CLIENT_CERT) or not os.path.exists(CLIENT_KEY):
return {"success": False, "error": "Panel certificate not configured"}
# Generate build ID
build_id = uuid.uuid4().hex[:8]
# Initialize build status in cache
frappe.cache.set_value(f"admin_panel:build:{build_id}", {
"status": "queued",
"logs": ["Build queued, starting..."],
"progress": 0,
"step": "queued",
}, expires_in_sec=3600)
# Enqueue background job
frappe.enqueue(
_build_image_job,
queue="long",
timeout=900,
build_id=build_id,
app_name=app_name,
version=version,
target_server=target_server,
server_url=server_url,
release=release,
doas_password=doas_password,
)
return {"success": True, "build_id": build_id}
@frappe.whitelist()
def get_build_status(build_id):
"""Poll build status by build_id."""
_check_admin_permission()
cache_key = f"admin_panel:build:{build_id}"
data = frappe.cache.get_value(cache_key)
if not data:
return {"status": "not_found", "logs": [], "progress": 0}
return data
def _build_image_job(build_id, app_name, version, target_server, server_url, release, doas_password):
"""Background job: build image via distrobuilder and import to Incus."""
build_dir = tempfile.mkdtemp(prefix=f"build-{app_name}-")
template_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
_update_build(build_id, status="running", step="preparing", progress=5)
_append_build_log(build_id,
"=== Build Configuration ===",
f"App: {app_name}",
f"Version: {version or 'latest'}",
f"Release: {release}",
f"Target Server: {target_server}",
f"Build directory: {build_dir}",
)
try:
build_template = os.path.join(build_dir, "template.yaml")
shutil.copy(template_path, build_template)
_append_build_log(build_id, "Template copied to build directory")
# --- Step 1: Build with distrobuilder ---
_update_build(build_id, step="building", progress=10)
_append_build_log(build_id, "", "=== Running distrobuilder ===", "This may take several minutes...")
shell_cmd = (
f"cd {build_dir} && "
f"distrobuilder build-incus "
f"-o image.architecture=amd64 "
f"-o image.release={release} "
f"template.yaml"
)
cmd = ["doas", "sh", "-c", shell_cmd]
# Stream output to cache in real-time
def on_build_output(line):
_append_build_log(build_id, line)
returncode, output = _run_doas_command(cmd, doas_password, timeout=600, on_output=on_build_output)
if returncode != 0:
_append_build_log(build_id, "✗ Build failed")
_update_build(build_id, status="failed", step="build_failed", progress=100,
error="distrobuilder build failed")
return
_append_build_log(build_id, "✓ Build completed successfully")
_update_build(build_id, progress=60)
# Check for output files
incus_tar = os.path.join(build_dir, "incus.tar.xz")
rootfs_squashfs = os.path.join(build_dir, "rootfs.squashfs")
if not os.path.exists(incus_tar):
_append_build_log(build_id, "✗ Output file not found: incus.tar.xz")
_update_build(build_id, status="failed", step="build_failed", progress=100,
error="Build output not found")
return
# --- Step 2: Import to Incus ---
_update_build(build_id, step="importing", progress=65)
alias = f"{app_name}:{version}" if version else app_name
_append_build_log(build_id, "", f"=== Importing to {target_server} via API ===")
if os.path.exists(rootfs_squashfs):
_append_build_log(build_id, "Importing split image (metadata + rootfs)")
with open(incus_tar, "rb") as meta_file, open(rootfs_squashfs, "rb") as rootfs_file:
headers = {"X-Incus-public": "false"}
files = {
"metadata": ("incus.tar.xz", meta_file, "application/octet-stream"),
"rootfs": ("rootfs.squashfs", rootfs_file, "application/octet-stream"),
}
r = requests.post(
f"{server_url}/1.0/images",
headers=headers,
files=files,
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=300,
)
else:
_append_build_log(build_id, "Importing unified image")
with open(incus_tar, "rb") as f:
headers = {
"Content-Type": "application/octet-stream",
"X-Incus-public": "false",
}
r = requests.post(
f"{server_url}/1.0/images",
headers=headers,
data=f,
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=300,
)
if r.status_code not in (200, 202):
error_msg = r.text
_append_build_log(build_id, f"✗ Import failed: {error_msg}")
_update_build(build_id, status="failed", step="import_failed", progress=100,
error=error_msg)
return
resp = r.json()
_update_build(build_id, progress=80)
# Wait for async operation if needed
op_url = ""
if resp.get("type") == "async":
op_url = resp.get("operation", "")
if op_url:
_append_build_log(build_id, "Waiting for import operation...")
wait_r = requests.get(
f"{server_url}{op_url}/wait",
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=300,
)
wait_resp = wait_r.json()
if wait_resp.get("metadata", {}).get("status") != "Success":
error = wait_resp.get("metadata", {}).get("err", "Unknown error")
_append_build_log(build_id, f"✗ Import operation failed: {error}")
_update_build(build_id, status="failed", step="import_failed", progress=100,
error=error)
return
_update_build(build_id, progress=90, step="setting_alias")
# Get fingerprint from response to set alias
fingerprint = ""
op_metadata = resp.get("metadata", {})
if resp.get("type") == "async" and op_url:
op_r = requests.get(
f"{server_url}{op_url}",
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=30,
)
op_resp = op_r.json()
op_meta = op_resp.get("metadata", {}).get("metadata", {})
fingerprint = op_meta.get("fingerprint", "")
else:
fingerprint = op_metadata.get("fingerprint", "")
# Set alias for the image
if fingerprint:
try:
_incus_api(server_url, "POST", "/1.0/images/aliases", {
"name": alias,
"target": fingerprint,
})
_append_build_log(build_id, f"✓ Alias '{alias}' set for image {fingerprint[:12]}")
except Exception as e:
try:
_incus_api(server_url, "PUT", f"/1.0/images/aliases/{alias}", {
"target": fingerprint,
})
_append_build_log(build_id, f"✓ Alias '{alias}' updated for image {fingerprint[:12]}")
except Exception:
_append_build_log(build_id, f"⚠ Could not set alias '{alias}': {str(e)}")
_append_build_log(build_id,
f"✓ Image imported as '{alias}'",
"",
"=== Build completed successfully! ===",
)
_update_build(build_id, status="completed", step="done", progress=100,
alias=alias, message=f"Image '{alias}' built and imported successfully")
except subprocess.TimeoutExpired:
_append_build_log(build_id, "✗ Build timed out (exceeded 10 minutes)")
_update_build(build_id, status="failed", step="timeout", progress=100,
error="Build timed out")
except Exception as e:
_append_build_log(build_id, f"✗ Error: {str(e)}")
_update_build(build_id, status="failed", step="error", progress=100,
error=str(e))
frappe.log_error(f"Build failed for {app_name}: {str(e)}", "Image Build Error")
finally:
try:
_run_doas_command(["doas", "rm", "-rf", build_dir], doas_password, timeout=30)
except Exception:
shutil.rmtree(build_dir, ignore_errors=True)
@frappe.whitelist()
def get_app_versions(app_name):
"""Get versions of an app from Incus images."""
_check_admin_permission()
image_server = _get_image_server()
if not image_server or not image_server.get("url"):
return []
all_images = _get_all_images(image_server["url"])
versions = _filter_images_by_prefix(all_images, app_name)
versions.sort(key=lambda x: x.get("created_at", ""), reverse=True)
formatted = []
for v in versions:
formatted.append(
{
"version": v.get("version", "latest"),
"alias": v.get("alias", ""),
"fingerprint": v.get("fingerprint", ""),
"created_at": v.get("created_at", ""),
"size": _format_size(v.get("size", 0)),
}
)
return formatted
@frappe.whitelist()
def delete_image(fingerprint, target_server=None):
"""Delete an image from Incus."""
_check_admin_permission()
if not target_server:
image_server = _get_image_server()
if image_server:
target_server = image_server["name"]
else:
return {"success": False, "error": "No image server configured"}
server_url = _get_server_url(target_server)
if not server_url:
return {"success": False, "error": f"Server '{target_server}' URL not found"}
try:
_incus_api(server_url, "DELETE", f"/1.0/images/{fingerprint}")
return {"success": True, "message": "Image deleted successfully"}
except Exception as e:
return {"success": False, "error": str(e)}
@frappe.whitelist()
def update_app_meta(app_name, description=None, status=None):
"""Update app metadata."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
meta = _load_meta()
if app_name not in meta:
meta[app_name] = {
"name": app_name,
"created": frappe.utils.today(),
}
if description is not None:
meta[app_name]["description"] = description
if status is not None:
meta[app_name]["status"] = status
_save_meta(meta)
return {"success": True, "message": f"Metadata for '{app_name}' updated successfully"}