panel: Forgejo integration, ssh deploy key and per-app environment variables
Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
/api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
dropdown; private repos are cloned over https with the stored token, or
over ssh with the deploy key when no token is connected. The app name and
domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
the provider ("Forgejo · main").
Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
(BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
kept; git errors now report the meaningful line instead of git's advice.
Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
interpolation works; by default deploy/restart also generate a compose
override listing the keys under every service's environment (values are
read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
import, hidden values, validation of names (reserved podman/compose vars
rejected), hints for ${VAR}s the compose file uses but aren't set, and
Save / Save & deploy. Removing an app deletes its variables.
The API still accepts the old source_type "github" / github_* fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
parent
76a1e09913
commit
c1ff6c8176
6 changed files with 1269 additions and 101 deletions
12
panel.nix
12
panel.nix
|
|
@ -1,5 +1,8 @@
|
||||||
{ config, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
forgejoServer = config.services.forgejo.settings.server;
|
||||||
|
in
|
||||||
{
|
{
|
||||||
environment.systemPackages = [
|
environment.systemPackages = [
|
||||||
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
|
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
|
||||||
|
|
@ -34,6 +37,7 @@
|
||||||
pkgs.unzip
|
pkgs.unzip
|
||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.util-linux # flock, used by panelctl to serialise routes file writes
|
pkgs.util-linux # flock, used by panelctl to serialise routes file writes
|
||||||
|
pkgs.openssh # cloning repositories over ssh with the panel's deploy key
|
||||||
];
|
];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
|
|
@ -52,6 +56,12 @@
|
||||||
PANEL_BASE_DIR = "/var/lib/containers";
|
PANEL_BASE_DIR = "/var/lib/containers";
|
||||||
PANELCTL_PATH = "/run/current-system/sw/bin/panelctl";
|
PANELCTL_PATH = "/run/current-system/sw/bin/panelctl";
|
||||||
PANEL_FRONTEND_DIR = "${./panel/frontend}";
|
PANEL_FRONTEND_DIR = "${./panel/frontend}";
|
||||||
|
|
||||||
|
# Forgejo integration (repo picker, private clones, commit links).
|
||||||
|
# The API is reached on localhost; clones use the public URLs.
|
||||||
|
PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL;
|
||||||
|
PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}";
|
||||||
|
PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
30
panel/API.md
30
panel/API.md
|
|
@ -13,6 +13,10 @@ Default bind: `127.0.0.1:9911`
|
||||||
| GET | `/` | Web UI (served from `frontend/index.html`) |
|
| GET | `/` | Web UI (served from `frontend/index.html`) |
|
||||||
| GET | `/health` | Health check |
|
| GET | `/health` | Health check |
|
||||||
| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) |
|
| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) |
|
||||||
|
| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key |
|
||||||
|
| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects |
|
||||||
|
| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) |
|
||||||
|
| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository |
|
||||||
|
|
||||||
### Apps — Read
|
### Apps — Read
|
||||||
|
|
||||||
|
|
@ -26,7 +30,8 @@ Default bind: `127.0.0.1:9911`
|
||||||
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
|
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
|
||||||
| GET | `/apps/<name>/backups` | List available backups |
|
| GET | `/apps/<name>/backups` | List available backups |
|
||||||
| GET | `/apps/<name>/backups/<file>` | Download backup zip |
|
| GET | `/apps/<name>/backups/<file>` | Download backup zip |
|
||||||
| GET | `/apps/<name>/repo` | Git source info (URL, branch, deployed commit, local changes) |
|
| GET | `/apps/<name>/env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` |
|
||||||
|
| GET | `/apps/<name>/repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) |
|
||||||
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
|
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
|
||||||
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
|
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
|
||||||
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
|
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
|
||||||
|
|
@ -50,28 +55,37 @@ Default bind: `127.0.0.1:9911`
|
||||||
| 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: fetch branch, hard-reset checkout to it, redeploy |
|
||||||
|
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` |
|
||||||
| 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 |
|
||||||
|
|
||||||
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).
|
`deploy` returns the compose output in `stdout` (or `stderr` on failure).
|
||||||
|
|
||||||
### Create app (git repository)
|
### Create app (git repository, with environment variables)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "blog",
|
"name": "blog",
|
||||||
"routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}],
|
"routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}],
|
||||||
"auth": true,
|
"auth": true,
|
||||||
"source_type": "github",
|
"source_type": "git",
|
||||||
"github_url": "https://git.srazka.com/reudy-net/blog.git",
|
"repo_url": "https://git.srazka.com/reudy-net/blog.git",
|
||||||
"github_branch": "",
|
"repo_branch": "",
|
||||||
"github_pat": ""
|
"use_forgejo_token": true,
|
||||||
|
"env": [{"key": "DATABASE_URL", "value": "postgres://..."}],
|
||||||
|
"env_inject": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Any http(s) git host works. An empty branch uses the repository's default branch.
|
- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or
|
||||||
The compose file must be at the repository root.
|
`git@host:owner/repo.git`. ssh URLs use the panel's deploy key.
|
||||||
|
- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the
|
||||||
|
token stored in Settings (only for URLs on the configured Forgejo host).
|
||||||
|
- An empty branch uses the repository's default branch. The compose file must
|
||||||
|
be at the repository root.
|
||||||
|
- The older `source_type: "github"` with `github_url` / `github_branch` /
|
||||||
|
`github_pat` is still accepted.
|
||||||
|
|
||||||
### Sync response (`repo-pull`)
|
### Sync response (`repo-pull`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,49 @@ panelctl remove whoami
|
||||||
|
|
||||||
### Git-backed apps
|
### Git-backed apps
|
||||||
|
|
||||||
Apps created from a repository (GitHub, Forgejo/Gitea or any https git host)
|
Apps created from a repository (Forgejo, GitHub or any git host, over https or
|
||||||
are cloned to `stacks/<app>/repo`. **Sync** fetches the configured branch and
|
ssh) are cloned to `stacks/<app>/repo`.
|
||||||
|
|
||||||
|
**Forgejo.** `panel.nix` points the panel at the local Forgejo
|
||||||
|
(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken
|
||||||
|
from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo
|
||||||
|
access token (read access to repositories and user). With it, the new-app
|
||||||
|
dialog lists your repositories and branches, and private ones are cloned over
|
||||||
|
https with the token. Without it, public repositories are listed and private
|
||||||
|
ones are cloned over ssh with the deploy key. Commit and compare links point at
|
||||||
|
Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600).
|
||||||
|
|
||||||
|
**SSH / deploy key.** The panel generates an ed25519 key pair in
|
||||||
|
`state/panel/ssh/` the first time it is needed. Its public half is shown in
|
||||||
|
Settings (and next to ssh URLs); add it as a read-only deploy key to a
|
||||||
|
repository — or to your Forgejo account for access to all repositories — to
|
||||||
|
clone `ssh://git@git.srazka.com:14922/owner/repo.git` style URLs. **Sync** fetches the configured branch and
|
||||||
hard-resets the checkout to it before redeploying, so the repository is the
|
hard-resets the checkout to it before redeploying, so the repository is the
|
||||||
source of truth: compose edits made in the panel are discarded on the next sync
|
source of truth: compose edits made in the panel are discarded on the next sync
|
||||||
(the UI warns about this). An access token for a private repository is stored in
|
(the UI warns about this). An access token for a private repository is stored in
|
||||||
the clone's `.git/config`; use a read-only token.
|
the clone's `.git/config`; use a read-only token.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Each app can have environment variables (the **Environment** tab, or when
|
||||||
|
creating the app; `.env` text can be pasted in). They are stored in
|
||||||
|
`state/env/<app>.env` as `KEY=VALUE` lines (mode 0600) — outside the repository
|
||||||
|
and stack directory, so git syncs never touch them — and `panelctl` passes them
|
||||||
|
to every compose command:
|
||||||
|
|
||||||
|
- They are always available for `${VAR}` interpolation in the compose file.
|
||||||
|
The UI points out variables the compose file uses without a default that
|
||||||
|
aren't set.
|
||||||
|
- With **Pass to every container** (the default), `deploy`/`restart` also
|
||||||
|
generate `stacks/<app>/.panel-env.yaml`, a compose override that lists the
|
||||||
|
keys under every service's `environment:`. Compose reads the values from its
|
||||||
|
own environment, so they are never quoted into YAML, and they take
|
||||||
|
precedence over values set in the compose file.
|
||||||
|
|
||||||
|
Values must be single-line. Names that would change how podman/compose run
|
||||||
|
(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected.
|
||||||
|
Changes apply on the next deploy. Backups do not include variables.
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
|
|
||||||
`panel-api` handles requests concurrently, so a long deploy never blocks status
|
`panel-api` handles requests concurrently, so a long deploy never blocks status
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,45 @@
|
||||||
}
|
}
|
||||||
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
||||||
|
|
||||||
|
/* ── Repo picker ── */
|
||||||
|
.picker-list {
|
||||||
|
margin-top: 6px; max-height: 232px; overflow: auto;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.picker-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||||
|
background: none; border: 0; border-top: 1px solid var(--border);
|
||||||
|
padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit;
|
||||||
|
}
|
||||||
|
.picker-item:first-child { border-top: 0; }
|
||||||
|
.picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; }
|
||||||
|
.picker-item.selected { background: var(--accent-soft); }
|
||||||
|
.picker-item .icon { color: var(--muted); }
|
||||||
|
.picker-name { font-weight: 600; font-size: 13.5px; }
|
||||||
|
.picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.picker-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* ── Settings ── */
|
||||||
|
.settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||||
|
.settings-section h3 { font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.keybox {
|
||||||
|
margin-top: 10px; display: flex; gap: 8px; align-items: flex-start;
|
||||||
|
background: var(--surface-2); border-radius: 8px; padding: 10px 12px;
|
||||||
|
font: 12px/1.5 var(--mono); word-break: break-all;
|
||||||
|
}
|
||||||
|
.keybox code { flex: 1; background: none; padding: 0; }
|
||||||
|
|
||||||
|
/* ── Environment editor ── */
|
||||||
|
.env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; }
|
||||||
|
.env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); }
|
||||||
|
.env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
|
||||||
|
.env-details summary { cursor: pointer; list-style: none; }
|
||||||
|
.env-details summary::-webkit-details-marker { display: none; }
|
||||||
|
.env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; }
|
||||||
|
.env-details[open] summary::before { transform: rotate(90deg); }
|
||||||
|
.missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
|
.chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); }
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.hide-sm { display: none !important; }
|
.hide-sm { display: none !important; }
|
||||||
|
|
@ -467,6 +506,10 @@
|
||||||
.route-row .arrow { display: none; }
|
.route-row .arrow { display: none; }
|
||||||
.route-row .route-path { grid-column: 1 / 3; }
|
.route-row .route-path { grid-column: 1 / 3; }
|
||||||
.route-head { display: none; }
|
.route-head { display: none; }
|
||||||
|
.env-row { grid-template-columns: minmax(0, 1fr) 28px; }
|
||||||
|
.env-row > :nth-child(2) { grid-column: 1; }
|
||||||
|
.env-row > :nth-child(3) { grid-column: 2; grid-row: 1; }
|
||||||
|
.env-head { display: none; }
|
||||||
.file-row { grid-template-columns: 20px minmax(0, 1fr) auto; }
|
.file-row { grid-template-columns: 20px minmax(0, 1fr) auto; }
|
||||||
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
|
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
|
||||||
.logs { height: 320px; }
|
.logs { height: 320px; }
|
||||||
|
|
@ -485,6 +528,7 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button id="syncBtn" class="sync" type="button"><span class="sync-dot"></span><span id="syncText">Connecting…</span></button>
|
<button id="syncBtn" class="sync" type="button"><span class="sync-dot"></span><span id="syncText">Connecting…</span></button>
|
||||||
|
<button id="settingsBtn" class="btn ghost icon-only" type="button" title="Settings — Forgejo and deploy key" aria-label="Settings"></button>
|
||||||
<button id="activityBtn" class="btn ghost" type="button" title="Activity — operations and their output">
|
<button id="activityBtn" class="btn ghost" type="button" title="Activity — operations and their output">
|
||||||
<span id="activityIcon"></span><span class="hide-sm">Activity</span><span id="activityCount" class="count" hidden></span>
|
<span id="activityIcon"></span><span class="hide-sm">Activity</span><span id="activityCount" class="count" hidden></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -538,7 +582,7 @@
|
||||||
<div class="segmented" id="naSource">
|
<div class="segmented" id="naSource">
|
||||||
<button type="button" data-v="default">Starter</button>
|
<button type="button" data-v="default">Starter</button>
|
||||||
<button type="button" data-v="raw">Compose file</button>
|
<button type="button" data-v="raw">Compose file</button>
|
||||||
<button type="button" data-v="github">Git repository</button>
|
<button type="button" data-v="git">Git repository</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" id="naSourceHint"></p>
|
<p class="hint" id="naSourceHint"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,24 +593,56 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="naGit" hidden>
|
<div id="naGit" hidden>
|
||||||
<div class="field">
|
<div class="field" id="naProviderField">
|
||||||
<label for="naUrl">Repository URL</label>
|
<div class="segmented" id="naProvider">
|
||||||
<input id="naUrl" class="input" placeholder="https://git.example.com/user/repo" autocomplete="off" spellcheck="false" />
|
<button type="button" data-v="forgejo" id="naProviderForgejo">Forgejo</button>
|
||||||
</div>
|
<button type="button" data-v="url">Any git URL</button>
|
||||||
<div class="grid-2 tight">
|
|
||||||
<div class="field">
|
|
||||||
<label for="naBranch">Branch</label>
|
|
||||||
<input id="naBranch" class="input" placeholder="default branch" autocomplete="off" spellcheck="false" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="naPat">Access token <span class="muted">(private repos)</span></label>
|
|
||||||
<input id="naPat" class="input" type="password" autocomplete="new-password" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" style="margin:-8px 0 16px">
|
|
||||||
Works with GitHub, Forgejo/Gitea and other https hosts. Needs a <code>compose.yaml</code> at the repository root.
|
<div id="naForgejo">
|
||||||
A token is kept in the clone's git config, so use a read-only one.
|
<div class="field">
|
||||||
</p>
|
<label for="naRepoSearch">Repository</label>
|
||||||
|
<div class="picker">
|
||||||
|
<input id="naRepoSearch" class="input" placeholder="Search repositories…" autocomplete="off" spellcheck="false" />
|
||||||
|
<div class="picker-list" id="naRepoList" role="listbox"></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="naForgejoHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2 tight">
|
||||||
|
<div class="field">
|
||||||
|
<label for="naForgejoBranch">Branch</label>
|
||||||
|
<select id="naForgejoBranch" class="select" style="width:100%" disabled><option value="">Pick a repository first</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Clone with</span>
|
||||||
|
<p class="muted small" id="naCloneVia" style="padding-top:7px">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="naUrlMode" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="naUrl">Repository URL</label>
|
||||||
|
<input id="naUrl" class="input" placeholder="https://github.com/user/repo or ssh://git@host:port/user/repo.git" autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="grid-2 tight">
|
||||||
|
<div class="field">
|
||||||
|
<label for="naBranch">Branch</label>
|
||||||
|
<input id="naBranch" class="input" placeholder="default branch" autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="field" id="naPatField">
|
||||||
|
<label for="naPat">Access token <span class="muted">(private https repos)</span></label>
|
||||||
|
<input id="naPat" class="input" type="password" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin:-8px 0 12px">
|
||||||
|
https, <code>ssh://</code> and <code>git@host:owner/repo</code> URLs all work. A token is kept in the clone's git config, so use a read-only one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice" id="naDeployKey" hidden></div>
|
||||||
|
<p class="hint" style="margin:0 0 16px">The repository needs a <code>compose.yaml</code> (or <code>docker-compose.yml</code>) at its root.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -576,6 +652,11 @@
|
||||||
<p class="hint">Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. <code>/api/*</code>. Wildcard domains need a DNS challenge.</p>
|
<p class="hint">Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. <code>/api/*</code>. Wildcard domains need a DNS challenge.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="field env-details" id="naEnvDetails">
|
||||||
|
<summary><span class="label" style="display:inline">Environment variables</span> <span class="muted small" id="naEnvCount">optional</span></summary>
|
||||||
|
<div id="naEnv" style="margin-top:8px"></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<label class="check"><input type="checkbox" id="naAuth" checked /><span><strong>Require login</strong><br><span class="muted small">Put the app behind Authelia.</span></span></label>
|
<label class="check"><input type="checkbox" id="naAuth" checked /><span><strong>Require login</strong><br><span class="muted small">Put the app behind Authelia.</span></span></label>
|
||||||
<label class="check"><input type="checkbox" id="naDeploy" checked /><span><strong>Deploy right away</strong><br><span class="muted small">Start the containers as soon as the app is created.</span></span></label>
|
<label class="check"><input type="checkbox" id="naDeploy" checked /><span><strong>Deploy right away</strong><br><span class="muted small">Start the containers as soon as the app is created.</span></span></label>
|
||||||
|
|
||||||
|
|
@ -588,6 +669,36 @@
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="settingsDlg">
|
||||||
|
<form id="settingsForm" novalidate>
|
||||||
|
<header class="dialog-head">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button type="button" class="btn ghost icon-only" data-close aria-label="Close"></button>
|
||||||
|
</header>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<section class="settings-section" id="setForgejo">
|
||||||
|
<h3>Forgejo</h3>
|
||||||
|
<p class="muted small" id="setForgejoStatus">Loading…</p>
|
||||||
|
<div class="field" id="setTokenField" style="margin-top:10px">
|
||||||
|
<label for="setToken">Access token</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input id="setToken" class="input" type="password" autocomplete="new-password" placeholder="Paste a Forgejo access token" />
|
||||||
|
<button type="submit" class="btn primary" id="setTokenSave">Connect</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Create one in Forgejo under <em>Settings → Applications</em> with <strong>read</strong> access to repositories (and your user).
|
||||||
|
It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn ghost danger sm" id="setTokenRemove" hidden>Disconnect</button>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>SSH deploy key</h3>
|
||||||
|
<p class="muted small">Used for repositories cloned over SSH (e.g. <code id="setSshExample">ssh://git@host/owner/repo.git</code>). Add it as a <strong>read-only deploy key</strong> in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.</p>
|
||||||
|
<div id="setSshKey" class="muted small">Loading…</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="confirmDlg" class="sm">
|
<dialog id="confirmDlg" class="sm">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<h2 id="cfTitle"></h2>
|
<h2 id="cfTitle"></h2>
|
||||||
|
|
@ -658,6 +769,10 @@ const ICONS = {
|
||||||
save: '<path d="M5 3h11l3 3v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M8 3v5h7"/><path d="M8 21v-7h8v7"/>',
|
save: '<path d="M5 3h11l3 3v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M8 3v5h7"/><path d="M8 21v-7h8v7"/>',
|
||||||
logs: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
logs: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
||||||
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
|
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
|
||||||
|
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
|
||||||
|
key: '<circle cx="7.5" cy="15.5" r="4.5"/><path d="M10.7 12.3L21 2"/><path d="M16 7l3 3"/><path d="M19 4l2 2"/>',
|
||||||
|
external: '<path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
|
||||||
|
braces: '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
function icon(name, cls = "") {
|
function icon(name, cls = "") {
|
||||||
|
|
@ -780,8 +895,44 @@ const api = {
|
||||||
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
|
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
|
||||||
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
|
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
|
||||||
init: (payload) => api.post("/apps/init", payload),
|
init: (payload) => api.post("/apps/init", payload),
|
||||||
|
getEnv: (n) => api.request(`/apps/${enc(n)}/env`),
|
||||||
|
saveEnv: (n, vars, inject) => api.post(`/apps/${enc(n)}/env`, { vars, inject }),
|
||||||
|
integrations: () => api.request("/integrations"),
|
||||||
|
saveForgejoToken: (token) => api.post("/integrations/forgejo", { token }),
|
||||||
|
forgejoRepos: (q) => api.request(`/forgejo/repos?q=${enc(q)}`),
|
||||||
|
forgejoBranches: (repo) => api.request(`/forgejo/branches?repo=${enc(repo)}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Forgejo connection + deploy key, loaded on demand and cached.
|
||||||
|
const integrations = {
|
||||||
|
data: null,
|
||||||
|
pending: null,
|
||||||
|
load(force = false) {
|
||||||
|
if (this.data && !force) return Promise.resolve(this.data);
|
||||||
|
if (!this.pending) {
|
||||||
|
this.pending = api.integrations()
|
||||||
|
.then((d) => (this.data = d))
|
||||||
|
.finally(() => { this.pending = null; });
|
||||||
|
}
|
||||||
|
return this.pending;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_NAMES = { forgejo: "Forgejo", github: "GitHub", git: "Git" };
|
||||||
|
|
||||||
|
function copyText(text, what = "Copied") {
|
||||||
|
navigator.clipboard?.writeText(text)
|
||||||
|
.then(() => toast("success", what), () => toast("error", "Couldn't copy to the clipboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyBox(publicKey) {
|
||||||
|
return h("div", { class: "keybox" }, h("code", null, publicKey),
|
||||||
|
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Copy public key", "aria-label": "Copy public key", onclick: () => copyText(publicKey, "Public key copied") }, icon("copy")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGitUrl = (u) => /^(https?:\/\/\S+|ssh:\/\/\S+|[\w.-]+@[\w.-]+:\S+)$/.test(u);
|
||||||
|
const isSshUrl = (u) => !!u && !/^https?:\/\//.test(u);
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
expired: false,
|
expired: false,
|
||||||
expire() {
|
expire() {
|
||||||
|
|
@ -811,7 +962,7 @@ const cards = new Map(); // name -> AppCard
|
||||||
const BUSY_LABELS = {
|
const BUSY_LABELS = {
|
||||||
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
|
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
|
||||||
"repo-pull": "Syncing…", backup: "Backing up…", restore: "Restoring…", remove: "Removing…",
|
"repo-pull": "Syncing…", backup: "Backing up…", restore: "Restoring…", remove: "Removing…",
|
||||||
compose: "Saving…", routes: "Updating routes…", "volume-clear": "Clearing…", "render-route": "Rendering…",
|
compose: "Saving…", routes: "Updating routes…", env: "Saving variables…", "volume-clear": "Clearing…", "render-route": "Rendering…",
|
||||||
};
|
};
|
||||||
const busyLabel = (b) => BUSY_LABELS[b] || "Working…";
|
const busyLabel = (b) => BUSY_LABELS[b] || "Working…";
|
||||||
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
|
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
|
||||||
|
|
@ -1049,7 +1200,7 @@ window.addEventListener("hashchange", applyHash);
|
||||||
// ─── App card ──────────────────────────────────────────────────────────────
|
// ─── App card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
["overview", "Overview"], ["compose", "Compose"], ["logs", "Logs"], ["routes", "Routes"],
|
["overview", "Overview"], ["compose", "Compose"], ["env", "Environment"], ["logs", "Logs"], ["routes", "Routes"],
|
||||||
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
|
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1094,13 +1245,15 @@ class AppCard {
|
||||||
this.pill.className = `pill pill-${st.key}`;
|
this.pill.className = `pill pill-${st.key}`;
|
||||||
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
|
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
|
||||||
}
|
}
|
||||||
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch]);
|
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count]);
|
||||||
if (subSig !== this.subSig) {
|
if (subSig !== this.subSig) {
|
||||||
this.subSig = subSig;
|
this.subSig = subSig;
|
||||||
fill(this.sub,
|
const provider = app.repo_provider === "forgejo" || app.repo_provider === "github" ? `${PROVIDER_NAMES[app.repo_provider]} · ` : "";
|
||||||
|
fill(this.sub,
|
||||||
...domainLinks(app.routes, 2),
|
...domainLinks(app.routes, 2),
|
||||||
app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null,
|
app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null,
|
||||||
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_url) }, icon("git"), app.repo_branch || "git") : null);
|
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_web_url || app.repo_url) }, icon("git"), `${provider}${app.repo_branch || "default"}`) : null,
|
||||||
|
app.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null);
|
||||||
}
|
}
|
||||||
this.deployBtn.disabled = st.key === "busy";
|
this.deployBtn.disabled = st.key === "busy";
|
||||||
if (this.panels) {
|
if (this.panels) {
|
||||||
|
|
@ -1113,6 +1266,7 @@ class AppCard {
|
||||||
this.panels = {
|
this.panels = {
|
||||||
overview: new OverviewPanel(this, "overview"),
|
overview: new OverviewPanel(this, "overview"),
|
||||||
compose: new ComposePanel(this, "compose"),
|
compose: new ComposePanel(this, "compose"),
|
||||||
|
env: new EnvPanel(this, "env"),
|
||||||
logs: new LogsPanel(this, "logs"),
|
logs: new LogsPanel(this, "logs"),
|
||||||
routes: new RoutesPanel(this, "routes"),
|
routes: new RoutesPanel(this, "routes"),
|
||||||
files: new FilesPanel(this, "files"),
|
files: new FilesPanel(this, "files"),
|
||||||
|
|
@ -1178,8 +1332,7 @@ class AppCard {
|
||||||
destroy() {
|
destroy() {
|
||||||
this.collapse();
|
this.collapse();
|
||||||
this.root.remove();
|
this.root.remove();
|
||||||
state.dirty.delete(`${this.name}:compose`);
|
for (const key of ["compose", "routes", "env"]) state.dirty.delete(`${this.name}:${key}`);
|
||||||
state.dirty.delete(`${this.name}:routes`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1200,7 +1353,7 @@ class OverviewPanel extends Panel {
|
||||||
update(app) {
|
update(app) {
|
||||||
const s = app.status || {};
|
const s = app.status || {};
|
||||||
const containers = s.containers || [];
|
const containers = s.containers || [];
|
||||||
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state]);
|
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state, app.env_count, app.env_inject]);
|
||||||
if (sig === this.sig) return;
|
if (sig === this.sig) return;
|
||||||
this.sig = sig;
|
this.sig = sig;
|
||||||
|
|
||||||
|
|
@ -1224,7 +1377,15 @@ class OverviewPanel extends Panel {
|
||||||
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
|
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
|
||||||
h("dl", { class: "facts" },
|
h("dl", { class: "facts" },
|
||||||
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
|
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
|
||||||
h("dt", null, "Source"), h("dd", null, app.repo_url ? `${stripUrl(app.repo_url)} @ ${app.repo_branch || "default"}` : "Compose file"),
|
h("dt", null, "Source"), h("dd", null, app.repo_url
|
||||||
|
? [`${PROVIDER_NAMES[app.repo_provider] || "Git"}: `,
|
||||||
|
app.repo_web_url ? h("a", { href: app.repo_web_url, target: "_blank", rel: "noopener" }, stripUrl(app.repo_web_url)) : stripUrl(app.repo_url),
|
||||||
|
` @ ${app.repo_branch || "default"}`]
|
||||||
|
: "Compose file"),
|
||||||
|
h("dt", null, "Environment"), h("dd", null,
|
||||||
|
h("button", { type: "button", class: "link", onclick: () => this.card.showTab("env") },
|
||||||
|
app.env_count ? `${app.env_count} variable${app.env_count === 1 ? "" : "s"}` : "None set"),
|
||||||
|
app.env_count && !app.env_inject ? h("span", { class: "muted" }, " (compose file only)") : null),
|
||||||
h("dt", null, "Compose"), h("dd", { class: "mono small" }, app.compose_file || "—")));
|
h("dt", null, "Compose"), h("dd", { class: "mono small" }, app.compose_file || "—")));
|
||||||
|
|
||||||
fill(this.el, h("div", { class: "grid-2" }, containerBox, infoBox));
|
fill(this.el, h("div", { class: "grid-2" }, containerBox, infoBox));
|
||||||
|
|
@ -1504,6 +1665,239 @@ class RoutesPanel extends Panel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Environment variables ──
|
||||||
|
|
||||||
|
const RESERVED_ENV = new Set(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"]);
|
||||||
|
const RESERVED_ENV_PREFIXES = ["XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", "PODMAN_", "BUILDAH_", "LD_", "BASH_"];
|
||||||
|
|
||||||
|
function envKeyError(key) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return `"${key || "(empty)"}" isn't a valid variable name (letters, digits and _, not starting with a digit).`;
|
||||||
|
if (RESERVED_ENV.has(key) || RESERVED_ENV_PREFIXES.some((p) => key.startsWith(p))) return `${key} is reserved — it would change how podman/compose run.`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse KEY=value lines as found in .env files (comments, `export`, quotes).
|
||||||
|
function parseDotenv(text) {
|
||||||
|
const out = [];
|
||||||
|
for (let line of String(text || "").split(/\r?\n/)) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
line = line.replace(/^export\s+/, "");
|
||||||
|
const i = line.indexOf("=");
|
||||||
|
if (i < 1) continue;
|
||||||
|
const key = line.slice(0, i).trim();
|
||||||
|
let value = line.slice(i + 1).trim();
|
||||||
|
const q = value[0];
|
||||||
|
if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
if (q === '"') value = value.replace(/\\(["\\$])/g, "$1");
|
||||||
|
} else {
|
||||||
|
const comment = value.search(/\s#/);
|
||||||
|
if (comment >= 0) value = value.slice(0, comment).trim();
|
||||||
|
}
|
||||||
|
out.push({ key, value });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables a compose file references: name -> true when every use has a default (${X:-y}).
|
||||||
|
function composeVarRefs(text) {
|
||||||
|
const refs = new Map();
|
||||||
|
const src = String(text || "")
|
||||||
|
.split("\n").map((l) => l.replace(/(^|\s)#.*$/, "")).join("\n")
|
||||||
|
.replace(/\$\$/g, "");
|
||||||
|
for (const m of src.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?[-?+])?[^}]*\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
||||||
|
const name = m[1] || m[3];
|
||||||
|
const hasDefault = !!m[2] && m[2].endsWith("-");
|
||||||
|
refs.set(name, (refs.get(name) ?? true) && hasDefault);
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envEditor({ onChange = () => {} } = {}) {
|
||||||
|
let reveal = false;
|
||||||
|
const rows = h("div");
|
||||||
|
const revealToggle = h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
onchange: () => {
|
||||||
|
reveal = revealToggle.checked;
|
||||||
|
for (const r of rows.children) r._inputs.v.type = reveal ? "text" : "password";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pasteArea = h("textarea", { class: "code", rows: "6", spellcheck: "false", style: "min-height:120px", placeholder: "KEY=value\n# comments, export and quotes are fine\nDATABASE_URL=postgres://…" });
|
||||||
|
const pasteBox = h("div", { hidden: true, style: "margin:8px 0" }, pasteArea,
|
||||||
|
h("div", { class: "row-actions" },
|
||||||
|
btn("Import", "check", () => { importText(pasteArea.value); pasteArea.value = ""; pasteBox.hidden = true; }, "primary"),
|
||||||
|
btn("Cancel", null, () => { pasteBox.hidden = true; }, "ghost")));
|
||||||
|
const empty = h("p", { class: "muted small", style: "margin:4px 0 8px" }, "No variables yet.");
|
||||||
|
const el = h("div", null,
|
||||||
|
h("div", { class: "env-row env-head" }, h("span", null, "Name"), h("span", null, "Value"), h("span")),
|
||||||
|
empty,
|
||||||
|
rows,
|
||||||
|
h("div", { class: "row-actions" },
|
||||||
|
btn("Add variable", "plus", () => { addRow({}, true); changed(); }, "ghost"),
|
||||||
|
btn("Paste .env", "file", () => { pasteBox.hidden = false; pasteArea.focus(); }, "ghost"),
|
||||||
|
h("label", { class: "toggle" }, revealToggle, "Show values")),
|
||||||
|
pasteBox);
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
for (const r of rows.children) { r._inputs.k.classList.remove("invalid"); r._inputs.v.classList.remove("invalid"); }
|
||||||
|
empty.hidden = rows.children.length > 0;
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
function addRow(v = {}, focus = false) {
|
||||||
|
const k = h("input", {
|
||||||
|
class: "input", placeholder: "NAME", value: v.key || "", spellcheck: "false", autocomplete: "off", "aria-label": "Variable name",
|
||||||
|
oninput: (e) => {
|
||||||
|
const cleaned = e.target.value.replace(/[^A-Za-z0-9_]/g, "_");
|
||||||
|
if (cleaned !== e.target.value) e.target.value = cleaned;
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const val = h("input", {
|
||||||
|
class: "input", type: reveal ? "text" : "password", placeholder: "value", value: v.value || "",
|
||||||
|
spellcheck: "false", autocomplete: "new-password", "aria-label": "Value", oninput: changed,
|
||||||
|
onpaste: (e) => {
|
||||||
|
// Pasting several KEY=value lines into a value field imports them all.
|
||||||
|
const text = e.clipboardData?.getData("text") || "";
|
||||||
|
if (text.includes("\n") && parseDotenv(text).length > 1) { e.preventDefault(); row.remove(); importText(text); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = h("div", { class: "env-row" }, k, val,
|
||||||
|
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Remove", "aria-label": "Remove variable", onclick: () => { row.remove(); changed(); } }, icon("x")));
|
||||||
|
row._inputs = { k, v: val };
|
||||||
|
rows.append(row);
|
||||||
|
empty.hidden = true;
|
||||||
|
if (focus) k.focus();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function importText(text) {
|
||||||
|
const parsed = parseDotenv(text);
|
||||||
|
for (const { key, value } of parsed) {
|
||||||
|
const existing = [...rows.children].find((r) => r._inputs.k.value === key);
|
||||||
|
if (existing) existing._inputs.v.value = value; else addRow({ key, value });
|
||||||
|
}
|
||||||
|
for (const r of [...rows.children]) if (!r._inputs.k.value && !r._inputs.v.value) r.remove();
|
||||||
|
changed();
|
||||||
|
toast(parsed.length ? "success" : "info", parsed.length ? `Imported ${parsed.length} variable${parsed.length === 1 ? "" : "s"}` : "No KEY=value lines found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
set(vars) { fill(rows); vars.forEach((v) => addRow(v)); empty.hidden = vars.length > 0; },
|
||||||
|
get() {
|
||||||
|
return [...rows.children]
|
||||||
|
.map((r) => ({ key: r._inputs.k.value.trim(), value: r._inputs.v.value }))
|
||||||
|
.filter((v) => v.key || v.value);
|
||||||
|
},
|
||||||
|
keys() { return new Set(this.get().map((v) => v.key)); },
|
||||||
|
add(key) { const r = addRow({ key }); r._inputs.v.focus(); changed(); },
|
||||||
|
validate() {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const r of rows.children) {
|
||||||
|
const { k, v } = r._inputs;
|
||||||
|
const key = k.value.trim();
|
||||||
|
if (!key && !v.value) continue;
|
||||||
|
const err = envKeyError(key) || (seen.has(key) ? `${key} is set more than once.` : null);
|
||||||
|
if (err) { k.classList.add("invalid"); k.focus(); return err; }
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class EnvPanel extends Panel {
|
||||||
|
constructor(card, key) {
|
||||||
|
super(card, key);
|
||||||
|
this.loaded = false;
|
||||||
|
this.original = "";
|
||||||
|
this.dirty = false;
|
||||||
|
this.refs = null;
|
||||||
|
this.editor = envEditor({ onChange: () => this.onChange() });
|
||||||
|
this.inject = h("input", { type: "checkbox", checked: true, onchange: () => this.onChange() });
|
||||||
|
this.missing = h("div", { class: "notice warn", hidden: true });
|
||||||
|
this.err = h("p", { class: "form-error", hidden: true });
|
||||||
|
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
|
||||||
|
this.el.append(
|
||||||
|
h("p", { class: "muted small", style: "margin-bottom:10px" },
|
||||||
|
"Available as ", h("code", null, "${NAME}"), " in the compose file and applied on the next deploy. ",
|
||||||
|
"Stored on the server outside the repository, readable only by the panel."),
|
||||||
|
this.missing,
|
||||||
|
this.editor.el,
|
||||||
|
h("label", { class: "check" }, this.inject,
|
||||||
|
h("span", null, h("strong", null, "Pass to every container"), h("br"),
|
||||||
|
h("span", { class: "muted small" }, "Otherwise they are only used to fill in ${…} references in the compose file."))),
|
||||||
|
this.err,
|
||||||
|
h("div", { class: "row-actions" },
|
||||||
|
btn("Save & deploy", "play", () => this.save(true), "primary"),
|
||||||
|
btn("Save", "save", () => this.save(false)),
|
||||||
|
btn("Revert", null, () => this.revert(), "ghost"),
|
||||||
|
h("span", { class: "spacer" }),
|
||||||
|
this.dirtyLabel));
|
||||||
|
}
|
||||||
|
show() {
|
||||||
|
if (!this.loaded) this.load();
|
||||||
|
this.checkMissing();
|
||||||
|
}
|
||||||
|
sig() { return JSON.stringify([this.editor.get(), this.inject.checked]); }
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const d = await api.getEnv(this.name);
|
||||||
|
this.editor.set(d.vars || []);
|
||||||
|
this.inject.checked = d.inject !== false;
|
||||||
|
this.loaded = true;
|
||||||
|
this.original = this.sig();
|
||||||
|
this.onChange();
|
||||||
|
} catch (e) {
|
||||||
|
this.err.textContent = `Couldn't load variables: ${e.message}`;
|
||||||
|
this.err.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange() {
|
||||||
|
this.dirty = this.loaded && this.sig() !== this.original;
|
||||||
|
this.dirtyLabel.hidden = !this.dirty;
|
||||||
|
this.err.hidden = true;
|
||||||
|
this.card.setDirty("env", this.dirty);
|
||||||
|
this.renderMissing();
|
||||||
|
}
|
||||||
|
async checkMissing() {
|
||||||
|
try {
|
||||||
|
this.refs = composeVarRefs((await api.getCompose(this.name)).content);
|
||||||
|
this.renderMissing();
|
||||||
|
} catch {
|
||||||
|
// Hints only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderMissing() {
|
||||||
|
if (!this.refs || !this.loaded) return;
|
||||||
|
const keys = this.editor.keys();
|
||||||
|
const missing = [...this.refs].filter(([k, hasDefault]) => !hasDefault && !keys.has(k)).map(([k]) => k);
|
||||||
|
this.missing.hidden = !missing.length;
|
||||||
|
if (missing.length) {
|
||||||
|
fill(this.missing, icon("alert"), h("div", { class: "missing-vars" },
|
||||||
|
h("span", null, "The compose file uses variables that aren't set:"),
|
||||||
|
missing.map((k) => h("button", { type: "button", class: "chip sm", title: `Add ${k}`, onclick: () => this.editor.add(k) }, `+ ${k}`))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async revert() {
|
||||||
|
this.loaded = false;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
async save(deploy) {
|
||||||
|
const error = this.editor.validate();
|
||||||
|
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
|
||||||
|
const vars = this.editor.get();
|
||||||
|
const res = await runOp(this.name, "env", `Save variables · ${this.name}`,
|
||||||
|
() => api.saveEnv(this.name, vars, this.inject.checked),
|
||||||
|
{ success: deploy ? null : "Variables saved", successDetail: () => "They apply on the next deploy." });
|
||||||
|
if (!res) return;
|
||||||
|
this.original = this.sig();
|
||||||
|
this.onChange();
|
||||||
|
if (deploy) actions.deploy(this.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Files ──
|
// ── Files ──
|
||||||
|
|
||||||
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
|
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
|
||||||
|
|
@ -1696,9 +2090,11 @@ class BackupsPanel extends Panel {
|
||||||
|
|
||||||
// ── Git source ──
|
// ── Git source ──
|
||||||
|
|
||||||
function commitView(c) {
|
function commitView(c, webUrl = "") {
|
||||||
|
const sha = h("code", null, c.short);
|
||||||
return h("span", { class: "commit" },
|
return h("span", { class: "commit" },
|
||||||
h("code", null, c.short), h("span", null, c.subject),
|
webUrl ? h("a", { href: `${webUrl}/commit/${c.sha}`, target: "_blank", rel: "noopener", title: "Open commit" }, sha) : sha,
|
||||||
|
h("span", null, c.subject),
|
||||||
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
|
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1729,14 +2125,22 @@ class SourcePanel extends Panel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render(d, fetched) {
|
render(d, fetched) {
|
||||||
const url = /^https?:\/\//.test(d.url) ? d.url : null;
|
const web = d.web_url || "";
|
||||||
fill(this.info,
|
const provider = PROVIDER_NAMES[d.provider] || "Git";
|
||||||
|
const authFailed = /permission denied|publickey|authentication failed|could not read username|403|401/i.test(d.fetch_error || "");
|
||||||
|
fill(this.info,
|
||||||
h("dl", { class: "facts" },
|
h("dl", { class: "facts" },
|
||||||
h("dt", null, "Repository"), h("dd", null, url ? h("a", { href: url, target: "_blank", rel: "noopener" }, stripUrl(url)) : d.url),
|
h("dt", null, "Repository"), h("dd", null,
|
||||||
|
web ? h("a", { href: web, target: "_blank", rel: "noopener" }, stripUrl(web)) : d.url,
|
||||||
|
h("span", { class: "muted" }, ` · ${provider}${d.ssh ? " over SSH" : ""}`)),
|
||||||
h("dt", null, "Branch"), h("dd", { class: "mono" }, d.branch || "default"),
|
h("dt", null, "Branch"), h("dd", { class: "mono" }, d.branch || "default"),
|
||||||
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
|
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit, web) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
|
||||||
d.dirty ? h("div", { class: "notice warn", style: "margin-top:12px" }, icon("alert"),
|
d.dirty ? h("div", { class: "notice warn", style: "margin-top:12px" }, icon("alert"),
|
||||||
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null);
|
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null,
|
||||||
|
d.ssh && d.public_key ? h("details", { class: "env-details", style: "margin-top:12px", open: authFailed },
|
||||||
|
h("summary", null, h("span", { class: "label", style: "display:inline" }, "Deploy key"),
|
||||||
|
h("span", { class: "muted small" }, " — add it to the repository if syncing fails with “permission denied”")),
|
||||||
|
keyBox(d.public_key)) : null);
|
||||||
if (!fetched) return;
|
if (!fetched) return;
|
||||||
this.check.hidden = false;
|
this.check.hidden = false;
|
||||||
if (d.fetch_error) {
|
if (d.fetch_error) {
|
||||||
|
|
@ -1745,9 +2149,10 @@ class SourcePanel extends Panel {
|
||||||
fill(this.check, icon("check"), "Up to date with ", h("code", null, `origin/${d.branch}`), ".");
|
fill(this.check, icon("check"), "Up to date with ", h("code", null, `origin/${d.branch}`), ".");
|
||||||
} else {
|
} else {
|
||||||
const n = d.behind;
|
const n = d.behind;
|
||||||
fill(this.check,
|
fill(this.check,
|
||||||
h("strong", null, n == null ? "New commits available" : `${n} new commit${n === 1 ? "" : "s"}`),
|
h("strong", null, n == null ? "New commits available" : `${n} new commit${n === 1 ? "" : "s"}`),
|
||||||
d.remote ? h("span", null, " — latest: ", commitView(d.remote)) : null);
|
d.remote ? h("span", null, " — latest: ", commitView(d.remote, web)) : null,
|
||||||
|
web && d.commit && d.remote ? h("a", { href: `${web}/compare/${d.commit.sha}...${d.remote.sha}`, target: "_blank", rel: "noopener" }, icon("external"), " Compare") : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2041,23 +2446,35 @@ const composeTemplate = (port) => `services:
|
||||||
const SOURCE_HINTS = {
|
const SOURCE_HINTS = {
|
||||||
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
|
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
|
||||||
raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.",
|
raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.",
|
||||||
github: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
|
git: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const slugify = (s) => String(s || "").toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
const newApp = {
|
const newApp = {
|
||||||
source: "default",
|
source: "default",
|
||||||
|
provider: "forgejo",
|
||||||
domainTouched: false,
|
domainTouched: false,
|
||||||
|
selectedRepo: null,
|
||||||
|
searchTimer: null,
|
||||||
|
searchSeq: 0,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.dlg = $("#newAppDlg");
|
this.dlg = $("#newAppDlg");
|
||||||
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
|
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
|
||||||
$("#naRoutes").append(this.routes.el);
|
$("#naRoutes").append(this.routes.el);
|
||||||
|
this.env = envEditor({ onChange: () => this.updateEnvCount() });
|
||||||
|
$("#naEnv").append(this.env.el);
|
||||||
$("#naAddRoute").append(icon("plus"), "Add route");
|
$("#naAddRoute").append(icon("plus"), "Add route");
|
||||||
$("#naAddRoute").onclick = () => this.routes.add();
|
$("#naAddRoute").onclick = () => this.routes.add();
|
||||||
$("#naSource").onclick = (e) => {
|
$("#naSource").onclick = (e) => {
|
||||||
const b = e.target.closest("button[data-v]");
|
const b = e.target.closest("button[data-v]");
|
||||||
if (b) this.setSource(b.dataset.v);
|
if (b) this.setSource(b.dataset.v);
|
||||||
};
|
};
|
||||||
|
$("#naProvider").onclick = (e) => {
|
||||||
|
const b = e.target.closest("button[data-v]");
|
||||||
|
if (b) this.setProvider(b.dataset.v);
|
||||||
|
};
|
||||||
$("#naName").addEventListener("input", (e) => {
|
$("#naName").addEventListener("input", (e) => {
|
||||||
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
|
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||||
if (v !== e.target.value) e.target.value = v;
|
if (v !== e.target.value) e.target.value = v;
|
||||||
|
|
@ -2067,6 +2484,20 @@ const newApp = {
|
||||||
this.routes.el.addEventListener("input", (e) => {
|
this.routes.el.addEventListener("input", (e) => {
|
||||||
if (e.target === this.routes.first()?.d) this.domainTouched = true;
|
if (e.target === this.routes.first()?.d) this.domainTouched = true;
|
||||||
});
|
});
|
||||||
|
$("#naRepoSearch").addEventListener("input", () => {
|
||||||
|
clearTimeout(this.searchTimer);
|
||||||
|
this.searchTimer = setTimeout(() => this.searchRepos($("#naRepoSearch").value.trim()), 250);
|
||||||
|
});
|
||||||
|
$("#naRepoSearch").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "ArrowDown") { e.preventDefault(); $("#naRepoList .picker-item")?.focus(); }
|
||||||
|
});
|
||||||
|
$("#naRepoList").addEventListener("keydown", (e) => {
|
||||||
|
const items = [...$("#naRepoList").querySelectorAll(".picker-item")];
|
||||||
|
const i = items.indexOf(document.activeElement);
|
||||||
|
if (e.key === "ArrowDown" && i < items.length - 1) { e.preventDefault(); items[i + 1].focus(); }
|
||||||
|
if (e.key === "ArrowUp") { e.preventDefault(); (i > 0 ? items[i - 1] : $("#naRepoSearch")).focus(); }
|
||||||
|
});
|
||||||
|
$("#naUrl").addEventListener("input", () => this.updateUrlMode());
|
||||||
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
|
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
|
||||||
if (!b.textContent.trim()) b.append(icon("x"));
|
if (!b.textContent.trim()) b.append(icon("x"));
|
||||||
b.onclick = () => this.dlg.close();
|
b.onclick = () => this.dlg.close();
|
||||||
|
|
@ -2078,7 +2509,12 @@ const newApp = {
|
||||||
$("#newAppForm").reset();
|
$("#newAppForm").reset();
|
||||||
$("#naError").hidden = true;
|
$("#naError").hidden = true;
|
||||||
$("#naCompose").value = "";
|
$("#naCompose").value = "";
|
||||||
|
$("#naEnvDetails").open = false;
|
||||||
|
this.env.set([]);
|
||||||
|
this.updateEnvCount();
|
||||||
this.domainTouched = false;
|
this.domainTouched = false;
|
||||||
|
this.selectedRepo = null;
|
||||||
|
this.reposLoaded = false;
|
||||||
this.port = suggestPort();
|
this.port = suggestPort();
|
||||||
this.base = suggestBaseDomain();
|
this.base = suggestBaseDomain();
|
||||||
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
|
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
|
||||||
|
|
@ -2088,6 +2524,11 @@ const newApp = {
|
||||||
$("#naName").focus();
|
$("#naName").focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateEnvCount() {
|
||||||
|
const n = this.env.get().length;
|
||||||
|
$("#naEnvCount").textContent = n ? `${n} set` : "optional";
|
||||||
|
},
|
||||||
|
|
||||||
autofill() {
|
autofill() {
|
||||||
const first = this.routes.first();
|
const first = this.routes.first();
|
||||||
if (!first || this.domainTouched) return;
|
if (!first || this.domainTouched) return;
|
||||||
|
|
@ -2099,12 +2540,129 @@ const newApp = {
|
||||||
this.source = v;
|
this.source = v;
|
||||||
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
|
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
|
||||||
$("#naRaw").hidden = v !== "raw";
|
$("#naRaw").hidden = v !== "raw";
|
||||||
$("#naGit").hidden = v !== "github";
|
$("#naGit").hidden = v !== "git";
|
||||||
$("#naSourceHint").textContent = SOURCE_HINTS[v];
|
$("#naSourceHint").textContent = SOURCE_HINTS[v];
|
||||||
if (v === "raw" && !$("#naCompose").value.trim()) {
|
if (v === "raw" && !$("#naCompose").value.trim()) {
|
||||||
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
|
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
|
||||||
$("#naCompose").value = composeTemplate(port);
|
$("#naCompose").value = composeTemplate(port);
|
||||||
}
|
}
|
||||||
|
if (v === "git") this.initGit();
|
||||||
|
},
|
||||||
|
|
||||||
|
async initGit() {
|
||||||
|
let d = null;
|
||||||
|
try { d = await integrations.load(); } catch { /* fall back to URL mode */ }
|
||||||
|
const configured = !!d?.forgejo?.configured;
|
||||||
|
$("#naProviderField").hidden = !configured;
|
||||||
|
if (configured) $("#naProviderForgejo").textContent = `Forgejo · ${stripUrl(d.forgejo.url)}`;
|
||||||
|
this.setProvider(configured ? this.provider : "url");
|
||||||
|
},
|
||||||
|
|
||||||
|
setProvider(v) {
|
||||||
|
this.provider = v;
|
||||||
|
for (const b of $("#naProvider").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
|
||||||
|
$("#naForgejo").hidden = v !== "forgejo";
|
||||||
|
$("#naUrlMode").hidden = v !== "url";
|
||||||
|
if (v === "forgejo" && !this.reposLoaded) this.searchRepos("");
|
||||||
|
if (v === "forgejo") this.updateCloneVia(); else this.updateUrlMode();
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchRepos(q) {
|
||||||
|
const seq = ++this.searchSeq;
|
||||||
|
const list = $("#naRepoList");
|
||||||
|
fill(list, h("div", { class: "empty-sm" }, h("span", { class: "spinner" }), " Loading repositories…"));
|
||||||
|
try {
|
||||||
|
const d = await api.forgejoRepos(q);
|
||||||
|
if (seq !== this.searchSeq) return;
|
||||||
|
this.reposLoaded = true;
|
||||||
|
const f = integrations.data?.forgejo;
|
||||||
|
fill($("#naForgejoHint"), d.authenticated ? null : [
|
||||||
|
"Showing public repositories only. ",
|
||||||
|
h("button", { type: "button", class: "link", onclick: () => settings.open() }, "Connect a Forgejo token"),
|
||||||
|
" to see private ones — or use “Any git URL” with an SSH URL and the deploy key."]);
|
||||||
|
const repos = d.repos.filter((r) => !r.archived);
|
||||||
|
if (!repos.length) {
|
||||||
|
fill(list, h("div", { class: "empty-sm" }, q ? `No repositories match “${q}”.` : "No repositories found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fill(list, repos.map((r) => h("button", {
|
||||||
|
type: "button", role: "option",
|
||||||
|
class: `picker-item${this.selectedRepo?.full_name === r.full_name ? " selected" : ""}`,
|
||||||
|
onclick: () => this.selectRepo(r),
|
||||||
|
},
|
||||||
|
icon(r.private ? "lock" : "git"),
|
||||||
|
h("span", { class: "picker-main" },
|
||||||
|
h("div", { class: "picker-name" }, r.full_name, r.empty ? h("span", { class: "muted small" }, " (empty)") : null),
|
||||||
|
r.description ? h("div", { class: "picker-desc" }, r.description) : null),
|
||||||
|
h("span", { class: "muted small hide-sm" }, r.updated_at ? ago(Date.parse(r.updated_at)) : ""))));
|
||||||
|
if (f && !f.has_token && d.repos.length === 0 && !q) {
|
||||||
|
fill(list, h("div", { class: "empty-sm" }, "No public repositories. Connect a token in Settings to list private ones."));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== this.searchSeq) return;
|
||||||
|
fill(list, h("div", { class: "empty-sm error" }, `Couldn't load repositories: ${e.message}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectRepo(r) {
|
||||||
|
this.selectedRepo = r;
|
||||||
|
$("#naError").hidden = true;
|
||||||
|
for (const item of $("#naRepoList").querySelectorAll(".picker-item")) {
|
||||||
|
item.classList.toggle("selected", item.querySelector(".picker-name")?.firstChild?.textContent === r.full_name);
|
||||||
|
}
|
||||||
|
if (!$("#naName").value) {
|
||||||
|
$("#naName").value = slugify(r.full_name.split("/")[1]);
|
||||||
|
this.autofill();
|
||||||
|
}
|
||||||
|
this.updateCloneVia();
|
||||||
|
const sel = $("#naForgejoBranch");
|
||||||
|
sel.disabled = true;
|
||||||
|
fill(sel, h("option", { value: "" }, "Loading branches…"));
|
||||||
|
try {
|
||||||
|
const d = await api.forgejoBranches(r.full_name);
|
||||||
|
if (this.selectedRepo !== r) return;
|
||||||
|
const branches = d.branches.length ? d.branches : [r.default_branch || "main"];
|
||||||
|
fill(sel, branches.map((b) => h("option", { value: b }, b === r.default_branch ? `${b} (default)` : b)));
|
||||||
|
sel.value = branches.includes(r.default_branch) ? r.default_branch : branches[0];
|
||||||
|
sel.disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
fill(sel, h("option", { value: r.default_branch || "" }, r.default_branch || "default branch"));
|
||||||
|
sel.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// How a Forgejo repository will be cloned, given the panel's credentials.
|
||||||
|
cloneVia(r) {
|
||||||
|
const f = integrations.data?.forgejo;
|
||||||
|
if (f?.has_token) return { url: r.clone_url, useToken: true, label: "HTTPS with your Forgejo token" };
|
||||||
|
if (r.private) return { url: r.ssh_url, useToken: false, ssh: true, label: "SSH with the panel's deploy key" };
|
||||||
|
return { url: r.clone_url, useToken: false, label: "HTTPS (public repository)" };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCloneVia() {
|
||||||
|
const r = this.selectedRepo;
|
||||||
|
const via = r ? this.cloneVia(r) : null;
|
||||||
|
$("#naCloneVia").textContent = via ? via.label : "—";
|
||||||
|
this.showDeployKey(!!via?.ssh);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUrlMode() {
|
||||||
|
const url = $("#naUrl").value.trim();
|
||||||
|
const ssh = !!url && isSshUrl(url);
|
||||||
|
$("#naPatField").hidden = ssh;
|
||||||
|
this.showDeployKey(ssh);
|
||||||
|
},
|
||||||
|
|
||||||
|
async showDeployKey(show) {
|
||||||
|
const box = $("#naDeployKey");
|
||||||
|
if (!show || this.source !== "git") { box.hidden = true; return; }
|
||||||
|
let key = null;
|
||||||
|
try { key = (await integrations.load()).ssh?.public_key; } catch { /* shown below */ }
|
||||||
|
box.hidden = false;
|
||||||
|
fill(box, icon("key"), h("div", { style: "flex:1;min-width:0" },
|
||||||
|
h("div", null, "Cloning over SSH uses the panel's deploy key. Add it to the repository as a ",
|
||||||
|
h("strong", null, "read-only deploy key"), " (Repository settings → Deploy keys) before creating the app."),
|
||||||
|
key ? keyBox(key) : h("div", { class: "error" }, "No deploy key available (ssh-keygen missing).")));
|
||||||
},
|
},
|
||||||
|
|
||||||
async submit() {
|
async submit() {
|
||||||
|
|
@ -2120,30 +2678,48 @@ const newApp = {
|
||||||
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
|
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
|
||||||
const routeError = this.routes.validate();
|
const routeError = this.routes.validate();
|
||||||
if (routeError) return fail(routeError);
|
if (routeError) return fail(routeError);
|
||||||
|
const envError = this.env.validate();
|
||||||
|
if (envError) { $("#naEnvDetails").open = true; return fail(envError); }
|
||||||
|
|
||||||
const payload = { name, routes: normRoutes(this.routes.get()), auth: $("#naAuth").checked, source_type: this.source };
|
const payload = {
|
||||||
|
name,
|
||||||
|
routes: normRoutes(this.routes.get()),
|
||||||
|
auth: $("#naAuth").checked,
|
||||||
|
source_type: this.source,
|
||||||
|
env: this.env.get(),
|
||||||
|
};
|
||||||
if (this.source === "raw") {
|
if (this.source === "raw") {
|
||||||
payload.compose_content = $("#naCompose").value;
|
payload.compose_content = $("#naCompose").value;
|
||||||
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
|
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
|
||||||
}
|
}
|
||||||
if (this.source === "github") {
|
if (this.source === "git") {
|
||||||
payload.github_url = $("#naUrl").value.trim();
|
if (this.provider === "forgejo") {
|
||||||
payload.github_branch = $("#naBranch").value.trim();
|
const r = this.selectedRepo;
|
||||||
payload.github_pat = $("#naPat").value.trim();
|
if (!r) return fail("Pick a repository.", $("#naRepoSearch"));
|
||||||
if (!/^https?:\/\/\S+$/.test(payload.github_url)) return fail("Enter the repository's https:// URL.", $("#naUrl"));
|
const via = this.cloneVia(r);
|
||||||
|
payload.repo_url = via.url;
|
||||||
|
payload.repo_branch = $("#naForgejoBranch").value;
|
||||||
|
payload.use_forgejo_token = via.useToken;
|
||||||
|
} else {
|
||||||
|
payload.repo_url = $("#naUrl").value.trim();
|
||||||
|
payload.repo_branch = $("#naBranch").value.trim();
|
||||||
|
payload.repo_token = isSshUrl(payload.repo_url) ? "" : $("#naPat").value.trim();
|
||||||
|
if (!isGitUrl(payload.repo_url)) return fail("Enter an https://, ssh:// or git@host:owner/repo URL.", $("#naUrl"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const deploy = $("#naDeploy").checked;
|
const deploy = $("#naDeploy").checked;
|
||||||
|
|
||||||
const submitBtn = $("#naSubmit");
|
const submitBtn = $("#naSubmit");
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
fill(submitBtn, h("span", { class: "spinner" }), this.source === "github" ? "Cloning…" : "Creating…");
|
fill(submitBtn, h("span", { class: "spinner" }), this.source === "git" ? "Cloning…" : "Creating…");
|
||||||
const entry = activity.start(`Create · ${name}`);
|
const entry = activity.start(`Create · ${name}`);
|
||||||
try {
|
try {
|
||||||
const res = await api.init(payload);
|
const res = await api.init(payload);
|
||||||
entry.finish(true, outputOf(res));
|
entry.finish(true, outputOf(res));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
entry.finish(false, e.detail || e.message);
|
entry.finish(false, e.detail || e.message);
|
||||||
return fail(e.message);
|
const hint = /permission denied|publickey/i.test(e.detail || e.message) ? " — has the deploy key been added to the repository?" : "";
|
||||||
|
return fail(e.message + hint);
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = "Create app";
|
submitBtn.textContent = "Create app";
|
||||||
|
|
@ -2161,6 +2737,85 @@ const newApp = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Settings dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
init() {
|
||||||
|
this.dlg = $("#settingsDlg");
|
||||||
|
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
|
||||||
|
b.append(icon("x"));
|
||||||
|
b.onclick = () => this.dlg.close();
|
||||||
|
});
|
||||||
|
$("#settingsForm").onsubmit = (e) => { e.preventDefault(); this.saveToken(); };
|
||||||
|
$("#setTokenRemove").onclick = () => this.removeToken();
|
||||||
|
},
|
||||||
|
async open() {
|
||||||
|
if (!this.dlg.open) this.dlg.showModal();
|
||||||
|
await this.render(true);
|
||||||
|
},
|
||||||
|
async render(force) {
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = await integrations.load(force);
|
||||||
|
} catch (e) {
|
||||||
|
$("#setForgejoStatus").textContent = `Couldn't load settings: ${e.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = d.forgejo;
|
||||||
|
$("#setForgejo").hidden = !f.configured;
|
||||||
|
if (f.configured) {
|
||||||
|
const link = h("a", { href: f.url, target: "_blank", rel: "noopener" }, stripUrl(f.url));
|
||||||
|
fill($("#setForgejoStatus"), f.has_token
|
||||||
|
? (f.user
|
||||||
|
? [icon("check"), " Connected to ", link, " as ", h("strong", null, f.user), "."]
|
||||||
|
: ["A token is saved, but it doesn't work: ", f.error || "unknown error", ". Paste a new one below."])
|
||||||
|
: ["Instance: ", link, ". Not connected — the panel can only list public repositories."]);
|
||||||
|
$("#setTokenField").hidden = !!(f.has_token && f.user);
|
||||||
|
$("#setTokenRemove").hidden = !f.has_token;
|
||||||
|
$("#setSshExample").textContent = f.ssh_url ? `${f.ssh_url}/owner/repo.git` : "ssh://git@host/owner/repo.git";
|
||||||
|
}
|
||||||
|
fill($("#setSshKey"), d.ssh?.public_key
|
||||||
|
? keyBox(d.ssh.public_key)
|
||||||
|
: h("span", { class: "error" }, "ssh-keygen isn't available, so cloning over SSH is disabled."));
|
||||||
|
},
|
||||||
|
async saveToken() {
|
||||||
|
const token = $("#setToken").value.trim();
|
||||||
|
if (!token) { $("#setToken").focus(); return; }
|
||||||
|
const b = $("#setTokenSave");
|
||||||
|
b.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api.saveForgejoToken(token);
|
||||||
|
$("#setToken").value = "";
|
||||||
|
toast("success", `Connected to Forgejo as ${r.user}`);
|
||||||
|
await this.render(true);
|
||||||
|
newApp.reposLoaded = false;
|
||||||
|
if (newApp.dlg.open && newApp.source === "git" && newApp.provider === "forgejo") {
|
||||||
|
newApp.searchRepos($("#naRepoSearch").value.trim());
|
||||||
|
newApp.updateCloneVia();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast("error", "Couldn't connect to Forgejo", { detail: e.message });
|
||||||
|
} finally {
|
||||||
|
b.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeToken() {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: "Disconnect Forgejo?",
|
||||||
|
body: [h("p", null, "The stored token is deleted. Apps already cloned with it keep syncing — the token stays in their git config.")],
|
||||||
|
confirmLabel: "Disconnect", danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api.saveForgejoToken("");
|
||||||
|
toast("success", "Forgejo disconnected");
|
||||||
|
} catch (e) {
|
||||||
|
toast("error", "Couldn't disconnect", { detail: e.message });
|
||||||
|
}
|
||||||
|
this.render(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Wiring ────────────────────────────────────────────────────────────────
|
// ─── Wiring ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$("#brandMark").append(icon("layers"));
|
$("#brandMark").append(icon("layers"));
|
||||||
|
|
@ -2170,6 +2825,8 @@ $("#newIcon").append(icon("plus"));
|
||||||
$("#activityClose").append(icon("x"));
|
$("#activityClose").append(icon("x"));
|
||||||
|
|
||||||
$("#newAppBtn").onclick = () => newApp.open();
|
$("#newAppBtn").onclick = () => newApp.open();
|
||||||
|
$("#settingsBtn").append(icon("settings"));
|
||||||
|
$("#settingsBtn").onclick = () => settings.open();
|
||||||
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
|
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
|
||||||
$("#reloginBtn").onclick = () => location.reload();
|
$("#reloginBtn").onclick = () => location.reload();
|
||||||
$("#activityBtn").onclick = () => activity.toggle();
|
$("#activityBtn").onclick = () => activity.toggle();
|
||||||
|
|
@ -2211,6 +2868,7 @@ setInterval(() => {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
newApp.init();
|
newApp.init();
|
||||||
|
settings.init();
|
||||||
renderFilters();
|
renderFilters();
|
||||||
renderSync();
|
renderSync();
|
||||||
poller.schedule(0);
|
poller.schedule(0);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
@ -22,12 +26,38 @@ FRONTEND_DIR = os.environ.get(
|
||||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optional Forgejo instance (set from panel.nix). The API URL may be an internal
|
||||||
|
# address; the public URL is what repositories are cloned from and linked to.
|
||||||
|
FORGEJO_URL = os.environ.get("PANEL_FORGEJO_URL", "").rstrip("/")
|
||||||
|
FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).rstrip("/")
|
||||||
|
FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/")
|
||||||
|
FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or ""
|
||||||
|
|
||||||
|
PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel")
|
||||||
|
FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token")
|
||||||
|
SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh")
|
||||||
|
SSH_KEY = os.path.join(SSH_DIR, "id_ed25519")
|
||||||
|
ENV_DIR = os.path.join(BASE_DIR, "state", "env")
|
||||||
|
|
||||||
COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]
|
COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]
|
||||||
GIT_TIMEOUT = 300
|
GIT_TIMEOUT = 300
|
||||||
# Manifest values are written into a file that panelctl sources with bash, so
|
# Manifest values are written into a file that panelctl sources with bash, so
|
||||||
# they must not contain anything that is special inside double quotes.
|
# they must not contain anything that is special inside double quotes.
|
||||||
REPO_URL_RE = re.compile(r"^https?://[^\s\"'`$\\]+$")
|
_URL_CHARS = r"[^\s\"'`$\\]"
|
||||||
|
REPO_URL_RE = re.compile(
|
||||||
|
rf"^(?:https?://{_URL_CHARS}+" # https://host/owner/repo.git
|
||||||
|
rf"|ssh://{_URL_CHARS}+" # ssh://git@host:port/owner/repo.git
|
||||||
|
rf"|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:{_URL_CHARS}+)$" # git@host:owner/repo.git
|
||||||
|
)
|
||||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$")
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$")
|
||||||
|
FORGEJO_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||||
|
|
||||||
|
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
# Variables are set on the compose process itself, so anything that changes how
|
||||||
|
# podman/compose run (or where they look for state) is off limits.
|
||||||
|
RESERVED_ENV = {"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"}
|
||||||
|
RESERVED_ENV_PREFIXES = ("XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_",
|
||||||
|
"PODMAN_", "BUILDAH_", "LD_", "BASH_")
|
||||||
|
|
||||||
|
|
||||||
def is_safe_name(name):
|
def is_safe_name(name):
|
||||||
|
|
@ -67,8 +97,9 @@ def busy_snapshot():
|
||||||
|
|
||||||
|
|
||||||
def redact_credentials(text, replacement="***@"):
|
def redact_credentials(text, replacement="***@"):
|
||||||
"""Hide user:token@ credentials embedded in URLs."""
|
"""Hide user:token@ credentials embedded in http(s) URLs.
|
||||||
return re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@", r"\1" + replacement, text or "")
|
(ssh://git@host is a username, not a secret, and must be kept.)"""
|
||||||
|
return re.sub(r"(https?://)[^/@\s]+@", r"\1" + replacement, text or "")
|
||||||
|
|
||||||
|
|
||||||
def last_line(text):
|
def last_line(text):
|
||||||
|
|
@ -76,17 +107,74 @@ def last_line(text):
|
||||||
return lines[-1] if lines else ""
|
return lines[-1] if lines else ""
|
||||||
|
|
||||||
|
|
||||||
|
def git_error(stderr):
|
||||||
|
"""The informative line of a git failure (git ends with generic advice)."""
|
||||||
|
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
|
||||||
|
for line in lines:
|
||||||
|
if re.match(r"^(ssh|fatal|error|remote):", line, re.I) and "could not read from remote" not in line.lower():
|
||||||
|
return re.sub(r"^fatal:\s*", "", line)
|
||||||
|
return last_line(stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_file(path, content):
|
||||||
|
"""Atomically write a file only the service user can read."""
|
||||||
|
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSH deploy key ──
|
||||||
|
# One key pair for the panel; add its public half as a (read-only) deploy key
|
||||||
|
# to repositories cloned over SSH.
|
||||||
|
|
||||||
|
def ssh_public_key(create=True):
|
||||||
|
pub = SSH_KEY + ".pub"
|
||||||
|
if not os.path.isfile(pub) and create:
|
||||||
|
keygen = shutil.which("ssh-keygen")
|
||||||
|
if not keygen:
|
||||||
|
return None
|
||||||
|
os.makedirs(SSH_DIR, mode=0o700, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
[keygen, "-t", "ed25519", "-N", "", "-q", "-C", f"panel@{socket.gethostname()}", "-f", SSH_KEY],
|
||||||
|
capture_output=True, check=False, timeout=30,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(pub, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_ssh_url(url):
|
||||||
|
return not re.match(r"^https?://", url or "")
|
||||||
|
|
||||||
|
|
||||||
# ── Git helpers ──
|
# ── Git helpers ──
|
||||||
|
|
||||||
|
def git_env():
|
||||||
|
# Never block on an interactive credential prompt.
|
||||||
|
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||||
|
if os.path.isfile(SSH_KEY):
|
||||||
|
env["GIT_SSH_COMMAND"] = " ".join([
|
||||||
|
"ssh", "-i", shlex.quote(SSH_KEY),
|
||||||
|
"-o", "IdentitiesOnly=yes",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "UserKnownHostsFile=" + shlex.quote(os.path.join(SSH_DIR, "known_hosts")),
|
||||||
|
])
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
||||||
git_bin = shutil.which("git")
|
git_bin = shutil.which("git")
|
||||||
if not git_bin:
|
if not git_bin:
|
||||||
return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"}
|
return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"}
|
||||||
cmd = [git_bin] + (["-C", cwd] if cwd else []) + args
|
cmd = [git_bin] + (["-C", cwd] if cwd else []) + args
|
||||||
# Never block on an interactive credential prompt.
|
|
||||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
|
proc = subprocess.run(cmd, capture_output=True, text=True, env=git_env(), timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"}
|
return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"}
|
||||||
return {
|
return {
|
||||||
|
|
@ -98,14 +186,145 @@ def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
||||||
|
|
||||||
def clone_repo(url, branch, target_dir, token=""):
|
def clone_repo(url, branch, target_dir, token=""):
|
||||||
auth_url = url
|
auth_url = url
|
||||||
if token:
|
if token and not is_ssh_url(url):
|
||||||
auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1)
|
auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1)
|
||||||
|
elif is_ssh_url(url):
|
||||||
|
ssh_public_key() # make sure the deploy key exists before the first clone
|
||||||
args = ["clone"]
|
args = ["clone"]
|
||||||
if branch:
|
if branch:
|
||||||
args += ["--branch", branch]
|
args += ["--branch", branch]
|
||||||
return run_git(args + ["--", auth_url, target_dir])
|
return run_git(args + ["--", auth_url, target_dir])
|
||||||
|
|
||||||
|
|
||||||
|
def repo_host_and_path(url):
|
||||||
|
url = redact_credentials(url or "", "")
|
||||||
|
m = (re.match(r"^https?://([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
|
||||||
|
or re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
|
||||||
|
or re.match(r"^(?:[^@/]+@)?([^/:]+):(?!/)(.+?)(?:\.git)?/?$", url))
|
||||||
|
return (m.group(1), m.group(2)) if m else (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def repo_provider(url):
|
||||||
|
host, _ = repo_host_and_path(url)
|
||||||
|
if host and FORGEJO_HOST and host == FORGEJO_HOST:
|
||||||
|
return "forgejo"
|
||||||
|
if host in ("github.com", "www.github.com"):
|
||||||
|
return "github"
|
||||||
|
return "git"
|
||||||
|
|
||||||
|
|
||||||
|
def repo_web_url(url):
|
||||||
|
"""Browser URL of a repository, for commit / compare links."""
|
||||||
|
host, path = repo_host_and_path(url)
|
||||||
|
if not host:
|
||||||
|
return ""
|
||||||
|
if FORGEJO_HOST and host == FORGEJO_HOST:
|
||||||
|
return f"{FORGEJO_URL}/{path}"
|
||||||
|
m = re.match(r"^(https?)://", url or "")
|
||||||
|
return f"{m.group(1) if m else 'https'}://{host}/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Forgejo ──
|
||||||
|
|
||||||
|
class ForgejoError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def forgejo_token():
|
||||||
|
try:
|
||||||
|
with open(FORGEJO_TOKEN_FILE, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def forgejo_api(path, token=None, timeout=10):
|
||||||
|
if not FORGEJO_API_URL:
|
||||||
|
raise ForgejoError("no Forgejo instance is configured")
|
||||||
|
token = forgejo_token() if token is None else token
|
||||||
|
req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"})
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as res:
|
||||||
|
return json.loads(res.read().decode("utf-8") or "null")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code in (401, 403):
|
||||||
|
raise ForgejoError("Forgejo rejected the token") from exc
|
||||||
|
if exc.code == 404:
|
||||||
|
raise ForgejoError("not found on Forgejo (or no access)") from exc
|
||||||
|
raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
|
||||||
|
raise ForgejoError(f"can't reach Forgejo: {getattr(exc, 'reason', exc)}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def is_forgejo_https_url(url):
|
||||||
|
return bool(FORGEJO_URL) and not is_ssh_url(url) and repo_provider(url) == "forgejo"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Environment variables ──
|
||||||
|
# Stored per app in state/env/<app>.env as KEY=VALUE lines (0600). panelctl
|
||||||
|
# passes them to every compose command, so they work for ${VAR} interpolation
|
||||||
|
# and — unless disabled — are injected into every service.
|
||||||
|
|
||||||
|
def env_file_path(name):
|
||||||
|
return os.path.join(ENV_DIR, f"{name}.env")
|
||||||
|
|
||||||
|
|
||||||
|
def read_app_env(name):
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
with open(env_file_path(name), "r", encoding="utf-8") as fh:
|
||||||
|
lines = fh.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return items
|
||||||
|
for line in lines:
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
items.append({"key": key, "value": value})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env(items):
|
||||||
|
if items is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise ValueError("env must be a list of {key, value}")
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("env must be a list of {key, value}")
|
||||||
|
key = str(item.get("key", "")).strip()
|
||||||
|
value = item.get("value", "")
|
||||||
|
value = "" if value is None else str(value)
|
||||||
|
if not key and not value:
|
||||||
|
continue
|
||||||
|
if not ENV_KEY_RE.match(key):
|
||||||
|
raise ValueError(f"'{key}' is not a valid variable name (letters, digits and _, not starting with a digit)")
|
||||||
|
if key in RESERVED_ENV or key.startswith(RESERVED_ENV_PREFIXES):
|
||||||
|
raise ValueError(f"'{key}' is reserved because it would change how podman/compose run")
|
||||||
|
if key in seen:
|
||||||
|
raise ValueError(f"'{key}' is set more than once")
|
||||||
|
if any(c in value for c in "\n\r\0"):
|
||||||
|
raise ValueError(f"the value of '{key}' must be a single line")
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"key": key, "value": value})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_app_env(name, items):
|
||||||
|
path = env_file_path(name)
|
||||||
|
if not items:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
write_private_file(path, "".join(f"{i['key']}={i['value']}\n" for i in items))
|
||||||
|
|
||||||
|
|
||||||
def repo_commit(repo_dir, ref="HEAD"):
|
def repo_commit(repo_dir, ref="HEAD"):
|
||||||
result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15)
|
result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15)
|
||||||
if not result["ok"] or not result["stdout"]:
|
if not result["ok"] or not result["stdout"]:
|
||||||
|
|
@ -200,13 +419,18 @@ def load_app_summaries():
|
||||||
env = parse_env_blob(fh.read())
|
env = parse_env_blob(fh.read())
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
repo_url = redact_credentials(env.get("APP_REPO_URL", ""), "")
|
||||||
apps.append({
|
apps.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"routes": manifest_routes(env),
|
"routes": manifest_routes(env),
|
||||||
"auth": env.get("APP_AUTH_PROTECTED", "true") == "true",
|
"auth": env.get("APP_AUTH_PROTECTED", "true") == "true",
|
||||||
"compose_file": env.get("APP_COMPOSE_FILE", ""),
|
"compose_file": env.get("APP_COMPOSE_FILE", ""),
|
||||||
"repo_url": redact_credentials(env.get("APP_REPO_URL", ""), ""),
|
"repo_url": repo_url,
|
||||||
"repo_branch": env.get("APP_REPO_BRANCH", ""),
|
"repo_branch": env.get("APP_REPO_BRANCH", ""),
|
||||||
|
"repo_provider": repo_provider(repo_url) if repo_url else "",
|
||||||
|
"repo_web_url": repo_web_url(repo_url) if repo_url else "",
|
||||||
|
"env_count": len(read_app_env(name)),
|
||||||
|
"env_inject": env.get("APP_ENV_INJECT", "true") != "false",
|
||||||
})
|
})
|
||||||
return apps
|
return apps
|
||||||
|
|
||||||
|
|
@ -456,6 +680,76 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(200, {"ok": True, "service": "panel-api"})
|
self._json(200, {"ok": True, "service": "panel-api"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# /integrations — Forgejo connection and the panel's SSH deploy key
|
||||||
|
if path == "/integrations":
|
||||||
|
token = forgejo_token()
|
||||||
|
forgejo = {
|
||||||
|
"configured": bool(FORGEJO_URL),
|
||||||
|
"url": FORGEJO_URL,
|
||||||
|
"ssh_url": FORGEJO_SSH_URL,
|
||||||
|
"has_token": bool(token),
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
if FORGEJO_URL and token:
|
||||||
|
try:
|
||||||
|
forgejo["user"] = (forgejo_api("/api/v1/user", timeout=5) or {}).get("login")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
forgejo["error"] = str(exc)
|
||||||
|
self._json(200, {"ok": True, "forgejo": forgejo, "ssh": {"public_key": ssh_public_key()}})
|
||||||
|
return
|
||||||
|
|
||||||
|
# /forgejo/repos?q= — repositories visible to the stored token (public ones without)
|
||||||
|
if path == "/forgejo/repos":
|
||||||
|
q = query.get("q", [""])[0].strip()
|
||||||
|
try:
|
||||||
|
data = forgejo_api(f"/api/v1/repos/search?q={quote(q)}&limit=50&sort=updated&order=desc")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
self._json(502, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
repos = [{
|
||||||
|
"full_name": r.get("full_name", ""),
|
||||||
|
"description": r.get("description", ""),
|
||||||
|
"private": bool(r.get("private")),
|
||||||
|
"empty": bool(r.get("empty")),
|
||||||
|
"archived": bool(r.get("archived")),
|
||||||
|
"default_branch": r.get("default_branch", ""),
|
||||||
|
"clone_url": r.get("clone_url", ""),
|
||||||
|
"ssh_url": r.get("ssh_url", ""),
|
||||||
|
"html_url": r.get("html_url", ""),
|
||||||
|
"updated_at": r.get("updated_at", ""),
|
||||||
|
} for r in (data or {}).get("data", [])]
|
||||||
|
self._json(200, {"ok": True, "repos": repos, "authenticated": bool(forgejo_token())})
|
||||||
|
return
|
||||||
|
|
||||||
|
# /forgejo/branches?repo=owner/name
|
||||||
|
if path == "/forgejo/branches":
|
||||||
|
repo = query.get("repo", [""])[0].strip()
|
||||||
|
if not FORGEJO_REPO_RE.match(repo):
|
||||||
|
self._json(400, {"ok": False, "error": "repo must look like owner/name"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = forgejo_api(f"/api/v1/repos/{repo}/branches?limit=100")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
self._json(502, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
self._json(200, {"ok": True, "branches": [b.get("name", "") for b in (data or [])]})
|
||||||
|
return
|
||||||
|
|
||||||
|
# /apps/<name>/env — environment variables used when deploying
|
||||||
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "env":
|
||||||
|
name = parts[1]
|
||||||
|
app, err = read_app_info(name)
|
||||||
|
if err is not None:
|
||||||
|
self._json(404, err)
|
||||||
|
return
|
||||||
|
self._json(200, {
|
||||||
|
"ok": True,
|
||||||
|
"name": name,
|
||||||
|
"vars": read_app_env(name),
|
||||||
|
"inject": app.get("APP_ENV_INJECT", "true") != "false",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
# /status — every app with routes, container status and running operation.
|
# /status — every app with routes, container status and running operation.
|
||||||
# This is what the UI polls, so it is one request regardless of app count.
|
# This is what the UI polls, so it is one request regardless of app count.
|
||||||
if path == "/status":
|
if path == "/status":
|
||||||
|
|
@ -625,9 +919,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"name": name,
|
"name": name,
|
||||||
"url": redact_credentials(repo_url, ""),
|
"url": redact_credentials(repo_url, ""),
|
||||||
|
"web_url": repo_web_url(repo_url),
|
||||||
|
"provider": repo_provider(repo_url),
|
||||||
|
"ssh": is_ssh_url(repo_url),
|
||||||
"branch": branch,
|
"branch": branch,
|
||||||
"cloned": os.path.isdir(os.path.join(repo_dir, ".git")),
|
"cloned": os.path.isdir(os.path.join(repo_dir, ".git")),
|
||||||
}
|
}
|
||||||
|
if info["ssh"]:
|
||||||
|
info["public_key"] = ssh_public_key()
|
||||||
if info["cloned"]:
|
if info["cloned"]:
|
||||||
info["commit"] = repo_commit(repo_dir)
|
info["commit"] = repo_commit(repo_dir)
|
||||||
status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15)
|
status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15)
|
||||||
|
|
@ -636,7 +935,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
ref = branch or repo_current_branch(repo_dir)
|
ref = branch or repo_current_branch(repo_dir)
|
||||||
fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir)
|
fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir)
|
||||||
if not fetched["ok"]:
|
if not fetched["ok"]:
|
||||||
info["fetch_error"] = last_line(fetched["stderr"]) or "git fetch failed"
|
info["fetch_error"] = git_error(fetched["stderr"]) or "git fetch failed"
|
||||||
else:
|
else:
|
||||||
info["remote"] = repo_commit(repo_dir, "FETCH_HEAD")
|
info["remote"] = repo_commit(repo_dir, "FETCH_HEAD")
|
||||||
count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15)
|
count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15)
|
||||||
|
|
@ -921,21 +1220,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
if source_type not in ["default", "raw", "github"]:
|
if source_type == "github": # older clients
|
||||||
|
source_type = "git"
|
||||||
|
if source_type not in ["default", "raw", "git"]:
|
||||||
self._json(400, {"ok": False, "error": "invalid source_type"})
|
self._json(400, {"ok": False, "error": "invalid source_type"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate git parameters before creating anything.
|
# Validate everything before creating anything.
|
||||||
if source_type == "github":
|
try:
|
||||||
repo_url = str(payload.get("github_url", "")).strip()
|
env_items = validate_env(payload.get("env"))
|
||||||
branch = str(payload.get("github_branch", "")).strip()
|
except ValueError as exc:
|
||||||
token = str(payload.get("github_pat", "")).strip()
|
self._json(400, {"ok": False, "error": str(exc)})
|
||||||
if not REPO_URL_RE.match(repo_url):
|
return
|
||||||
self._json(400, {"ok": False, "error": "repository URL must be a plain http(s) URL"})
|
env_inject = payload.get("env_inject", True) is not False
|
||||||
|
|
||||||
|
if source_type == "git":
|
||||||
|
repo_url = str(payload.get("repo_url") or payload.get("github_url") or "").strip()
|
||||||
|
branch = str(payload.get("repo_branch") or payload.get("github_branch") or "").strip()
|
||||||
|
token = str(payload.get("repo_token") or payload.get("github_pat") or "").strip()
|
||||||
|
if not REPO_URL_RE.match(repo_url) or repo_url.startswith("-"):
|
||||||
|
self._json(400, {"ok": False, "error": "repository URL must be an https://, ssh:// or git@host:owner/repo URL"})
|
||||||
return
|
return
|
||||||
if branch and not BRANCH_RE.match(branch):
|
if branch and not BRANCH_RE.match(branch):
|
||||||
self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"})
|
self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"})
|
||||||
return
|
return
|
||||||
|
# Clone a Forgejo repository with the panel's stored token.
|
||||||
|
if not token and payload.get("use_forgejo_token") and is_forgejo_https_url(repo_url):
|
||||||
|
token = forgejo_token()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = run_panelctl(["init", name, routes_str, auth])
|
result = run_panelctl(["init", name, routes_str, auth])
|
||||||
|
|
@ -960,9 +1271,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
|
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
elif source_type == "github":
|
summary = "initialized successfully"
|
||||||
# Any http(s) git host works (GitHub, Forgejo, ...). A token is
|
if source_type == "git":
|
||||||
# embedded in the clone URL, so later syncs reuse it from .git/config.
|
# Any git host works (Forgejo, GitHub, ...), over https or ssh.
|
||||||
|
# An https token is embedded in the clone URL, so later syncs
|
||||||
|
# reuse it from .git/config; ssh uses the panel's deploy key.
|
||||||
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
||||||
if os.path.exists(target_dir):
|
if os.path.exists(target_dir):
|
||||||
shutil.rmtree(target_dir)
|
shutil.rmtree(target_dir)
|
||||||
|
|
@ -972,7 +1285,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
run_panelctl(["remove", name])
|
run_panelctl(["remove", name])
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git clone failed: {last_line(cloned['stderr'])}",
|
"error": f"git clone failed: {git_error(cloned['stderr'])}",
|
||||||
"stderr": cloned["stderr"],
|
"stderr": cloned["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -997,19 +1310,78 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
commit = repo_commit(target_dir)
|
commit = repo_commit(target_dir)
|
||||||
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
|
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
|
||||||
self._json(200, {"ok": True, "code": 0, "stdout": summary})
|
|
||||||
return
|
|
||||||
|
|
||||||
self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
|
if env_items or not env_inject:
|
||||||
|
write_app_env(name, env_items)
|
||||||
|
update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"})
|
||||||
|
summary += f"\n{len(env_items)} environment variable(s) set"
|
||||||
|
|
||||||
|
self._json(200, {"ok": True, "code": 0, "stdout": summary})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
run_panelctl(["remove", name])
|
run_panelctl(["remove", name])
|
||||||
self._json(500, {"ok": False, "error": f"init failed: {exc}"})
|
self._json(500, {"ok": False, "error": f"init failed: {exc}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# POST /integrations/forgejo {"token": "..."} — verify and store ("" clears it)
|
||||||
|
if path == "/integrations/forgejo":
|
||||||
|
if not FORGEJO_URL:
|
||||||
|
self._json(400, {"ok": False, "error": "no Forgejo instance is configured (PANEL_FORGEJO_URL)"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
token = str(self._read_json().get("token", "")).strip()
|
||||||
|
except Exception as exc:
|
||||||
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||||
|
return
|
||||||
|
if not token:
|
||||||
|
try:
|
||||||
|
os.remove(FORGEJO_TOKEN_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
self._json(200, {"ok": True, "has_token": False})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
user = (forgejo_api("/api/v1/user", token=token) or {}).get("login")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
self._json(400, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
write_private_file(FORGEJO_TOKEN_FILE, token + "\n")
|
||||||
|
self._json(200, {"ok": True, "has_token": True, "user": user})
|
||||||
|
return
|
||||||
|
|
||||||
if len(parts) >= 3 and parts[0] == "apps":
|
if len(parts) >= 3 and parts[0] == "apps":
|
||||||
name = parts[1]
|
name = parts[1]
|
||||||
action = parts[2]
|
action = parts[2]
|
||||||
|
|
||||||
|
# POST /apps/<name>/env — replace environment variables, optionally redeploy
|
||||||
|
if action == "env":
|
||||||
|
app, err = read_app_info(name)
|
||||||
|
if err is not None:
|
||||||
|
self._json(404, err)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = self._read_json()
|
||||||
|
items = validate_env(payload.get("vars", []))
|
||||||
|
except ValueError as exc:
|
||||||
|
self._json(400, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
inject = payload.get("inject", True) is not False
|
||||||
|
try:
|
||||||
|
write_app_env(name, items)
|
||||||
|
update_manifest(name, {"APP_ENV_INJECT": "true" if inject else "false"})
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self._json(500, {"ok": False, "error": f"failed to save variables: {exc}"})
|
||||||
|
return
|
||||||
|
result = {"ok": True, "name": name, "count": len(items),
|
||||||
|
"stdout": f"saved {len(items)} environment variable(s)"}
|
||||||
|
if payload.get("deploy"):
|
||||||
|
deployed = run_panelctl(["deploy", name])
|
||||||
|
deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]]))
|
||||||
|
deployed["count"] = len(items)
|
||||||
|
self._json(200 if deployed["ok"] else 400, deployed)
|
||||||
|
return
|
||||||
|
self._json(200, result)
|
||||||
|
return
|
||||||
|
|
||||||
# POST /apps/<name>/compose — save compose file
|
# POST /apps/<name>/compose — save compose file
|
||||||
if action == "compose":
|
if action == "compose":
|
||||||
app, err = read_app_info(name)
|
app, err = read_app_info(name)
|
||||||
|
|
@ -1141,7 +1513,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not fetched["ok"]:
|
if not fetched["ok"]:
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git fetch failed: {last_line(fetched['stderr'])}",
|
"error": f"git fetch failed: {git_error(fetched['stderr'])}",
|
||||||
"stderr": fetched["stderr"],
|
"stderr": fetched["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -1149,7 +1521,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not reset["ok"]:
|
if not reset["ok"]:
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git reset failed: {last_line(reset['stderr'])}",
|
"error": f"git reset failed: {git_error(reset['stderr'])}",
|
||||||
"stderr": reset["stderr"],
|
"stderr": reset["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -1162,7 +1534,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not cloned["ok"]:
|
if not cloned["ok"]:
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git clone failed: {last_line(cloned['stderr'])}",
|
"error": f"git clone failed: {git_error(cloned['stderr'])}",
|
||||||
"stderr": cloned["stderr"],
|
"stderr": cloned["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,15 @@ VOLUMES_DIR="${BASE_DIR}/volumes"
|
||||||
ROUTES_DIR="${BASE_DIR}/routes"
|
ROUTES_DIR="${BASE_DIR}/routes"
|
||||||
STATE_DIR="${BASE_DIR}/state"
|
STATE_DIR="${BASE_DIR}/state"
|
||||||
APPS_DIR="${STATE_DIR}/apps"
|
APPS_DIR="${STATE_DIR}/apps"
|
||||||
|
ENV_DIR="${STATE_DIR}/env"
|
||||||
BACKUPS_DIR="${BASE_DIR}/backups"
|
BACKUPS_DIR="${BASE_DIR}/backups"
|
||||||
|
|
||||||
|
# Set by load_app: compose file arguments, and the app's environment variables
|
||||||
|
# as KEY=VALUE words (passed to compose via env(1), never sourced).
|
||||||
|
COMPOSE_ARGS=()
|
||||||
|
APP_ENV_ARGS=()
|
||||||
|
APP_ENV_KEYS=()
|
||||||
|
|
||||||
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
||||||
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
|
||||||
|
|
@ -186,6 +193,75 @@ load_app() {
|
||||||
done
|
done
|
||||||
APP_ROUTES="${routes}"
|
APP_ROUTES="${routes}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
load_app_env "${name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_env_file() {
|
||||||
|
echo "${ENV_DIR}/$1.env"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generated compose override that passes the app's variables into every service.
|
||||||
|
app_env_override() {
|
||||||
|
echo "${STACKS_DIR}/$1/.panel-env.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_app_env() {
|
||||||
|
local name="$1"
|
||||||
|
local file line key override
|
||||||
|
file="$(app_env_file "${name}")"
|
||||||
|
APP_ENV_ARGS=()
|
||||||
|
APP_ENV_KEYS=()
|
||||||
|
if [[ -f "${file}" ]]; then
|
||||||
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||||
|
[[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||||
|
APP_ENV_ARGS+=("${line}")
|
||||||
|
APP_ENV_KEYS+=("${key}")
|
||||||
|
done <"${file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
|
||||||
|
override="$(app_env_override "${name}")"
|
||||||
|
if [[ -f "${override}" ]]; then
|
||||||
|
COMPOSE_ARGS+=(-f "${override}")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# (Re)generate the env override before containers are created. Variables are
|
||||||
|
# always available for ${VAR} interpolation; with APP_ENV_INJECT (default true)
|
||||||
|
# every service also receives them. Bare keys make compose read the values from
|
||||||
|
# its own environment, so values never have to be quoted into YAML.
|
||||||
|
prepare_env_override() {
|
||||||
|
local name="$1"
|
||||||
|
local override services svc key tmp
|
||||||
|
override="$(app_env_override "${name}")"
|
||||||
|
|
||||||
|
if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then
|
||||||
|
rm -f "${override}"
|
||||||
|
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \
|
||||||
|
|| fail "could not list compose services to pass environment variables (is the compose file valid?)"
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "# Generated by panelctl from the app's environment variables. Do not edit."
|
||||||
|
echo "services:"
|
||||||
|
while IFS= read -r svc; do
|
||||||
|
[[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue
|
||||||
|
printf ' "%s":\n environment:\n' "${svc}"
|
||||||
|
for key in "${APP_ENV_KEYS[@]}"; do
|
||||||
|
printf ' - %s\n' "${key}"
|
||||||
|
done
|
||||||
|
done <<<"${services}"
|
||||||
|
} >"${tmp}"
|
||||||
|
install -m 0640 "${tmp}" "${override}"
|
||||||
|
rm -f "${tmp}"
|
||||||
|
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose_command() {
|
compose_command() {
|
||||||
|
|
@ -252,11 +328,11 @@ run_compose() {
|
||||||
|
|
||||||
if [[ "${compose}" == *" compose" ]]; then
|
if [[ "${compose}" == *" compose" ]]; then
|
||||||
local podman_bin="${compose% compose}"
|
local podman_bin="${compose% compose}"
|
||||||
"${podman_bin}" compose "$@"
|
env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"${compose}" "$@"
|
env "${APP_ENV_ARGS[@]}" "${compose}" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_default_compose() {
|
write_default_compose() {
|
||||||
|
|
@ -404,11 +480,12 @@ 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_env_override "${name}"
|
||||||
|
|
||||||
# Capture compose output so callers (the web UI) can show why a deploy failed,
|
# Capture compose output so callers (the web UI) can show why a deploy failed,
|
||||||
# and still forward it to the journal.
|
# and still forward it to the journal.
|
||||||
local output
|
local output
|
||||||
if ! output="$(run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1)"; 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}" | systemd-cat -t panelctl -p err 2>/dev/null || true
|
||||||
printf '%s\n' "${output}" >&2
|
printf '%s\n' "${output}" >&2
|
||||||
log err "Deployment failed for app '${name}'"
|
log err "Deployment failed for app '${name}'"
|
||||||
|
|
@ -427,9 +504,10 @@ cmd_restart() {
|
||||||
|
|
||||||
log info "Restarting app '${name}'"
|
log info "Restarting app '${name}'"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed"
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
|
||||||
|
prepare_env_override "${name}"
|
||||||
|
|
||||||
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1; then
|
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
|
||||||
fail "compose up failed during restart"
|
fail "compose up failed during restart"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -441,7 +519,7 @@ cmd_stop() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed"
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
|
||||||
log info "stopped app '${name}'"
|
log info "stopped app '${name}'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,8 +528,8 @@ cmd_status() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \
|
run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \
|
run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \
|
||||||
log info "no containers running"
|
log info "no containers running"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,7 +552,7 @@ cmd_logs() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
|
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_validate_compose() {
|
cmd_validate_compose() {
|
||||||
|
|
@ -482,11 +560,11 @@ cmd_validate_compose() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then
|
if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then
|
||||||
log info "compose file is valid"
|
log info "compose file is valid"
|
||||||
else
|
else
|
||||||
local output
|
local output
|
||||||
output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)"
|
output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)"
|
||||||
fail "compose validation failed: ${output}"
|
fail "compose validation failed: ${output}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +575,7 @@ cmd_remove() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
# Remove this app's block from the aggregate routes file.
|
# Remove this app's block from the aggregate routes file.
|
||||||
local route_file
|
local route_file
|
||||||
|
|
@ -512,7 +590,7 @@ cmd_remove() {
|
||||||
routes_unlock
|
routes_unlock
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$(app_manifest "${name}")"
|
rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")"
|
||||||
rm -rf "${APP_STACK_DIR}"
|
rm -rf "${APP_STACK_DIR}"
|
||||||
|
|
||||||
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
|
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
|
||||||
|
|
@ -578,10 +656,10 @@ cmd_backup() {
|
||||||
|
|
||||||
# Stop containers before backup for consistency
|
# Stop containers before backup for consistency
|
||||||
local was_running=false
|
local was_running=false
|
||||||
if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
|
if run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
|
||||||
was_running=true
|
was_running=true
|
||||||
log info "stopping containers for consistent backup..."
|
log info "stopping containers for consistent backup..."
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
|
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
|
||||||
|
|
@ -596,7 +674,7 @@ cmd_backup() {
|
||||||
# Restart if it was running
|
# Restart if it was running
|
||||||
if [[ "${was_running}" == "true" ]]; then
|
if [[ "${was_running}" == "true" ]]; then
|
||||||
log info "restarting containers after backup..."
|
log info "restarting containers after backup..."
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local size
|
local size
|
||||||
|
|
@ -633,7 +711,7 @@ cmd_volume_clear() {
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
log info "clearing volume data for app '${name}'"
|
log info "clearing volume data for app '${name}'"
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
local data_dir="${APP_VOLUME_DIR}/data"
|
local data_dir="${APP_VOLUME_DIR}/data"
|
||||||
if [[ -d "${data_dir}" ]]; then
|
if [[ -d "${data_dir}" ]]; then
|
||||||
|
|
@ -670,7 +748,7 @@ cmd_restore() {
|
||||||
|
|
||||||
# Stop containers before restore
|
# Stop containers before restore
|
||||||
log info "stopping containers for restore..."
|
log info "stopping containers for restore..."
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
|
||||||
|
|
||||||
# Clear existing volume data and extract backup
|
# Clear existing volume data and extract backup
|
||||||
rm -rf "${volume_dir:?}"/*
|
rm -rf "${volume_dir:?}"/*
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue