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 /
- GET /apps - GET /apps
- GET /apps/<name> - GET /apps/<name>
- GET /apps/<name>/compose
- POST /apps/init - POST /apps/init
- POST /apps/<name>/compose
- POST /apps/<name>/render-route - POST /apps/<name>/render-route
- POST /apps/<name>/deploy - POST /apps/<name>/deploy
- POST /apps/<name>/stop - POST /apps/<name>/stop

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os import os
import re
import subprocess import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse 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") PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
PORT = int(os.environ.get("PANEL_API_PORT", "9911")) PORT = int(os.environ.get("PANEL_API_PORT", "9911"))
BASE_DIR = os.environ.get("PANEL_BASE_DIR", "/home/reudy/containers")
INDEX_HTML = """<!doctype html> INDEX_HTML = """<!doctype html>
<html lang="en"> <html lang="en">
@ -173,6 +175,21 @@ INDEX_HTML = """<!doctype html>
.status.ok { border-color: #1b7a39; } .status.ok { border-color: #1b7a39; }
.status.err { border-color: var(--warn); } .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 { .fade-in {
animation: appear 280ms ease; animation: appear 280ms ease;
} }
@ -229,6 +246,15 @@ INDEX_HTML = """<!doctype html>
</thead> </thead>
<tbody id="appsBody"></tbody> <tbody id="appsBody"></tbody>
</table> </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> </article>
</section> </section>
@ -238,6 +264,9 @@ INDEX_HTML = """<!doctype html>
<script> <script>
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const appsBody = document.getElementById("appsBody"); const appsBody = document.getElementById("appsBody");
const composeText = document.getElementById("composeText");
const composeTarget = document.getElementById("composeTarget");
let selectedComposeApp = null;
function setStatus(msg, isError = false) { function setStatus(msg, isError = false) {
statusEl.textContent = msg; statusEl.textContent = msg;
@ -276,6 +305,34 @@ INDEX_HTML = """<!doctype html>
} catch (err) { } catch (err) {
setStatus(`${action} failed for ${name}: ${err.message}`, true); 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) { function rowForApp(app) {
@ -286,6 +343,7 @@ INDEX_HTML = """<!doctype html>
actions.className = "stack"; actions.className = "stack";
actions.appendChild(actionButton("Deploy", () => runAction(app.name, "deploy"))); actions.appendChild(actionButton("Deploy", () => runAction(app.name, "deploy")));
actions.appendChild(actionButton("Stop", () => runAction(app.name, "stop"))); 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("Route", () => runAction(app.name, "render-route")));
actions.appendChild(actionButton("Remove", async () => { actions.appendChild(actionButton("Remove", async () => {
const keep = window.confirm("Keep volumes? Press OK to keep, Cancel to delete."); 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("createBtn").addEventListener("click", createApp);
document.getElementById("refreshBtn").addEventListener("click", loadApps); document.getElementById("refreshBtn").addEventListener("click", loadApps);
document.getElementById("saveComposeBtn").addEventListener("click", saveCompose);
loadApps(); loadApps();
</script> </script>
</body> </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): def run_panelctl(args):
proc = subprocess.run( proc = subprocess.run(
[PANELCTL, *args], [PANELCTL, *args],
@ -438,6 +523,28 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "apps": apps}) self._json(200, {"ok": True, "apps": apps})
return 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/"): if path.startswith("/apps/"):
name = path.split("/")[-1] name = path.split("/")[-1]
if not name: if not name:
@ -476,6 +583,34 @@ class Handler(BaseHTTPRequestHandler):
action_prefix = "/apps/" action_prefix = "/apps/"
if path.startswith(action_prefix): if path.startswith(action_prefix):
parts = [p for p in path.split("/") if p] 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> # /apps/<name>/<action>
if len(parts) == 3: if len(parts) == 3:
_, name, action = parts _, name, action = parts

View file

@ -89,13 +89,28 @@ load_app() {
} }
compose_command() { compose_command() {
if podman compose version >/dev/null 2>&1; then local podman_bin=""
echo "podman compose" local podman_compose_bin=""
return
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 fi
if command -v podman-compose >/dev/null 2>&1; then 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 return
fi fi