Enhance panel-api with compose functionality and update podman integration

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jakub Dorfman 2026-04-26 19:48:35 +02:00
parent 8986399a92
commit ce60fc8f5c
3 changed files with 156 additions and 4 deletions

2
API.md
View file

@ -13,7 +13,9 @@ Default bind:
- GET /
- GET /apps
- GET /apps/<name>
- GET /apps/<name>/compose
- POST /apps/init
- POST /apps/<name>/compose
- POST /apps/<name>/render-route
- POST /apps/<name>/deploy
- POST /apps/<name>/stop

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3
import json
import os
import re
import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
@ -8,6 +9,7 @@ from urllib.parse import urlparse
PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
PORT = int(os.environ.get("PANEL_API_PORT", "9911"))
BASE_DIR = os.environ.get("PANEL_BASE_DIR", "/home/reudy/containers")
INDEX_HTML = """<!doctype html>
<html lang="en">
@ -173,6 +175,21 @@ INDEX_HTML = """<!doctype html>
.status.ok { border-color: #1b7a39; }
.status.err { border-color: var(--warn); }
.editor-wrap {
margin-top: 14px;
}
textarea {
width: 100%;
min-height: 260px;
border: 2px solid var(--ink);
border-radius: 8px;
padding: 10px;
font: 0.88rem/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: #fff;
resize: vertical;
}
.fade-in {
animation: appear 280ms ease;
}
@ -229,6 +246,15 @@ INDEX_HTML = """<!doctype html>
</thead>
<tbody id="appsBody"></tbody>
</table>
<div class="editor-wrap">
<h2 style="margin-top: 12px;">Compose Editor</h2>
<div class="stack" style="margin-bottom: 8px;">
<div class="mono" id="composeTarget">No app selected.</div>
<button id="saveComposeBtn">Save Compose</button>
</div>
<textarea id="composeText" placeholder="Select an app and click Compose to load compose.yaml"></textarea>
</div>
</article>
</section>
@ -238,6 +264,9 @@ INDEX_HTML = """<!doctype html>
<script>
const statusEl = document.getElementById("status");
const appsBody = document.getElementById("appsBody");
const composeText = document.getElementById("composeText");
const composeTarget = document.getElementById("composeTarget");
let selectedComposeApp = null;
function setStatus(msg, isError = false) {
statusEl.textContent = msg;
@ -276,6 +305,34 @@ INDEX_HTML = """<!doctype html>
} catch (err) {
setStatus(`${action} failed for ${name}: ${err.message}`, true);
}
async function loadCompose(name) {
try {
setStatus(`Loading compose for ${name}...`);
const data = await api(`/apps/${name}/compose`);
composeText.value = data.content;
selectedComposeApp = name;
composeTarget.textContent = `Editing: ${name}`;
setStatus(`Compose loaded for ${name}.`);
} catch (err) {
setStatus(`Failed to load compose: ${err.message}`, true);
}
}
async function saveCompose() {
if (!selectedComposeApp) {
setStatus("No app selected for compose editing.", true);
return;
}
try {
setStatus(`Saving compose for ${selectedComposeApp}...`);
await api(`/apps/${selectedComposeApp}/compose`, "POST", { content: composeText.value });
setStatus(`Compose saved for ${selectedComposeApp}.`);
} catch (err) {
setStatus(`Failed to save compose: ${err.message}`, true);
}
}
}
function rowForApp(app) {
@ -286,6 +343,7 @@ INDEX_HTML = """<!doctype html>
actions.className = "stack";
actions.appendChild(actionButton("Deploy", () => runAction(app.name, "deploy")));
actions.appendChild(actionButton("Stop", () => runAction(app.name, "stop")));
actions.appendChild(actionButton("Compose", () => loadCompose(app.name)));
actions.appendChild(actionButton("Route", () => runAction(app.name, "render-route")));
actions.appendChild(actionButton("Remove", async () => {
const keep = window.confirm("Keep volumes? Press OK to keep, Cancel to delete.");
@ -341,6 +399,7 @@ INDEX_HTML = """<!doctype html>
document.getElementById("createBtn").addEventListener("click", createApp);
document.getElementById("refreshBtn").addEventListener("click", loadApps);
document.getElementById("saveComposeBtn").addEventListener("click", saveCompose);
loadApps();
</script>
</body>
@ -348,6 +407,32 @@ INDEX_HTML = """<!doctype html>
"""
def is_safe_name(name):
return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None
def read_app_info(name):
if not is_safe_name(name):
return None, {"ok": False, "error": "invalid app name"}
result = run_panelctl(["show", name])
if not result["ok"]:
return None, result
app = parse_env_blob(result["stdout"])
compose_file = app.get("APP_COMPOSE_FILE", "")
if not compose_file:
return None, {"ok": False, "error": "missing APP_COMPOSE_FILE in manifest"}
base_stacks = os.path.join(BASE_DIR, "stacks") + os.sep
norm_compose = os.path.abspath(compose_file)
if not norm_compose.startswith(base_stacks):
return None, {"ok": False, "error": "compose path is outside allowed base directory"}
app["APP_COMPOSE_FILE"] = norm_compose
return app, None
def run_panelctl(args):
proc = subprocess.run(
[PANELCTL, *args],
@ -438,6 +523,28 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "apps": apps})
return
if path.startswith("/apps/") and path.endswith("/compose"):
parts = [p for p in path.split("/") if p]
if len(parts) != 3:
self._json(404, {"ok": False, "error": "not found"})
return
_, name, _ = parts
app, err = read_app_info(name)
if err is not None:
self._json(404, err)
return
try:
with open(app["APP_COMPOSE_FILE"], "r", encoding="utf-8") as fh:
content = fh.read()
except OSError as exc:
self._json(500, {"ok": False, "error": f"failed to read compose file: {exc}"})
return
self._json(200, {"ok": True, "name": name, "content": content})
return
if path.startswith("/apps/"):
name = path.split("/")[-1]
if not name:
@ -476,6 +583,34 @@ class Handler(BaseHTTPRequestHandler):
action_prefix = "/apps/"
if path.startswith(action_prefix):
parts = [p for p in path.split("/") if p]
if len(parts) == 3 and parts[2] == "compose":
_, name, _ = parts
app, err = read_app_info(name)
if err is not None:
self._json(404, err)
return
try:
payload = self._read_json()
except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
content = payload.get("content", "")
if not isinstance(content, str) or not content.strip():
self._json(400, {"ok": False, "error": "compose content must be a non-empty string"})
return
try:
with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
self._json(500, {"ok": False, "error": f"failed to write compose file: {exc}"})
return
self._json(200, {"ok": True, "name": name, "saved": True})
return
# /apps/<name>/<action>
if len(parts) == 3:
_, name, action = parts

View file

@ -89,13 +89,28 @@ load_app() {
}
compose_command() {
if podman compose version >/dev/null 2>&1; then
echo "podman compose"
return
local podman_bin=""
local podman_compose_bin=""
if command -v podman >/dev/null 2>&1; then
podman_bin="$(command -v podman)"
elif [[ -x /run/current-system/sw/bin/podman ]]; then
podman_bin="/run/current-system/sw/bin/podman"
fi
if command -v podman-compose >/dev/null 2>&1; then
echo "podman-compose"
podman_compose_bin="$(command -v podman-compose)"
elif [[ -x /run/current-system/sw/bin/podman-compose ]]; then
podman_compose_bin="/run/current-system/sw/bin/podman-compose"
fi
if [[ -n "${podman_bin}" ]] && "${podman_bin}" compose version >/dev/null 2>&1; then
echo "${podman_bin} compose"
return
fi
if [[ -n "${podman_compose_bin}" ]]; then
echo "${podman_compose_bin}"
return
fi