From fabcfceb6dfc3bb3e46e2d8474212535e7a4605f Mon Sep 17 00:00:00 2001 From: Jakub Dorfman Date: Wed, 29 Apr 2026 18:47:11 +0200 Subject: [PATCH] feat: add repo-pull action to re-clone/pull repository and redeploy app Co-authored-by: Copilot --- panel/frontend/index.html | 8 ++++ panel/panel-api.py | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/panel/frontend/index.html b/panel/frontend/index.html index af3490b..42dd781 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -390,6 +390,7 @@ const api = { deploy: (n) => api.request(`/apps/${n}/deploy`, "POST"), restart: (n) => api.request(`/apps/${n}/restart`, "POST"), stop: (n) => api.request(`/apps/${n}/stop`, "POST"), + repoPull: (n) => api.request(`/apps/${n}/repo-pull`, "POST"), remove: (n, keep) => api.request(`/apps/${n}/remove`, "POST", { keepVolumes: keep }), saveCompose: (n, c) => api.request(`/apps/${n}/compose`, "POST", { content: c }), validateCompose: (n) => api.request(`/apps/${n}/validate-compose`, "POST"), @@ -534,6 +535,7 @@ function renderAppCard(app) { + @@ -699,6 +701,12 @@ async function handleAction(action, name, btnElement) { setStatus(`Stopped ${name}.`); await loadApps(); break; + case "repo-pull": + setStatus(`Pulling repo for ${name}...`); + await api.repoPull(name); + setStatus(`Repo pulled for ${name}.`); + await loadApps(); + break; case "remove": { const keep = confirm("Keep volumes? OK = keep, Cancel = delete everything."); setStatus(`Removing ${name}...`); diff --git a/panel/panel-api.py b/panel/panel-api.py index e320f88..0aa7847 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -738,6 +738,98 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return + # POST /apps//repo-pull — re-clone/pull repo and redeploy + if action == "repo-pull": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + + try: + 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() + branch = app.get("APP_REPO_BRANCH", "main").strip() + + if not repo_url: + self._json(400, {"ok": False, "error": "app has no APP_REPO_URL"}) + return + + git_bin = shutil.which("git") + if not git_bin: + self._json(400, {"ok": False, "error": "git is not installed"}) + return + + target_dir = os.path.join(app["APP_STACK_DIR"], "repo") + pat = "" + + auth_url = repo_url + if pat: + auth_url = auth_url.replace("://", f"://{pat}@") + if not auth_url.endswith(".git"): + auth_url += ".git" + + if os.path.exists(target_dir): + # Already cloned — try git pull + pull_result = subprocess.run( + [git_bin, "-C", target_dir, "pull", "origin", branch], + capture_output=True, text=True + ) + if pull_result.returncode != 0: + # Fall back to re-clone + shutil.rmtree(target_dir) + clone_result = subprocess.run( + [git_bin, "clone", "--branch", branch, auth_url, target_dir], + capture_output=True, text=True + ) + if clone_result.returncode != 0: + self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + return + else: + clone_result = subprocess.run( + [git_bin, "clone", "--branch", branch, auth_url, target_dir], + capture_output=True, text=True + ) + if clone_result.returncode != 0: + self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + return + + # Find compose file + compose_path = None + for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]: + candidate = os.path.join(target_dir, fname) + if os.path.isfile(candidate): + compose_path = candidate + break + + if not compose_path: + self._json(400, {"ok": False, "error": "compose file not found in repository"}) + return + + # Update manifest compose path + manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") + try: + with open(manifest_path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + with open(manifest_path, "w", encoding="utf-8") as fh: + for line in lines: + if line.startswith("APP_COMPOSE_FILE="): + fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') + else: + fh.write(line) + except OSError as exc: + self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) + return + + # Redeploy + result = run_panelctl(["deploy", name]) + self._json(200 if result["ok"] else 400, result) + except Exception as exc: + self._json(500, {"ok": False, "error": f"repo-pull failed: {exc}"}) + return + # POST /apps//remove if action == "remove": if not is_safe_name(name):