diff --git a/AppImage/components/secure-gateway-setup.tsx b/AppImage/components/secure-gateway-setup.tsx new file mode 100644 index 00000000..044a95eb --- /dev/null +++ b/AppImage/components/secure-gateway-setup.tsx @@ -0,0 +1,922 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { Checkbox } from "./ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog" +import { + ShieldCheck, Globe, ExternalLink, Loader2, CheckCircle, XCircle, + Play, Square, RotateCw, Trash2, FileText, ChevronRight, ChevronDown, + AlertTriangle, Info, Network, Eye, EyeOff, Settings, Wifi, +} from "lucide-react" +import { fetchApi } from "../lib/api-config" + +interface NetworkInfo { + interface: string + type: string + address: string + subnet: string + prefixlen: number + recommended: boolean +} + +interface AppStatus { + state: "not_installed" | "running" | "stopped" | "error" + health: string + uptime_seconds: number + last_check: string +} + +interface ConfigSchema { + [key: string]: { + type: string + label: string + description: string + placeholder?: string + default?: any + required?: boolean + sensitive?: boolean + env_var?: string + help_url?: string + help_text?: string + options?: Array<{ value: string; label: string; description?: string }> + depends_on?: { field: string; values: string[] } + flag?: string + warning?: string + validation?: { pattern?: string; max_length?: number; message?: string } + } +} + +interface WizardStep { + id: string + title: string + description: string + fields?: string[] +} + +export function SecureGatewaySetup() { + // State + const [loading, setLoading] = useState(true) + const [runtimeAvailable, setRuntimeAvailable] = useState(false) + const [runtimeInfo, setRuntimeInfo] = useState<{ runtime: string; version: string } | null>(null) + const [appStatus, setAppStatus] = useState({ state: "not_installed", health: "unknown", uptime_seconds: 0, last_check: "" }) + const [configSchema, setConfigSchema] = useState(null) + const [wizardSteps, setWizardSteps] = useState([]) + const [networks, setNetworks] = useState([]) + + // Wizard state + const [showWizard, setShowWizard] = useState(false) + const [currentStep, setCurrentStep] = useState(0) + const [config, setConfig] = useState>({}) + const [deploying, setDeploying] = useState(false) + const [deployProgress, setDeployProgress] = useState("") + const [deployError, setDeployError] = useState("") + + // Installed state + const [actionLoading, setActionLoading] = useState(null) + const [showLogs, setShowLogs] = useState(false) + const [logs, setLogs] = useState("") + const [logsLoading, setLogsLoading] = useState(false) + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) + const [showAuthKey, setShowAuthKey] = useState(false) + + // Password visibility + const [visiblePasswords, setVisiblePasswords] = useState>(new Set()) + + useEffect(() => { + loadInitialData() + }, []) + + const loadInitialData = async () => { + setLoading(true) + try { + // Load runtime info + const runtimeRes = await fetchApi("/api/oci/runtime") + if (runtimeRes.success && runtimeRes.available) { + setRuntimeAvailable(true) + setRuntimeInfo({ runtime: runtimeRes.runtime, version: runtimeRes.version }) + } + + // Load app definition + const catalogRes = await fetchApi("/api/oci/catalog/secure-gateway") + if (catalogRes.success && catalogRes.app) { + setConfigSchema(catalogRes.app.config_schema || {}) + setWizardSteps(catalogRes.app.ui?.wizard_steps || []) + + // Set defaults + const defaults: Record = {} + for (const [key, field] of Object.entries(catalogRes.app.config_schema || {})) { + if (field.default !== undefined) { + defaults[key] = field.default + } + } + setConfig(defaults) + } + + // Load status + await loadStatus() + + // Load networks + const networksRes = await fetchApi("/api/oci/networks") + if (networksRes.success) { + setNetworks(networksRes.networks || []) + } + } catch (err) { + console.error("Failed to load data:", err) + } finally { + setLoading(false) + } + } + + const loadStatus = async () => { + try { + const statusRes = await fetchApi("/api/oci/status/secure-gateway") + if (statusRes.success) { + setAppStatus(statusRes.status) + } + } catch (err) { + // Not installed is ok + } + } + + const handleDeploy = async () => { + setDeploying(true) + setDeployError("") + setDeployProgress("Preparing deployment...") + + try { + // Validate required fields + const step = wizardSteps[currentStep] + if (step?.fields) { + for (const fieldName of step.fields) { + const field = configSchema?.[fieldName] + if (field?.required && !config[fieldName]) { + setDeployError(`${field.label} is required`) + setDeploying(false) + return + } + } + } + + setDeployProgress("Pulling container image...") + + const result = await fetchApi("/api/oci/deploy", { + method: "POST", + body: JSON.stringify({ + app_id: "secure-gateway", + config: config + }) + }) + + if (!result.success) { + setDeployError(result.message || "Deployment failed") + setDeploying(false) + return + } + + setDeployProgress("Gateway deployed successfully!") + + // Wait and reload status + setTimeout(async () => { + await loadStatus() + setShowWizard(false) + setDeploying(false) + setCurrentStep(0) + }, 2000) + + } catch (err: any) { + setDeployError(err.message || "Deployment failed") + setDeploying(false) + } + } + + const handleAction = async (action: "start" | "stop" | "restart") => { + setActionLoading(action) + try { + const result = await fetchApi(`/api/oci/installed/secure-gateway/${action}`, { + method: "POST" + }) + if (result.success) { + await loadStatus() + } + } catch (err) { + console.error(`Failed to ${action}:`, err) + } finally { + setActionLoading(null) + } + } + + const handleRemove = async () => { + setActionLoading("remove") + try { + const result = await fetchApi("/api/oci/installed/secure-gateway?remove_data=false", { + method: "DELETE" + }) + if (result.success) { + setAppStatus({ state: "not_installed", health: "unknown", uptime_seconds: 0, last_check: "" }) + setShowRemoveConfirm(false) + } + } catch (err) { + console.error("Failed to remove:", err) + } finally { + setActionLoading(null) + } + } + + const loadLogs = async () => { + setLogsLoading(true) + try { + const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100") + if (result.success) { + setLogs(result.logs || "No logs available") + } + } catch (err) { + setLogs("Failed to load logs") + } finally { + setLogsLoading(false) + } + } + + const formatUptime = (seconds: number): string => { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` + return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h` + } + + const renderField = (fieldName: string) => { + const field = configSchema?.[fieldName] + if (!field) return null + + // Check depends_on + if (field.depends_on) { + const depValue = config[field.depends_on.field] + if (!field.depends_on.values.includes(depValue)) { + return null + } + } + + const isVisible = visiblePasswords.has(fieldName) + + switch (field.type) { + case "password": + return ( +
+ +
+ setConfig({ ...config, [fieldName]: e.target.value })} + placeholder={field.placeholder} + className="pr-10 bg-background border-border" + /> + +
+

