Deployments, auto deploy, service routes, live logs, metrics and a terminal
Deployments: deploy, restart and git sync now run in the background, one at
a time per app (a newer request replaces a queued one). Each run is recorded
in SQLite with its log, streamed to the UI while it runs, and can be
cancelled. Any earlier deployment can be deployed again, which rolls back to
its commit, or to its saved compose file for compose apps.
Auto deploy: POST /hooks/<app>, verified with the app's secret (Forgejo,
Gitea and GitHub HMAC signatures, or the secret as a token for CI). With a
Forgejo token stored, the panel adds the webhook to the repository itself.
The NixOS module routes /hooks/* past Authelia. Caddy matches the cleaned
path but forwards the original, so the panel refuses dot segments and only
accepts webhook deliveries from that route (tagged with X-Panel-Hook).
Domains: a route can point at a compose service's container port
("web:8080"). The panel picks a free 127.0.0.1 port and panelctl publishes
it through a generated .panel-ports.yaml override, so compose files need no
ports: section. Existing host:port upstreams keep working.
Logs stream live over server-sent events, with service and text filters.
A sampler keeps an hour of CPU and memory per container for the new
Monitoring tab. The Terminal tab opens `podman exec` in a container over a
WebSocket, using xterm.js bundled by the Nix package.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
parent
e11fc00840
commit
71fed39f17
8 changed files with 2997 additions and 313 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
|||
result
|
||||
result-*
|
||||
frontend/vendor/
|
||||
__pycache__/
|
||||
|
|
|
|||
107
API.md
107
API.md
|
|
@ -44,9 +44,9 @@ Default bind: `127.0.0.1:9911`
|
|||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/apps/init` | Create a new app |
|
||||
| POST | `/apps/<name>/routes` | Update routes (hot — Caddy reloads automatically) |
|
||||
| POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) |
|
||||
| POST | `/apps/<name>/restart` | Restart (compose down + up) |
|
||||
| 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` | Queue a deployment (compose up + caddy reload) — see *Deployments* |
|
||||
| POST | `/apps/<name>/restart` | Queue a restart (compose down + up) |
|
||||
| POST | `/apps/<name>/stop` | Stop (compose down) |
|
||||
| POST | `/apps/<name>/render-route` | Re-render Caddy route |
|
||||
| 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>/restore` | Restore from backup |
|
||||
| POST | `/apps/<name>/remove` | Remove app |
|
||||
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy |
|
||||
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` |
|
||||
| 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}` (`deploy` queues a deployment) |
|
||||
| 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
|
||||
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)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "blog",
|
||||
"routes": [{"domain": "blog.reudy.net", "upstream": "127.0.0.1:18090"}],
|
||||
"routes": [{"domain": "blog.reudy.net", "target": "web:2368"}],
|
||||
"auth": true,
|
||||
"source_type": "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` /
|
||||
`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
|
||||
|
|
@ -113,7 +171,10 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
|
|||
"compose_file": "/var/lib/containers/stacks/whoami/compose.yaml",
|
||||
"repo_url": "",
|
||||
"repo_branch": "",
|
||||
"autodeploy": false,
|
||||
"busy": null,
|
||||
"queued": false,
|
||||
"last_deployment": {"id": 7, "status": "success", "title": "Deploy", "finished": 1790460640, "...": "..."},
|
||||
"status": {
|
||||
"state": "running",
|
||||
"running": true,
|
||||
|
|
@ -136,20 +197,20 @@ same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
|
|||
{
|
||||
"name": "whoami",
|
||||
"routes": [
|
||||
{"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"}
|
||||
{"domain": "whoami.reudy.net", "target": "80"}
|
||||
],
|
||||
"auth": true
|
||||
}
|
||||
```
|
||||
|
||||
### Create app (multiple routes, different ports)
|
||||
### Create app (multiple routes, different services)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"routes": [
|
||||
{"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"},
|
||||
{"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"}
|
||||
{"domain": "app.reudy.net", "target": "web:3000"},
|
||||
{"domain": "api.app.reudy.net", "target": "api:8080"}
|
||||
],
|
||||
"auth": true
|
||||
}
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
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
|
||||
|
||||
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/
|
||||
├── stacks/<app>/compose.yaml # Compose file per app
|
||||
├── stacks/<app>/.panel-*.yaml # Generated overrides: published route ports, env vars
|
||||
├── volumes/<app>/data # Persistent volumes
|
||||
├── routes/routes.caddy # Single aggregate Caddy routes file
|
||||
├── 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.
|
||||
|
|
|
|||
1122
frontend/index.html
1122
frontend/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
type = lib.types.bool;
|
||||
default = config.services.forgejo.enable;
|
||||
|
|
@ -131,6 +142,10 @@ in
|
|||
PANEL_GROUP = cfg.group;
|
||||
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 {
|
||||
# The API is reached on localhost; clones use the public URLs.
|
||||
PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL;
|
||||
|
|
@ -150,14 +165,32 @@ in
|
|||
|
||||
virtualHosts = lib.mkIf (cfg.domain != null) {
|
||||
${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} {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
''
|
||||
+ ''
|
||||
reverse_proxy ${cfg.listenAddress}:${toString cfg.port}
|
||||
reverse_proxy ${upstream}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
fetchurl,
|
||||
makeWrapper,
|
||||
bash,
|
||||
python3,
|
||||
|
|
@ -37,10 +38,23 @@ let
|
|||
util-linux # flock, used to serialise routes file writes
|
||||
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
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "reudy-panel";
|
||||
version = "0.1.0";
|
||||
version = "0.2.0";
|
||||
|
||||
src = lib.fileset.toSource {
|
||||
root = ../.;
|
||||
|
|
@ -63,12 +77,18 @@ stdenvNoCC.mkDerivation {
|
|||
install -Dm644 panel-api.py $out/share/panel/panel-api.py
|
||||
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
|
||||
patchShebangs --host $out/bin/panelctl
|
||||
wrapProgram $out/bin/panelctl \
|
||||
--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 \
|
||||
--suffix PATH : ${lib.makeBinPath runtimeDeps} \
|
||||
--set-default PANELCTL_PATH $out/bin/panelctl \
|
||||
|
|
|
|||
1756
panel-api.py
1756
panel-api.py
File diff suppressed because it is too large
Load diff
247
panelctl.sh
247
panelctl.sh
|
|
@ -15,6 +15,7 @@ PANEL_GROUP="${PANEL_GROUP:-panelroutes}"
|
|||
|
||||
# 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).
|
||||
CURRENT_APP=""
|
||||
COMPOSE_ARGS=()
|
||||
APP_ENV_ARGS=()
|
||||
APP_ENV_KEYS=()
|
||||
|
|
@ -27,8 +28,11 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
|||
|
||||
validate_route_entry() {
|
||||
local entry="$1"
|
||||
# Format: domain|upstream[/path] or domain|upstream (path is optional)
|
||||
IFS='|' read -r domain upstream path <<< "${entry}"
|
||||
# Format: domain|upstream[|path[|service:port]]. The optional last field names
|
||||
# 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 "${upstream}" ]] || fail "empty upstream in route entry '${entry}'"
|
||||
validate_single_domain "${domain}"
|
||||
|
|
@ -39,6 +43,11 @@ validate_route_entry() {
|
|||
if [[ -n "${path}" ]]; then
|
||||
[[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'"
|
||||
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() {
|
||||
|
|
@ -63,7 +72,11 @@ Usage:
|
|||
panelctl restart <name>
|
||||
panelctl stop <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 backup <name>
|
||||
panelctl list-backups <name>
|
||||
|
|
@ -73,7 +86,9 @@ Usage:
|
|||
panelctl list
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
# 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() {
|
||||
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
|
||||
}
|
||||
|
|
@ -174,6 +194,7 @@ routes_unlock() {
|
|||
|
||||
load_app() {
|
||||
local name="$1"
|
||||
CURRENT_APP="${name}"
|
||||
local manifest
|
||||
manifest="$(app_manifest "${name}")"
|
||||
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
|
||||
|
|
@ -209,6 +230,22 @@ app_env_override() {
|
|||
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() {
|
||||
local name="$1"
|
||||
local file line key override
|
||||
|
|
@ -225,11 +262,7 @@ load_app_env() {
|
|||
done <"${file}"
|
||||
fi
|
||||
|
||||
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
|
||||
override="$(app_env_override "${name}")"
|
||||
if [[ -f "${override}" ]]; then
|
||||
COMPOSE_ARGS+=(-f "${override}")
|
||||
fi
|
||||
refresh_compose_args
|
||||
}
|
||||
|
||||
# (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
|
||||
rm -f "${override}"
|
||||
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
|
||||
refresh_compose_args
|
||||
return
|
||||
fi
|
||||
|
||||
|
|
@ -264,7 +297,83 @@ prepare_env_override() {
|
|||
} >"${tmp}"
|
||||
install -m 0640 "${tmp}" "${override}"
|
||||
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() {
|
||||
|
|
@ -346,18 +455,31 @@ write_default_compose() {
|
|||
stack_dir="$(app_stack_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_upstream="${first_route#*|}"
|
||||
local container_port="${first_upstream##*:}"
|
||||
local domain upstream path target
|
||||
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
|
||||
services:
|
||||
app:
|
||||
image: docker.io/traefik/whoami:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${container_port}:80"
|
||||
- "127.0.0.1:${upstream##*:}:80"
|
||||
volumes:
|
||||
- ${volume_dir}/data:/data
|
||||
EOF
|
||||
|
|
@ -446,7 +568,7 @@ cmd_render_route() {
|
|||
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
|
||||
for entry in "${route_entries[@]}"; do
|
||||
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 [[ -z "${upstream}" ]]; then
|
||||
upstream="${path}"
|
||||
|
|
@ -483,19 +605,15 @@ cmd_deploy() {
|
|||
log info "Starting deployment for app '${name}'"
|
||||
|
||||
cmd_render_route "${name}"
|
||||
prepare_ports_override "${name}"
|
||||
prepare_env_override "${name}"
|
||||
|
||||
# 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
|
||||
# Stream compose output as it happens (the panel shows it live in the
|
||||
# deployment log) and keep a copy in the journal.
|
||||
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1 | tee >(journal_copy); then
|
||||
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}'"
|
||||
}
|
||||
|
|
@ -508,6 +626,7 @@ cmd_restart() {
|
|||
log info "Restarting app '${name}'"
|
||||
|
||||
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
|
||||
prepare_ports_override "${name}"
|
||||
prepare_env_override "${name}"
|
||||
|
||||
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
|
||||
|
|
@ -543,19 +662,64 @@ cmd_logs() {
|
|||
load_app "${name}"
|
||||
|
||||
local tail_lines="100"
|
||||
local -a extra=() services=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tail)
|
||||
[[ "${2:-}" =~ ^[0-9]+$ ]] || fail "--tail needs a number"
|
||||
tail_lines="$2"
|
||||
shift 2
|
||||
;;
|
||||
--follow|-f)
|
||||
extra+=(--follow)
|
||||
shift
|
||||
;;
|
||||
--service)
|
||||
[[ "${2:-}" =~ ^[A-Za-z0-9._-]+$ ]] || fail "invalid service name"
|
||||
services+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
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() {
|
||||
|
|
@ -768,19 +932,12 @@ cmd_inspect_volumes() {
|
|||
load_app "${name}"
|
||||
|
||||
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
|
||||
"${podman_bin}" volume ls --filter label=io.podman.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
|
||||
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.
|
||||
fi | sort -u | grep -v '^$' || true
|
||||
} | sort -u | grep -v '^$' || true
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
|
|
@ -865,9 +1022,25 @@ main() {
|
|||
cmd_status "$2"
|
||||
;;
|
||||
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}"
|
||||
;;
|
||||
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)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
|
||||
cmd_validate_compose "$2"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue