Merge pull request 'Deployments, auto deploy, service routes, live logs, metrics and a terminal' (#1) from agent/deployments into main
Reviewed-on: #1
This commit is contained in:
commit
d9d0136d70
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