{field.description}

+ {field.help_url && ( + + {field.help_text || "Learn more"} + + )} +
+ ) + + case "text": + return ( +
+ + setConfig({ ...config, [fieldName]: e.target.value })} + placeholder={field.placeholder} + className="bg-background border-border" + /> +

{field.description}

+
+ ) + + case "select": + return ( +
+ +
+ {field.options?.map((opt) => ( +
setConfig({ ...config, [fieldName]: opt.value })} + className={`p-3 rounded-lg border cursor-pointer transition-colors ${ + config[fieldName] === opt.value + ? "border-cyan-500 bg-cyan-500/10" + : "border-border hover:border-muted-foreground/50" + }`} + > +
+
+ {config[fieldName] === opt.value && ( +
+ )} +
+
+

{opt.label}

+ {opt.description && ( +

{opt.description}

+ )} +
+
+
+ ))} +
+
+ ) + + case "networks": + return ( +
+ +

{field.description}

+
+ {networks.length === 0 ? ( +

+ No networks detected +

+ ) : ( + networks.map((net) => { + const selected = (config[fieldName] || []).includes(net.subnet) + return ( +
{ + const current = config[fieldName] || [] + const updated = selected + ? current.filter((s: string) => s !== net.subnet) + : [...current, net.subnet] + setConfig({ ...config, [fieldName]: updated }) + }} + className={`p-3 rounded-lg border cursor-pointer transition-colors flex items-center gap-3 ${ + selected + ? "border-cyan-500 bg-cyan-500/10" + : "border-border hover:border-muted-foreground/50" + }`} + > + +
+
+ + {net.subnet} + {net.recommended && ( + + Recommended + + )} +
+

+ {net.interface} ({net.type}) +

+
+
+ ) + }) + )} +
+
+ ) + + case "boolean": + return ( +
+
setConfig({ ...config, [fieldName]: !config[fieldName] })} + className={`p-3 rounded-lg border cursor-pointer transition-colors flex items-start gap-3 ${ + config[fieldName] + ? "border-cyan-500 bg-cyan-500/10" + : "border-border hover:border-muted-foreground/50" + }`} + > + +
+

{field.label}

+

{field.description}

+ {field.warning && config[fieldName] && ( +

+ + {field.warning} +

+ )} +
+
+
+ ) + + default: + return null + } + } + + const renderWizardContent = () => { + const step = wizardSteps[currentStep] + if (!step) return null + + if (step.id === "intro") { + return ( +
+
+
+ +
+
+
+

Secure Remote Access

+

+ Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports. +

+
+
+

What you{"'"}ll get:

+
    +
  • + + Access ProxMenux Monitor from anywhere +
  • +
  • + + Secure access to Proxmox web UI +
  • +
  • + + Optionally expose VMs and LXC containers +
  • +
  • + + End-to-end encryption +
  • +
  • + + No port forwarding required +
  • +
+
+
+

+ + You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "} + + tailscale.com + +

+
+
+ ) + } + + if (step.id === "deploy") { + return ( +
+
+

Review & Deploy

+

+ Review your configuration before deploying the gateway. +

+
+ +
+

Configuration Summary

+
+
+ Hostname: + {config.hostname || "proxmox-gateway"} +
+
+ Access Mode: + {config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"} +
+ {(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && ( +
+ Networks: + {config.advertise_routes.join(", ")} +
+ )} +
+ Exit Node: + {config.exit_node ? "Yes" : "No"} +
+
+ Accept Routes: + {config.accept_routes ? "Yes" : "No"} +
+
+
+ + {deploying && ( +
+
+ + {deployProgress} +
+
+ )} + + {deployError && ( +
+

+ + {deployError} +

+
+ )} +
+ ) + } + + // Regular step with fields + return ( +
+
+

{step.title}

+

{step.description}

+
+
+ {step.fields?.map((fieldName) => renderField(fieldName))} +
+
+ ) + } + + // Loading state + if (loading) { + return ( + + +
+ + Secure Gateway +
+
+ +
+ +
+
+
+ ) + } + + // Runtime not available + if (!runtimeAvailable) { + return ( + + +
+ + Secure Gateway +
+ VPN access without opening ports +
+ +
+
+ +
+

Container Runtime Required

+

+ Install Podman or Docker to use OCI applications. +

