685 lines
20 KiB
Python
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"}
|