diff --git a/API.md b/API.md index ce5fe4b..90cc538 100644 --- a/API.md +++ b/API.md @@ -13,7 +13,9 @@ Default bind: - GET / - GET /apps - GET /apps/ +- GET /apps//compose - POST /apps/init +- POST /apps//compose - POST /apps//render-route - POST /apps//deploy - POST /apps//stop diff --git a/panel-api.py b/panel-api.py index ebe8efb..d384207 100644 --- a/panel-api.py +++ b/panel-api.py @@ -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 = """ @@ -173,6 +175,21 @@ INDEX_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 = """ + +
+

Compose Editor

+
+
No app selected.
+ +
+ +
@@ -238,6 +264,9 @@ INDEX_HTML = """ @@ -348,6 +407,32 @@ INDEX_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// if len(parts) == 3: _, name, action = parts diff --git a/panelctl.sh b/panelctl.sh index 70e0499..9d046c0 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -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