diff --git a/panel.nix b/panel.nix index 68e0e96..c104b27 100644 --- a/panel.nix +++ b/panel.nix @@ -1,5 +1,8 @@ -{ config, pkgs, ... }: +{ config, lib, pkgs, ... }: +let + forgejoServer = config.services.forgejo.settings.server; +in { environment.systemPackages = [ (pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh)) @@ -33,6 +36,8 @@ pkgs.zip pkgs.unzip pkgs.git + pkgs.util-linux # flock, used by panelctl to serialise routes file writes + pkgs.openssh # cloning repositories over ssh with the panel's deploy key ]; serviceConfig = { @@ -51,6 +56,12 @@ PANEL_BASE_DIR = "/var/lib/containers"; PANELCTL_PATH = "/run/current-system/sw/bin/panelctl"; PANEL_FRONTEND_DIR = "${./panel/frontend}"; + + # Forgejo integration (repo picker, private clones, commit links). + # The API is reached on localhost; clones use the public URLs. + PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL; + PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}"; + PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}"; }; }; diff --git a/panel/API.md b/panel/API.md index 256cef6..298faea 100644 --- a/panel/API.md +++ b/panel/API.md @@ -12,6 +12,11 @@ Default bind: `127.0.0.1:9911` |--------|------|-------------| | GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/health` | Health check | +| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) | +| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key | +| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects | +| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) | +| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository | ### Apps — Read @@ -25,6 +30,14 @@ Default bind: `127.0.0.1:9911` | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | +| GET | `/apps//env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` | +| GET | `/apps//repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) | +| GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | +| GET | `/apps//volumes` | Volumes the file browser can open | +| GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | +| GET | `/apps//volume/download?vol=&path=` | Download a file from a volume | +| PUT | `/apps//volume/files?vol=&path=` | Upload a file (raw body) | +| DELETE | `/apps//volume/files?vol=&path=` | Delete a file or folder | ### Apps — Write @@ -41,6 +54,79 @@ Default bind: `127.0.0.1:9911` | POST | `/apps//backup` | Create volume backup (zip) | | POST | `/apps//restore` | Restore from backup | | POST | `/apps//remove` | Remove app | +| POST | `/apps//repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | +| POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` | +| POST | `/apps//volume-clear` | Stop the app and empty its default data folder | + +Write operations are serialised per app. While one runs, another write to the +same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. +`deploy` returns the compose output in `stdout` (or `stderr` on failure). + +### Create app (git repository, with environment variables) + +```json +{ + "name": "blog", + "routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}], + "auth": true, + "source_type": "git", + "repo_url": "https://git.srazka.com/reudy-net/blog.git", + "repo_branch": "", + "use_forgejo_token": true, + "env": [{"key": "DATABASE_URL", "value": "postgres://..."}], + "env_inject": true +} +``` + +- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or + `git@host:owner/repo.git`. ssh URLs use the panel's deploy key. +- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the + token stored in Settings (only for URLs on the configured Forgejo host). +- An empty branch uses the repository's default branch. The compose file must + be at the repository root. +- The older `source_type: "github"` with `github_url` / `github_branch` / + `github_pat` is still accepted. + +### Sync response (`repo-pull`) + +```json +{ + "ok": true, + "stdout": "HEAD is now at d7df557 Bump image tag\n...compose output...", + "before": {"sha": "4fb7976...", "short": "4fb7976", "subject": "Initial compose", "author": "reudy", "time": 1790460618}, + "after": {"sha": "d7df557...", "short": "d7df557", "subject": "Bump image tag", "author": "reudy", "time": 1790460643}, + "changed": true +} +``` + +### Status response (`/status`) + +```json +{ + "ok": true, + "time": 1790460650, + "apps": [ + { + "name": "whoami", + "routes": [{"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}], + "auth": true, + "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", + "repo_url": "", + "repo_branch": "", + "busy": null, + "status": { + "state": "running", + "running": true, + "running_count": 1, + "total": 1, + "containers": [{"name": "whoami-app-1", "state": "running", "status": "Up 3 minutes", "image": "docker.io/traefik/whoami:latest", "running": true}] + } + } + ] +} +``` + +`state` is one of `running`, `partial` (some containers down), `stopped` or `unknown`. ## Example payloads diff --git a/panel/README.md b/panel/README.md index 02ede16..5f9cc49 100644 --- a/panel/README.md +++ b/panel/README.md @@ -22,14 +22,19 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp ## Quick workflow ```bash -# Create a new app (single domain) -panelctl init whoami whoami.srazka.com 18080 true +# Routes are "domain|upstream[|path]" entries, comma-separated. -# Create with multiple domains -panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true +# Create a new app (single route, protected by Authelia) +panelctl init whoami "whoami.srazka.com|127.0.0.1:18080" true + +# Create with multiple routes (different ports, optional path) +panelctl init myapp "app.srazka.com|127.0.0.1:18081,api.srazka.com|127.0.0.1:18082|/api/*" true # Create with wildcard domain (requires DNS challenge in Caddy) -panelctl init wild "*.srazka.com" 18082 false +panelctl init wild "*.srazka.com|127.0.0.1:18083" false + +# Change routes later (Caddy reloads automatically) +panelctl set-routes whoami "whoami.srazka.com|127.0.0.1:18080,who.srazka.com|127.0.0.1:18080" # Deploy (compose up + caddy reload) panelctl deploy whoami @@ -91,9 +96,70 @@ panelctl remove whoami ### Web UI features -- Create apps with multiple domains and wildcard support -- Live container status indicators (auto-refreshes) -- Deploy, restart, stop, remove from the UI -- Inline compose editor with save, validate, and save+deploy -- Log viewer with configurable tail length -- Volume backup management: create, list, download, restore +- Live status: one `/status` poll every few seconds (faster while something is + running, paused when the tab is hidden) updates cards in place, so open tabs, + unsaved edits and scroll positions are never lost. The header shows when the + panel last synced and warns when the Authelia session has expired. +- Per-app status (running / partial / stopped), container list, and a busy + indicator that is shared between browsers while an operation runs. +- New-app dialog: starter container, pasted compose file or git repository; + suggests the next free port and a domain based on the app name. +- Compose editor with unsaved-changes tracking, Ctrl+S, save & deploy, validate. +- Logs with follow mode, routes editor with validation, file browser with + drag-and-drop upload, backups with restore (and optional redeploy). +- Git source tab: deployed commit, "check for updates", and sync & deploy. +- Activity drawer with the output of every operation (e.g. why a deploy failed). +- Keyboard: `/` search, `N` new app, `Esc` closes menus. Deep links like + `#/whoami/logs` open an app on a specific tab. + +### Git-backed apps + +Apps created from a repository (Forgejo, GitHub or any git host, over https or +ssh) are cloned to `stacks//repo`. + +**Forgejo.** `panel.nix` points the panel at the local Forgejo +(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken +from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo +access token (read access to repositories and user). With it, the new-app +dialog lists your repositories and branches, and private ones are cloned over +https with the token. Without it, public repositories are listed and private +ones are cloned over ssh with the deploy key. Commit and compare links point at +Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600). + +**SSH / deploy key.** The panel generates an ed25519 key pair in +`state/panel/ssh/` the first time it is needed. Its public half is shown in +Settings (and next to ssh URLs); add it as a read-only deploy key to a +repository — or to your Forgejo account for access to all repositories — to +clone `ssh://git@git.srazka.com:14922/owner/repo.git` style URLs. **Sync** fetches the configured branch and +hard-resets the checkout to it before redeploying, so the repository is the +source of truth: compose edits made in the panel are discarded on the next sync +(the UI warns about this). An access token for a private repository is stored in +the clone's `.git/config`; use a read-only token. + +### Environment variables + +Each app can have environment variables (the **Environment** tab, or when +creating the app; `.env` text can be pasted in). They are stored in +`state/env/.env` as `KEY=VALUE` lines (mode 0600) — outside the repository +and stack directory, so git syncs never touch them — and `panelctl` passes them +to every compose command: + +- They are always available for `${VAR}` interpolation in the compose file. + The UI points out variables the compose file uses without a default that + aren't set. +- With **Pass to every container** (the default), `deploy`/`restart` also + generate `stacks//.panel-env.yaml`, a compose override that lists the + keys under every service's `environment:`. Compose reads the values from its + own environment, so they are never quoted into YAML, and they take + precedence over values set in the compose file. + +Values must be single-line. Names that would change how podman/compose run +(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected. +Changes apply on the next deploy. Backups do not include variables. + +### Concurrency + +`panel-api` handles requests concurrently, so a long deploy never blocks status +or logs. Mutating operations are serialised per app — a second operation on a +busy app gets HTTP 409 — and `panelctl` takes a `flock` on the shared routes +file while rewriting it. diff --git a/panel/frontend/index.html b/panel/frontend/index.html index 154e071..298ffb1 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -1,1191 +1,2877 @@ - - - Panel - - - - + /* ── Menu ── */ + .menu { + position: fixed; z-index: 50; min-width: 190px; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 5px; + } + .menu-item { + display: flex; align-items: center; gap: 9px; width: 100%; + background: none; border: 0; border-radius: 6px; padding: 7px 10px; + color: var(--text); font: 13.5px var(--font); cursor: pointer; text-align: left; + } + .menu-item:hover:not(:disabled) { background: var(--surface-2); } + .menu-item:disabled { opacity: .45; cursor: default; } + .menu-item.danger { color: var(--danger); } + .menu-sep { height: 1px; background: var(--border); margin: 4px 2px; } + + /* ── Toasts ── */ + .toasts { position: fixed; right: 16px; bottom: 16px; z-index: 60; display: flex; flex-direction: column; gap: 8px; width: min(380px, calc(100vw - 32px)); } + .toast { + display: flex; gap: 10px; align-items: flex-start; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 11px 12px; + animation: toast-in .18s ease-out; + } + .toast.leaving { opacity: 0; transform: translateY(6px); transition: .18s; } + @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + .toast > .icon { margin-top: 2px; } + .toast-success > .icon { color: var(--ok); } + .toast-error > .icon { color: var(--danger); } + .toast-info > .icon { color: var(--accent); } + .toast-body { flex: 1; min-width: 0; } + .toast-title { font-weight: 600; font-size: 13.5px; } + .toast-detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } + .toast .link { font-size: 12.5px; margin-top: 4px; } + .toast-close { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; border-radius: 4px; } + .toast-close:hover { color: var(--text); background: var(--surface-2); } + + /* ── Activity drawer ── */ + .drawer { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 40; + width: min(460px, 100vw); + background: var(--surface); border-left: 1px solid var(--border-strong); + box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; + animation: slide-in .18s ease-out; + } + @keyframes slide-in { from { transform: translateX(24px); opacity: 0; } } + .drawer-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); } + .drawer-head h2 { font-size: 15px; flex: 1; } + .drawer-body { flex: 1; overflow: auto; padding: 8px; } + .act { border-radius: 8px; padding: 2px 0; } + .act + .act { border-top: 1px solid var(--border); } + .act summary { list-style: none; cursor: pointer; border-radius: 6px; } + .act summary::-webkit-details-marker { display: none; } + .act summary:hover { background: var(--surface-2); } + .act-head { display: flex; align-items: center; gap: 8px; padding: 8px; font-size: 13px; } + .act-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .act-ok { color: var(--ok); } + .act-error { color: var(--danger); } + .act pre { margin: 0 8px 8px; } + + /* ── Dialogs ── */ + dialog { + border: 1px solid var(--border-strong); border-radius: 14px; padding: 0; + background: var(--surface); color: var(--text); + box-shadow: var(--shadow-lg); + width: min(640px, calc(100vw - 24px)); max-height: calc(100vh - 32px); + } + dialog.sm { width: min(440px, calc(100vw - 24px)); } + dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } + dialog form { display: flex; flex-direction: column; max-height: calc(100vh - 34px); } + .dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 6px; } + .dialog-head h2, dialog.sm h2 { font-size: 17px; } + dialog.sm form { padding: 18px; } + dialog.sm .dialog-body { padding: 10px 0 4px; } + .dialog-body { padding: 10px 18px; overflow: auto; } + .dialog-body p + p { margin-top: 8px; } + .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; border-top: 1px solid var(--border); } + dialog.sm .dialog-actions { padding: 14px 0 0; border: 0; } + .segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 9px; padding: 3px; gap: 2px; background: var(--surface-2); flex-wrap: wrap; } + .segmented button { + border: 0; background: none; padding: 6px 12px; border-radius: 6px; + font: 500 13px var(--font); color: var(--muted); cursor: pointer; + } + .segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); } + + /* ── Repo picker ── */ + .picker-list { + margin-top: 6px; max-height: 232px; overflow: auto; + border: 1px solid var(--border); border-radius: 8px; + } + .picker-item { + display: flex; align-items: center; gap: 10px; width: 100%; + background: none; border: 0; border-top: 1px solid var(--border); + padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit; + } + .picker-item:first-child { border-top: 0; } + .picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; } + .picker-item.selected { background: var(--accent-soft); } + .picker-item .icon { color: var(--muted); } + .picker-name { font-weight: 600; font-size: 13.5px; } + .picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .picker-main { flex: 1; min-width: 0; } + + /* ── Settings ── */ + .settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); } + .settings-section h3 { font-size: 14px; margin-bottom: 4px; } + .keybox { + margin-top: 10px; display: flex; gap: 8px; align-items: flex-start; + background: var(--surface-2); border-radius: 8px; padding: 10px 12px; + font: 12px/1.5 var(--mono); word-break: break-all; + } + .keybox code { flex: 1; background: none; padding: 0; } + + /* ── Environment editor ── */ + .env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; } + .env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); } + .env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; } + .env-details summary { cursor: pointer; list-style: none; } + .env-details summary::-webkit-details-marker { display: none; } + .env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; } + .env-details[open] summary::before { transform: rotate(90deg); } + .missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + .chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); } + + /* ── Responsive ── */ + @media (max-width: 760px) { + .hide-sm { display: none !important; } + .topbar-inner { flex-wrap: wrap; } + .search { order: 3; max-width: none; margin-left: 0; flex-basis: 100%; } + .search kbd, .shortcuts { display: none; } + .grid-2 { grid-template-columns: 1fr; } + /* Card header: name + actions on top, status pill underneath. */ + .app-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px 10px; padding: 12px; } + .app-head > .chev { grid-row: 1 / span 2; align-self: start; margin-top: 3px; } + .app-title { grid-column: 2; grid-row: 1; } + .app-actions { grid-column: 3; grid-row: 1; align-self: start; } + .app-head > .pill { grid-column: 2; grid-row: 2; justify-self: start; min-width: 0; } + .route-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px; } + .route-row .arrow { display: none; } + .route-row .route-path { grid-column: 1 / 3; } + .route-head { display: none; } + .env-row { grid-template-columns: minmax(0, 1fr) 28px; } + .env-row > :nth-child(2) { grid-column: 1; } + .env-row > :nth-child(3) { grid-column: 2; grid-row: 1; } + .env-head { display: none; } + .file-row { grid-template-columns: 20px minmax(0, 1fr) auto; } + .file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; } + .logs { height: 320px; } + } + + +
+
+
Panel
+ +
+ + + + +
+
+
+ + +
- -
-
-

Containers Panel

-

Rootless Podman + Caddy routes from one place.

-
-
- -
-
+
+
+
/ search · N new app
+
+
+
+
+
- -
Ready.
+ - -
- -
-
-

Create App

- - +
+ - -
-
- + +
+
+

New app

+ +
+
+
+ + +

Lowercase letters, digits and dashes. Used for the compose project and data folder.

+
+ +
+ Source +
+ + +
-

- Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT. Optional path: /_/* -

+

+
-
- - + + +
- +
+ + +
+ + + + +
+
+

Settings

+ +
+
+
+

Forgejo

+

Loading…

+
+ +
+ + +
+

Create one in Forgejo under Settings → Applications with read access to repositories (and your user). + It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.

+
+ +
+
+

SSH deploy key

+

Used for repositories cloned over SSH (e.g. ssh://git@host/owner/repo.git). Add it as a read-only deploy key in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.

+
Loading…
+
+
+
+
+ + +
+

+
+ +
+ + +
+
+ + +
+
+
diff --git a/panel/panel-api.py b/panel/panel-api.py index ae01dfd..5e2edd6 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -4,12 +4,18 @@ import json import os import re +import shlex import shutil +import socket import subprocess -from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import urlparse, parse_qs -import urllib.request +import threading +import time import urllib.error +import urllib.request +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") @@ -20,11 +26,415 @@ FRONTEND_DIR = os.environ.get( os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"), ) +# Optional Forgejo instance (set from panel.nix). The API URL may be an internal +# address; the public URL is what repositories are cloned from and linked to. +FORGEJO_URL = os.environ.get("PANEL_FORGEJO_URL", "").rstrip("/") +FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).rstrip("/") +FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/") +FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or "" + +PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel") +FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token") +SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh") +SSH_KEY = os.path.join(SSH_DIR, "id_ed25519") +ENV_DIR = os.path.join(BASE_DIR, "state", "env") + +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. +_URL_CHARS = r"[^\s\"'`$\\]" +REPO_URL_RE = re.compile( + rf"^(?:https?://{_URL_CHARS}+" # https://host/owner/repo.git + rf"|ssh://{_URL_CHARS}+" # ssh://git@host:port/owner/repo.git + rf"|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:{_URL_CHARS}+)$" # git@host:owner/repo.git +) +BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$") +FORGEJO_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") + +ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# Variables are set on the compose process itself, so anything that changes how +# podman/compose run (or where they look for state) is off limits. +RESERVED_ENV = {"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"} +RESERVED_ENV_PREFIXES = ("XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", + "PODMAN_", "BUILDAH_", "LD_", "BASH_") + 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 http(s) URLs. + (ssh://git@host is a username, not a secret, and must be kept.)""" + return re.sub(r"(https?://)[^/@\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 "" + + +def git_error(stderr): + """The informative line of a git failure (git ends with generic advice).""" + lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()] + for line in lines: + if re.match(r"^(ssh|fatal|error|remote):", line, re.I) and "could not read from remote" not in line.lower(): + return re.sub(r"^fatal:\s*", "", line) + return last_line(stderr) + + +def write_private_file(path, content): + """Atomically write a file only the service user can read.""" + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + tmp = f"{path}.tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + os.replace(tmp, path) + + +# ── SSH deploy key ── +# One key pair for the panel; add its public half as a (read-only) deploy key +# to repositories cloned over SSH. + +def ssh_public_key(create=True): + pub = SSH_KEY + ".pub" + if not os.path.isfile(pub) and create: + keygen = shutil.which("ssh-keygen") + if not keygen: + return None + os.makedirs(SSH_DIR, mode=0o700, exist_ok=True) + subprocess.run( + [keygen, "-t", "ed25519", "-N", "", "-q", "-C", f"panel@{socket.gethostname()}", "-f", SSH_KEY], + capture_output=True, check=False, timeout=30, + ) + try: + with open(pub, "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return None + + +def is_ssh_url(url): + return not re.match(r"^https?://", url or "") + + +# ── Git helpers ── + +def git_env(): + # Never block on an interactive credential prompt. + env = dict(os.environ, GIT_TERMINAL_PROMPT="0") + if os.path.isfile(SSH_KEY): + env["GIT_SSH_COMMAND"] = " ".join([ + "ssh", "-i", shlex.quote(SSH_KEY), + "-o", "IdentitiesOnly=yes", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UserKnownHostsFile=" + shlex.quote(os.path.join(SSH_DIR, "known_hosts")), + ]) + return env + + +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 + try: + proc = subprocess.run(cmd, capture_output=True, text=True, env=git_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 and not is_ssh_url(url): + auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1) + elif is_ssh_url(url): + ssh_public_key() # make sure the deploy key exists before the first clone + args = ["clone"] + if branch: + args += ["--branch", branch] + return run_git(args + ["--", auth_url, target_dir]) + + +def repo_host_and_path(url): + url = redact_credentials(url or "", "") + m = (re.match(r"^https?://([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) + or re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) + or re.match(r"^(?:[^@/]+@)?([^/:]+):(?!/)(.+?)(?:\.git)?/?$", url)) + return (m.group(1), m.group(2)) if m else (None, None) + + +def repo_provider(url): + host, _ = repo_host_and_path(url) + if host and FORGEJO_HOST and host == FORGEJO_HOST: + return "forgejo" + if host in ("github.com", "www.github.com"): + return "github" + return "git" + + +def repo_web_url(url): + """Browser URL of a repository, for commit / compare links.""" + host, path = repo_host_and_path(url) + if not host: + return "" + if FORGEJO_HOST and host == FORGEJO_HOST: + return f"{FORGEJO_URL}/{path}" + m = re.match(r"^(https?)://", url or "") + return f"{m.group(1) if m else 'https'}://{host}/{path}" + + +# ── Forgejo ── + +class ForgejoError(Exception): + pass + + +def forgejo_token(): + try: + with open(FORGEJO_TOKEN_FILE, "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return "" + + +def forgejo_api(path, token=None, timeout=10): + if not FORGEJO_API_URL: + raise ForgejoError("no Forgejo instance is configured") + token = forgejo_token() if token is None else token + req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"}) + if token: + req.add_header("Authorization", f"token {token}") + try: + with urllib.request.urlopen(req, timeout=timeout) as res: + return json.loads(res.read().decode("utf-8") or "null") + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise ForgejoError("Forgejo rejected the token") from exc + if exc.code == 404: + raise ForgejoError("not found on Forgejo (or no access)") from exc + raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc + except (urllib.error.URLError, TimeoutError, ValueError) as exc: + raise ForgejoError(f"can't reach Forgejo: {getattr(exc, 'reason', exc)}") from exc + + +def is_forgejo_https_url(url): + return bool(FORGEJO_URL) and not is_ssh_url(url) and repo_provider(url) == "forgejo" + + +# ── Environment variables ── +# Stored per app in state/env/.env as KEY=VALUE lines (0600). panelctl +# passes them to every compose command, so they work for ${VAR} interpolation +# and — unless disabled — are injected into every service. + +def env_file_path(name): + return os.path.join(ENV_DIR, f"{name}.env") + + +def read_app_env(name): + items = [] + try: + with open(env_file_path(name), "r", encoding="utf-8") as fh: + lines = fh.read().splitlines() + except OSError: + return items + for line in lines: + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + items.append({"key": key, "value": value}) + return items + + +def validate_env(items): + if items is None: + return [] + if not isinstance(items, list): + raise ValueError("env must be a list of {key, value}") + seen = set() + out = [] + for item in items: + if not isinstance(item, dict): + raise ValueError("env must be a list of {key, value}") + key = str(item.get("key", "")).strip() + value = item.get("value", "") + value = "" if value is None else str(value) + if not key and not value: + continue + if not ENV_KEY_RE.match(key): + raise ValueError(f"'{key}' is not a valid variable name (letters, digits and _, not starting with a digit)") + if key in RESERVED_ENV or key.startswith(RESERVED_ENV_PREFIXES): + raise ValueError(f"'{key}' is reserved because it would change how podman/compose run") + if key in seen: + raise ValueError(f"'{key}' is set more than once") + if any(c in value for c in "\n\r\0"): + raise ValueError(f"the value of '{key}' must be a single line") + seen.add(key) + out.append({"key": key, "value": value}) + return out + + +def write_app_env(name, items): + path = env_file_path(name) + if not items: + try: + os.remove(path) + except FileNotFoundError: + pass + return + write_private_file(path, "".join(f"{i['key']}={i['value']}\n" for i in items)) + + +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 + repo_url = redact_credentials(env.get("APP_REPO_URL", ""), "") + 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": repo_url, + "repo_branch": env.get("APP_REPO_BRANCH", ""), + "repo_provider": repo_provider(repo_url) if repo_url else "", + "repo_web_url": repo_web_url(repo_url) if repo_url else "", + "env_count": len(read_app_env(name)), + "env_inject": env.get("APP_ENV_INJECT", "true") != "false", + }) + return apps + + def run_panelctl(args): proc = subprocess.run( [PANELCTL, *args], @@ -54,11 +464,12 @@ def parse_env_blob(blob): def get_app_volumes(name): result = run_panelctl(["inspect-volumes", name]) volumes = {} - if result["ok"]: - for line in result["stdout"].splitlines(): - if "|" in line: - vname, vpath = line.split("|", 1) - volumes[vname.strip()] = vpath.strip() + # 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): @@ -83,36 +494,92 @@ def read_app_info(name): 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): - """Try to determine if any container is running from panelctl status output.""" - text = stdout.lower() - if not text or "no containers" in text: - return {"running": False, "raw": stdout} - # podman compose ps --format json returns JSON array - try: - containers = json.loads(stdout) - if isinstance(containers, list): - running = any( - c.get("State", "").lower() == "running" - or c.get("status", "").lower().startswith("up") - for c in containers - ) - return { - "running": running, - "containers": [ - { - "name": c.get("Name", c.get("name", "?")), - "state": c.get("State", c.get("status", "unknown")), - "image": c.get("Image", c.get("image", "")), - } - for c in containers - ], - } - except (json.JSONDecodeError, TypeError): - pass - # Fallback: check for "Up" or "running" in text - running = "up" in text or "running" in text - return {"running": running, "raw": 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): @@ -136,6 +603,10 @@ def parse_backups_output(stdout): 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") @@ -160,17 +631,24 @@ class Handler(BaseHTTPRequestHandler): 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: - return {} - raw = self.rfile.read(length) - return json.loads(raw.decode("utf-8")) + 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) @@ -202,6 +680,92 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "service": "panel-api"}) return + # /integrations — Forgejo connection and the panel's SSH deploy key + if path == "/integrations": + token = forgejo_token() + forgejo = { + "configured": bool(FORGEJO_URL), + "url": FORGEJO_URL, + "ssh_url": FORGEJO_SSH_URL, + "has_token": bool(token), + "user": None, + } + if FORGEJO_URL and token: + try: + forgejo["user"] = (forgejo_api("/api/v1/user", timeout=5) or {}).get("login") + except ForgejoError as exc: + forgejo["error"] = str(exc) + self._json(200, {"ok": True, "forgejo": forgejo, "ssh": {"public_key": ssh_public_key()}}) + return + + # /forgejo/repos?q= — repositories visible to the stored token (public ones without) + if path == "/forgejo/repos": + q = query.get("q", [""])[0].strip() + try: + data = forgejo_api(f"/api/v1/repos/search?q={quote(q)}&limit=50&sort=updated&order=desc") + except ForgejoError as exc: + self._json(502, {"ok": False, "error": str(exc)}) + return + repos = [{ + "full_name": r.get("full_name", ""), + "description": r.get("description", ""), + "private": bool(r.get("private")), + "empty": bool(r.get("empty")), + "archived": bool(r.get("archived")), + "default_branch": r.get("default_branch", ""), + "clone_url": r.get("clone_url", ""), + "ssh_url": r.get("ssh_url", ""), + "html_url": r.get("html_url", ""), + "updated_at": r.get("updated_at", ""), + } for r in (data or {}).get("data", [])] + self._json(200, {"ok": True, "repos": repos, "authenticated": bool(forgejo_token())}) + return + + # /forgejo/branches?repo=owner/name + if path == "/forgejo/branches": + repo = query.get("repo", [""])[0].strip() + if not FORGEJO_REPO_RE.match(repo): + self._json(400, {"ok": False, "error": "repo must look like owner/name"}) + return + try: + data = forgejo_api(f"/api/v1/repos/{repo}/branches?limit=100") + except ForgejoError as exc: + self._json(502, {"ok": False, "error": str(exc)}) + return + self._json(200, {"ok": True, "branches": [b.get("name", "") for b in (data or [])]}) + return + + # /apps//env — environment variables used when deploying + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "env": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + self._json(200, { + "ok": True, + "name": name, + "vars": read_app_env(name), + "inject": app.get("APP_ENV_INJECT", "true") != "false", + }) + 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"]: @@ -334,31 +898,51 @@ class Handler(BaseHTTPRequestHandler): if not result["ok"]: self._json(404, result) return - env = parse_env_blob(result["stdout"]) - 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_str = env.get("APP_DOMAINS", env["APP_DOMAIN"]) - routes_parts = [] - for d in domains_str.split(","): - d = d.strip() - if d: - routes_parts.append(f"{d}|{upstream}") - routes_raw = ",".join(routes_parts) - routes = [] - for entry in routes_raw.split(","): - entry = entry.strip() - if not entry: - continue - parts = entry.split("|", 2) - route = {"domain": parts[0].strip(), "upstream": parts[1].strip()} - if len(parts) > 2: - route["path"] = parts[2].strip() - routes.append(route) + routes = manifest_routes(parse_env_blob(result["stdout"])) self._json(200, {"ok": True, "name": name, "routes": routes}) return + # /apps//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, ""), + "web_url": repo_web_url(repo_url), + "provider": repo_provider(repo_url), + "ssh": is_ssh_url(repo_url), + "branch": branch, + "cloned": os.path.isdir(os.path.join(repo_dir, ".git")), + } + if info["ssh"]: + info["public_key"] = ssh_public_key() + 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"] = git_error(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/ — show single app if len(parts) == 2 and parts[0] == "apps": name = parts[1] @@ -575,6 +1159,28 @@ class Handler(BaseHTTPRequestHandler): 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: @@ -614,10 +1220,34 @@ class Handler(BaseHTTPRequestHandler): self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) return - if source_type not in ["default", "raw", "github"]: + if source_type == "github": # older clients + source_type = "git" + if source_type not in ["default", "raw", "git"]: self._json(400, {"ok": False, "error": "invalid source_type"}) return + # Validate everything before creating anything. + try: + env_items = validate_env(payload.get("env")) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + env_inject = payload.get("env_inject", True) is not False + + if source_type == "git": + repo_url = str(payload.get("repo_url") or payload.get("github_url") or "").strip() + branch = str(payload.get("repo_branch") or payload.get("github_branch") or "").strip() + token = str(payload.get("repo_token") or payload.get("github_pat") or "").strip() + if not REPO_URL_RE.match(repo_url) or repo_url.startswith("-"): + self._json(400, {"ok": False, "error": "repository URL must be an https://, ssh:// or git@host:owner/repo URL"}) + return + if branch and not BRANCH_RE.match(branch): + self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"}) + return + # Clone a Forgejo repository with the panel's stored token. + if not token and payload.get("use_forgejo_token") and is_forgejo_https_url(repo_url): + token = forgejo_token() + try: result = run_panelctl(["init", name, routes_str, auth]) if not result["ok"]: @@ -641,105 +1271,117 @@ class Handler(BaseHTTPRequestHandler): self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"}) return - elif source_type == "github": - repo_url = payload.get("github_url", "").strip() - branch = payload.get("github_branch", "main").strip() - pat = payload.get("github_pat", "").strip() - - if not repo_url: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "github_url is required"}) - return - - # Validate URL and extract owner/repo - match = re.match(r'^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?$', repo_url) - if not match: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "invalid github_url format"}) - return - - owner, repo = match.groups() - - if pat: - # Check GitHub API access - api_url = f"https://api.github.com/repos/{owner}/{repo}" - req = urllib.request.Request(api_url, headers={"Authorization": f"Bearer {pat}"}) - try: - urllib.request.urlopen(req) - except urllib.error.URLError as e: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": f"github api check failed: {e.reason}"}) - return - - # Clone the repository - git_bin = shutil.which("git") - if not git_bin: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "git is not installed or not in PATH"}) - return - + summary = "initialized successfully" + if source_type == "git": + # Any git host works (Forgejo, GitHub, ...), over https or ssh. + # An https token is embedded in the clone URL, so later syncs + # reuse it from .git/config; ssh uses the panel's deploy key. target_dir = os.path.join(app["APP_STACK_DIR"], "repo") if os.path.exists(target_dir): shutil.rmtree(target_dir) - auth_url = repo_url - if pat: - auth_url = auth_url.replace("://", f"://{pat}@") - - if not auth_url.endswith(".git"): - auth_url += ".git" - - clone_result = subprocess.run( - [git_bin, "clone", "--branch", branch, auth_url, target_dir], - capture_output=True, text=True - ) - - if clone_result.returncode != 0: + 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: {clone_result.stderr.strip()}"}) + self._json(400, { + "ok": False, + "error": f"git clone failed: {git_error(cloned['stderr'])}", + "stderr": cloned["stderr"], + }) return + branch = branch or repo_current_branch(target_dir) or "main" - # 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 - + compose_path = find_compose_file(target_dir) if not compose_path: run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "could not find compose file in repository root"}) + self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"}) return - - # Update manifest - 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) - fh.write(f'APP_REPO_URL="{repo_url}"\n') - fh.write(f'APP_REPO_BRANCH="{branch}"\n') - except OSError as exc: + 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 - self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"}) + commit = repo_commit(target_dir) + summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned" + + if env_items or not env_inject: + write_app_env(name, env_items) + update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"}) + summary += f"\n{len(env_items)} environment variable(s) set" + + self._json(200, {"ok": True, "code": 0, "stdout": summary}) except Exception as exc: run_panelctl(["remove", name]) self._json(500, {"ok": False, "error": f"init failed: {exc}"}) return + # POST /integrations/forgejo {"token": "..."} — verify and store ("" clears it) + if path == "/integrations/forgejo": + if not FORGEJO_URL: + self._json(400, {"ok": False, "error": "no Forgejo instance is configured (PANEL_FORGEJO_URL)"}) + return + try: + token = str(self._read_json().get("token", "")).strip() + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + if not token: + try: + os.remove(FORGEJO_TOKEN_FILE) + except FileNotFoundError: + pass + self._json(200, {"ok": True, "has_token": False}) + return + try: + user = (forgejo_api("/api/v1/user", token=token) or {}).get("login") + except ForgejoError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + write_private_file(FORGEJO_TOKEN_FILE, token + "\n") + self._json(200, {"ok": True, "has_token": True, "user": user}) + return + if len(parts) >= 3 and parts[0] == "apps": name = parts[1] action = parts[2] + # POST /apps//env — replace environment variables, optionally redeploy + if action == "env": + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + try: + payload = self._read_json() + items = validate_env(payload.get("vars", [])) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + inject = payload.get("inject", True) is not False + try: + write_app_env(name, items) + update_manifest(name, {"APP_ENV_INJECT": "true" if inject else "false"}) + except (OSError, ValueError) as exc: + self._json(500, {"ok": False, "error": f"failed to save variables: {exc}"}) + return + result = {"ok": True, "name": name, "count": len(items), + "stdout": f"saved {len(items)} environment variable(s)"} + if payload.get("deploy"): + deployed = run_panelctl(["deploy", name]) + deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]])) + deployed["count"] = len(items) + self._json(200 if deployed["ok"] else 400, deployed) + return + self._json(200, result) + return + # POST /apps//compose — save compose file if action == "compose": app, err = read_app_info(name) @@ -839,96 +1481,82 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return - # POST /apps//repo-pull — re-clone/pull repo and redeploy + # POST /apps//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: {git_error(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: {git_error(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: {git_error(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: - 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 + 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 - 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}"}) + 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//remove @@ -952,7 +1580,8 @@ class Handler(BaseHTTPRequestHandler): def main(): - server = HTTPServer((BIND, PORT), Handler) + 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() diff --git a/panel/panelctl.sh b/panel/panelctl.sh index 4490418..4e9d056 100644 --- a/panel/panelctl.sh +++ b/panel/panelctl.sh @@ -7,8 +7,15 @@ VOLUMES_DIR="${BASE_DIR}/volumes" ROUTES_DIR="${BASE_DIR}/routes" STATE_DIR="${BASE_DIR}/state" APPS_DIR="${STATE_DIR}/apps" +ENV_DIR="${STATE_DIR}/env" BACKUPS_DIR="${BASE_DIR}/backups" +# Set by load_app: compose file arguments, and the app's environment variables +# as KEY=VALUE words (passed to compose via env(1), never sourced). +COMPOSE_ARGS=() +APP_ENV_ARGS=() +APP_ENV_KEYS=() + FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Email Remote-Name @@ -149,6 +156,19 @@ app_route_file() { echo "${ROUTES_DIR}/routes.caddy" } +# The routes file is shared by all apps and rewritten read-modify-write, so +# concurrent panelctl runs (the API handles requests in parallel) must take turns. +routes_lock() { + exec 9>"${ROUTES_DIR}/.routes.lock" + if command -v flock >/dev/null 2>&1; then + flock -w 30 9 || fail "timed out waiting for the routes file lock" + fi +} + +routes_unlock() { + exec 9>&- +} + load_app() { local name="$1" local manifest @@ -173,6 +193,75 @@ load_app() { done APP_ROUTES="${routes}" fi + + load_app_env "${name}" +} + +app_env_file() { + echo "${ENV_DIR}/$1.env" +} + +# Generated compose override that passes the app's variables into every service. +app_env_override() { + echo "${STACKS_DIR}/$1/.panel-env.yaml" +} + +load_app_env() { + local name="$1" + local file line key override + file="$(app_env_file "${name}")" + APP_ENV_ARGS=() + APP_ENV_KEYS=() + if [[ -f "${file}" ]]; then + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue + key="${line%%=*}" + [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + APP_ENV_ARGS+=("${line}") + APP_ENV_KEYS+=("${key}") + done <"${file}" + fi + + COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") + override="$(app_env_override "${name}")" + if [[ -f "${override}" ]]; then + COMPOSE_ARGS+=(-f "${override}") + fi +} + +# (Re)generate the env override before containers are created. Variables are +# always available for ${VAR} interpolation; with APP_ENV_INJECT (default true) +# every service also receives them. Bare keys make compose read the values from +# its own environment, so values never have to be quoted into YAML. +prepare_env_override() { + local name="$1" + local override services svc key tmp + override="$(app_env_override "${name}")" + + if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then + rm -f "${override}" + COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") + return + fi + + services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \ + || fail "could not list compose services to pass environment variables (is the compose file valid?)" + + tmp="$(mktemp)" + { + echo "# Generated by panelctl from the app's environment variables. Do not edit." + echo "services:" + while IFS= read -r svc; do + [[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue + printf ' "%s":\n environment:\n' "${svc}" + for key in "${APP_ENV_KEYS[@]}"; do + printf ' - %s\n' "${key}" + done + done <<<"${services}" + } >"${tmp}" + install -m 0640 "${tmp}" "${override}" + rm -f "${tmp}" + COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}") } compose_command() { @@ -239,11 +328,11 @@ run_compose() { if [[ "${compose}" == *" compose" ]]; then local podman_bin="${compose% compose}" - "${podman_bin}" compose "$@" + env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@" return fi - "${compose}" "$@" + env "${APP_ENV_ARGS[@]}" "${compose}" "$@" } write_default_compose() { @@ -337,6 +426,8 @@ cmd_render_route() { local route_file route_file="$(app_route_file)" + routes_lock + # Strip any existing block for this app from the aggregate file. local tmp tmp="$(mktemp)" @@ -376,6 +467,8 @@ cmd_render_route() { } >>"${tmp}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock log info "rendered route ${route_file}" } @@ -387,11 +480,19 @@ cmd_deploy() { log info "Starting deployment for app '${name}'" cmd_render_route "${name}" + prepare_env_override "${name}" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then + # Capture compose output so callers (the web UI) can show why a deploy failed, + # and still forward it to the journal. + local output + if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then + printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true + printf '%s\n' "${output}" >&2 log err "Deployment failed for app '${name}'" fail "compose up failed" fi + printf '%s\n' "${output}" | systemd-cat -t panelctl -p info 2>/dev/null || true + printf '%s\n' "${output}" log info "Successfully deployed app '${name}'" } @@ -403,9 +504,10 @@ cmd_restart() { log info "Restarting app '${name}'" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" + prepare_env_override "${name}" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1; then + if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then fail "compose up failed during restart" fi @@ -417,7 +519,7 @@ cmd_stop() { validate_name "${name}" load_app "${name}" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" log info "stopped app '${name}'" } @@ -426,8 +528,8 @@ cmd_status() { validate_name "${name}" load_app "${name}" - run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \ - run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \ + run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \ + run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \ log info "no containers running" } @@ -450,7 +552,7 @@ cmd_logs() { esac done - run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" + run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" } cmd_validate_compose() { @@ -458,11 +560,11 @@ cmd_validate_compose() { validate_name "${name}" load_app "${name}" - if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then + if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then log info "compose file is valid" else local output - output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)" + output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)" fail "compose validation failed: ${output}" fi } @@ -473,19 +575,22 @@ cmd_remove() { validate_name "${name}" load_app "${name}" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true # Remove this app's block from the aggregate routes file. local route_file route_file="$(app_route_file)" if [[ -f "${route_file}" ]]; then + routes_lock local tmp tmp="$(mktemp)" sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock fi - rm -f "$(app_manifest "${name}")" + rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")" rm -rf "${APP_STACK_DIR}" if [[ "${keep_volumes}" != "--keep-volumes" ]]; then @@ -551,10 +656,10 @@ cmd_backup() { # Stop containers before backup for consistency local was_running=false - if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then + if run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then was_running=true log info "stopping containers for consistent backup..." - run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true fi (cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed" @@ -569,7 +674,7 @@ cmd_backup() { # Restart if it was running if [[ "${was_running}" == "true" ]]; then log info "restarting containers after backup..." - run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true fi local size @@ -606,7 +711,7 @@ cmd_volume_clear() { load_app "${name}" log info "clearing volume data for app '${name}'" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true local data_dir="${APP_VOLUME_DIR}/data" if [[ -d "${data_dir}" ]]; then @@ -643,7 +748,7 @@ cmd_restore() { # Stop containers before restore log info "stopping containers for restore..." - run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true # Clear existing volume data and extract backup rm -rf "${volume_dir:?}"/* @@ -671,7 +776,8 @@ cmd_inspect_volumes() { if [[ -n "${podman_bin}" ]]; then "${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true "${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true - fi | sort -u | grep -v '^$' + # grep exits 1 when there are no named volumes; that is not an error. + fi | sort -u | grep -v '^$' || true } cmd_list() {