Deployments, auto deploy, service routes, live logs, metrics and a terminal #1

Merged
reudy merged 1 commit from agent/deployments into main 2026-09-27 21:28:26 +02:00 AGit
8 changed files with 2997 additions and 313 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
result result
result-* result-*
frontend/vendor/
__pycache__/

107
API.md
View file

@ -44,9 +44,9 @@ Default bind: `127.0.0.1:9911`
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| POST | `/apps/init` | Create a new app | | POST | `/apps/init` | Create a new app |
| POST | `/apps/<name>/routes` | Update routes (hot — Caddy reloads automatically) | | POST | `/apps/<name>/routes` | Update routes (hot — Caddy reloads automatically); answers `needs_deploy` when a newly published port needs a deploy |
| POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) | | POST | `/apps/<name>/deploy` | Queue a deployment (compose up + caddy reload) — see *Deployments* |
| POST | `/apps/<name>/restart` | Restart (compose down + up) | | POST | `/apps/<name>/restart` | Queue a restart (compose down + up) |
| POST | `/apps/<name>/stop` | Stop (compose down) | | POST | `/apps/<name>/stop` | Stop (compose down) |
| POST | `/apps/<name>/render-route` | Re-render Caddy route | | POST | `/apps/<name>/render-route` | Re-render Caddy route |
| POST | `/apps/<name>/compose` | Save compose.yaml content | | POST | `/apps/<name>/compose` | Save compose.yaml content |
@ -54,20 +54,90 @@ Default bind: `127.0.0.1:9911`
| POST | `/apps/<name>/backup` | Create volume backup (zip) | | POST | `/apps/<name>/backup` | Create volume backup (zip) |
| POST | `/apps/<name>/restore` | Restore from backup | | POST | `/apps/<name>/restore` | Restore from backup |
| POST | `/apps/<name>/remove` | Remove app | | POST | `/apps/<name>/remove` | Remove app |
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | | POST | `/apps/<name>/repo-pull` | Git apps: queue a sync (fetch branch, hard-reset checkout to it, deploy) |
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` | | POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` (`deploy` queues a deployment) |
| POST | `/apps/<name>/volume-clear` | Stop the app and empty its default data folder | | POST | `/apps/<name>/volume-clear` | Stop the app and empty its default data folder |
| POST | `/apps/<name>/autodeploy` | Auto deploy on push: `{"enabled": true, "register": true, "regenerate": false}` |
| POST | `/compose/inspect` | `{"content": "..."}` → services of a compose file and the container ports they mention |
Write operations are serialised per app. While one runs, another write to the Write operations are serialised per app. While one runs, another write to the
same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
`deploy` returns the compose output in `stdout` (or `stderr` on failure). Deploys, restarts and syncs are the exception: they are queued (see below).
### Deployments
`deploy`, `restart` and `repo-pull` run in the background, one at a time per
app, and answer `202` at once with the queued deployment:
```json
{"ok": true, "deployment": {"id": 12, "app": "blog", "kind": "sync", "trigger": "manual", "title": "Sync from git", "status": "queued", ...}}
```
A newer request replaces one that is still queued (that one ends as
`cancelled`, "superseded by #13"). Send `{"wait": true}` to block until it
ends; the answer then has `ok`, the `deployment` and its log in `stdout` /
`stderr`, like the old synchronous API.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/apps/<name>/deployments?limit=N` | History, newest first (the last 50 are kept, with logs) |
| GET | `/deployments/<id>` | One deployment |
| GET | `/deployments/<id>/log` | Its full log |
| GET | `/deployments/<id>/stream?offset=N` | Server-sent events: `status`, `log` (`{text, offset}`) while it runs, then `done` |
| POST | `/deployments/<id>/cancel` | Cancel a queued or running deployment |
| POST | `/deployments/<id>/redeploy` | Deploy that deployment again: its commit for git apps, its saved compose file otherwise (a rollback) |
A deployment has `status` (`queued`, `running`, `success`, `failed`,
`cancelled`), `kind` (`deploy`, `sync`, `restart`), `trigger` (`manual`,
`webhook`, `rollback`), `commit_sha` / `commit_subject` / `commit_author`
for git apps, `error`, and `created` / `started` / `finished` / `duration`.
### Webhooks (auto deploy)
`POST /hooks/<name>` deploys an app whose auto deploy is switched on. It is
routed past Authelia by the NixOS module and authenticated with the app's
secret instead: an `X-Forgejo-Signature` / `X-Gitea-Signature` /
`X-Hub-Signature-256` HMAC of the body, or the secret itself as
`X-Gitlab-Token`, `X-Panel-Token` or `?token=`. For git apps a push to another
branch is ignored; everything else queues a sync.
`GET /apps/<name>/autodeploy` returns `enabled`, the webhook `url` and
`secret`, the `branch`, whether the panel registered the webhook on Forgejo
(`forgejo.hook_id`) and the `last` delivery. Enabling it with a Forgejo token
stored adds the webhook to the repository (the token needs write access to it).
### Logs, metrics and terminal
| Method | Path | Description |
|--------|------|-------------|
| GET | `/apps/<name>/logs/stream?tail=N&service=S` | Server-sent events: `lines` (`{lines: [...]}`) as the containers write them, `end` when they stop |
| GET | `/apps/<name>/stats` | Per container: current CPU / memory / network / block IO and an hour of `[time, cpu %, memory bytes]` history |
| GET | `/apps/<name>/services` | Compose services with the container ports they mention |
| GET (WebSocket) | `/apps/<name>/terminal?container=C` | Shell in a container (`podman exec`). Send `{"type": "input", "data": "..."}` and `{"type": "resize", "cols", "rows"}`; output arrives as binary frames |
### Route targets
A route points at the container port it serves instead of an upstream:
```json
{"domain": "blog.reudy.net", "target": "web:2368"}
```
`target` is a port (`2368`, for a compose file with one service),
`service:port`, or `host:port` for something outside the app
(`127.0.0.1:8081`, same as the older `upstream` field). For service targets
the panel picks a free port in 18000–19999 and `panelctl` publishes the
container port on `127.0.0.1:<port>` through a generated
`.panel-ports.yaml` compose override, so compose files need no `ports:`.
Routes keep their port when they are saved again. In `/status` such routes
have `service` and `port` next to the `upstream` Caddy uses.
### Create app (git repository, with environment variables) ### Create app (git repository, with environment variables)
```json ```json
{ {
"name": "blog", "name": "blog",
"routes": [{"domain": "blog.reudy.net", "upstream": "127.0.0.1:18090"}], "routes": [{"domain": "blog.reudy.net", "target": "web:2368"}],
"auth": true, "auth": true,
"source_type": "git", "source_type": "git",
"repo_url": "https://git.reudy.net/reudy-net/blog.git", "repo_url": "https://git.reudy.net/reudy-net/blog.git",
@ -87,18 +157,6 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
- The older `source_type: "github"` with `github_url` / `github_branch` / - The older `source_type: "github"` with `github_url` / `github_branch` /
`github_pat` is still accepted. `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`) ### Status response (`/status`)
```json ```json
@ -113,7 +171,10 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
"compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml",
"repo_url": "", "repo_url": "",
"repo_branch": "", "repo_branch": "",
"autodeploy": false,
"busy": null, "busy": null,
"queued": false,
"last_deployment": {"id": 7, "status": "success", "title": "Deploy", "finished": 1790460640, "...": "..."},
"status": { "status": {
"state": "running", "state": "running",
"running": true, "running": true,
@ -136,20 +197,20 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
{ {
"name": "whoami", "name": "whoami",
"routes": [ "routes": [
{"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"} {"domain": "whoami.reudy.net", "target": "80"}
], ],
"auth": true "auth": true
} }
``` ```
### Create app (multiple routes, different ports) ### Create app (multiple routes, different services)
```json ```json
{ {
"name": "myapp", "name": "myapp",
"routes": [ "routes": [
{"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, {"domain": "app.reudy.net", "target": "web:3000"},
{"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"} {"domain": "api.app.reudy.net", "target": "api:8080"}
], ],
"auth": true "auth": true
} }

View file

@ -2,6 +2,16 @@
Minimal container management panel for rootless Podman + Caddy. Minimal container management panel for rootless Podman + Caddy.
- **Deployments** run in the background with a live log, a history per app
(the last 50, with their output), cancel, redeploy and one-click rollback to
an earlier commit or compose file.
- **Auto deploy**: a push to the app's branch deploys it. The panel adds the
webhook to Forgejo itself; GitHub and CI use the shown URL and secret.
- **Domains** point at a service's container port (`web:8080`); the panel
picks a free local port and publishes it, so compose files need no `ports:`.
- **Live logs**, **CPU / memory graphs** per container and a **web terminal**
(`podman exec`) in the browser.
## Installing on NixOS ## Installing on NixOS
This repository is a flake that provides the panel as a package This repository is a flake that provides the panel as a package
@ -56,10 +66,13 @@ Checks (package build and a module evaluation) run with `nix flake check`.
``` ```
/var/lib/containers/ /var/lib/containers/
├── stacks/<app>/compose.yaml # Compose file per app ├── stacks/<app>/compose.yaml # Compose file per app
├── stacks/<app>/.panel-*.yaml # Generated overrides: published route ports, env vars
├── volumes/<app>/data # Persistent volumes ├── volumes/<app>/data # Persistent volumes
├── routes/routes.caddy # Single aggregate Caddy routes file ├── routes/routes.caddy # Single aggregate Caddy routes file
├── backups/<app>-<timestamp>.zip # Volume backups ├── backups/<app>-<timestamp>.zip # Volume backups
└── state/apps/<app>.env # App manifest ├── state/apps/<app>.env # App manifest
└── state/panel/ # Panel state: panel.db (deployment history),
# deployments/<app>/<id>.log, webhook secrets, keys
``` ```
All app routes are written to a single `routes/routes.caddy` file that Caddy imports. All app routes are written to a single `routes/routes.caddy` file that Caddy imports.

File diff suppressed because it is too large Load diff

View file

@ -72,6 +72,17 @@ in
''; '';
}; };
webhooks = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Let `/hooks/<app>` on the panel's domain through without Authelia, so
Forgejo, GitHub or CI can trigger deployments. Every delivery must be
signed with (or carry) the app's webhook secret, and the endpoint only
answers for apps with auto deploy switched on.
'';
};
forgejo.enable = lib.mkOption { forgejo.enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = config.services.forgejo.enable; default = config.services.forgejo.enable;
@ -131,6 +142,10 @@ in
PANEL_GROUP = cfg.group; PANEL_GROUP = cfg.group;
PANELCTL_PATH = lib.getExe' cfg.package "panelctl"; PANELCTL_PATH = lib.getExe' cfg.package "panelctl";
} }
// lib.optionalAttrs (cfg.domain != null) {
# Used for the webhook URLs handed to Forgejo / GitHub.
PANEL_PUBLIC_URL = "https://${cfg.domain}";
}
// lib.optionalAttrs cfg.forgejo.enable { // lib.optionalAttrs cfg.forgejo.enable {
# The API is reached on localhost; clones use the public URLs. # The API is reached on localhost; clones use the public URLs.
PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL; PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL;
@ -150,14 +165,32 @@ in
virtualHosts = lib.mkIf (cfg.domain != null) { virtualHosts = lib.mkIf (cfg.domain != null) {
${cfg.domain}.extraConfig = ${cfg.domain}.extraConfig =
lib.optionalString (cfg.autheliaAddress != null) '' let
upstream = "${cfg.listenAddress}:${toString cfg.port}";
in
lib.optionalString cfg.webhooks ''
# Push webhooks authenticate with the app's secret, not a login.
# The header tells the panel the request skipped forward_auth, so it
# accepts nothing but a webhook delivery from here (Caddy matches the
# cleaned path but forwards the original one).
handle /hooks/* {
reverse_proxy ${upstream} {
header_up X-Panel-Hook 1
}
}
''
+ ''
handle {
''
+ lib.optionalString (cfg.autheliaAddress != null) ''
forward_auth ${cfg.autheliaAddress} { forward_auth ${cfg.autheliaAddress} {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
} }
'' ''
+ '' + ''
reverse_proxy ${cfg.listenAddress}:${toString cfg.port} reverse_proxy ${upstream}
}
''; '';
}; };
}; };

View file

@ -1,6 +1,7 @@
{ {
lib, lib,
stdenvNoCC, stdenvNoCC,
fetchurl,
makeWrapper, makeWrapper,
bash, bash,
python3, python3,
@ -37,10 +38,23 @@ let
util-linux # flock, used to serialise routes file writes util-linux # flock, used to serialise routes file writes
openssh # cloning repositories over ssh with the panel's deploy key openssh # cloning repositories over ssh with the panel's deploy key
]; ];
# PyYAML lets the panel suggest services and ports from compose files.
python = python3.withPackages (ps: [ ps.pyyaml ]);
# xterm.js for the web terminal, served by the panel itself (no CDN at runtime).
xterm = fetchurl {
url = "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz";
hash = "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==";
};
xtermFit = fetchurl {
url = "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz";
hash = "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==";
};
in in
stdenvNoCC.mkDerivation { stdenvNoCC.mkDerivation {
pname = "reudy-panel"; pname = "reudy-panel";
version = "0.1.0"; version = "0.2.0";
src = lib.fileset.toSource { src = lib.fileset.toSource {
root = ../.; root = ../.;
@ -63,12 +77,18 @@ stdenvNoCC.mkDerivation {
install -Dm644 panel-api.py $out/share/panel/panel-api.py install -Dm644 panel-api.py $out/share/panel/panel-api.py
cp -r frontend $out/share/panel/frontend cp -r frontend $out/share/panel/frontend
mkdir -p xterm fit $out/share/panel/frontend/vendor
tar -xzf ${xterm} -C xterm
tar -xzf ${xtermFit} -C fit
install -m644 xterm/package/lib/xterm.js xterm/package/css/xterm.css fit/package/lib/addon-fit.js \
$out/share/panel/frontend/vendor/
install -Dm755 panelctl.sh $out/bin/panelctl install -Dm755 panelctl.sh $out/bin/panelctl
patchShebangs --host $out/bin/panelctl patchShebangs --host $out/bin/panelctl
wrapProgram $out/bin/panelctl \ wrapProgram $out/bin/panelctl \
--suffix PATH : ${lib.makeBinPath runtimeDeps} --suffix PATH : ${lib.makeBinPath runtimeDeps}
makeWrapper ${python3.interpreter} $out/bin/panel-api \ makeWrapper ${python.interpreter} $out/bin/panel-api \
--add-flags $out/share/panel/panel-api.py \ --add-flags $out/share/panel/panel-api.py \
--suffix PATH : ${lib.makeBinPath runtimeDeps} \ --suffix PATH : ${lib.makeBinPath runtimeDeps} \
--set-default PANELCTL_PATH $out/bin/panelctl \ --set-default PANELCTL_PATH $out/bin/panelctl \

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ PANEL_GROUP="${PANEL_GROUP:-panelroutes}"
# Set by load_app: compose file arguments, and the app's environment variables # 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). # as KEY=VALUE words (passed to compose via env(1), never sourced).
CURRENT_APP=""
COMPOSE_ARGS=() COMPOSE_ARGS=()
APP_ENV_ARGS=() APP_ENV_ARGS=()
APP_ENV_KEYS=() APP_ENV_KEYS=()
@ -27,8 +28,11 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
validate_route_entry() { validate_route_entry() {
local entry="$1" local entry="$1"
# Format: domain|upstream[/path] or domain|upstream (path is optional) # Format: domain|upstream[|path[|service:port]]. The optional last field names
IFS='|' read -r domain upstream path <<< "${entry}" # the compose service and container port that upstream (a host port the panel
# picked) is published from; see prepare_ports_override.
local domain upstream path target
IFS='|' read -r domain upstream path target <<< "${entry}"
[[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'" [[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'"
[[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'" [[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'"
validate_single_domain "${domain}" validate_single_domain "${domain}"
@ -39,6 +43,11 @@ validate_route_entry() {
if [[ -n "${path}" ]]; then if [[ -n "${path}" ]]; then
[[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'" [[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'"
fi fi
if [[ -n "${target}" ]]; then
[[ "${target}" =~ ^([A-Za-z0-9._-]*):([0-9]+)$ ]] || fail "target '${target}' must look like service:port in route entry '${entry}'"
(( BASH_REMATCH[2] >= 1 && BASH_REMATCH[2] <= 65535 )) || fail "container port in '${target}' must be between 1 and 65535"
[[ "${upstream}" =~ ^127\.0\.0\.1:[0-9]+$ ]] || fail "a route to a service must use a 127.0.0.1 upstream in route entry '${entry}'"
fi
} }
validate_routes() { validate_routes() {
@ -63,7 +72,11 @@ Usage:
panelctl restart <name> panelctl restart <name>
panelctl stop <name> panelctl stop <name>
panelctl status <name> panelctl status <name>
panelctl logs <name> [--tail N] panelctl logs <name> [--tail N] [--follow] [--service S]
panelctl services <name>
panelctl containers
panelctl stats
panelctl exec <name> <container>
panelctl remove <name> [--keep-volumes] panelctl remove <name> [--keep-volumes]
panelctl backup <name> panelctl backup <name>
panelctl list-backups <name> panelctl list-backups <name>
@ -73,7 +86,9 @@ Usage:
panelctl list panelctl list
panelctl show <name> panelctl show <name>
Each route is a domain|upstream pair. Upstream is host:port. Each route is a domain|upstream pair. Upstream is host:port. An optional path
and service:port target follow: domain|127.0.0.1:18090||web:8080 publishes
container port 8080 of service "web" on 127.0.0.1:18090 when the app deploys.
Multiple routes are comma-separated: Multiple routes are comma-separated:
panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true
@ -103,6 +118,11 @@ log() {
echo "${msg}" | systemd-cat -t panelctl -p "${level}" 2>/dev/null || true echo "${msg}" | systemd-cat -t panelctl -p "${level}" 2>/dev/null || true
} }
# Forward stdin to the journal (or drop it where there is none).
journal_copy() {
systemd-cat -t panelctl -p info 2>/dev/null || cat >/dev/null
}
ensure_base_dirs() { ensure_base_dirs() {
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}" mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
} }
@ -174,6 +194,7 @@ routes_unlock() {
load_app() { load_app() {
local name="$1" local name="$1"
CURRENT_APP="${name}"
local manifest local manifest
manifest="$(app_manifest "${name}")" manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist" [[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
@ -209,6 +230,22 @@ app_env_override() {
echo "${STACKS_DIR}/$1/.panel-env.yaml" echo "${STACKS_DIR}/$1/.panel-env.yaml"
} }
# Generated compose override that publishes the ports routes point at.
app_ports_override() {
echo "${STACKS_DIR}/$1/.panel-ports.yaml"
}
# The app's compose file followed by whichever generated overrides exist.
refresh_compose_args() {
local override
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
for override in "$(app_ports_override "${CURRENT_APP}")" "$(app_env_override "${CURRENT_APP}")"; do
if [[ -f "${override}" ]]; then
COMPOSE_ARGS+=(-f "${override}")
fi
done
}
load_app_env() { load_app_env() {
local name="$1" local name="$1"
local file line key override local file line key override
@ -225,11 +262,7 @@ load_app_env() {
done <"${file}" done <"${file}"
fi fi
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") refresh_compose_args
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 # (Re)generate the env override before containers are created. Variables are
@ -243,7 +276,7 @@ prepare_env_override() {
if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then
rm -f "${override}" rm -f "${override}"
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") refresh_compose_args
return return
fi fi
@ -264,7 +297,83 @@ prepare_env_override() {
} >"${tmp}" } >"${tmp}"
install -m 0640 "${tmp}" "${override}" install -m 0640 "${tmp}" "${override}"
rm -f "${tmp}" rm -f "${tmp}"
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}") refresh_compose_args
}
# (Re)generate the ports override from routes that target a service. Each one
# publishes the service's container port on the 127.0.0.1 port Caddy proxies
# to, so compose files don't need a ports: section for the panel's routes.
# A route with an empty service name uses the compose file's only service.
prepare_ports_override() {
local name="$1"
local override services="" entry domain upstream path target svc cport hport tmp
local -a lines=() seen=()
override="$(app_ports_override "${name}")"
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
for entry in "${route_entries[@]}"; do
entry="$(echo "${entry}" | xargs)"
IFS='|' read -r domain upstream path target <<< "${entry}"
[[ -n "${target}" ]] || continue
if [[ -z "${services}" ]]; then
services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \
|| fail "could not list compose services to publish route ports (is the compose file valid?)"
fi
svc="${target%:*}"
cport="${target##*:}"
hport="${upstream##*:}"
if [[ -z "${svc}" ]]; then
[[ "$(grep -c . <<<"${services}")" -eq 1 ]] \
|| fail "route ${domain} targets port ${cport} but the compose file has several services; pick one in the app's domains"
svc="${services}"
fi
grep -qxF -- "${svc}" <<<"${services}" \
|| fail "route ${domain} targets service '${svc}', which isn't in the compose file"
[[ " ${seen[*]:-} " == *" ${svc}:${cport}:${hport} "* ]] && continue
seen+=("${svc}:${cport}:${hport}")
lines+=("${svc}|127.0.0.1:${hport}:${cport}")
done
if [[ ${#lines[@]} -eq 0 ]]; then
rm -f "${override}"
refresh_compose_args
return
fi
tmp="$(mktemp)"
{
echo "# Generated by panelctl from the app's routes. Do not edit."
echo "services:"
local current="" line
while IFS= read -r line; do
svc="${line%%|*}"
if [[ "${svc}" != "${current}" ]]; then
printf ' "%s":\n ports:\n' "${svc}"
current="${svc}"
fi
printf ' - "%s"\n' "${line#*|}"
done < <(printf '%s\n' "${lines[@]}" | sort)
} >"${tmp}"
install -m 0640 "${tmp}" "${override}"
rm -f "${tmp}"
refresh_compose_args
}
podman_command() {
if command -v podman >/dev/null 2>&1; then
command -v podman
elif [[ -x /run/current-system/sw/bin/podman ]]; then
echo /run/current-system/sw/bin/podman
else
fail "podman is not installed"
fi
}
run_podman() {
local podman_bin
podman_bin="$(podman_command)"
ensure_podman_runtime_env
"${podman_bin}" "$@"
} }
compose_command() { compose_command() {
@ -346,18 +455,31 @@ write_default_compose() {
stack_dir="$(app_stack_dir "${name}")" stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")" volume_dir="$(app_volume_dir "${name}")"
# Use first route's upstream port for the default compose mapping
local first_route="${routes%%,*}" local first_route="${routes%%,*}"
local first_upstream="${first_route#*|}" local domain upstream path target
local container_port="${first_upstream##*:}" IFS='|' read -r domain upstream path target <<< "${first_route}"
if [[ -n "${target}" ]]; then
# The route publishes the port (see prepare_ports_override).
cat >"${stack_dir}/compose.yaml" <<EOF
services:
app:
image: docker.io/traefik/whoami:latest
restart: unless-stopped
volumes:
- ${volume_dir}/data:/data
EOF
return
fi
# Older style: publish the first route's upstream port directly.
cat >"${stack_dir}/compose.yaml" <<EOF cat >"${stack_dir}/compose.yaml" <<EOF
services: services:
app: app:
image: docker.io/traefik/whoami:latest image: docker.io/traefik/whoami:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:${container_port}:80" - "127.0.0.1:${upstream##*:}:80"
volumes: volumes:
- ${volume_dir}/data:/data - ${volume_dir}/data:/data
EOF EOF
@ -446,7 +568,7 @@ cmd_render_route() {
IFS=',' read -ra route_entries <<< "${APP_ROUTES}" IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
for entry in "${route_entries[@]}"; do for entry in "${route_entries[@]}"; do
entry="$(echo "${entry}" | xargs)" entry="$(echo "${entry}" | xargs)"
IFS='|' read -r domain upstream path <<< "${entry}" IFS='|' read -r domain upstream path target <<< "${entry}"
# If upstream is empty (no second pipe), this is the old format # If upstream is empty (no second pipe), this is the old format
if [[ -z "${upstream}" ]]; then if [[ -z "${upstream}" ]]; then
upstream="${path}" upstream="${path}"
@ -483,19 +605,15 @@ cmd_deploy() {
log info "Starting deployment for app '${name}'" log info "Starting deployment for app '${name}'"
cmd_render_route "${name}" cmd_render_route "${name}"
prepare_ports_override "${name}"
prepare_env_override "${name}" prepare_env_override "${name}"
# Capture compose output so callers (the web UI) can show why a deploy failed, # Stream compose output as it happens (the panel shows it live in the
# and still forward it to the journal. # deployment log) and keep a copy in the journal.
local output if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1 | tee >(journal_copy); then
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}'" log err "Deployment failed for app '${name}'"
fail "compose up failed" fail "compose up failed"
fi 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}'" log info "Successfully deployed app '${name}'"
} }
@ -508,6 +626,7 @@ cmd_restart() {
log info "Restarting app '${name}'" log info "Restarting app '${name}'"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
prepare_ports_override "${name}"
prepare_env_override "${name}" prepare_env_override "${name}"
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
@ -543,19 +662,64 @@ cmd_logs() {
load_app "${name}" load_app "${name}"
local tail_lines="100" local tail_lines="100"
local -a extra=() services=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--tail) --tail)
[[ "${2:-}" =~ ^[0-9]+$ ]] || fail "--tail needs a number"
tail_lines="$2" tail_lines="$2"
shift 2 shift 2
;; ;;
--follow|-f)
extra+=(--follow)
shift
;;
--service)
[[ "${2:-}" =~ ^[A-Za-z0-9._-]+$ ]] || fail "invalid service name"
services+=("$2")
shift 2
;;
*) *)
shift shift
;; ;;
esac esac
done done
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" "${extra[@]}" "${services[@]}" 2>&1 || log info "no logs available"
}
cmd_services() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" config --services
}
# Every container of the user, with labels (the panel maps them to apps).
cmd_containers() {
run_podman ps --all --format json
}
# One sample of resource usage for all running containers.
cmd_stats() {
run_podman stats --no-stream --format json
}
# Interactive shell in one of the app's containers (used by the web terminal).
cmd_exec() {
local name="$1"
local container="$2"
validate_name "${name}"
load_app "${name}"
[[ "${container}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || fail "invalid container name"
local labels
labels="$(run_podman inspect --format '{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "io.podman.compose.project"}}' "${container}" 2>/dev/null)" \
|| fail "container '${container}' does not exist"
[[ "|${labels}|" == *"|${name}|"* ]] || fail "container '${container}' does not belong to app '${name}'"
exec "$(podman_command)" exec -it -e TERM="${TERM:-xterm-256color}" "${container}" \
sh -c 'if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi'
} }
cmd_validate_compose() { cmd_validate_compose() {
@ -768,19 +932,12 @@ cmd_inspect_volumes() {
load_app "${name}" load_app "${name}"
echo "default|${APP_VOLUME_DIR}/data" echo "default|${APP_VOLUME_DIR}/data"
local podman_bin=""
if command -v podman >/dev/null 2>&1; then
podman_bin="$(command -v podman)"
elif [[ -x /run/current-system/sw/bin/podman ]]; then
podman_bin="/run/current-system/sw/bin/podman"
fi
if [[ -n "${podman_bin}" ]]; then {
"${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true run_podman 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 run_podman volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
# grep exits 1 when there are no named volumes; that is not an error. # grep exits 1 when there are no named volumes; that is not an error.
fi | sort -u | grep -v '^$' || true } | sort -u | grep -v '^$' || true
} }
cmd_list() { cmd_list() {
@ -865,9 +1022,25 @@ main() {
cmd_status "$2" cmd_status "$2"
;; ;;
logs) logs)
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N]" [[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N] [--follow] [--service S]"
cmd_logs "$2" "${@:3}" cmd_logs "$2" "${@:3}"
;; ;;
services)
[[ $# -eq 2 ]] || fail "usage: panelctl services <name>"
cmd_services "$2"
;;
containers)
[[ $# -eq 1 ]] || fail "usage: panelctl containers"
cmd_containers
;;
stats)
[[ $# -eq 1 ]] || fail "usage: panelctl stats"
cmd_stats
;;
exec)
[[ $# -eq 3 ]] || fail "usage: panelctl exec <name> <container>"
cmd_exec "$2" "$3"
;;
validate-compose) validate-compose)
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>" [[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
cmd_validate_compose "$2" cmd_validate_compose "$2"