+ + apt install podman + +
+
+
+
+
+ ) + } + + // Installed state + if (appStatus.state !== "not_installed") { + const isRunning = appStatus.state === "running" + const isStopped = appStatus.state === "stopped" + const isError = appStatus.state === "error" + + return ( + <> + + +
+
+ + Secure Gateway +
+
+ {isRunning ? : + isStopped ? : + } + {isRunning ? "Connected" : isStopped ? "Stopped" : "Error"} +
+
+ Tailscale VPN Gateway +
+ + {/* Status info */} + {isRunning && appStatus.uptime_seconds > 0 && ( +
+ Uptime: {formatUptime(appStatus.uptime_seconds)} +
+ )} + + {/* Actions */} +
+ {isStopped && ( + + )} + {isRunning && ( + <> + + + + )} + + +
+ + {/* Tailscale admin link */} + +
+
+ + {/* Logs Dialog */} + + + + Secure Gateway Logs + Recent container logs + +
+ {logsLoading ? ( +
+ +
+ ) : ( +
+                  {logs || "No logs available"}
+                
+ )} +
+
+ +
+
+
+ + {/* Remove Confirm Dialog */} + + + + Remove Secure Gateway? + + This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment. + + +
+ + +
+
+
+ + ) + } + + // Not installed state + return ( + <> + + +
+ + Secure Gateway +
+ VPN access without opening ports +
+ +

+ Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required. +

