Web UI (panel/frontend/index.html) rewritten: - Cards update in place from a single /status poll instead of being rebuilt on every action, so open tabs, unsaved compose/route edits, logs and the file browser position survive refreshes. Polling speeds up while an operation runs and pauses in background tabs; a header indicator shows when the panel last synced and detects an expired Authelia session. - New-app dialog (starter / compose / git), suggested port and domain, proper confirm dialogs (the old "OK = keep volumes" remove prompt is gone), toasts, an activity drawer with operation output, overflow menu, search, status filters, keyboard shortcuts, deep links, dark mode and mobile layout. - Tabs: overview (containers + routes), compose editor (dirty tracking, Ctrl+S), logs with follow, validated routes editor, file browser with drag-and-drop upload, backups, and a git source tab (deployed commit, check for updates, sync & deploy). API (panel/panel-api.py): - ThreadingHTTPServer so a long deploy no longer blocks every other request. - Per-app operation lock; concurrent writes to a busy app return 409. - GET /status: all apps, routes and container status in one request (statuses gathered in parallel); status reports running/partial/stopped. - Git sync is fetch + hard reset instead of pull-or-reclone, keeps the stored token, reports before/after commits; GET /apps/<name>/repo[?fetch=1]. - Any http(s) git host (e.g. Forgejo), default branch detection, git timeouts, no credential prompts, tokens redacted from errors, and manifest values validated before being written into the bash-sourced manifest. panelctl: - flock around routes.caddy rewrites (util-linux added to the service path). - deploy returns compose output so failures are visible in the UI. - inspect-volumes no longer fails for apps without named podman volumes, which broke the file browser. Docs: README/API.md updated; fixed outdated panelctl init examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
1219 lines
48 KiB
Python
1219 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""panel-api — HTTP wrapper around panelctl with a web UI."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import urlparse, parse_qs, quote
|
|
|
|
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", "/var/lib/containers")
|
|
FRONTEND_DIR = os.environ.get(
|
|
"PANEL_FRONTEND_DIR",
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
|
|
)
|
|
|
|
COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]
|
|
GIT_TIMEOUT = 300
|
|
# Manifest values are written into a file that panelctl sources with bash, so
|
|
# they must not contain anything that is special inside double quotes.
|
|
REPO_URL_RE = re.compile(r"^https?://[^\s\"'`$\\]+$")
|
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$")
|
|
|
|
|
|
def is_safe_name(name):
|
|
return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None
|
|
|
|
|
|
# ── Per-app operation locks ──
|
|
# Requests are handled concurrently, so two mutating operations on the same app
|
|
# (e.g. a double-clicked deploy, or deploy + restore) must not overlap.
|
|
|
|
_busy = {}
|
|
_busy_lock = threading.Lock()
|
|
|
|
|
|
class AppBusy(Exception):
|
|
def __init__(self, name, action):
|
|
super().__init__(f"another operation ({action}) is already running on '{name}'")
|
|
self.action = action
|
|
|
|
|
|
@contextmanager
|
|
def app_operation(name, action):
|
|
with _busy_lock:
|
|
if name in _busy:
|
|
raise AppBusy(name, _busy[name])
|
|
_busy[name] = action
|
|
try:
|
|
yield
|
|
finally:
|
|
with _busy_lock:
|
|
_busy.pop(name, None)
|
|
|
|
|
|
def busy_snapshot():
|
|
with _busy_lock:
|
|
return dict(_busy)
|
|
|
|
|
|
def redact_credentials(text, replacement="***@"):
|
|
"""Hide user:token@ credentials embedded in URLs."""
|
|
return re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@", r"\1" + replacement, text or "")
|
|
|
|
|
|
def last_line(text):
|
|
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
|
|
return lines[-1] if lines else ""
|
|
|
|
|
|
# ── Git helpers ──
|
|
|
|
def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
|
git_bin = shutil.which("git")
|
|
if not git_bin:
|
|
return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"}
|
|
cmd = [git_bin] + (["-C", cwd] if cwd else []) + args
|
|
# Never block on an interactive credential prompt.
|
|
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
try:
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"}
|
|
return {
|
|
"ok": proc.returncode == 0,
|
|
"stdout": redact_credentials(proc.stdout.strip()),
|
|
"stderr": redact_credentials(proc.stderr.strip()),
|
|
}
|
|
|
|
|
|
def clone_repo(url, branch, target_dir, token=""):
|
|
auth_url = url
|
|
if token:
|
|
auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1)
|
|
args = ["clone"]
|
|
if branch:
|
|
args += ["--branch", branch]
|
|
return run_git(args + ["--", auth_url, target_dir])
|
|
|
|
|
|
def repo_commit(repo_dir, ref="HEAD"):
|
|
result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15)
|
|
if not result["ok"] or not result["stdout"]:
|
|
return None
|
|
sha, subject, author, ts = (result["stdout"].split("\x1f") + ["", "", "", ""])[:4]
|
|
return {
|
|
"sha": sha,
|
|
"short": sha[:7],
|
|
"subject": subject,
|
|
"author": author,
|
|
"time": int(ts) if ts.isdigit() else None,
|
|
}
|
|
|
|
|
|
def repo_current_branch(repo_dir):
|
|
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir, timeout=15)
|
|
if result["ok"] and result["stdout"] and result["stdout"] != "HEAD":
|
|
return result["stdout"]
|
|
return ""
|
|
|
|
|
|
def find_compose_file(repo_dir):
|
|
for fname in COMPOSE_FILENAMES:
|
|
candidate = os.path.join(repo_dir, fname)
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
# ── Manifest helpers ──
|
|
|
|
def update_manifest(name, values):
|
|
"""Set KEY="value" lines in an app manifest, replacing existing keys."""
|
|
for key, value in values.items():
|
|
if re.search(r'["`$\\\n]', value):
|
|
raise ValueError(f"unsafe characters in {key}")
|
|
manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env")
|
|
with open(manifest_path, "r", encoding="utf-8") as fh:
|
|
lines = fh.readlines()
|
|
remaining = dict(values)
|
|
out = []
|
|
for line in lines:
|
|
key = line.split("=", 1)[0].strip()
|
|
if key in remaining:
|
|
out.append(f'{key}="{remaining.pop(key)}"\n')
|
|
else:
|
|
out.append(line if line.endswith("\n") else line + "\n")
|
|
for key, value in remaining.items():
|
|
out.append(f'{key}="{value}"\n')
|
|
with open(manifest_path, "w", encoding="utf-8") as fh:
|
|
fh.writelines(out)
|
|
|
|
|
|
def manifest_routes(env):
|
|
routes_raw = env.get("APP_ROUTES", "")
|
|
# Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM
|
|
if not routes_raw and "APP_DOMAIN" in env:
|
|
upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}")
|
|
domains = env.get("APP_DOMAINS", env["APP_DOMAIN"])
|
|
routes_raw = ",".join(f"{d.strip()}|{upstream}" for d in domains.split(",") if d.strip())
|
|
routes = []
|
|
for entry in routes_raw.split(","):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
fields = entry.split("|", 2)
|
|
if len(fields) < 2:
|
|
continue
|
|
route = {"domain": fields[0].strip(), "upstream": fields[1].strip()}
|
|
if len(fields) > 2 and fields[2].strip():
|
|
route["path"] = fields[2].strip()
|
|
routes.append(route)
|
|
return routes
|
|
|
|
|
|
def load_app_summaries():
|
|
"""Read every app manifest directly (much faster than shelling out per app)."""
|
|
apps_dir = os.path.join(BASE_DIR, "state", "apps")
|
|
try:
|
|
entries = sorted(os.listdir(apps_dir))
|
|
except OSError:
|
|
return []
|
|
apps = []
|
|
for fname in entries:
|
|
if not fname.endswith(".env"):
|
|
continue
|
|
name = fname[:-4]
|
|
if not is_safe_name(name):
|
|
continue
|
|
try:
|
|
with open(os.path.join(apps_dir, fname), "r", encoding="utf-8") as fh:
|
|
env = parse_env_blob(fh.read())
|
|
except OSError:
|
|
continue
|
|
apps.append({
|
|
"name": name,
|
|
"routes": manifest_routes(env),
|
|
"auth": env.get("APP_AUTH_PROTECTED", "true") == "true",
|
|
"compose_file": env.get("APP_COMPOSE_FILE", ""),
|
|
"repo_url": redact_credentials(env.get("APP_REPO_URL", ""), ""),
|
|
"repo_branch": env.get("APP_REPO_BRANCH", ""),
|
|
})
|
|
return apps
|
|
|
|
|
|
def run_panelctl(args):
|
|
proc = subprocess.run(
|
|
[PANELCTL, *args],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return {
|
|
"ok": proc.returncode == 0,
|
|
"code": proc.returncode,
|
|
"stdout": proc.stdout.strip(),
|
|
"stderr": proc.stderr.strip(),
|
|
}
|
|
|
|
|
|
def parse_env_blob(blob):
|
|
out = {}
|
|
for line in blob.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
out[key] = value.strip().strip('"')
|
|
return out
|
|
|
|
|
|
def get_app_volumes(name):
|
|
result = run_panelctl(["inspect-volumes", name])
|
|
volumes = {}
|
|
# Parse whatever was printed even on a non-zero exit, so one failing
|
|
# `podman volume ls` doesn't hide the app's default data folder.
|
|
for line in result["stdout"].splitlines():
|
|
if "|" in line:
|
|
vname, vpath = line.split("|", 1)
|
|
volumes[vname.strip()] = vpath.strip()
|
|
return volumes
|
|
|
|
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 _decode_containers(stdout):
|
|
"""`compose ps --format json` prints either a JSON array or one object per line,
|
|
sometimes mixed with other output. Returns a list of dicts, or None."""
|
|
lines = stdout.splitlines()
|
|
for i, line in enumerate(lines):
|
|
if line.lstrip().startswith("["):
|
|
try:
|
|
data, _ = json.JSONDecoder().raw_decode("\n".join(lines[i:]).lstrip())
|
|
except ValueError:
|
|
continue
|
|
if isinstance(data, list):
|
|
return [c for c in data if isinstance(c, dict)]
|
|
items = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(obj, dict):
|
|
items.append(obj)
|
|
return items or None
|
|
|
|
|
|
def _container_name(c):
|
|
name = c.get("Name") or c.get("name")
|
|
if not name:
|
|
names = c.get("Names")
|
|
if isinstance(names, list) and names:
|
|
name = names[0]
|
|
elif isinstance(names, str):
|
|
name = names
|
|
return name or "?"
|
|
|
|
|
|
def parse_status_output(stdout):
|
|
"""Summarise panelctl status output as running / partial / stopped / unknown."""
|
|
stdout = stdout or ""
|
|
containers = _decode_containers(stdout)
|
|
if containers is None:
|
|
text = stdout.lower()
|
|
if not text.strip() or "no containers" in text:
|
|
return {"state": "stopped", "running": False, "running_count": 0, "total": 0, "containers": []}
|
|
running = re.search(r"\b(up|running)\b", text) is not None
|
|
return {
|
|
"state": "running" if running else "unknown",
|
|
"running": running,
|
|
"running_count": None,
|
|
"total": None,
|
|
"containers": [],
|
|
"raw": stdout,
|
|
}
|
|
|
|
parsed = []
|
|
for c in containers:
|
|
state = str(c.get("State") or c.get("state") or "").lower()
|
|
status = str(c.get("Status") or c.get("status") or "")
|
|
is_running = state == "running" or status.lower().startswith("up")
|
|
parsed.append({
|
|
"name": _container_name(c),
|
|
"state": state or ("running" if is_running else "unknown"),
|
|
"status": status,
|
|
"image": c.get("Image") or c.get("image") or "",
|
|
"running": is_running,
|
|
})
|
|
running_count = sum(1 for c in parsed if c["running"])
|
|
total = len(parsed)
|
|
if total and running_count == total:
|
|
state = "running"
|
|
elif running_count:
|
|
state = "partial"
|
|
else:
|
|
state = "stopped"
|
|
return {
|
|
"state": state,
|
|
"running": running_count > 0,
|
|
"running_count": running_count,
|
|
"total": total,
|
|
"containers": parsed,
|
|
}
|
|
|
|
|
|
def app_status(name):
|
|
return parse_status_output(run_panelctl(["status", name])["stdout"])
|
|
|
|
|
|
def parse_backups_output(stdout):
|
|
"""Parse panelctl list-backups output into structured data."""
|
|
backups = []
|
|
for line in stdout.splitlines():
|
|
line = line.strip()
|
|
if not line or "no backups" in line.lower():
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) >= 1:
|
|
entry = {"name": parts[0]}
|
|
if len(parts) >= 2:
|
|
entry["size"] = parts[1]
|
|
if len(parts) >= 3:
|
|
try:
|
|
entry["mtime"] = int(parts[2])
|
|
except ValueError:
|
|
pass
|
|
backups.append(entry)
|
|
return backups
|
|
|
|
|
|
# Actions that only read state and may run alongside anything else.
|
|
LOCK_FREE_ACTIONS = {"validate-compose"}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _html(self, code, body):
|
|
payload = body.encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def _json(self, code, payload):
|
|
body = json.dumps(payload, indent=2).encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _file(self, code, filepath, content_type):
|
|
try:
|
|
with open(filepath, "rb") as fh:
|
|
data = fh.read()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
# The UI is a single file that changes with every rebuild.
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
except OSError:
|
|
self._json(500, {"ok": False, "error": "failed to read file"})
|
|
|
|
def _read_json(self):
|
|
# Cached: do_POST may read the body before dispatching.
|
|
if hasattr(self, "_payload"):
|
|
return self._payload
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length == 0:
|
|
self._payload = {}
|
|
else:
|
|
raw = self.rfile.read(length)
|
|
self._payload = json.loads(raw.decode("utf-8"))
|
|
return self._payload
|
|
|
|
def log_message(self, fmt, *args):
|
|
# Log to stdout (goes to systemd journal)
|
|
print(f"[panel-api] {self.address_string()} {fmt % args}")
|
|
|
|
# ── Routing helpers ──
|
|
|
|
def _parse_path(self):
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
query = parse_qs(parsed.query)
|
|
parts = [p for p in path.split("/") if p]
|
|
return path, parts, query
|
|
|
|
# ── GET ──
|
|
|
|
def do_GET(self):
|
|
path, parts, query = self._parse_path()
|
|
|
|
if path == "/":
|
|
index = os.path.join(FRONTEND_DIR, "index.html")
|
|
if os.path.isfile(index):
|
|
self._file(200, index, "text/html; charset=utf-8")
|
|
else:
|
|
self._html(200, "<h1>Panel</h1><p>Frontend not found.</p>")
|
|
return
|
|
|
|
if path == "/health":
|
|
self._json(200, {"ok": True, "service": "panel-api"})
|
|
return
|
|
|
|
# /status — every app with routes, container status and running operation.
|
|
# This is what the UI polls, so it is one request regardless of app count.
|
|
if path == "/status":
|
|
apps = load_app_summaries()
|
|
names = [a["name"] for a in apps]
|
|
statuses = {}
|
|
if names:
|
|
with ThreadPoolExecutor(max_workers=min(8, len(names))) as pool:
|
|
statuses = dict(zip(names, pool.map(app_status, names)))
|
|
busy = busy_snapshot()
|
|
for app in apps:
|
|
app["status"] = statuses.get(app["name"], {"state": "unknown"})
|
|
app["busy"] = busy.get(app["name"])
|
|
self._json(200, {"ok": True, "time": int(time.time()), "apps": apps})
|
|
return
|
|
|
|
if path == "/apps":
|
|
result = run_panelctl(["list"])
|
|
if not result["ok"]:
|
|
self._json(500, result)
|
|
return
|
|
apps = []
|
|
for line in result["stdout"].splitlines():
|
|
if not line.strip() or line.strip() == "no apps found":
|
|
continue
|
|
fields = line.split()
|
|
if len(fields) < 4:
|
|
continue
|
|
# New format: name domain|upstream routes=N auth=bool [repo_url]
|
|
first_route = fields[1]
|
|
route_parts = first_route.split("|")
|
|
domain = route_parts[0].split(",")[0] if route_parts else first_route
|
|
upstream = route_parts[1] if len(route_parts) > 1 else ""
|
|
route_count_str = fields[2].replace("routes=", "")
|
|
# Backward compat: fields[2] may be upstream if old format
|
|
if not route_count_str.isdigit():
|
|
upstream = fields[2]
|
|
route_count_str = "1"
|
|
apps.append({
|
|
"name": fields[0],
|
|
"domain": domain,
|
|
"domains": domain,
|
|
"upstream": upstream,
|
|
"first_route": first_route,
|
|
"route_count": route_count_str,
|
|
"auth": fields[3].replace("auth=", ""),
|
|
"repo_url": fields[4] if len(fields) >= 5 else "",
|
|
})
|
|
self._json(200, {"ok": True, "apps": apps})
|
|
return
|
|
|
|
# /apps/<name>/compose
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "compose":
|
|
name = parts[1]
|
|
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: {exc}"})
|
|
return
|
|
self._json(200, {"ok": True, "name": name, "content": content})
|
|
return
|
|
|
|
# /apps/<name>/status
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "status":
|
|
name = parts[1]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl(["status", name])
|
|
status = parse_status_output(result["stdout"])
|
|
self._json(200, {"ok": True, "name": name, **status})
|
|
return
|
|
|
|
# /apps/<name>/logs
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "logs":
|
|
name = parts[1]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
tail = query.get("tail", ["100"])[0]
|
|
try:
|
|
tail = str(int(tail))
|
|
except ValueError:
|
|
tail = "100"
|
|
result = run_panelctl(["logs", name, "--tail", tail])
|
|
self._json(200, {"ok": True, "name": name, "logs": result["stdout"]})
|
|
return
|
|
|
|
# /apps/<name>/backups
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "backups":
|
|
name = parts[1]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl(["list-backups", name])
|
|
backups = parse_backups_output(result["stdout"])
|
|
self._json(200, {"ok": True, "name": name, "backups": backups})
|
|
return
|
|
|
|
# /apps/<name>/backups/<filename> — download backup zip
|
|
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "backups":
|
|
name = parts[1]
|
|
filename = parts[3]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
# Validate filename: must match <name>-<timestamp>.zip
|
|
if not re.match(r"^[a-z0-9-]+-\d{8}-\d{6}\.zip$", filename):
|
|
self._json(400, {"ok": False, "error": "invalid backup filename"})
|
|
return
|
|
backup_path = os.path.join(BASE_DIR, "backups", filename)
|
|
norm_path = os.path.abspath(backup_path)
|
|
norm_backups = os.path.abspath(os.path.join(BASE_DIR, "backups")) + os.sep
|
|
if not norm_path.startswith(norm_backups):
|
|
self._json(403, {"ok": False, "error": "path traversal denied"})
|
|
return
|
|
if not os.path.isfile(norm_path):
|
|
self._json(404, {"ok": False, "error": "backup not found"})
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/zip")
|
|
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
|
size = os.path.getsize(norm_path)
|
|
self.send_header("Content-Length", str(size))
|
|
self.end_headers()
|
|
with open(norm_path, "rb") as fh:
|
|
while True:
|
|
chunk = fh.read(65536)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
return
|
|
|
|
# /apps/<name>/routes — get parsed routes
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "routes":
|
|
name = parts[1]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl(["show", name])
|
|
if not result["ok"]:
|
|
self._json(404, result)
|
|
return
|
|
routes = manifest_routes(parse_env_blob(result["stdout"]))
|
|
self._json(200, {"ok": True, "name": name, "routes": routes})
|
|
return
|
|
|
|
# /apps/<name>/repo[?fetch=1] — git source info; fetch=1 also checks the remote
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "repo":
|
|
name = parts[1]
|
|
app, err = read_app_info(name)
|
|
if err is not None:
|
|
self._json(404, err)
|
|
return
|
|
repo_url = app.get("APP_REPO_URL", "")
|
|
if not repo_url:
|
|
self._json(404, {"ok": False, "error": "app is not linked to a git repository"})
|
|
return
|
|
repo_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
|
branch = app.get("APP_REPO_BRANCH", "")
|
|
info = {
|
|
"ok": True,
|
|
"name": name,
|
|
"url": redact_credentials(repo_url, ""),
|
|
"branch": branch,
|
|
"cloned": os.path.isdir(os.path.join(repo_dir, ".git")),
|
|
}
|
|
if info["cloned"]:
|
|
info["commit"] = repo_commit(repo_dir)
|
|
status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15)
|
|
info["dirty"] = bool(status["stdout"]) if status["ok"] else None
|
|
if query.get("fetch", ["0"])[0] == "1":
|
|
ref = branch or repo_current_branch(repo_dir)
|
|
fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir)
|
|
if not fetched["ok"]:
|
|
info["fetch_error"] = last_line(fetched["stderr"]) or "git fetch failed"
|
|
else:
|
|
info["remote"] = repo_commit(repo_dir, "FETCH_HEAD")
|
|
count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15)
|
|
info["behind"] = int(count["stdout"]) if count["ok"] and count["stdout"].isdigit() else None
|
|
self._json(200, info)
|
|
return
|
|
|
|
# /apps/<name> — show single app
|
|
if len(parts) == 2 and parts[0] == "apps":
|
|
name = parts[1]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl(["show", name])
|
|
if not result["ok"]:
|
|
self._json(404, result)
|
|
return
|
|
self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])})
|
|
return
|
|
|
|
# /apps/<name>/volumes
|
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "volumes":
|
|
name = parts[1]
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
volumes = get_app_volumes(name)
|
|
self._json(200, {"ok": True, "name": name, "volumes": volumes})
|
|
return
|
|
|
|
# /apps/<name>/volume/files
|
|
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files":
|
|
name = parts[1]
|
|
app, err = read_app_info(name)
|
|
if err is not None:
|
|
self._json(404, err)
|
|
return
|
|
|
|
volumes = get_app_volumes(name)
|
|
vol_key = query.get("vol", ["default"])[0]
|
|
if vol_key not in volumes:
|
|
self._json(400, {"ok": False, "error": "invalid volume specified"})
|
|
return
|
|
|
|
subpath = query.get("path", [""])[0].strip("/")
|
|
data_dir = volumes[vol_key]
|
|
target_dir = os.path.abspath(os.path.join(data_dir, subpath))
|
|
|
|
# Ensure traversal didn't escape data_dir
|
|
if not target_dir.startswith(os.path.abspath(data_dir)):
|
|
self._json(403, {"ok": False, "error": "path traversal denied"})
|
|
return
|
|
|
|
if not os.path.exists(target_dir):
|
|
self._json(404, {"ok": False, "error": "directory not found"})
|
|
return
|
|
|
|
if not os.path.isdir(target_dir):
|
|
self._json(400, {"ok": False, "error": "target is not a directory"})
|
|
return
|
|
|
|
files = []
|
|
for item in os.listdir(target_dir):
|
|
if item == "." or item == "..":
|
|
continue
|
|
item_path = os.path.join(target_dir, item)
|
|
try:
|
|
stat = os.stat(item_path)
|
|
files.append({
|
|
"name": item,
|
|
"is_dir": os.path.isdir(item_path),
|
|
"size": stat.st_size,
|
|
"mtime": stat.st_mtime
|
|
})
|
|
except OSError:
|
|
continue
|
|
|
|
files.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
|
self._json(200, {"ok": True, "path": subpath, "files": files})
|
|
return
|
|
|
|
# /apps/<name>/volume/download
|
|
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "download":
|
|
name = parts[1]
|
|
app, err = read_app_info(name)
|
|
if err is not None:
|
|
self._json(404, err)
|
|
return
|
|
|
|
volumes = get_app_volumes(name)
|
|
vol_key = query.get("vol", ["default"])[0]
|
|
if vol_key not in volumes:
|
|
self._json(400, {"ok": False, "error": "invalid volume specified"})
|
|
return
|
|
|
|
subpath = query.get("path", [""])[0].strip("/")
|
|
if not subpath:
|
|
self._json(400, {"ok": False, "error": "path parameter required"})
|
|
return
|
|
|
|
data_dir = volumes[vol_key]
|
|
target_file = os.path.abspath(os.path.join(data_dir, subpath))
|
|
|
|
if not target_file.startswith(os.path.abspath(data_dir)):
|
|
self._json(403, {"ok": False, "error": "path traversal denied"})
|
|
return
|
|
|
|
if not os.path.isfile(target_file):
|
|
self._json(404, {"ok": False, "error": "file not found"})
|
|
return
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(target_file)}"')
|
|
size = os.path.getsize(target_file)
|
|
self.send_header("Content-Length", str(size))
|
|
self.end_headers()
|
|
with open(target_file, "rb") as fh:
|
|
while True:
|
|
chunk = fh.read(65536)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
return
|
|
|
|
self._json(404, {"ok": False, "error": "not found"})
|
|
|
|
# ── PUT ──
|
|
def do_PUT(self):
|
|
path, parts, query = self._parse_path()
|
|
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files":
|
|
name = parts[1]
|
|
app, err = read_app_info(name)
|
|
if err is not None:
|
|
self._json(404, err)
|
|
return
|
|
|
|
volumes = get_app_volumes(name)
|
|
vol_key = query.get("vol", ["default"])[0]
|
|
if vol_key not in volumes:
|
|
self._json(400, {"ok": False, "error": "invalid volume specified"})
|
|
return
|
|
|
|
subpath = query.get("path", [""])[0].strip("/")
|
|
if not subpath:
|
|
self._json(400, {"ok": False, "error": "path parameter required"})
|
|
return
|
|
|
|
data_dir = volumes[vol_key]
|
|
target_file = os.path.abspath(os.path.join(data_dir, subpath))
|
|
if not target_file.startswith(os.path.abspath(data_dir)):
|
|
self._json(403, {"ok": False, "error": "path traversal denied"})
|
|
return
|
|
|
|
try:
|
|
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
with open(target_file, "wb") as fh:
|
|
bytes_read = 0
|
|
while bytes_read < length:
|
|
chunk = self.rfile.read(min(65536, length - bytes_read))
|
|
if not chunk:
|
|
break
|
|
fh.write(chunk)
|
|
bytes_read += len(chunk)
|
|
self._json(200, {"ok": True, "path": subpath})
|
|
except Exception as exc:
|
|
self._json(500, {"ok": False, "error": str(exc)})
|
|
return
|
|
|
|
self._json(404, {"ok": False, "error": "not found"})
|
|
|
|
# ── DELETE ──
|
|
def do_DELETE(self):
|
|
path, parts, query = self._parse_path()
|
|
|
|
if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files":
|
|
name = parts[1]
|
|
app, err = read_app_info(name)
|
|
if err is not None:
|
|
self._json(404, err)
|
|
return
|
|
|
|
volumes = get_app_volumes(name)
|
|
vol_key = query.get("vol", ["default"])[0]
|
|
if vol_key not in volumes:
|
|
self._json(400, {"ok": False, "error": "invalid volume specified"})
|
|
return
|
|
|
|
subpath = query.get("path", [""])[0].strip("/")
|
|
if not subpath:
|
|
self._json(400, {"ok": False, "error": "path parameter required"})
|
|
return
|
|
|
|
data_dir = volumes[vol_key]
|
|
target_file = os.path.abspath(os.path.join(data_dir, subpath))
|
|
|
|
if not target_file.startswith(os.path.abspath(data_dir)):
|
|
self._json(403, {"ok": False, "error": "path traversal denied"})
|
|
return
|
|
|
|
if not os.path.exists(target_file):
|
|
self._json(404, {"ok": False, "error": "file or directory not found"})
|
|
return
|
|
|
|
try:
|
|
if os.path.isdir(target_file):
|
|
import shutil
|
|
shutil.rmtree(target_file)
|
|
else:
|
|
os.remove(target_file)
|
|
self._json(200, {"ok": True, "deleted": subpath})
|
|
except Exception as exc:
|
|
self._json(500, {"ok": False, "error": str(exc)})
|
|
return
|
|
|
|
self._json(404, {"ok": False, "error": "not found"})
|
|
|
|
# ── POST ──
|
|
|
|
def do_POST(self):
|
|
path, parts, query = self._parse_path()
|
|
|
|
name, action = None, None
|
|
if path == "/apps/init":
|
|
try:
|
|
payload = self._read_json()
|
|
except Exception as exc:
|
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
|
return
|
|
name = str(payload.get("name", "")) if isinstance(payload, dict) else ""
|
|
action = "init"
|
|
elif len(parts) >= 3 and parts[0] == "apps" and parts[2] not in LOCK_FREE_ACTIONS:
|
|
name, action = parts[1], parts[2]
|
|
|
|
if not name:
|
|
self._handle_post(path, parts, query)
|
|
return
|
|
try:
|
|
with app_operation(name, action):
|
|
self._handle_post(path, parts, query)
|
|
except AppBusy as exc:
|
|
self._json(409, {"ok": False, "error": str(exc), "busy": exc.action})
|
|
|
|
def _handle_post(self, path, parts, query):
|
|
# POST /apps/init
|
|
if path == "/apps/init":
|
|
try:
|
|
payload = self._read_json()
|
|
name = payload["name"]
|
|
auth = str(payload.get("auth", True)).lower()
|
|
source_type = payload.get("source_type", "default")
|
|
|
|
# Build routes string: "domain|upstream,domain|upstream,..."
|
|
routes_parts = []
|
|
if "routes" in payload and isinstance(payload["routes"], list):
|
|
for r in payload["routes"]:
|
|
d = r.get("domain", "").strip()
|
|
u = r.get("upstream", "").strip()
|
|
p = r.get("path", "").strip()
|
|
if d and u:
|
|
if p:
|
|
routes_parts.append(f"{d}|{u}|{p}")
|
|
else:
|
|
routes_parts.append(f"{d}|{u}")
|
|
elif "domain" in payload and "port" in payload:
|
|
# Backward compat: single domain + port
|
|
domain_str = payload.get("domain", "")
|
|
if "domains" in payload and isinstance(payload["domains"], list):
|
|
domain_str = ",".join(payload["domains"])
|
|
port = str(payload["port"])
|
|
for d in domain_str.split(","):
|
|
d = d.strip()
|
|
if d:
|
|
routes_parts.append(f"{d}|127.0.0.1:{port}")
|
|
else:
|
|
self._json(400, {"ok": False, "error": "missing 'routes' array or 'domain'+'port' fields"})
|
|
return
|
|
|
|
routes_str = ",".join(routes_parts)
|
|
except Exception as exc:
|
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
|
return
|
|
|
|
if source_type not in ["default", "raw", "github"]:
|
|
self._json(400, {"ok": False, "error": "invalid source_type"})
|
|
return
|
|
|
|
# Validate git parameters before creating anything.
|
|
if source_type == "github":
|
|
repo_url = str(payload.get("github_url", "")).strip()
|
|
branch = str(payload.get("github_branch", "")).strip()
|
|
token = str(payload.get("github_pat", "")).strip()
|
|
if not REPO_URL_RE.match(repo_url):
|
|
self._json(400, {"ok": False, "error": "repository URL must be a plain http(s) URL"})
|
|
return
|
|
if branch and not BRANCH_RE.match(branch):
|
|
self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"})
|
|
return
|
|
|
|
try:
|
|
result = run_panelctl(["init", name, routes_str, auth])
|
|
if not result["ok"]:
|
|
self._json(400, result)
|
|
return
|
|
|
|
app, err = read_app_info(name)
|
|
if err is not None or app is None:
|
|
run_panelctl(["remove", name])
|
|
err_msg = (err or {}).get("error", "unknown error") if err else "app state unavailable"
|
|
self._json(500, {"ok": False, "error": f"failed to read app state: {err_msg}"})
|
|
return
|
|
|
|
if source_type == "raw":
|
|
content = payload.get("compose_content", "")
|
|
try:
|
|
with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh:
|
|
fh.write(content)
|
|
except OSError as exc:
|
|
run_panelctl(["remove", name])
|
|
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
|
|
return
|
|
|
|
elif source_type == "github":
|
|
# Any http(s) git host works (GitHub, Forgejo, ...). A token is
|
|
# embedded in the clone URL, so later syncs reuse it from .git/config.
|
|
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
|
if os.path.exists(target_dir):
|
|
shutil.rmtree(target_dir)
|
|
|
|
cloned = clone_repo(repo_url, branch, target_dir, token)
|
|
if not cloned["ok"]:
|
|
run_panelctl(["remove", name])
|
|
self._json(400, {
|
|
"ok": False,
|
|
"error": f"git clone failed: {last_line(cloned['stderr'])}",
|
|
"stderr": cloned["stderr"],
|
|
})
|
|
return
|
|
branch = branch or repo_current_branch(target_dir) or "main"
|
|
|
|
compose_path = find_compose_file(target_dir)
|
|
if not compose_path:
|
|
run_panelctl(["remove", name])
|
|
self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"})
|
|
return
|
|
|
|
try:
|
|
update_manifest(name, {
|
|
"APP_COMPOSE_FILE": compose_path,
|
|
"APP_REPO_URL": redact_credentials(repo_url, ""),
|
|
"APP_REPO_BRANCH": branch,
|
|
})
|
|
except (OSError, ValueError) as exc:
|
|
run_panelctl(["remove", name])
|
|
self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"})
|
|
return
|
|
|
|
commit = repo_commit(target_dir)
|
|
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
|
|
self._json(200, {"ok": True, "code": 0, "stdout": summary})
|
|
return
|
|
|
|
self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
|
|
except Exception as exc:
|
|
run_panelctl(["remove", name])
|
|
self._json(500, {"ok": False, "error": f"init failed: {exc}"})
|
|
return
|
|
|
|
if len(parts) >= 3 and parts[0] == "apps":
|
|
name = parts[1]
|
|
action = parts[2]
|
|
|
|
# POST /apps/<name>/compose — save compose file
|
|
if action == "compose":
|
|
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: {exc}"})
|
|
return
|
|
self._json(200, {"ok": True, "name": name, "saved": True})
|
|
return
|
|
|
|
# POST /apps/<name>/validate-compose
|
|
if action == "validate-compose":
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl(["validate-compose", name])
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
# POST /apps/<name>/backup
|
|
if action == "backup":
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl(["backup", name])
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
# POST /apps/<name>/restore
|
|
if action == "restore":
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
try:
|
|
payload = self._read_json()
|
|
except Exception:
|
|
payload = {}
|
|
backup_file = payload.get("file", "")
|
|
if not backup_file:
|
|
self._json(400, {"ok": False, "error": "backup file name is required"})
|
|
return
|
|
result = run_panelctl(["restore", name, backup_file])
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
# POST /apps/<name>/routes — hot update routes
|
|
if action == "routes":
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
try:
|
|
payload = self._read_json()
|
|
except Exception as exc:
|
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
|
return
|
|
route_list = payload.get("routes", [])
|
|
if not isinstance(route_list, list) or not route_list:
|
|
self._json(400, {"ok": False, "error": "routes must be a non-empty array"})
|
|
return
|
|
routes_parts = []
|
|
for r in route_list:
|
|
d = r.get("domain", "").strip()
|
|
u = r.get("upstream", "").strip()
|
|
p = r.get("path", "").strip()
|
|
if not d or not u:
|
|
self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"})
|
|
return
|
|
if p:
|
|
routes_parts.append(f"{d}|{u}|{p}")
|
|
else:
|
|
routes_parts.append(f"{d}|{u}")
|
|
routes_str = ",".join(routes_parts)
|
|
result = run_panelctl(["set-routes", name, routes_str])
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
# Simple panelctl pass-through actions
|
|
if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}:
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
result = run_panelctl([action, name])
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
# POST /apps/<name>/repo-pull — sync the checkout to the remote branch and redeploy.
|
|
# The repository is the source of truth: fetch + hard reset, so local
|
|
# edits or force-pushes never leave the checkout stuck mid-merge.
|
|
if action == "repo-pull":
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
|
|
app, err = read_app_info(name)
|
|
if err is not None or app is None:
|
|
self._json(404, {"ok": False, "error": "app not found"})
|
|
return
|
|
repo_url = app.get("APP_REPO_URL", "").strip()
|
|
if not repo_url:
|
|
self._json(400, {"ok": False, "error": "app is not linked to a git repository"})
|
|
return
|
|
|
|
repo_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
|
branch = app.get("APP_REPO_BRANCH", "").strip()
|
|
git_log = []
|
|
before = None
|
|
|
|
if os.path.isdir(os.path.join(repo_dir, ".git")):
|
|
before = repo_commit(repo_dir)
|
|
ref = branch or repo_current_branch(repo_dir)
|
|
if not ref:
|
|
self._json(400, {"ok": False, "error": "cannot determine which branch to sync"})
|
|
return
|
|
fetched = run_git(["fetch", "origin", ref], cwd=repo_dir)
|
|
if not fetched["ok"]:
|
|
self._json(400, {
|
|
"ok": False,
|
|
"error": f"git fetch failed: {last_line(fetched['stderr'])}",
|
|
"stderr": fetched["stderr"],
|
|
})
|
|
return
|
|
reset = run_git(["reset", "--hard", "FETCH_HEAD"], cwd=repo_dir, timeout=60)
|
|
if not reset["ok"]:
|
|
self._json(400, {
|
|
"ok": False,
|
|
"error": f"git reset failed: {last_line(reset['stderr'])}",
|
|
"stderr": reset["stderr"],
|
|
})
|
|
return
|
|
git_log.append(reset["stdout"])
|
|
else:
|
|
# No checkout yet (e.g. deleted by hand): clone it fresh.
|
|
if os.path.exists(repo_dir):
|
|
shutil.rmtree(repo_dir)
|
|
cloned = clone_repo(repo_url, branch, repo_dir)
|
|
if not cloned["ok"]:
|
|
self._json(400, {
|
|
"ok": False,
|
|
"error": f"git clone failed: {last_line(cloned['stderr'])}",
|
|
"stderr": cloned["stderr"],
|
|
})
|
|
return
|
|
git_log.append("cloned repository")
|
|
|
|
after = repo_commit(repo_dir)
|
|
compose_path = find_compose_file(repo_dir)
|
|
if not compose_path:
|
|
self._json(400, {"ok": False, "error": "compose file not found in repository root"})
|
|
return
|
|
try:
|
|
update_manifest(name, {"APP_COMPOSE_FILE": compose_path})
|
|
except (OSError, ValueError) as exc:
|
|
self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"})
|
|
return
|
|
|
|
result = run_panelctl(["deploy", name])
|
|
result["stdout"] = "\n".join(filter(None, git_log + [result["stdout"]]))
|
|
result["before"] = before
|
|
result["after"] = after
|
|
result["changed"] = not before or not after or before["sha"] != after["sha"]
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
# POST /apps/<name>/remove
|
|
if action == "remove":
|
|
if not is_safe_name(name):
|
|
self._json(400, {"ok": False, "error": "invalid app name"})
|
|
return
|
|
try:
|
|
payload = self._read_json()
|
|
except Exception:
|
|
payload = {}
|
|
keep = payload.get("keepVolumes", False)
|
|
args = ["remove", name]
|
|
if keep:
|
|
args.append("--keep-volumes")
|
|
result = run_panelctl(args)
|
|
self._json(200 if result["ok"] else 400, result)
|
|
return
|
|
|
|
self._json(404, {"ok": False, "error": "not found"})
|
|
|
|
|
|
def main():
|
|
server = ThreadingHTTPServer((BIND, PORT), Handler)
|
|
server.daemon_threads = True
|
|
print(f"panel-api listening on http://{BIND}:{PORT}")
|
|
print(f"frontend dir: {FRONTEND_DIR}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|