Enhance panel-api with compose functionality and update podman integration
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
49534ed568
commit
8f944aefcb
4 changed files with 163 additions and 4 deletions
|
|
@ -9,6 +9,7 @@
|
|||
pkgs.vim
|
||||
pkgs.git
|
||||
pkgs.agenix
|
||||
pkgs.podman
|
||||
pkgs.python3
|
||||
pkgs.podman-compose
|
||||
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
|
||||
|
|
@ -52,6 +53,12 @@
|
|||
description = "Minimal panel API service";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.podman
|
||||
pkgs.podman-compose
|
||||
pkgs.sudo
|
||||
pkgs.coreutils
|
||||
];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue