mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-25 08:56:21 +00:00
Create oci manager
This commit is contained in:
922
AppImage/components/secure-gateway-setup.tsx
Normal file
922
AppImage/components/secure-gateway-setup.tsx
Normal file
@@ -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<AppStatus>({ state: "not_installed", health: "unknown", uptime_seconds: 0, last_check: "" })
|
||||||
|
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
|
||||||
|
const [wizardSteps, setWizardSteps] = useState<WizardStep[]>([])
|
||||||
|
const [networks, setNetworks] = useState<NetworkInfo[]>([])
|
||||||
|
|
||||||
|
// Wizard state
|
||||||
|
const [showWizard, setShowWizard] = useState(false)
|
||||||
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
|
const [config, setConfig] = useState<Record<string, any>>({})
|
||||||
|
const [deploying, setDeploying] = useState(false)
|
||||||
|
const [deployProgress, setDeployProgress] = useState("")
|
||||||
|
const [deployError, setDeployError] = useState("")
|
||||||
|
|
||||||
|
// Installed state
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(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<Set<string>>(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<string, any> = {}
|
||||||
|
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 (
|
||||||
|
<div key={fieldName} className="space-y-2">
|
||||||
|
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id={fieldName}
|
||||||
|
type={isVisible ? "text" : "password"}
|
||||||
|
value={config[fieldName] || ""}
|
||||||
|
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="pr-10 bg-background border-border"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newSet = new Set(visiblePasswords)
|
||||||
|
if (isVisible) newSet.delete(fieldName)
|
||||||
|
else newSet.add(fieldName)
|
||||||
|
setVisiblePasswords(newSet)
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
{field.help_url && (
|
||||||
|
<a
|
||||||
|
href={field.help_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{field.help_text || "Learn more"} <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<div key={fieldName} className="space-y-2">
|
||||||
|
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldName}
|
||||||
|
type="text"
|
||||||
|
value={config[fieldName] || ""}
|
||||||
|
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="bg-background border-border"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<div key={fieldName} className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{field.options?.map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
config[fieldName] === opt.value ? "border-cyan-500" : "border-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{config[fieldName] === opt.value && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-cyan-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{opt.label}</p>
|
||||||
|
{opt.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{opt.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "networks":
|
||||||
|
return (
|
||||||
|
<div key={fieldName} className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{networks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
|
||||||
|
No networks detected
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
networks.map((net) => {
|
||||||
|
const selected = (config[fieldName] || []).includes(net.subnet)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={net.subnet}
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox checked={selected} className="pointer-events-none" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Network className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-mono text-sm">{net.subnet}</span>
|
||||||
|
{net.recommended && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{net.interface} ({net.type})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
return (
|
||||||
|
<div key={fieldName} className="space-y-2">
|
||||||
|
<div
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{field.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
{field.warning && config[fieldName] && (
|
||||||
|
<p className="text-xs text-yellow-500 mt-1 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{field.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderWizardContent = () => {
|
||||||
|
const step = wizardSteps[currentStep]
|
||||||
|
if (!step) return null
|
||||||
|
|
||||||
|
if (step.id === "intro") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-cyan-500/10 flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-10 w-10 text-cyan-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Secure Remote Access</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||||
|
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">What you{"'"}ll get:</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
|
Access ProxMenux Monitor from anywhere
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
|
Secure access to Proxmox web UI
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
|
Optionally expose VMs and LXC containers
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
|
End-to-end encryption
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
|
No port forwarding required
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-yellow-500 flex items-start gap-2">
|
||||||
|
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
|
You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "}
|
||||||
|
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline">
|
||||||
|
tailscale.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.id === "deploy") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Review & Deploy</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Review your configuration before deploying the gateway.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">Configuration Summary</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Hostname:</span>
|
||||||
|
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Access Mode:</span>
|
||||||
|
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span>
|
||||||
|
</div>
|
||||||
|
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Networks:</span>
|
||||||
|
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Exit Node:</span>
|
||||||
|
<span>{config.exit_node ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Accept Routes:</span>
|
||||||
|
<span>{config.accept_routes ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deploying && (
|
||||||
|
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="h-5 w-5 text-cyan-500 animate-spin" />
|
||||||
|
<span className="text-sm">{deployProgress}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deployError && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-500 flex items-center gap-2">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
{deployError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular step with fields
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">{step.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{step.fields?.map((fieldName) => renderField(fieldName))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border bg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
|
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime not available
|
||||||
|
if (!runtimeAvailable) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border bg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
|
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>VPN access without opening ports</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-yellow-500">Container Runtime Required</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Install Podman or Docker to use OCI applications.
|
||||||
|
</p>
|
||||||
|
<code className="text-xs mt-2 block bg-muted/50 p-2 rounded">
|
||||||
|
apt install podman
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed state
|
||||||
|
if (appStatus.state !== "not_installed") {
|
||||||
|
const isRunning = appStatus.state === "running"
|
||||||
|
const isStopped = appStatus.state === "stopped"
|
||||||
|
const isError = appStatus.state === "error"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="border-border bg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
|
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
isRunning ? "bg-green-500/10 text-green-500" :
|
||||||
|
isStopped ? "bg-yellow-500/10 text-yellow-500" :
|
||||||
|
"bg-red-500/10 text-red-500"
|
||||||
|
}`}>
|
||||||
|
{isRunning ? <Wifi className="h-3 w-3" /> :
|
||||||
|
isStopped ? <Square className="h-3 w-3" /> :
|
||||||
|
<XCircle className="h-3 w-3" />}
|
||||||
|
{isRunning ? "Connected" : isStopped ? "Stopped" : "Error"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Tailscale VPN Gateway</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Status info */}
|
||||||
|
{isRunning && appStatus.uptime_seconds > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Uptime: {formatUptime(appStatus.uptime_seconds)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{isStopped && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction("start")}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{actionLoading === "start" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isRunning && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction("stop")}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
>
|
||||||
|
{actionLoading === "stop" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAction("restart")}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
>
|
||||||
|
{actionLoading === "restart" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowLogs(true)
|
||||||
|
loadLogs()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
|
Logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
onClick={() => setShowRemoveConfirm(true)}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tailscale admin link */}
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<a
|
||||||
|
href="https://login.tailscale.com/admin/machines"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Open Tailscale Admin <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Logs Dialog */}
|
||||||
|
<Dialog open={showLogs} onOpenChange={setShowLogs}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Secure Gateway Logs</DialogTitle>
|
||||||
|
<DialogDescription>Recent container logs</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
|
||||||
|
{logsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
|
||||||
|
{logs || "No logs available"}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadLogs}>
|
||||||
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Remove Confirm Dialog */}
|
||||||
|
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove Secure Gateway?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={actionLoading === "remove"}
|
||||||
|
>
|
||||||
|
{actionLoading === "remove" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not installed state
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="border-border bg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
|
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>VPN access without opening ports</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
<span>{runtimeInfo?.runtime} {runtimeInfo?.version} available</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
className="w-full bg-cyan-600 hover:bg-cyan-700"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||||
|
Deploy Secure Gateway
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Wizard Dialog */}
|
||||||
|
<Dialog open={showWizard} onOpenChange={(open) => {
|
||||||
|
if (!deploying) {
|
||||||
|
setShowWizard(open)
|
||||||
|
if (!open) {
|
||||||
|
setCurrentStep(0)
|
||||||
|
setDeployError("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
|
Secure Gateway Setup
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="flex items-center gap-1 mb-4">
|
||||||
|
{wizardSteps.map((step, idx) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={`flex-1 h-1 rounded-full transition-colors ${
|
||||||
|
idx < currentStep ? "bg-cyan-500" :
|
||||||
|
idx === currentStep ? "bg-cyan-500" :
|
||||||
|
"bg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderWizardContent()}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between pt-4 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentStep(Math.max(0, currentStep - 1))}
|
||||||
|
disabled={currentStep === 0 || deploying}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentStep < wizardSteps.length - 1 ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentStep(currentStep + 1)}
|
||||||
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleDeploy}
|
||||||
|
disabled={deploying}
|
||||||
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
|
>
|
||||||
|
{deploying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Deploying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Deploy Gateway
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||||
import { TwoFactorSetup } from "./two-factor-setup"
|
import { TwoFactorSetup } from "./two-factor-setup"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
|
import { SecureGatewaySetup } from "./secure-gateway-setup"
|
||||||
|
|
||||||
interface ApiTokenEntry {
|
interface ApiTokenEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -2946,6 +2947,9 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Secure Gateway */}
|
||||||
|
<SecureGatewaySetup />
|
||||||
|
|
||||||
{/* Fail2Ban */}
|
{/* Fail2Ban */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -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_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/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/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..."
|
echo "📋 Adding translation support..."
|
||||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||||
|
|||||||
444
AppImage/scripts/flask_oci_routes.py
Normal file
444
AppImage/scripts/flask_oci_routes.py
Normal file
@@ -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/<app_id>", 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/<app_id>/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/<app_id>", 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/<app_id>/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/<app_id>/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/<app_id>/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/<app_id>/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/<app_id>", 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/<app_id>/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/<app_id>", 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
|
||||||
@@ -48,6 +48,7 @@ from flask_auth_routes import auth_bp # noqa: E402
|
|||||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||||
from flask_security_routes import security_bp # noqa: E402
|
from flask_security_routes import security_bp # noqa: E402
|
||||||
from flask_notification_routes import notification_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 notification_manager import notification_manager # noqa: E402
|
||||||
from jwt_middleware import require_auth # noqa: E402
|
from jwt_middleware import require_auth # noqa: E402
|
||||||
import auth_manager # noqa: E402
|
import auth_manager # noqa: E402
|
||||||
@@ -124,6 +125,7 @@ app.register_blueprint(health_bp)
|
|||||||
app.register_blueprint(proxmenux_bp)
|
app.register_blueprint(proxmenux_bp)
|
||||||
app.register_blueprint(security_bp)
|
app.register_blueprint(security_bp)
|
||||||
app.register_blueprint(notification_bp)
|
app.register_blueprint(notification_bp)
|
||||||
|
app.register_blueprint(oci_bp)
|
||||||
|
|
||||||
# Initialize terminal / WebSocket routes
|
# Initialize terminal / WebSocket routes
|
||||||
init_terminal_routes(app)
|
init_terminal_routes(app)
|
||||||
|
|||||||
@@ -1125,6 +1125,40 @@ class HealthMonitor:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Build checks dict from storage_details
|
||||||
# We consolidate disk error entries (like /Dev/Sda) into physical disk entries
|
# We consolidate disk error entries (like /Dev/Sda) into physical disk entries
|
||||||
# and only show disks with problems (not healthy ones).
|
# and only show disks with problems (not healthy ones).
|
||||||
|
|||||||
799
AppImage/scripts/oci_manager.py
Normal file
799
AppImage/scripts/oci_manager.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user