+ +
+ + {runtimeInfo?.runtime} {runtimeInfo?.version} available +
+ + +
+
+ + {/* Wizard Dialog */} + { + if (!deploying) { + setShowWizard(open) + if (!open) { + setCurrentStep(0) + setDeployError("") + } + } + }}> + + + + + Secure Gateway Setup + + + + {/* Progress indicator */} +
+ {wizardSteps.map((step, idx) => ( +
+ ))} +
+ + {renderWizardContent()} + + {/* Navigation */} +
+ + + {currentStep < wizardSteps.length - 1 ? ( + + ) : ( + + )} +
+ +
+ + ) +} diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index 0665829b..3282c2cd 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -15,6 +15,7 @@ import { import { getApiUrl, fetchApi } from "../lib/api-config" import { TwoFactorSetup } from "./two-factor-setup" import { ScriptTerminalModal } from "./script-terminal-modal" +import { SecureGatewaySetup } from "./secure-gateway-setup" interface ApiTokenEntry { id: string @@ -2946,6 +2947,9 @@ ${(report.sections && report.sections.length > 0) ? ` + {/* Secure Gateway */} + + {/* Fail2Ban */} diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 3b5f3090..6ace6c92 100644 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -96,6 +96,8 @@ cp "$SCRIPT_DIR/notification_channels.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech cp "$SCRIPT_DIR/notification_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_templates.py not found" cp "$SCRIPT_DIR/notification_events.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_events.py not found" cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found" +cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found" +cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found" echo "📋 Adding translation support..." cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF' diff --git a/AppImage/scripts/flask_oci_routes.py b/AppImage/scripts/flask_oci_routes.py new file mode 100644 index 00000000..a814ad8f --- /dev/null +++ b/AppImage/scripts/flask_oci_routes.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ProxMenux OCI Routes + +REST API endpoints for OCI container app management. +""" + +import logging +from flask import Blueprint, jsonify, request + +import oci_manager +from jwt_middleware import require_auth + +# Logging +logger = logging.getLogger("proxmenux.oci.routes") + +# Blueprint +oci_bp = Blueprint("oci", __name__, url_prefix="/api/oci") + + +# ================================================================= +# Catalog Endpoints +# ================================================================= + +@oci_bp.route("/catalog", methods=["GET"]) +@require_auth +def get_catalog(): + """ + List all available apps from the catalog. + + Returns: + List of apps with basic info and installation status. + """ + try: + apps = oci_manager.list_available_apps() + return jsonify({ + "success": True, + "apps": apps + }) + except Exception as e: + logger.error(f"Failed to get catalog: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/catalog/", methods=["GET"]) +@require_auth +def get_app_definition(app_id: str): + """ + Get the full definition for a specific app. + + Args: + app_id: The app identifier + + Returns: + Full app definition including config schema. + """ + try: + app_def = oci_manager.get_app_definition(app_id) + + if not app_def: + return jsonify({ + "success": False, + "message": f"App '{app_id}' not found in catalog" + }), 404 + + return jsonify({ + "success": True, + "app": app_def, + "installed": oci_manager.is_installed(app_id) + }) + except Exception as e: + logger.error(f"Failed to get app definition: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/catalog//schema", methods=["GET"]) +@require_auth +def get_app_schema(app_id: str): + """ + Get only the config schema for an app. + + Args: + app_id: The app identifier + + Returns: + Config schema for building dynamic forms. + """ + try: + app_def = oci_manager.get_app_definition(app_id) + + if not app_def: + return jsonify({ + "success": False, + "message": f"App '{app_id}' not found in catalog" + }), 404 + + return jsonify({ + "success": True, + "app_id": app_id, + "name": app_def.get("name", app_id), + "schema": app_def.get("config_schema", {}) + }) + except Exception as e: + logger.error(f"Failed to get app schema: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +# ================================================================= +# Installed Apps Endpoints +# ================================================================= + +@oci_bp.route("/installed", methods=["GET"]) +@require_auth +def list_installed(): + """ + List all installed apps with their current status. + + Returns: + List of installed apps with status info. + """ + try: + apps = oci_manager.list_installed_apps() + return jsonify({ + "success": True, + "instances": apps + }) + except Exception as e: + logger.error(f"Failed to list installed apps: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/installed/", methods=["GET"]) +@require_auth +def get_installed_app(app_id: str): + """ + Get details of an installed app including current status. + + Args: + app_id: The app identifier + + Returns: + Installed app details with container info and status. + """ + try: + app = oci_manager.get_installed_app(app_id) + + if not app: + return jsonify({ + "success": False, + "message": f"App '{app_id}' is not installed" + }), 404 + + return jsonify({ + "success": True, + "instance": app + }) + except Exception as e: + logger.error(f"Failed to get installed app: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/installed//logs", methods=["GET"]) +@require_auth +def get_app_logs(app_id: str): + """ + Get recent logs from an app's container. + + Args: + app_id: The app identifier + + Query params: + lines: Number of lines to return (default 100) + + Returns: + Container logs. + """ + try: + lines = request.args.get("lines", 100, type=int) + result = oci_manager.get_app_logs(app_id, lines=lines) + + if not result.get("success"): + return jsonify(result), 404 if "not installed" in result.get("message", "") else 500 + + return jsonify(result) + except Exception as e: + logger.error(f"Failed to get app logs: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +# ================================================================= +# Deployment Endpoint +# ================================================================= + +@oci_bp.route("/deploy", methods=["POST"]) +@require_auth +def deploy_app(): + """ + Deploy an OCI app with the given configuration. + + Body: + { + "app_id": "secure-gateway", + "config": { + "auth_key": "tskey-auth-xxx", + "hostname": "proxmox-gateway", + ... + } + } + + Returns: + Deployment result with container ID if successful. + """ + try: + data = request.get_json() + + if not data: + return jsonify({ + "success": False, + "message": "Request body is required" + }), 400 + + app_id = data.get("app_id") + config = data.get("config", {}) + + if not app_id: + return jsonify({ + "success": False, + "message": "app_id is required" + }), 400 + + result = oci_manager.deploy_app(app_id, config, installed_by="web") + + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + + except Exception as e: + logger.error(f"Failed to deploy app: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +# ================================================================= +# Lifecycle Action Endpoints +# ================================================================= + +@oci_bp.route("/installed//start", methods=["POST"]) +@require_auth +def start_app(app_id: str): + """Start an installed app's container.""" + try: + result = oci_manager.start_app(app_id) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + except Exception as e: + logger.error(f"Failed to start app: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/installed//stop", methods=["POST"]) +@require_auth +def stop_app(app_id: str): + """Stop an installed app's container.""" + try: + result = oci_manager.stop_app(app_id) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + except Exception as e: + logger.error(f"Failed to stop app: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/installed//restart", methods=["POST"]) +@require_auth +def restart_app(app_id: str): + """Restart an installed app's container.""" + try: + result = oci_manager.restart_app(app_id) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + except Exception as e: + logger.error(f"Failed to restart app: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/installed/", methods=["DELETE"]) +@require_auth +def remove_app(app_id: str): + """ + Remove an installed app. + + Query params: + remove_data: If true, also remove persistent data (default false) + """ + try: + remove_data = request.args.get("remove_data", "false").lower() == "true" + result = oci_manager.remove_app(app_id, remove_data=remove_data) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + except Exception as e: + logger.error(f"Failed to remove app: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +# ================================================================= +# Configuration Update Endpoint +# ================================================================= + +@oci_bp.route("/installed//config", methods=["PUT"]) +@require_auth +def update_app_config(app_id: str): + """ + Update an app's configuration and recreate the container. + + Body: + { + "config": { ... new config values ... } + } + """ + try: + data = request.get_json() + + if not data or "config" not in data: + return jsonify({ + "success": False, + "message": "config is required in request body" + }), 400 + + result = oci_manager.update_app_config(app_id, data["config"]) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + + except Exception as e: + logger.error(f"Failed to update app config: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +# ================================================================= +# Utility Endpoints +# ================================================================= + +@oci_bp.route("/networks", methods=["GET"]) +@require_auth +def get_networks(): + """ + Get available networks for VPN routing. + + Returns: + List of detected network interfaces with their subnets. + """ + try: + networks = oci_manager.detect_networks() + return jsonify({ + "success": True, + "networks": networks + }) + except Exception as e: + logger.error(f"Failed to detect networks: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/runtime", methods=["GET"]) +@require_auth +def get_runtime(): + """ + Get container runtime information. + + Returns: + Runtime type (podman/docker), version, and availability. + """ + try: + runtime_info = oci_manager.detect_runtime() + return jsonify({ + "success": True, + **runtime_info + }) + except Exception as e: + logger.error(f"Failed to detect runtime: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 + + +@oci_bp.route("/status/", methods=["GET"]) +@require_auth +def get_app_status(app_id: str): + """ + Get the current status of an app's container. + + Returns: + Container state, health, and uptime. + """ + try: + status = oci_manager.get_app_status(app_id) + return jsonify({ + "success": True, + "app_id": app_id, + "status": status + }) + except Exception as e: + logger.error(f"Failed to get app status: {e}") + return jsonify({ + "success": False, + "message": str(e) + }), 500 diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 90579fe4..1657e588 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -48,6 +48,7 @@ from flask_auth_routes import auth_bp # noqa: E402 from flask_proxmenux_routes import proxmenux_bp # noqa: E402 from flask_security_routes import security_bp # noqa: E402 from flask_notification_routes import notification_bp # noqa: E402 +from flask_oci_routes import oci_bp # noqa: E402 from notification_manager import notification_manager # noqa: E402 from jwt_middleware import require_auth # noqa: E402 import auth_manager # noqa: E402 @@ -124,6 +125,7 @@ app.register_blueprint(health_bp) app.register_blueprint(proxmenux_bp) app.register_blueprint(security_bp) app.register_blueprint(notification_bp) +app.register_blueprint(oci_bp) # Initialize terminal / WebSocket routes init_terminal_routes(app) diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index f56c2a1c..9d1ac43a 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -1125,6 +1125,40 @@ class HealthMonitor: except Exception: pass + # Check disk_observations for active (non-dismissed) warnings + # This ensures disks with persistent observations appear in Health Monitor + # even if the error is not currently in the logs + try: + all_observations = health_persistence.get_disk_observations() + for obs in all_observations: + device_name = obs.get('device_name', '').replace('/dev/', '') + if not device_name: + continue + severity = (obs.get('severity') or 'warning').upper() + if severity in ('WARNING', 'CRITICAL') and not obs.get('dismissed'): + # Add to issues if not already present + obs_reason = obs.get('raw_message', f'{device_name}: Disk observation recorded') + obs_key = f'/dev/{device_name}' + if obs_key not in storage_details: + issues.append(obs_reason) + storage_details[obs_key] = { + 'status': severity, + 'reason': obs_reason, + 'dismissable': True, + } + # Ensure disk is in disk_errors_by_device for consolidation + if device_name not in disk_errors_by_device: + disk_errors_by_device[device_name] = { + 'status': severity, + 'reason': obs_reason, + 'error_type': obs.get('error_type', 'disk_observation'), + 'serial': obs.get('serial', ''), + 'model': obs.get('model', ''), + 'dismissable': True, + } + except Exception: + pass + # Build checks dict from storage_details # We consolidate disk error entries (like /Dev/Sda) into physical disk entries # and only show disks with problems (not healthy ones). diff --git a/AppImage/scripts/oci_manager.py b/AppImage/scripts/oci_manager.py new file mode 100644 index 00000000..2d019338 --- /dev/null +++ b/AppImage/scripts/oci_manager.py @@ -0,0 +1,799 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ProxMenux OCI Manager + +Manages deployment and lifecycle of OCI container applications. +Supports both podman and docker runtimes. + +Usage: + - As library: import oci_manager; oci_manager.deploy_app(...) + - As CLI: python oci_manager.py deploy --app-id secure-gateway --config '{...}' +""" + +import json +import logging +import os +import shutil +import subprocess +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# Logging +logger = logging.getLogger("proxmenux.oci") + +# ================================================================= +# Paths +# ================================================================= +# Production paths - persistent data in /usr/local/share/proxmenux/oci +OCI_BASE_DIR = "/usr/local/share/proxmenux/oci" +CATALOG_FILE = os.path.join(OCI_BASE_DIR, "catalog.json") +INSTALLED_FILE = os.path.join(OCI_BASE_DIR, "installed.json") +INSTANCES_DIR = os.path.join(OCI_BASE_DIR, "instances") + +# Source catalog from Scripts (bundled with ProxMenux) +SCRIPTS_CATALOG = "/usr/local/share/proxmenux/scripts/oci/catalog.json" + +# For development/testing in v0 environment +DEV_SCRIPTS_CATALOG = os.path.join(os.path.dirname(__file__), "..", "..", "Scripts", "oci", "catalog.json") + + +def ensure_oci_directories(): + """ + Ensure OCI directories exist and catalog is available. + Called on first use to initialize the OCI environment. + """ + # Create base directories + os.makedirs(OCI_BASE_DIR, exist_ok=True) + os.makedirs(INSTANCES_DIR, exist_ok=True) + + # Copy catalog from Scripts if not present in OCI dir + if not os.path.exists(CATALOG_FILE): + # Try production path first + if os.path.exists(SCRIPTS_CATALOG): + shutil.copy2(SCRIPTS_CATALOG, CATALOG_FILE) + logger.info(f"Copied catalog from {SCRIPTS_CATALOG}") + # Try development path + elif os.path.exists(DEV_SCRIPTS_CATALOG): + shutil.copy2(DEV_SCRIPTS_CATALOG, CATALOG_FILE) + logger.info(f"Copied catalog from {DEV_SCRIPTS_CATALOG}") + + # Create empty installed.json if not present + if not os.path.exists(INSTALLED_FILE): + with open(INSTALLED_FILE, 'w') as f: + json.dump({"version": "1.0.0", "instances": {}}, f, indent=2) + logger.info(f"Created empty installed.json") + +# Container name prefix +CONTAINER_PREFIX = "proxmenux" + + +# ================================================================= +# Runtime Detection +# ================================================================= +def detect_runtime() -> Dict[str, Any]: + """ + Detect available container runtime (podman or docker). + Returns dict with runtime info. + """ + result = { + "available": False, + "runtime": None, + "version": None, + "path": None, + "error": None + } + + # Try podman first (preferred for Proxmox) + podman_path = shutil.which("podman") + if podman_path: + try: + proc = subprocess.run( + ["podman", "--version"], + capture_output=True, text=True, timeout=5 + ) + if proc.returncode == 0: + version = proc.stdout.strip().replace("podman version ", "") + result.update({ + "available": True, + "runtime": "podman", + "version": version, + "path": podman_path + }) + return result + except Exception as e: + logger.warning(f"Podman found but failed to get version: {e}") + + # Try docker as fallback + docker_path = shutil.which("docker") + if docker_path: + try: + proc = subprocess.run( + ["docker", "--version"], + capture_output=True, text=True, timeout=5 + ) + if proc.returncode == 0: + # Parse "Docker version 24.0.5, build abc123" + version = proc.stdout.strip() + if "version" in version.lower(): + version = version.split("version")[1].split(",")[0].strip() + result.update({ + "available": True, + "runtime": "docker", + "version": version, + "path": docker_path + }) + return result + except Exception as e: + logger.warning(f"Docker found but failed to get version: {e}") + + result["error"] = "No container runtime found. Install podman or docker." + return result + + +def _get_runtime() -> Optional[str]: + """Get the runtime command (podman or docker) or None if unavailable.""" + info = detect_runtime() + return info["runtime"] if info["available"] else None + + +def _run_container_cmd(args: List[str], timeout: int = 30) -> Tuple[int, str, str]: + """Run a container command with the detected runtime.""" + runtime = _get_runtime() + if not runtime: + return -1, "", "No container runtime available" + + cmd = [runtime] + args + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + except subprocess.TimeoutExpired: + return -1, "", "Command timed out" + except Exception as e: + return -1, "", str(e) + + +# ================================================================= +# Catalog Management +# ================================================================= +def load_catalog() -> Dict[str, Any]: + """Load the OCI app catalog.""" + # Ensure directories and files exist on first call + ensure_oci_directories() + + if not os.path.exists(CATALOG_FILE): + return {"version": "1.0.0", "apps": {}} + + try: + with open(CATALOG_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load catalog: {e}") + return {"version": "1.0.0", "apps": {}, "error": str(e)} + + +def get_app_definition(app_id: str) -> Optional[Dict[str, Any]]: + """Get the definition for a specific app.""" + catalog = load_catalog() + return catalog.get("apps", {}).get(app_id) + + +def list_available_apps() -> List[Dict[str, Any]]: + """List all available apps from the catalog.""" + catalog = load_catalog() + apps = [] + for app_id, app_def in catalog.get("apps", {}).items(): + apps.append({ + "id": app_id, + "name": app_def.get("name", app_id), + "short_name": app_def.get("short_name", app_def.get("name", app_id)), + "category": app_def.get("category", "uncategorized"), + "subcategory": app_def.get("subcategory", ""), + "icon": app_def.get("icon", "box"), + "color": app_def.get("color", "#6366F1"), + "summary": app_def.get("summary", ""), + "installed": is_installed(app_id) + }) + return apps + + +# ================================================================= +# Installed Apps Management +# ================================================================= +def _load_installed() -> Dict[str, Any]: + """Load the installed apps registry.""" + # Ensure directories exist + ensure_oci_directories() + + if not os.path.exists(INSTALLED_FILE): + return {"version": "1.0.0", "instances": {}} + + try: + with open(INSTALLED_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load installed registry: {e}") + return {"version": "1.0.0", "instances": {}} + + +def _save_installed(data: Dict[str, Any]) -> bool: + """Save the installed apps registry.""" + try: + os.makedirs(os.path.dirname(INSTALLED_FILE), exist_ok=True) + with open(INSTALLED_FILE, 'w') as f: + json.dump(data, f, indent=2) + return True + except Exception as e: + logger.error(f"Failed to save installed registry: {e}") + return False + + +def is_installed(app_id: str) -> bool: + """Check if an app is installed.""" + installed = _load_installed() + return app_id in installed.get("instances", {}) + + +def list_installed_apps() -> List[Dict[str, Any]]: + """List all installed apps with their status.""" + installed = _load_installed() + apps = [] + + for app_id, instance in installed.get("instances", {}).items(): + # Get current container status + status = get_app_status(app_id) + + apps.append({ + "id": app_id, + "instance_name": instance.get("instance_name", app_id), + "installed_at": instance.get("installed_at"), + "installed_by": instance.get("installed_by", "unknown"), + "container": instance.get("container", {}), + "status": status + }) + + return apps + + +def get_installed_app(app_id: str) -> Optional[Dict[str, Any]]: + """Get details of an installed app.""" + installed = _load_installed() + instance = installed.get("instances", {}).get(app_id) + + if not instance: + return None + + # Enrich with current status + instance["status"] = get_app_status(app_id) + + return instance + + +# ================================================================= +# Container Status +# ================================================================= +def get_app_status(app_id: str) -> Dict[str, Any]: + """Get the current status of an app's container.""" + container_name = f"{CONTAINER_PREFIX}-{app_id}" + + result = { + "state": "not_installed", + "health": "unknown", + "uptime_seconds": 0, + "last_check": datetime.now().isoformat() + } + + if not is_installed(app_id): + return result + + # Check container status + rc, out, _ = _run_container_cmd([ + "inspect", container_name, + "--format", "{{.State.Status}}|{{.State.Running}}|{{.State.StartedAt}}" + ]) + + if rc != 0: + result["state"] = "error" + result["health"] = "unhealthy" + return result + + try: + parts = out.split("|") + status = parts[0] if len(parts) > 0 else "unknown" + running = parts[1].lower() == "true" if len(parts) > 1 else False + started_at = parts[2] if len(parts) > 2 else "" + + result["state"] = "running" if running else status + result["health"] = "healthy" if running else "stopped" + + # Calculate uptime + if running and started_at: + try: + # Parse ISO timestamp + started = datetime.fromisoformat(started_at.replace("Z", "+00:00")) + result["uptime_seconds"] = int((datetime.now(started.tzinfo) - started).total_seconds()) + except: + pass + except Exception as e: + logger.error(f"Failed to parse container status: {e}") + result["state"] = "error" + + return result + + +# ================================================================= +# Network Detection +# ================================================================= +def detect_networks() -> List[Dict[str, Any]]: + """Detect available networks for VPN routing.""" + networks = [] + + # Excluded interface prefixes + excluded_prefixes = ('lo', 'docker', 'br-', 'veth', 'tailscale', 'wg', 'tun', 'tap') + + try: + # Use ip command to get interfaces and addresses + proc = subprocess.run( + ["ip", "-j", "addr", "show"], + capture_output=True, text=True, timeout=5 + ) + + if proc.returncode != 0: + return networks + + interfaces = json.loads(proc.stdout) + + for iface in interfaces: + name = iface.get("ifname", "") + + # Skip excluded interfaces + if any(name.startswith(p) for p in excluded_prefixes): + continue + + # Get IPv4 addresses + for addr_info in iface.get("addr_info", []): + if addr_info.get("family") != "inet": + continue + + local = addr_info.get("local", "") + prefixlen = addr_info.get("prefixlen", 24) + + if not local: + continue + + # Calculate network address + import ipaddress + try: + network = ipaddress.IPv4Network(f"{local}/{prefixlen}", strict=False) + + # Determine interface type + iface_type = "physical" + if name.startswith("vmbr"): + iface_type = "bridge" + elif name.startswith("bond"): + iface_type = "bond" + elif "." in name: + iface_type = "vlan" + + networks.append({ + "interface": name, + "type": iface_type, + "address": local, + "subnet": str(network), + "prefixlen": prefixlen, + "recommended": iface_type in ("bridge", "physical") + }) + except Exception: + pass + except Exception as e: + logger.error(f"Failed to detect networks: {e}") + + return networks + + +# ================================================================= +# Deployment +# ================================================================= +def deploy_app(app_id: str, config: Dict[str, Any], installed_by: str = "web") -> Dict[str, Any]: + """ + Deploy an OCI app with the given configuration. + + Args: + app_id: ID of the app from the catalog + config: User configuration values + installed_by: Source of installation ('web' or 'cli') + + Returns: + Dict with success status and details + """ + result = { + "success": False, + "message": "", + "app_id": app_id + } + + # Check runtime + runtime_info = detect_runtime() + if not runtime_info["available"]: + result["message"] = runtime_info.get("error", "No container runtime available") + return result + + runtime = runtime_info["runtime"] + + # Get app definition + app_def = get_app_definition(app_id) + if not app_def: + result["message"] = f"App '{app_id}' not found in catalog" + return result + + # Check if already installed + if is_installed(app_id): + result["message"] = f"App '{app_id}' is already installed" + return result + + container_name = f"{CONTAINER_PREFIX}-{app_id}" + container_def = app_def.get("container", {}) + image = container_def.get("image") + + if not image: + result["message"] = "App definition missing container image" + return result + + # Create instance directory + instance_dir = os.path.join(INSTANCES_DIR, app_id) + state_dir = os.path.join(instance_dir, "state") + + try: + os.makedirs(instance_dir, exist_ok=True) + os.makedirs(state_dir, exist_ok=True) + except Exception as e: + result["message"] = f"Failed to create instance directory: {e}" + return result + + # Save user config + config_file = os.path.join(instance_dir, "config.json") + try: + with open(config_file, 'w') as f: + json.dump({ + "app_id": app_id, + "created_at": datetime.now().isoformat(), + "values": config + }, f, indent=2) + except Exception as e: + result["message"] = f"Failed to save config: {e}" + return result + + # Build container run command + cmd = ["run", "-d", "--name", container_name] + + # Network mode + network_mode = container_def.get("network_mode") + if network_mode: + cmd.extend(["--network", network_mode]) + + # Restart policy + restart_policy = container_def.get("restart_policy", "unless-stopped") + cmd.extend(["--restart", restart_policy]) + + # Capabilities + for cap in container_def.get("capabilities", []): + cmd.extend(["--cap-add", cap]) + + # Devices + for device in container_def.get("devices", []): + cmd.extend(["--device", device]) + + # Volumes + for vol_name, vol_def in app_def.get("volumes", {}).items(): + container_path = vol_def.get("container_path", "") + if container_path: + host_path = os.path.join(state_dir, vol_name) + os.makedirs(host_path, exist_ok=True) + cmd.extend(["-v", f"{host_path}:{container_path}"]) + + # Static environment variables + for key, value in app_def.get("environment", {}).items(): + cmd.extend(["-e", f"{key}={value}"]) + + # Dynamic environment variables from config + config_schema = app_def.get("config_schema", {}) + for field_name, field_def in config_schema.items(): + env_var = field_def.get("env_var") + if not env_var: + continue + + value = config.get(field_name) + if value is None: + value = field_def.get("default", "") + + # Handle special formats + env_format = field_def.get("env_format") + if env_format == "csv" and isinstance(value, list): + value = ",".join(str(v) for v in value) + + if value: + cmd.extend(["-e", f"{env_var}={value}"]) + + # Build extra args from flags + extra_args = [] + for field_name, field_def in config_schema.items(): + flag = field_def.get("flag") + if not flag: + continue + + value = config.get(field_name) + if value is True: + extra_args.append(flag) + + # For Tailscale, set TS_EXTRA_ARGS + if extra_args and "tailscale" in image.lower(): + # Also add routes if specified + routes = config.get("advertise_routes", []) + if routes: + extra_args.append(f"--advertise-routes={','.join(routes)}") + + cmd.extend(["-e", f"TS_EXTRA_ARGS={' '.join(extra_args)}"]) + + # Add image + cmd.append(image) + + # Pull image first if needed + pull_policy = container_def.get("pull_policy", "if_not_present") + if pull_policy != "never": + logger.info(f"Pulling image: {image}") + pull_rc, _, pull_err = _run_container_cmd(["pull", image], timeout=300) + if pull_rc != 0 and pull_policy == "always": + result["message"] = f"Failed to pull image: {pull_err}" + return result + + # Run container + logger.info(f"Starting container: {container_name}") + rc, out, err = _run_container_cmd(cmd, timeout=60) + + if rc != 0: + result["message"] = f"Failed to start container: {err}" + # Cleanup on failure + _run_container_cmd(["rm", "-f", container_name]) + return result + + container_id = out[:12] if out else "" + + # Get image ID + img_rc, img_out, _ = _run_container_cmd(["inspect", image, "--format", "{{.Id}}"]) + image_id = img_out[:12] if img_rc == 0 and img_out else "" + + # Save to installed registry + installed = _load_installed() + installed["instances"][app_id] = { + "app_id": app_id, + "instance_name": app_id, + "installed_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "installed_by": installed_by, + "installed_version": app_def.get("version", "1.0.0"), + "container": { + "runtime": runtime, + "container_id": container_id, + "container_name": container_name, + "image_id": image_id, + "image_tag": image + }, + "paths": { + "config": config_file, + "runtime": os.path.join(instance_dir, "runtime.json"), + "state": state_dir + } + } + + if not _save_installed(installed): + result["message"] = "Container started but failed to save registry" + return result + + result["success"] = True + result["message"] = f"App '{app_id}' deployed successfully" + result["container_id"] = container_id + + return result + + +# ================================================================= +# Lifecycle Actions +# ================================================================= +def start_app(app_id: str) -> Dict[str, Any]: + """Start an installed app's container.""" + if not is_installed(app_id): + return {"success": False, "message": f"App '{app_id}' is not installed"} + + container_name = f"{CONTAINER_PREFIX}-{app_id}" + rc, _, err = _run_container_cmd(["start", container_name]) + + if rc != 0: + return {"success": False, "message": f"Failed to start: {err}"} + + return {"success": True, "message": f"App '{app_id}' started"} + + +def stop_app(app_id: str) -> Dict[str, Any]: + """Stop an installed app's container.""" + if not is_installed(app_id): + return {"success": False, "message": f"App '{app_id}' is not installed"} + + container_name = f"{CONTAINER_PREFIX}-{app_id}" + rc, _, err = _run_container_cmd(["stop", container_name], timeout=30) + + if rc != 0: + return {"success": False, "message": f"Failed to stop: {err}"} + + return {"success": True, "message": f"App '{app_id}' stopped"} + + +def restart_app(app_id: str) -> Dict[str, Any]: + """Restart an installed app's container.""" + if not is_installed(app_id): + return {"success": False, "message": f"App '{app_id}' is not installed"} + + container_name = f"{CONTAINER_PREFIX}-{app_id}" + rc, _, err = _run_container_cmd(["restart", container_name], timeout=60) + + if rc != 0: + return {"success": False, "message": f"Failed to restart: {err}"} + + return {"success": True, "message": f"App '{app_id}' restarted"} + + +def remove_app(app_id: str, remove_data: bool = False) -> Dict[str, Any]: + """Remove an installed app.""" + if not is_installed(app_id): + return {"success": False, "message": f"App '{app_id}' is not installed"} + + container_name = f"{CONTAINER_PREFIX}-{app_id}" + + # Stop and remove container + _run_container_cmd(["stop", container_name], timeout=30) + rc, _, err = _run_container_cmd(["rm", "-f", container_name]) + + if rc != 0: + return {"success": False, "message": f"Failed to remove container: {err}"} + + # Remove from registry + installed = _load_installed() + if app_id in installed.get("instances", {}): + del installed["instances"][app_id] + _save_installed(installed) + + # Optionally remove data + if remove_data: + instance_dir = os.path.join(INSTANCES_DIR, app_id) + if os.path.exists(instance_dir): + shutil.rmtree(instance_dir, ignore_errors=True) + + return {"success": True, "message": f"App '{app_id}' removed"} + + +# ================================================================= +# Logs +# ================================================================= +def get_app_logs(app_id: str, lines: int = 100) -> Dict[str, Any]: + """Get recent logs from an app's container.""" + if not is_installed(app_id): + return {"success": False, "logs": "", "message": "App not installed"} + + container_name = f"{CONTAINER_PREFIX}-{app_id}" + rc, out, err = _run_container_cmd(["logs", "--tail", str(lines), container_name], timeout=10) + + if rc != 0: + return {"success": False, "logs": "", "message": f"Failed to get logs: {err}"} + + # Combine stdout and stderr (logs go to both) + logs = out if out else err + + return {"success": True, "logs": logs} + + +# ================================================================= +# Configuration Update +# ================================================================= +def update_app_config(app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: + """Update an app's configuration and recreate the container.""" + if not is_installed(app_id): + return {"success": False, "message": f"App '{app_id}' is not installed"} + + # Get current installation info + installed = _load_installed() + instance = installed.get("instances", {}).get(app_id, {}) + installed_by = instance.get("installed_by", "web") + + # Remove the app (but keep data) + remove_result = remove_app(app_id, remove_data=False) + if not remove_result["success"]: + return remove_result + + # Redeploy with new config + return deploy_app(app_id, config, installed_by=installed_by) + + +# ================================================================= +# CLI Interface +# ================================================================= +def main(): + """CLI entry point for use from bash scripts.""" + import argparse + + parser = argparse.ArgumentParser(description="ProxMenux OCI Manager") + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # deploy + deploy_parser = subparsers.add_parser("deploy", help="Deploy an app") + deploy_parser.add_argument("--app-id", required=True, help="App ID from catalog") + deploy_parser.add_argument("--config", required=True, help="JSON config string") + deploy_parser.add_argument("--source", default="cli", help="Installation source") + + # start + start_parser = subparsers.add_parser("start", help="Start an app") + start_parser.add_argument("--app-id", required=True) + + # stop + stop_parser = subparsers.add_parser("stop", help="Stop an app") + stop_parser.add_argument("--app-id", required=True) + + # restart + restart_parser = subparsers.add_parser("restart", help="Restart an app") + restart_parser.add_argument("--app-id", required=True) + + # remove + remove_parser = subparsers.add_parser("remove", help="Remove an app") + remove_parser.add_argument("--app-id", required=True) + remove_parser.add_argument("--remove-data", action="store_true") + + # status + status_parser = subparsers.add_parser("status", help="Get app status") + status_parser.add_argument("--app-id", required=True) + + # list + subparsers.add_parser("list", help="List installed apps") + + # catalog + subparsers.add_parser("catalog", help="List available apps") + + # networks + subparsers.add_parser("networks", help="Detect available networks") + + # runtime + subparsers.add_parser("runtime", help="Detect container runtime") + + args = parser.parse_args() + + if args.command == "deploy": + config = json.loads(args.config) + result = deploy_app(args.app_id, config, installed_by=args.source) + elif args.command == "start": + result = start_app(args.app_id) + elif args.command == "stop": + result = stop_app(args.app_id) + elif args.command == "restart": + result = restart_app(args.app_id) + elif args.command == "remove": + result = remove_app(args.app_id, remove_data=args.remove_data) + elif args.command == "status": + result = get_app_status(args.app_id) + elif args.command == "list": + result = list_installed_apps() + elif args.command == "catalog": + result = list_available_apps() + elif args.command == "networks": + result = detect_networks() + elif args.command == "runtime": + result = detect_runtime() + else: + parser.print_help() + return + + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main()