From 04b4437c958650e7711515e5ac12d9618cc0d29d Mon Sep 17 00:00:00 2001 From: Lars Rossen Date: Tue, 25 Aug 2026 09:30:11 +0200 Subject: [PATCH] Merge portainer into podman: engine plus GUI in one module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Podman is the container foundation, Portainer CE is the web interface onto it, running as a container on the very engine it manages. This is what the separate portainer module already did, so it is retired rather than duplicated. Two sockets on purpose: the rootful one is the Docker-compatible API Portainer drives (it does not support rootless), the rootless user socket is for people who ssh in and run containers by hand — which was the original podman module's point and is worth keeping. An explicit proxyDomain lets the module publish at the environment's own domain instead of ., making it that environment's gateway. DESIGN.md keeps the Cockpit identity analysis: it is the reason for the change, and anyone proposing 'just put Cockpit behind forward-auth' should read it. --- README.md | 14 +- src/containers/podman/DESIGN.md | 15 +- src/containers/podman/INSTALL.md | 135 ++++++------- src/containers/podman/README.md | 94 +++++---- src/containers/podman/install.sh | 11 +- src/containers/podman/podman.json | 25 +-- src/containers/podman/test.sh | 60 +++--- src/containers/podman/update.sh | 249 ++++++++++++++++++----- src/containers/portainer/INSTALL.md | 91 --------- src/containers/portainer/README.md | 84 -------- src/containers/portainer/install.sh | 18 -- src/containers/portainer/portainer.json | 40 ---- src/containers/portainer/test.sh | 85 -------- src/containers/portainer/update.sh | 252 ------------------------ src/module-catalog.json | 9 - 15 files changed, 389 insertions(+), 793 deletions(-) delete mode 100644 src/containers/portainer/INSTALL.md delete mode 100644 src/containers/portainer/README.md delete mode 100755 src/containers/portainer/install.sh delete mode 100644 src/containers/portainer/portainer.json delete mode 100755 src/containers/portainer/test.sh delete mode 100755 src/containers/portainer/update.sh diff --git a/README.md b/README.md index e77cb84..2d4d39c 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,16 @@ URL, and the clone is made over HTTPS. ## Modules -Three parallel takes on the same job — run containers on a lab host, let registered people -manage them, reach the other hosts in the zone. They exist side by side on purpose. +Two takes on the same job — run containers on a lab host, let registered people manage them, +reach the other hosts in the zone. | Module | What it is | Status | | --- | --- | --- | -| [portainer](src/containers/portainer) | Portainer CE on rootful Podman; OIDC login, agents on other hosts | incomplete — **the one the session uses** | +| [podman](src/containers/podman) | Podman engine + Portainer CE console; OIDC login, agents on other hosts | incomplete — **the one the session uses** | | [komodo](src/containers/komodo) | GPL Core + Periphery alternative; scaffold, scripts specified but not written | scaffold | -| [podman](src/containers/podman) | Plain Podman host with the Cockpit console; single host, local login | incomplete | -Why three: Cockpit turned out to fit neither requirement — it is not an OIDC client and -cannot be made one, and its multi-host switcher is deprecated and disabled by default because -it "cannot be secure". The reasoning is written up in +`podman` absorbed the former standalone `portainer` module: Podman is the container +foundation, Portainer is the GUI onto it, one VM. Cockpit was dropped because it fits neither +requirement — it is not an OIDC client and cannot be made one, and its multi-host switcher is +deprecated and disabled by default because it "cannot be secure". The reasoning is in [podman/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis). diff --git a/src/containers/podman/DESIGN.md b/src/containers/podman/DESIGN.md index 460b9ea..ecaa043 100644 --- a/src/containers/podman/DESIGN.md +++ b/src/containers/podman/DESIGN.md @@ -75,11 +75,18 @@ Option 1 or 2 plus a custom Cockpit auth command that trusts `X-Authentik-Userna full bypass if the port is reachable any other way, and this module currently *advertises* direct `https://:9090` access as a feature. -### Recommendation +### What was actually done (2026-08-25) -Take option 1 now, open an upstream issue for option 2, and treat option 3 as out of scope. -Note that the direct `:9090` URL stays open under option 1 — the gate is on the friendly -name only, which is honest but worth saying out loud in `README.md`. +**None of the three.** All of them are workarounds for a console that cannot take an OIDC +login, and the requirement had grown: registered people managing containers *across the hosts +in the zone*, which Cockpit's deprecated host switcher cannot do securely either. + +So Cockpit was dropped and **Portainer CE** became this module's GUI, with Podman still the +engine underneath it. Portainer *is* an OIDC client, so `identity:identity` — the native TAPPaaS +OIDC path — works exactly as designed, and multi-host is an agent per host rather than a +browser loading remote JavaScript. The analysis above is kept because it is the reason for +the change, and because anyone proposing "just put Cockpit behind forward-auth" should read it +first. ### One thing to verify on a live system first diff --git a/src/containers/podman/INSTALL.md b/src/containers/podman/INSTALL.md index 2d855dd..80eb687 100644 --- a/src/containers/podman/INSTALL.md +++ b/src/containers/podman/INSTALL.md @@ -1,110 +1,95 @@ -# Podman container host — Installation +# Podman + Portainer — Installation Primary audience: TAPPaaS admin. Steps the scripts cannot automate. ## Prerequisites - `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access. -- The dependencies are pulled in automatically (`cluster:vm`, `templates:debian`, - `backup:vm`, `network:proxy`). -- For the friendly HTTPS name to present a valid certificate, the TAPPaaS wildcard must be in - OPNsense Trust (run `acme-setup.sh` once — see the platform INSTALL). Until then the internal - endpoint still works, with a cert warning (Cockpit also ships a self-signed cert). +- Dependencies resolve automatically (`cluster:vm`, `templates:debian`, `backup:vm`, + `network:proxy`, `identity:identity`). +- The identity foundation must be up — the login depends on it. ## Install ```bash -cd /home/tappaas/Community/src/larsrossen/containers/podman -install-module.sh podman +# Published at .: +module-manager module add podman --environment lab1 + +# …or as the environment's gateway, at its own domain: +module-manager module add podman --environment lab1 --proxyDomain lab1.makerfloss.eu ``` This automatically: -- creates a Debian 13 VM (vmid 812, 2 vCPU / 2 GB / 20 GB) and OS-preps it; -- installs `podman`, `podman-compose`, `cockpit` and `cockpit-podman` from apt; -- enables the rootless podman socket (with linger) and the Cockpit console on `:9090`; -- publishes the internal reverse-proxy name `podman.` (console reachable from the - `mgmt` zone only, no internet). -### Placement / zone +- creates a Debian 13 VM (vmid 812, 2 vCPU / 2 GB / 32 GB) and OS-preps it; +- installs `podman`, `podman-compose`, `slirp4netns`, `uidmap`; +- enables the **rootful** `podman.socket` (Portainer's API) and `podman-restart.service`, + plus the **rootless** user socket with linger, for people who ssh in; +- runs `portainer-ce:lts` against the rootful socket with a named volume; +- creates a break-glass local admin at `/etc/secrets/podman-admin`; +- reads the OIDC credentials `identity:identity` left in `/etc/secrets/podman.env` and + switches Portainer to OAuth login; +- publishes the console through the reverse proxy. -The module JSON sets **no `zone0`**, so the VM lands in the **default environment's zone** -(ADR-007 P5: with `zone0` unset, install resolves the zone from -`config/environments/.json → network.zone`). To place it in a specific environment — and -therefore its associated zone — pass `--environment` at install time: +## Post-install (manual) + +### 1. Who can log in + +Authentik binds the application to the allowed groups at install. To let the hackerspace in: ```bash -install-module.sh podman --environment # VM joins 's network.zone +people-manager user modify --add-groups devops +people-manager reconcile --apply ``` -An explicit `zone0` in the JSON would override this, so it is intentionally omitted. +### 2. Other hosts in the zone -## Post-install (manual — required for the web login) - -The Cockpit console authenticates against **Linux (PAM) accounts on the VM**. The cloud-init -`tappaas` user has SSH-key auth and **no password**, so it cannot log in to the web console -until you give it one (or create a dedicated admin user). - -**Option A — give the `tappaas` user a password:** +On each additional VM or physical machine: ```bash -ssh tappaas@ 'sudo passwd tappaas' +podman run -d --name portainer_agent --restart=always \ + -p 9001:9001 \ + -v /run/podman/podman.sock:/var/run/docker.sock \ + -v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \ + docker.io/portainer/agent:lts ``` -**Option B — create a dedicated admin user (recommended for shared access):** +Then **Environments → Add environment → Docker Standalone → Agent**, address `:9001`. +Same-zone traffic is allowed by default; another zone needs a pinhole into `:9001`. + +### 3. Break-glass access ```bash -ssh tappaas@ -sudo useradd -m -s /bin/bash -G sudo podadmin && sudo passwd podadmin -sudo loginctl enable-linger podadmin # keep its rootless podman socket alive +ssh tappaas@ 'sudo cat /etc/secrets/podman-admin' ``` -Then open **`https://podman.`** (or `https://:9090`), log in with that account, -and choose **Podman containers** in the left menu (Cockpit starts the user's podman service on -first use). +Username `admin`. This is the way back in when Authentik is unavailable — keep it. -## Using it +## Notes from the live installs (lab1, 2026-08-24/25) -- CLI: `ssh tappaas@` then `podman run …`, `podman ps`, `podman images`. -- Compose files: `podman-compose up -d` in a directory with a `compose.yaml`. -- Web: manage/start/stop containers, pull images and inspect logs from the Cockpit - **Podman containers** page. +Four things bit during the first real install; all are handled in `update.sh` now, but they +are worth knowing when debugging: -## Verification +- **Portainer ≥ 2.39 requires a setup token.** `POST /api/users/admin/init` returns `403` + without an `X-Setup-Token` header carrying the token printed once at startup. The log line + puts ANSI colour codes between the key and the value, so the parser matches the 64-hex + token, not the `setup_token=` prefix. +- **The admin-init window closes.** A few minutes after start Portainer answers `303` with + `Redirect-Reason: AdminInitTimeout`. Any transient failure during that window would make the + module permanently un-bootstrappable, so `update.sh` restarts the container to reset the + timer and retries once. +- **Never gate the bootstrap on the password file existing.** A failed init leaves a password + behind; keying off it makes every later run skip the bootstrap and then fail to + authenticate. The guard is "can we log in?". +- **`proxyDomain` is not persisted into the installed config.** It lands as `null` despite + `copy-update-json.sh` documenting that `install-module.sh` computes it, so consumers must + derive it — from the module's **own** environment, never the default one. + +## Verify ```bash -test-module.sh podman +module-manager module test podman-lab1 # effective name outside the default environment ``` -Manual checks: - -| Check | Expected | -|-------|----------| -| `ssh tappaas@ podman --version` | prints the Podman version | -| `https://podman.` from a `mgmt` browser | Cockpit login page loads | -| Same URL from an out-of-policy zone or the internet | 403 (blocked) | -| Cockpit → **Podman containers** after login | the Podman page renders | - -## Troubleshooting - -**Web login rejected for `tappaas`** -The account has no password by default — set one (Option A above) or use a dedicated admin -user (Option B). Cockpit cannot log in a key-only account. - -**Podman page in Cockpit says the service is not running** -Cockpit starts the *per-user* podman service on first open; if it does not, on the VM run -`systemctl --user start podman.socket` as that user, and ensure `loginctl enable-linger ` -was set so the socket survives logout. - -**Friendly name unreachable** -Confirm you are in the `mgmt` zone (others get 403 by design). Check the Caddy route: -`network:proxy test-service.sh podman`. Re-apply with `install-module.sh podman --force`. - -**Friendly name loads a blank page (valid cert, no UI)** -Cockpit rides a WebSocket; behind a TLS reverse proxy Caddy must talk HTTP/1.1 to the upstream. -This module sets `proxyUpstreamHttp1: true` (os-caddy `HttpVersion=http1`). If you see a blank -page, verify the handler has HTTP Version = HTTP/1.1 and re-apply: -`install-module.sh podman --force`. The direct console `https://:9090` always works. - -**Re-run / upgrade** -`install-module.sh podman --force` is idempotent: apt re-runs (a no-op when already current) -and the version marker `/etc/tappaas-podman.version` is refreshed. +Checks podman itself, both sockets, the container, the API, and that authentication is still +OAuth (method `3`) rather than having fallen back to internal accounts. diff --git a/src/containers/podman/README.md b/src/containers/podman/README.md index 497ddea..e0a0bea 100644 --- a/src/containers/podman/README.md +++ b/src/containers/podman/README.md @@ -1,40 +1,61 @@ -# Podman — container host with a web console +# Podman — container host with a Portainer console -Primary audience: TAPPaaS operator who wants a plain place to run OCI containers. +Primary audience: TAPPaaS operator who wants a place to run OCI containers, and several +people managing them. -A **Debian 13 (trixie) VM with [Podman](https://podman.io) installed** — the daemonless, -rootless-capable container engine (a drop-in for `docker`). On top of the engine it adds the -**Cockpit web console** with the **`cockpit-podman`** plugin, so containers, images and pods -can be managed from a browser instead of only the CLI. Cockpit's console has a **login -screen**, so per the module brief it is published internally as `https://podman.` via -`network:proxy`. +A **Debian 13 (trixie) VM running [Podman](https://podman.io)** as the container engine, with +**[Portainer CE](https://www.portainer.io)** as its web interface. Portainer runs *as a +container on the very engine it manages*: Podman is the foundation, Portainer is the GUI. -## What you get +People sign in with their **TAPPaaS identity** over OIDC — no per-VM Linux accounts to hand +out. From the console they create, start, stop, exec into and inspect containers, pull images +and deploy compose stacks — on this VM, and on any other host in the zone running a Portainer +agent. -| Capability | Access from | How | -|------------|-------------|-----| -| Podman container engine (rootless + CLI) | on the VM | `ssh tappaas@` then `podman …` | -| Cockpit web console (login + Podman page) | `mgmt` zone | `https://podman.` (internal) | -| Direct console | LAN reachable from `mgmt` | `https://:9090` | -| `podman-compose` for compose files | on the VM | `podman-compose up -d` | +## Two engines, on purpose -## What is not included +| Socket | Who uses it | Why | +|--------|-------------|-----| +| **rootful** `/run/podman/podman.sock` | Portainer | It drives the Docker-compatible API; Portainer does not support rootless | +| **rootless** user socket (`tappaas`) | people who `ssh` in | The safer way to run day-to-day containers by hand | -- **No pre-loaded containers.** This is a clean host — you bring the images/compose files. -- **No internet exposure.** The friendly name is reachable only from the `mgmt` zone; it is - not published to the internet (no inbound NAT/port-forward). -- **No Docker daemon / Kubernetes.** Podman is daemonless; there is no `dockerd` and no k8s - control plane here (Podman can generate/play Kubernetes YAML, but that is out of scope). -- **No default web login.** The console authenticates against Linux (PAM) accounts on the VM; - the cloud-init `tappaas` user has no password until you set one — see [INSTALL.md](./INSTALL.md). +Portainer's access to the rootful socket is **root-equivalent on this VM**. That is inherent to +every container manager with socket access — treat the VM as a lab host and put nothing +precious on it. -## Access (internal only) +## Why Portainer and not Cockpit -`network:proxy` publishes **`https://podman.`** (split-horizon DNS → Caddy → the VM's -`:9090`, HTTPS upstream), with an access-list permitting only the **`mgmt`** zone — **not -reachable from the internet**. Cockpit rides WebSockets, so the route is created with -`proxyUpstreamHttp1: true` (Caddy talks HTTP/1.1 to the upstream); the direct -`https://:9090` always works too. +The first version of this module shipped Cockpit with `cockpit-podman`. It cannot meet either +requirement: Cockpit is not an OIDC client and cannot be configured into one, it always needs +a local Unix account (`cockpit-bridge` runs in a PAM session as the user), and its multi-host +switcher is deprecated and disabled by default — the project states it "cannot be secure". +The full analysis, including the two options that were rejected, is in [DESIGN.md](./DESIGN.md). + +[komodo](../komodo) is the GPL alternative that was weighed against Portainer. + +## Gateway role + +Given an explicit `proxyDomain`, this module publishes at a name of your choosing rather than +the usual `.`. Pointing it at the environment's own domain makes +it that environment's front door: + +```bash +module-manager module add podman --environment lab1 --proxyDomain lab1.makerfloss.eu +``` + +…and the console answers at **`https://lab1.makerfloss.eu`** with no extra DNS label. + +## Identity, and what CE cannot do + +`identity:identity` creates the OIDC application in Authentik, binds it to the allowed groups +— that binding **is** the access gate, since Authentik fails open without one — and writes +`OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_DISCOVERY_URI` to `/etc/secrets/podman.env`. +`update.sh` reads the discovery document and PUTs the endpoints into Portainer's +`/api/settings`. + +**Mapping Authentik groups onto Portainer teams is a Business Edition feature.** In CE +everyone who passes the Authentik binding lands with the same access. For a shared lab that is +the intent; if you need per-person isolation, CE will not give it to you. ## Dependencies @@ -43,18 +64,19 @@ reachable from the internet**. Cockpit rides WebSockets, so the route is created | `cluster:vm` | Creates the Debian 13 VM from the cloud image | | `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) | | `backup:vm` | Scheduled VM backup to PBS | -| `network:proxy` | Internal-only reverse proxy for `podman.` | +| `network:proxy` | The published HTTPS name, upstream on `:9443` | +| `identity:identity` | OIDC application, group binding, client credentials | ## Placement -The module sets no explicit `zone0`, so the VM is placed in the **default environment's zone**; -install it with `--environment ` to put it in a specific environment and its associated -zone instead (see [INSTALL.md](./INSTALL.md#placement--zone)). +No `zone0` is set, so the VM lands in the environment's zone (ADR-007 P5). +`proxyAllowedZones` is deliberately unset, which gives the internal default — every Active +service zone plus `home`, `work`, `mgmt` and the netbird overlay, but **not** the internet — so +members reach the console from a client zone while Authentik decides who gets in. ## Sizing -2 vCPU / **2 GB** RAM / 20 GB disk — enough for the engine, Cockpit and a handful of light -containers. Bump memory/disk to match whatever you actually run on it (container images and -volumes land on the VM disk, thin-provisioned on ZFS). +2 vCPU / 2 GB RAM / 32 GB disk. Podman and Portainer are small; the disk is for the images and +volumes of whatever gets run here. For installation steps see [INSTALL.md](./INSTALL.md). diff --git a/src/containers/podman/install.sh b/src/containers/podman/install.sh index db8bfd0..2ff7a4a 100755 --- a/src/containers/podman/install.sh +++ b/src/containers/podman/install.sh @@ -2,11 +2,12 @@ # # podman module install — thin wrapper. # -# The VM itself is created by the cluster:vm provider (Debian 13 cloud image), -# then OS-prepped by the templates:debian provider (apt update + qemu-guest-agent -# via update-os.sh). This script applies the module-specific step: installing -# Podman + the Cockpit web console onto the Debian VM. All real work lives in -# update.sh (install == update for this module). +# The VM is created by the cluster:vm provider (Debian 13 cloud image) and +# OS-prepped by templates:debian. identity:identity has, by this point, created +# the OIDC application in Authentik and written OIDC_CLIENT_ID / +# OIDC_CLIENT_SECRET / OIDC_DISCOVERY_URI to the VM's secrets env file. This +# script applies the module-specific step; all real work lives in update.sh +# (install == update for this module). # # Usage: install.sh # diff --git a/src/containers/podman/podman.json b/src/containers/podman/podman.json index 81deb1c..79ac038 100644 --- a/src/containers/podman/podman.json +++ b/src/containers/podman/podman.json @@ -1,25 +1,30 @@ { - "description": "Podman container host — a Debian 13 VM with rootless Podman + the Cockpit web console (cockpit-podman) for managing containers, images and pods from a browser", - "version": "0.1.0", + "description": "Podman container host with a Portainer CE web console — create, start, stop and inspect OCI containers on this VM and on agent hosts in the same zone, signed in with TAPPaaS identity", + "version": "0.2.0", "appVersion": "5.4", - "releaseDate": "2026-08-18", + "releaseDate": "2026-08-25", "maintainer": "@larsrossen", "status": "Development", "vmname": "podman", "vmid": 812, - "vmtag": "TAPPaaS,Community,Containers", + "vmtag": "TAPPaaS,MakerFLOSS,Containers", "ports": [ - { "port": 9090, "protocol": "TCP", "description": "Cockpit web console UI + login (HTTPS)" } + { "port": 9443, "protocol": "TCP", "description": "Portainer web console (HTTPS)" }, + { "port": 8000, "protocol": "TCP", "description": "Edge agent tunnel (inbound from edge agents)" } ], - "dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy"], + "dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy", "identity:identity"], "provides": [], + "identity": { + "oidcRedirectPaths": ["/"], + "secretsEnv": "/etc/secrets/podman.env" + }, "config": { "cluster:vm": { "bios": "seabios", "ostype": "l26", "cores": 2, "memory": "2048", - "diskSize": "20G", + "diskSize": "32G", "storage": "tanka1", "imageType": "img", "image": "debian-13-generic-amd64.qcow2", @@ -28,10 +33,8 @@ "bridge0": "lan" }, "network:proxy": { - "proxyPort": 9090, - "proxyUpstreamTls": true, - "proxyUpstreamHttp1": true, - "proxyAllowedZones": ["mgmt"] + "proxyPort": 9443, + "proxyUpstreamTls": true } } } diff --git a/src/containers/podman/test.sh b/src/containers/podman/test.sh index a2ac1fe..e67a942 100755 --- a/src/containers/podman/test.sh +++ b/src/containers/podman/test.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # -# podman module test — health checks for the Podman container-host VM. +# podman module test — health checks for the Podman host and its Portainer GUI. # # Verifies (from tappaas-cicd, over the Proxmox guest agent + SSH): # - the VM is reachable -# - podman is installed (version marker + working binary) -# - the cockpit-podman plugin is present -# - the Cockpit web console answers (HTTPS, :9090) +# - the rootful podman socket is active (this is Portainer's Docker API) +# - the portainer container is running +# - the API answers on :9443 +# - authentication is set to OAuth, i.e. identity wiring survived # # Usage: test.sh # @@ -47,35 +48,48 @@ ok "VM IP resolved (${IP})" vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; } -# Version marker + working podman binary -GOT_VER="$(vm "cat /etc/tappaas-podman.version 2>/dev/null" || true)" -if [[ -n "${GOT_VER}" ]]; then - ok "podman version marker present (${GOT_VER})" -else - no "podman version marker missing (not installed?)" -fi +# The engine itself, and the Docker-compatible API Portainer drives. if vm "command -v podman >/dev/null 2>&1 && podman --version >/dev/null 2>&1"; then - ok "podman binary works ($(vm "podman --version 2>/dev/null" || echo '?'))" + ok "podman works ($(vm "podman --version 2>/dev/null" | tr -d '\r'))" else no "podman binary not working" fi - -# cockpit-podman plugin installed -if vm "dpkg -s cockpit-podman >/dev/null 2>&1"; then - ok "cockpit-podman plugin installed" +if vm "systemctl is-active --quiet podman.socket"; then + ok "rootful podman.socket active (Portainer's container API)" else - no "cockpit-podman plugin not installed" + no "podman.socket not active (Portainer has no container API without it)" +fi +if vm "systemctl --user is-active --quiet podman.socket"; then + ok "rootless podman.socket active (for CLI users)" +else + no "rootless podman.socket not active" fi -# Cockpit web console answers on :9090 (login page pre-auth is a pass). -# Plain -s (no --fail): --fail exits 22 on 4xx and would corrupt the -w code. -code="$(vm "curl -sk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)" -if [[ "${code}" =~ ^(200|302|401|403)$ ]]; then - ok "Cockpit console answers on :9090 (HTTP ${code})" +# Container up and healthy. +STATE="$(vm "sudo podman container inspect --format '{{.State.Status}}' portainer 2>/dev/null" | tr -d '\r' || true)" +if [[ "${STATE}" == "running" ]]; then + ok "portainer container running" else - no "Cockpit console did not answer on :9090 (HTTP ${code})" + no "portainer container not running (state: ${STATE:-absent})" fi +# API answers. /api/status is unauthenticated and returns the version. +VER="$(vm "curl -fsk https://localhost:9443/api/status 2>/dev/null" | jq -r '.Version // empty' || true)" +if [[ -n "${VER}" ]]; then + ok "API answers on :9443 (Portainer ${VER})" +else + no "API did not answer on :9443" +fi + +# Identity wiring. /api/settings/public is unauthenticated and carries +# AuthenticationMethod: 1 internal, 2 LDAP, 3 OAuth. +METHOD="$(vm "curl -fsk https://localhost:9443/api/settings/public 2>/dev/null" | jq -r '.AuthenticationMethod // empty' || true)" +case "${METHOD}" in + 3) ok "authentication is OAuth (TAPPaaS identity)" ;; + "") no "could not read /api/settings/public" ;; + *) no "authentication is method ${METHOD}, expected 3 (OAuth) — identity wiring missing" ;; +esac + echo "" info "Result: ${PASS} passed, ${FAIL} failed" [[ "${FAIL}" -eq 0 ]] diff --git a/src/containers/podman/update.sh b/src/containers/podman/update.sh index 274efd7..f92c836 100755 --- a/src/containers/podman/update.sh +++ b/src/containers/podman/update.sh @@ -1,19 +1,23 @@ #!/usr/bin/env bash # -# podman module update — install/upgrade Podman + the Cockpit web console on the -# Debian 13 VM. +# podman module update — a Podman container host with Portainer CE as its GUI. # -# Podman is the daemonless, rootless-capable container engine; it ships in the -# Debian 13 (trixie) main repo, so no external download is needed. To give it the -# browser interface the module promises, we also install Cockpit and its -# cockpit-podman plugin — Cockpit serves an HTTPS admin console with a PAM LOGIN -# SCREEN on :9090, and cockpit-podman adds the "Podman containers" page for -# managing containers, images and pods. network:proxy publishes that console as -# https://podman. (internal, mgmt zone only). +# Podman is the engine; Portainer is the web interface onto it. Both live on the +# same VM: Portainer runs AS a container on the very engine it manages. +# +# Portainer manages containers through the Docker-compatible API. Podman exposes +# exactly that API on its ROOTFUL socket (/run/podman/podman.sock), which is what +# Portainer's own Podman install guide uses — rootless Podman is explicitly not +# supported by Portainer, so this module runs the system socket. +# +# Login is OIDC against Authentik: identity:identity created the application and +# wrote the client credentials to ${SECRETS_ENV} on the VM before this ran. We +# read the discovery document to find the three endpoints Portainer wants, then +# PUT them into /api/settings. Portainer has no config file for this — the API is +# the only way to configure it unattended. # # This runs on tappaas-cicd: it resolves the VM's IP via the Proxmox guest agent, -# then over SSH installs the packages from apt. Idempotent: it re-runs apt every -# time (apt is a no-op when already current) and records a version marker. +# then drives the VM over SSH. Idempotent throughout. # # Usage: update.sh # @@ -24,12 +28,15 @@ set -euo pipefail MODULE="${1:-podman}" readonly MGMT="mgmt" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly MARKER="/etc/tappaas-podman.version" # on the VM: records installed podman version +readonly IMAGE="docker.io/portainer/portainer-ce:lts" +readonly CONTAINER="portainer" +readonly MARKER="/etc/tappaas-podman.version" # on the VM: image ID we installed +readonly ADMIN_SECRET="/etc/secrets/podman-admin" # on the VM: bootstrap admin password VMNAME="$(get_config_value 'vmname' "${MODULE}")" VMID="$(get_config_value 'vmid')" +SECRETS_ENV="$(get_config_value 'secretsEnv' '/etc/secrets/podman.env')" [[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}" # ── Locate the node hosting the VM (HA-safe) and resolve its IP ─────── @@ -47,78 +54,214 @@ get_vm_ip() { | grep -v '^127\.' | head -1 } -info "${BOLD}Installing Podman + Cockpit${CL} on ${VMNAME} (VM ${VMID}, node ${NODE})" +info "${BOLD}Installing Podman + Portainer${CL} on ${VMNAME} (VM ${VMID}, node ${NODE})" IP="" for _ in $(seq 1 18); do IP="$(get_vm_ip)"; [[ -n "${IP}" ]] && break; sleep 10; done [[ -n "${IP}" ]] || die "could not resolve ${VMNAME} IP via guest agent (is qemu-guest-agent up? templates:debian installs it)" info " VM IP: ${IP}" -# SSH helper: run a command on the VM as the tappaas user. vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; } -# Wait for SSH (cloud-init may still be finishing). for _ in $(seq 1 40); do vm "exit 0" 2>/dev/null && break; sleep 3; done vm "exit 0" 2>/dev/null || die "SSH to tappaas@${IP} not available" # ── Friendly URL for the operator ──────────────────────────────────── -DOMAIN="$(get_variant_config "" 2>/dev/null | jq -r '.domain // empty' || true)" +# proxyDomain is NOT persisted into the installed config by install-module.sh, so +# it is normally empty here and we must derive it — from the module's OWN +# environment, not the default one (identity/install-service.sh does the same). +ENVIRONMENT="$(get_config_value 'environment' '')" +DOMAIN="$(get_variant_config "${ENVIRONMENT}" 2>/dev/null | jq -r '.domain // empty' || true)" PROXY_DOMAIN="$(get_config_value 'proxyDomain' "${VMNAME}${DOMAIN:+.${DOMAIN}}")" -COCKPIT_DIRECT_URL="https://${IP}:9090" +UI_DIRECT_URL="https://${IP}:9443" if [[ -n "${PROXY_DOMAIN}" ]] && getent hosts "${PROXY_DOMAIN}" >/dev/null 2>&1; then - COCKPIT_URL="https://${PROXY_DOMAIN}" + UI_URL="https://${PROXY_DOMAIN}" else [[ -n "${PROXY_DOMAIN}" ]] && info " ${PROXY_DOMAIN} does not resolve yet — using the direct URL" - COCKPIT_URL="${COCKPIT_DIRECT_URL}" + UI_URL="${UI_DIRECT_URL}" fi -# ── 1. Install podman + cockpit + cockpit-podman (apt) ─────────────── -# podman-compose/slirp4netns give rootless networking + compose-file support; -# cockpit + cockpit-podman provide the web console with a login screen on :9090. -info " Installing podman, podman-compose, cockpit, cockpit-podman (apt)..." +# ── 1. Podman engine + the rootful API socket ──────────────────────── +info " Installing podman + tooling (apt)..." vm "sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq" || die "apt-get update failed" vm "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ - podman podman-compose slirp4netns uidmap \ - cockpit cockpit-podman \ - curl ca-certificates" \ - || die "failed to install podman/cockpit packages" + podman podman-compose slirp4netns uidmap curl jq ca-certificates" \ + || die "failed to install podman" PODMAN_VER="$(vm "podman --version 2>/dev/null | awk '{print \$3}'" || true)" [[ -n "${PODMAN_VER}" ]] || die "podman did not install correctly (no version reported)" info " podman version: ${PODMAN_VER}" - -# ── 2. Enable podman socket + Cockpit web console ──────────────────── -# Rootless podman API socket for the tappaas user (lets cockpit-podman talk to it -# without root), and the Cockpit HTTPS console on :9090. -info " Enabling the rootless podman socket + Cockpit web console..." -vm "systemctl --user enable --now podman.socket 2>/dev/null || true" -vm "sudo loginctl enable-linger tappaas 2>/dev/null || true" # keep the user socket alive after logout -vm "sudo systemctl enable --now cockpit.socket" || die "failed to enable cockpit.socket" - -# Record version marker. vm "echo '${PODMAN_VER}' | sudo tee ${MARKER} >/dev/null" || warn "could not write version marker" -# ── 3. Wait for the Cockpit console to answer (port 9090) ──────────── -info " Waiting for the Cockpit console to come up (https://${IP}:9090)..." +# Two engines on one host, on purpose: +# - the ROOTFUL socket is the Docker-compatible API Portainer drives (Portainer +# does not support rootless); +# - the ROOTLESS user socket is for people who ssh in and run podman themselves, +# which is the safer way to run day-to-day containers. +# podman-restart.service is what makes --restart=always survive a reboot. +vm "sudo systemctl enable --now podman.socket" || die "failed to enable podman.socket" +vm "sudo systemctl enable --now podman-restart.service 2>/dev/null || true" +vm "systemctl --user enable --now podman.socket 2>/dev/null || true" +vm "sudo loginctl enable-linger tappaas 2>/dev/null || true" + +# ── 2. Portainer container ─────────────────────────────────────────── +info " Pulling ${IMAGE}..." +vm "sudo podman pull -q ${IMAGE}" >/dev/null || die "failed to pull ${IMAGE}" +IMAGE_ID="$(vm "sudo podman image inspect --format '{{.Id}}' ${IMAGE}" | tr -d '\r')" +[[ -n "${IMAGE_ID}" ]] || die "could not read the image id for ${IMAGE}" + +RUNNING_ID="$(vm "sudo podman container inspect --format '{{.Image}}' ${CONTAINER} 2>/dev/null" | tr -d '\r' || true)" +if [[ "${RUNNING_ID}" != "${IMAGE_ID}" ]]; then + # Recreate only when the image actually changed. The named volume carries all + # state, so removing the container loses nothing. + info " (Re)creating the ${CONTAINER} container..." + vm "sudo podman volume create portainer_data >/dev/null 2>&1 || true" + vm "sudo podman rm -f ${CONTAINER} >/dev/null 2>&1 || true" + vm "sudo podman run -d --name ${CONTAINER} --restart=always --privileged \ + -p 8000:8000 -p 9443:9443 \ + -v /run/podman/podman.sock:/var/run/docker.sock \ + -v portainer_data:/data ${IMAGE}" \ + || die "failed to start ${CONTAINER}" + vm "echo '${IMAGE_ID}' | sudo tee /etc/tappaas-portainer.image >/dev/null" || warn "could not write image marker" +else + info " ${CONTAINER} already running the current image — leaving it alone" +fi + +# ── 3. Wait for the API ────────────────────────────────────────────── +info " Waiting for Portainer to answer on :9443..." UP=0 -for _ in $(seq 1 18); do - code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)" - [[ "${code}" =~ ^(200|302|401|403)$ ]] && { UP=1; break; } +for _ in $(seq 1 24); do + code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)" + [[ "${code}" == "200" ]] && { UP=1; break; } sleep 5 done +[[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443" -echo "" -if [[ "${UP}" -eq 1 ]]; then - info "${GN}✓ Podman ${PODMAN_VER} + Cockpit console installed${CL}" +# ── 4. Bootstrap the local admin ───────────────────────────────────── +# The password is kept on the VM for break-glass access — OIDC is the everyday +# path, this is the account that survives an Authentik outage. +# +# The guard is "can we log in?", NOT "does the password file exist": a failed +# init leaves a password file behind, and keying off the file makes every later +# run skip the bootstrap forever (seen on the first lab1 install). +if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then + vm "sudo install -d -m 0700 \$(dirname ${ADMIN_SECRET})" + vm "openssl rand -base64 24 | sudo tee ${ADMIN_SECRET} >/dev/null && sudo chmod 0600 ${ADMIN_SECRET}" +fi +ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')" + +portainer_jwt() { + vm "curl -sk -X POST https://localhost:9443/api/auth \ + -H 'Content-Type: application/json' \ + -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null | jq -r '.jwt // empty' +} + +wait_for_api() { + local code + for _ in $(seq 1 24); do + code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)" + [[ "${code}" == "200" ]] && return 0 + sleep 5 + done + return 1 +} + +# Echoes the HTTP status of an admin-init attempt. The setup token is minted +# afresh at every container start, so re-read it each time. +init_admin() { + local tok hdr="" + tok="$(vm "sudo podman logs ${CONTAINER} 2>&1 | grep setup_token | grep -oE '[0-9a-f]{64}' | tail -1" | tr -d '\r')" + [[ -n "${tok}" ]] && hdr="-H 'X-Setup-Token: ${tok}'" + vm "curl -sk -o /dev/null -w '%{http_code}' -X POST https://localhost:9443/api/users/admin/init \ + -H 'Content-Type: application/json' ${hdr} \ + -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null || echo 000 +} + +JWT="$(portainer_jwt)" +if [[ -z "${JWT}" ]]; then + info " Bootstrapping the break-glass admin account..." + init_code="$(init_admin)" + if [[ "${init_code}" == "303" ]]; then + # Portainer closes the admin-init window a few minutes after start + # (Redirect-Reason: AdminInitTimeout). Without this the module can never + # be bootstrapped again after any transient failure — restarting resets + # the timer and mints a new setup token. + info " admin-init window had closed — restarting ${CONTAINER} to reopen it" + vm "sudo podman restart ${CONTAINER} >/dev/null" || warn " restart failed" + wait_for_api && init_code="$(init_admin)" + fi + case "${init_code}" in + 200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;; + 409) warn " an admin exists but ${ADMIN_SECRET} does not match it — reset it by hand" ;; + *) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;; + esac + JWT="$(portainer_jwt)" else - warn "Packages installed but the Cockpit console did not answer yet — it may still be starting." + info " break-glass admin already provisioned" +fi + +# ── 5. Point Portainer at TAPPaaS identity (OIDC) ──────────────────── +# identity:identity wrote these three values; if they are missing the module is +# still usable with the local admin, so warn rather than fail. +if [[ -z "${JWT:-}" ]]; then + warn " no admin session — skipping OIDC configuration (see the admin init warning above)" +elif ! vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then + warn " ${SECRETS_ENV} missing — is identity:identity in dependsOn?" +else + info " Configuring OIDC login against Authentik..." + CLIENT_ID="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_ID\"'" | tr -d '\r')" + CLIENT_SECRET="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_SECRET\"'" | tr -d '\r')" + DISCOVERY="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_DISCOVERY_URI\"'" | tr -d '\r')" + + if [[ -n "${CLIENT_ID}" && -n "${CLIENT_SECRET}" && -n "${DISCOVERY}" ]]; then + # Portainer wants the three endpoints separately; the discovery document has them. + WELL_KNOWN="$(vm "curl -fsk ${DISCOVERY}" || true)" + AUTH_URI="$(jq -r '.authorization_endpoint // empty' <<<"${WELL_KNOWN}")" + TOKEN_URI="$(jq -r '.token_endpoint // empty' <<<"${WELL_KNOWN}")" + USER_URI="$(jq -r '.userinfo_endpoint // empty' <<<"${WELL_KNOWN}")" + + if [[ -n "${AUTH_URI}" && -n "${TOKEN_URI}" && -n "${USER_URI}" ]]; then + if [[ -n "${JWT}" ]]; then + # AuthenticationMethod 3 = OAuth. OAuthAutoCreateUsers lets a + # TAPPaaS identity log in without an admin pre-creating it; + # Authentik's group binding is the actual access gate. + SETTINGS="$(jq -n \ + --arg cid "${CLIENT_ID}" --arg csec "${CLIENT_SECRET}" \ + --arg auth "${AUTH_URI}" --arg tok "${TOKEN_URI}" --arg usr "${USER_URI}" \ + --arg redir "https://${PROXY_DOMAIN}/" \ + '{AuthenticationMethod: 3, + OAuthSettings: {ClientID: $cid, ClientSecret: $csec, + AuthorizationURI: $auth, AccessTokenURI: $tok, + ResourceURI: $usr, RedirectURI: $redir, + UserIdentifier: "preferred_username", + Scopes: "openid profile email", + OAuthAutoCreateUsers: true, SSO: true}}')" + set_code="$(vm "curl -sk -o /dev/null -w '%{http_code}' -X PUT https://localhost:9443/api/settings \ + -H 'Authorization: Bearer ${JWT}' -H 'Content-Type: application/json' \ + -d '$(printf '%s' "${SETTINGS}" | tr -d '\n')'" || echo 000)" + if [[ "${set_code}" == "200" ]]; then + info " ${GN}✓${CL} OIDC login configured" + else + warn " settings update returned HTTP ${set_code} — configure OAuth by hand at ${UI_URL}" + fi + else + warn " could not authenticate as the local admin — skipping OIDC configuration" + fi + else + warn " discovery document at ${DISCOVERY} lacked the endpoints — skipping OIDC configuration" + fi + else + warn " ${SECRETS_ENV} did not carry all three OIDC values — skipping OIDC configuration" + fi fi +echo "" +info "${GN}✓ Podman + Portainer installed${CL}" echo "" info "${BOLD}═══ Next steps ═══${CL}" -info " ${BOLD}Console:${CL} ${COCKPIT_URL} (direct: https://${IP}:9090)" -info " Log in with a Linux account on the VM. The cloud-init ${BOLD}tappaas${CL} user has" -info " SSH-key auth and no password, so set one first for the web login:" -info " ssh tappaas@${IP} 'sudo passwd tappaas'" -info " Then open the console and pick ${BOLD}Podman containers${CL} in the left menu." -info " See INSTALL.md for the details (and how to add a dedicated admin user instead)." +info " ${BOLD}Console:${CL} ${UI_URL} (direct: ${UI_DIRECT_URL})" +info " Sign in with your TAPPaaS identity. Break-glass admin password:" +info " ssh tappaas@${IP} 'sudo cat ${ADMIN_SECRET}'" +info " Containers on this host: ssh tappaas@${IP} and run podman (rootless)." +info " Other hosts in the zone: run the Portainer agent there, add it under" +info " Environments — see INSTALL.md." diff --git a/src/containers/portainer/INSTALL.md b/src/containers/portainer/INSTALL.md deleted file mode 100644 index 7965e85..0000000 --- a/src/containers/portainer/INSTALL.md +++ /dev/null @@ -1,91 +0,0 @@ -# Portainer CE — Installation - -Primary audience: TAPPaaS admin. Steps the scripts cannot automate. - -## Prerequisites - -- `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access. -- Dependencies resolve automatically (`cluster:vm`, `templates:debian`, `backup:vm`, - `network:proxy`, `identity:identity`). -- The identity foundation must be up — this module's login depends on it. -- For a valid certificate on the friendly name, the TAPPaaS wildcard must be in OPNsense - Trust (`acme-setup.sh`, once — see the platform INSTALL). - -## Install - -```bash -module-manager module add portainer --environment -``` - -This automatically: - -- creates a Debian 13 VM (vmid 813, 2 vCPU / 2 GB / 32 GB) and OS-preps it; -- installs `podman` and enables the **rootful** `podman.socket` plus `podman-restart.service`; -- runs the `portainer-ce:lts` container with the socket and a named volume; -- creates a break-glass local admin, storing its password at `/etc/secrets/portainer-admin`; -- reads the OIDC credentials `identity:identity` left in `/etc/secrets/portainer.env`, - resolves the endpoints from the discovery document, and switches Portainer to OAuth login; -- publishes `https://portainer.` through the reverse proxy. - -## Post-install (manual) - -### 1. Check who can log in - -Authentik binds the application to the allowed groups at install. Anyone in those groups can -sign in; **anyone not in them cannot**, which is the access gate. To let the hackerspace in: - -```bash -people-manager user modify --add-groups devops -people-manager reconcile --apply -``` - -### 2. Add other hosts in the zone - -On each additional VM or physical machine, run the agent: - -```bash -podman run -d --name portainer_agent --restart=always \ - -p 9001:9001 \ - -v /run/podman/podman.sock:/var/run/docker.sock \ - -v /var/lib/docker/volumes:/var/lib/docker/volumes \ - docker.io/portainer/agent:lts -``` - -Then in Portainer: **Environments → Add environment → Docker Standalone → Agent**, address -`:9001`. Same-zone traffic is allowed by default; a host in a different zone needs a -pinhole into `:9001`. - -> Portainer now describes this classic agent as legacy and prefers the Edge Agent, which -> dials out to `:8000` instead of being dialled into. For hosts on the lab LAN the classic -> agent is simpler; use Edge if a host sits behind NAT. - -### 3. Break-glass access - -If Authentik is down, log in locally: - -```bash -ssh tappaas@ 'sudo cat /etc/secrets/portainer-admin' -``` - -Username `admin`. Keep this — it is the only way back in when identity is unavailable. - -## Notes from the first live install (2026-08-24, lab1) - -- **Portainer ≥ 2.39 requires a setup token.** `POST /api/users/admin/init` returns `403` - unless the `X-Setup-Token` header carries the token Portainer prints once at startup. - `update.sh` now reads it from `podman logs`. The log line wraps the value in ANSI colour - codes, so it matches the 64-hex token rather than the `setup_token=` prefix. -- **`proxyDomain` is not persisted into the installed config.** `copy-update-json.sh` - documents that `install-module.sh` computes it from the environment, but it lands as - `null`, so every consumer has to derive it — and must do so from the module's *own* - environment. The published name is `.`, e.g. - **`portainer-lab1.lab1.makerfloss.eu`** — not `portainer.lab1.…`. - -## Verify - -```bash -module-manager module test portainer -``` - -Checks the podman socket, the container, the API, and that authentication is still OAuth -(method `3`) rather than having fallen back to internal accounts. diff --git a/src/containers/portainer/README.md b/src/containers/portainer/README.md deleted file mode 100644 index 45cbe26..0000000 --- a/src/containers/portainer/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Portainer CE — container management with TAPPaaS login - -Primary audience: TAPPaaS operator who wants **several people** creating, starting, stopping -and inspecting containers — on this VM and on other hosts in the same zone. - -A **Debian 13 (trixie) VM running [Portainer CE](https://www.portainer.io)** on top of rootful -Podman. Portainer is a web console for OCI containers: create, start, stop, exec, read logs, -inspect, pull images, deploy compose stacks. People sign in with their **TAPPaaS identity** -over OIDC — no per-VM Linux accounts. - -> **Status: Development, and not yet run end to end on a live TAPPaaS.** Written against the -> published Portainer install docs and API, and against the `identity:identity` contract, but -> the first real install is still ahead. - -## What you get - -| Capability | Access from | How | -|------------|-------------|-----| -| Manage containers on this VM | any internal zone | `https://portainer.` | -| Manage containers on other lab hosts | same | add each host as an Environment (agent on `:9001`) | -| Sign-in with your TAPPaaS account | same | OIDC against Authentik | -| Break-glass local admin | the VM | password in `/etc/secrets/portainer-admin` | - -## Why not Cockpit - -The obvious alternative — Cockpit with `cockpit-podman` — cannot meet either requirement. -Cockpit is not an OIDC client and cannot be configured into one, and it always needs a local -Unix account because `cockpit-bridge` runs in a PAM session as the user. Its multi-host host -switcher is **deprecated and disabled by default**, and the project states it "cannot be -secure" because a remote host's JavaScript then runs against every other connected host. See -[podman/DESIGN.md](../podman/DESIGN.md) for the full analysis, and [komodo](../komodo) for the -GPL alternative that was weighed against this one. - -## Rootful, deliberately - -Portainer drives containers through the **Docker-compatible API**, which Podman serves on its -rootful socket `/run/podman/podman.sock`. Portainer's documentation says rootless Podman "may -work but is currently not officially supported", so this module enables the system socket. - -That means **Portainer has root-equivalent control of this VM** — as any container manager -with socket access does. The VM is therefore a lab host and nothing else: do not co-locate -anything you care about. - -## Multi-host - -The Portainer **agent** is one container on each additional host, listening on `:9001`, which -the server then adds as an Environment. Hosts in the same zone reach each other directly; -crossing a zone needs a pinhole. See [INSTALL.md](./INSTALL.md). - -## Identity, and what CE can't do - -`identity:identity` creates the OIDC application in Authentik, binds it to the allowed groups -— that binding **is** the access gate, since Authentik fails open without one — and writes -`OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_DISCOVERY_URI` to `/etc/secrets/portainer.env`. -`update.sh` reads the discovery document and PUTs the three endpoints into `/api/settings`. - -**Mapping Authentik groups onto Portainer teams is a Business Edition feature.** In CE every -person who passes the Authentik binding lands with the same access. For a shared lab that is -the intent; if you need per-person isolation, CE will not give it to you. - -## Dependencies - -| Depends on | Purpose | -|------------|---------| -| `cluster:vm` | Creates the Debian 13 VM from the cloud image | -| `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) | -| `backup:vm` | Scheduled VM backup to PBS | -| `network:proxy` | `https://portainer.`, HTTPS upstream on `:9443` | -| `identity:identity` | OIDC application, group binding, client credentials | - -## Placement - -No `zone0` is set, so the VM lands in the environment's zone (ADR-007 P5). Install with -`--environment ` to place it. `proxyAllowedZones` is deliberately **unset**, which gives -the internal default — every Active service zone plus `home`, `work`, `mgmt` and the netbird -overlay, but **not** the internet — so members can reach the console from a client zone while -Authentik gates who actually gets in. - -## Sizing - -2 vCPU / 2 GB RAM / 32 GB disk. Portainer itself is tiny; the disk is for the images and -volumes of whatever gets run here. - -For installation steps see [INSTALL.md](./INSTALL.md). diff --git a/src/containers/portainer/install.sh b/src/containers/portainer/install.sh deleted file mode 100755 index 1e81802..0000000 --- a/src/containers/portainer/install.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# -# portainer module install — thin wrapper. -# -# The VM is created by the cluster:vm provider (Debian 13 cloud image) and -# OS-prepped by templates:debian. identity:identity has, by this point, created -# the OIDC application in Authentik and written OIDC_CLIENT_ID / -# OIDC_CLIENT_SECRET / OIDC_DISCOVERY_URI to the VM's secrets env file. This -# script applies the module-specific step; all real work lives in update.sh -# (install == update for this module). -# -# Usage: install.sh -# - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec "${SCRIPT_DIR}/update.sh" "$@" diff --git a/src/containers/portainer/portainer.json b/src/containers/portainer/portainer.json deleted file mode 100644 index e0acf64..0000000 --- a/src/containers/portainer/portainer.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "description": "Portainer CE — web console for creating, starting, stopping and inspecting OCI containers on this VM and on agent hosts in the same zone, with TAPPaaS identity login over OIDC", - "version": "0.1.0", - "appVersion": "lts", - "releaseDate": "2026-08-23", - "maintainer": "@larsrossen", - "status": "Development", - "vmname": "portainer", - "vmid": 813, - "vmtag": "TAPPaaS,MakerFLOSS,Containers", - "ports": [ - { "port": 9443, "protocol": "TCP", "description": "Portainer web UI (HTTPS)" }, - { "port": 8000, "protocol": "TCP", "description": "Edge agent tunnel (inbound from edge agents)" } - ], - "dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy", "identity:identity"], - "provides": [], - "identity": { - "oidcRedirectPaths": ["/"], - "secretsEnv": "/etc/secrets/portainer.env" - }, - "config": { - "cluster:vm": { - "bios": "seabios", - "ostype": "l26", - "cores": 2, - "memory": "2048", - "diskSize": "32G", - "storage": "tanka1", - "imageType": "img", - "image": "debian-13-generic-amd64.qcow2", - "imageLocation": "https://cdimage.debian.org/cdimage/cloud/trixie/latest", - "cloudInit": "true", - "bridge0": "lan" - }, - "network:proxy": { - "proxyPort": 9443, - "proxyUpstreamTls": true - } - } -} diff --git a/src/containers/portainer/test.sh b/src/containers/portainer/test.sh deleted file mode 100755 index 7c93a6f..0000000 --- a/src/containers/portainer/test.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# -# portainer module test — health checks for the Portainer CE VM. -# -# Verifies (from tappaas-cicd, over the Proxmox guest agent + SSH): -# - the VM is reachable -# - the rootful podman socket is active (this is Portainer's Docker API) -# - the portainer container is running -# - the API answers on :9443 -# - authentication is set to OAuth, i.e. identity wiring survived -# -# Usage: test.sh -# - -set -uo pipefail - -. /home/tappaas/bin/common-install-routines.sh - -MODULE="${1:-portainer}" -readonly MGMT="mgmt" -VMID="$(get_config_value 'vmid')" -VMNAME="$(get_config_value 'vmname' "${MODULE}")" - -PASS=0; FAIL=0 -ok() { info " ${GN}✓${CL} $1"; PASS=$((PASS+1)); } -no() { error " ✗ $1"; FAIL=$((FAIL+1)); } - -[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}" - -PRIMARY="$(get_primary_node_fqdn 2>/dev/null || echo "tappaas1.${MGMT}.internal")" -NODE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${PRIMARY}" \ - "pvesh get /cluster/resources --type vm --output-format json 2>/dev/null" \ - | jq -r --arg v "${VMID}" '.[] | select(.vmid==($v|tonumber)) | .node' 2>/dev/null | head -1)" -[[ -n "${NODE}" ]] || NODE="$(get_config_value 'node' "$(get_node_hostname 0)")" - -IP="$(ssh -o BatchMode=yes "root@${NODE}.${MGMT}.internal" \ - "qm guest cmd ${VMID} network-get-interfaces" 2>/dev/null \ - | jq -r '.[] | select(.name | test("^lo$") | not) | ."ip-addresses"[]? | select(."ip-address-type"=="ipv4") | ."ip-address"' 2>/dev/null \ - | grep -v '^127\.' | head -1)" - -info "${BOLD}portainer test${CL} (${VMNAME}, VM ${VMID} on ${NODE})" -if [[ -z "${IP}" ]]; then - no "could not resolve VM IP via guest agent" - info "Result: ${PASS} passed, ${FAIL} failed" - exit 1 -fi -ok "VM IP resolved (${IP})" - -vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; } - -# The Docker-compatible API Portainer drives. -if vm "systemctl is-active --quiet podman.socket"; then - ok "rootful podman.socket active" -else - no "podman.socket not active (Portainer has no container API without it)" -fi - -# Container up and healthy. -STATE="$(vm "sudo podman container inspect --format '{{.State.Status}}' portainer 2>/dev/null" | tr -d '\r' || true)" -if [[ "${STATE}" == "running" ]]; then - ok "portainer container running" -else - no "portainer container not running (state: ${STATE:-absent})" -fi - -# API answers. /api/status is unauthenticated and returns the version. -VER="$(vm "curl -fsk https://localhost:9443/api/status 2>/dev/null" | jq -r '.Version // empty' || true)" -if [[ -n "${VER}" ]]; then - ok "API answers on :9443 (Portainer ${VER})" -else - no "API did not answer on :9443" -fi - -# Identity wiring. /api/settings/public is unauthenticated and carries -# AuthenticationMethod: 1 internal, 2 LDAP, 3 OAuth. -METHOD="$(vm "curl -fsk https://localhost:9443/api/settings/public 2>/dev/null" | jq -r '.AuthenticationMethod // empty' || true)" -case "${METHOD}" in - 3) ok "authentication is OAuth (TAPPaaS identity)" ;; - "") no "could not read /api/settings/public" ;; - *) no "authentication is method ${METHOD}, expected 3 (OAuth) — identity wiring missing" ;; -esac - -echo "" -info "Result: ${PASS} passed, ${FAIL} failed" -[[ "${FAIL}" -eq 0 ]] diff --git a/src/containers/portainer/update.sh b/src/containers/portainer/update.sh deleted file mode 100755 index b0a9203..0000000 --- a/src/containers/portainer/update.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env bash -# -# portainer module update — install/upgrade Portainer CE on the Debian 13 VM and -# point it at TAPPaaS identity for login. -# -# Portainer manages containers through the Docker-compatible API. Podman exposes -# exactly that API on its ROOTFUL socket (/run/podman/podman.sock), which is what -# Portainer's own Podman install guide uses — rootless Podman is explicitly not -# supported by Portainer, so this module runs the system socket. -# -# Login is OIDC against Authentik: identity:identity created the application and -# wrote the client credentials to ${SECRETS_ENV} on the VM before this ran. We -# read the discovery document to find the three endpoints Portainer wants, then -# PUT them into /api/settings. Portainer has no config file for this — the API is -# the only way to configure it unattended. -# -# This runs on tappaas-cicd: it resolves the VM's IP via the Proxmox guest agent, -# then drives the VM over SSH. Idempotent throughout. -# -# Usage: update.sh -# - -set -euo pipefail - -. /home/tappaas/bin/common-install-routines.sh - -MODULE="${1:-portainer}" -readonly MGMT="mgmt" - -readonly IMAGE="docker.io/portainer/portainer-ce:lts" -readonly CONTAINER="portainer" -readonly MARKER="/etc/tappaas-portainer.version" # on the VM: image ID we installed -readonly ADMIN_SECRET="/etc/secrets/portainer-admin" # on the VM: bootstrap admin password - -VMNAME="$(get_config_value 'vmname' "${MODULE}")" -VMID="$(get_config_value 'vmid')" -SECRETS_ENV="$(get_config_value 'secretsEnv' '/etc/secrets/portainer.env')" -[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}" - -# ── Locate the node hosting the VM (HA-safe) and resolve its IP ─────── -PRIMARY="$(get_primary_node_fqdn 2>/dev/null || echo "tappaas1.${MGMT}.internal")" -NODE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${PRIMARY}" \ - "pvesh get /cluster/resources --type vm --output-format json 2>/dev/null" \ - | jq -r --arg v "${VMID}" '.[] | select(.vmid==($v|tonumber)) | .node' 2>/dev/null | head -1)" -[[ -n "${NODE}" ]] || NODE="$(get_config_value 'node' "$(get_node_hostname 0)")" -[[ -n "${NODE}" ]] || die "could not locate the node hosting VM ${VMID}" - -get_vm_ip() { - ssh -o BatchMode=yes "root@${NODE}.${MGMT}.internal" \ - "qm guest cmd ${VMID} network-get-interfaces" 2>/dev/null \ - | jq -r '.[] | select(.name | test("^lo$") | not) | ."ip-addresses"[]? | select(."ip-address-type"=="ipv4") | ."ip-address"' 2>/dev/null \ - | grep -v '^127\.' | head -1 -} - -info "${BOLD}Installing Portainer CE${CL} on ${VMNAME} (VM ${VMID}, node ${NODE})" - -IP="" -for _ in $(seq 1 18); do IP="$(get_vm_ip)"; [[ -n "${IP}" ]] && break; sleep 10; done -[[ -n "${IP}" ]] || die "could not resolve ${VMNAME} IP via guest agent (is qemu-guest-agent up? templates:debian installs it)" -info " VM IP: ${IP}" - -vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; } - -for _ in $(seq 1 40); do vm "exit 0" 2>/dev/null && break; sleep 3; done -vm "exit 0" 2>/dev/null || die "SSH to tappaas@${IP} not available" - -# ── Friendly URL for the operator ──────────────────────────────────── -# proxyDomain is NOT persisted into the installed config by install-module.sh, so -# it is normally empty here and we must derive it — from the module's OWN -# environment, not the default one (identity/install-service.sh does the same). -ENVIRONMENT="$(get_config_value 'environment' '')" -DOMAIN="$(get_variant_config "${ENVIRONMENT}" 2>/dev/null | jq -r '.domain // empty' || true)" -PROXY_DOMAIN="$(get_config_value 'proxyDomain' "${VMNAME}${DOMAIN:+.${DOMAIN}}")" -UI_DIRECT_URL="https://${IP}:9443" -if [[ -n "${PROXY_DOMAIN}" ]] && getent hosts "${PROXY_DOMAIN}" >/dev/null 2>&1; then - UI_URL="https://${PROXY_DOMAIN}" -else - [[ -n "${PROXY_DOMAIN}" ]] && info " ${PROXY_DOMAIN} does not resolve yet — using the direct URL" - UI_URL="${UI_DIRECT_URL}" -fi - -# ── 1. Podman engine + the rootful API socket ──────────────────────── -info " Installing podman + tooling (apt)..." -vm "sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq" || die "apt-get update failed" -vm "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y podman curl jq ca-certificates" \ - || die "failed to install podman" - -# Rootful socket: this is the Docker-compatible API Portainer talks to. -# podman-restart.service is what makes --restart=always survive a reboot. -vm "sudo systemctl enable --now podman.socket" || die "failed to enable podman.socket" -vm "sudo systemctl enable --now podman-restart.service 2>/dev/null || true" - -# ── 2. Portainer container ─────────────────────────────────────────── -info " Pulling ${IMAGE}..." -vm "sudo podman pull -q ${IMAGE}" >/dev/null || die "failed to pull ${IMAGE}" -IMAGE_ID="$(vm "sudo podman image inspect --format '{{.Id}}' ${IMAGE}" | tr -d '\r')" -[[ -n "${IMAGE_ID}" ]] || die "could not read the image id for ${IMAGE}" - -RUNNING_ID="$(vm "sudo podman container inspect --format '{{.Image}}' ${CONTAINER} 2>/dev/null" | tr -d '\r' || true)" -if [[ "${RUNNING_ID}" != "${IMAGE_ID}" ]]; then - # Recreate only when the image actually changed. The named volume carries all - # state, so removing the container loses nothing. - info " (Re)creating the ${CONTAINER} container..." - vm "sudo podman volume create portainer_data >/dev/null 2>&1 || true" - vm "sudo podman rm -f ${CONTAINER} >/dev/null 2>&1 || true" - vm "sudo podman run -d --name ${CONTAINER} --restart=always --privileged \ - -p 8000:8000 -p 9443:9443 \ - -v /run/podman/podman.sock:/var/run/docker.sock \ - -v portainer_data:/data ${IMAGE}" \ - || die "failed to start ${CONTAINER}" - vm "echo '${IMAGE_ID}' | sudo tee ${MARKER} >/dev/null" || warn "could not write version marker" -else - info " ${CONTAINER} already running the current image — leaving it alone" -fi - -# ── 3. Wait for the API ────────────────────────────────────────────── -info " Waiting for Portainer to answer on :9443..." -UP=0 -for _ in $(seq 1 24); do - code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)" - [[ "${code}" == "200" ]] && { UP=1; break; } - sleep 5 -done -[[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443" - -# ── 4. Bootstrap the local admin ───────────────────────────────────── -# The password is kept on the VM for break-glass access — OIDC is the everyday -# path, this is the account that survives an Authentik outage. -# -# The guard is "can we log in?", NOT "does the password file exist": a failed -# init leaves a password file behind, and keying off the file makes every later -# run skip the bootstrap forever (seen on the first lab1 install). -if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then - vm "sudo install -d -m 0700 \$(dirname ${ADMIN_SECRET})" - vm "openssl rand -base64 24 | sudo tee ${ADMIN_SECRET} >/dev/null && sudo chmod 0600 ${ADMIN_SECRET}" -fi -ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')" - -portainer_jwt() { - vm "curl -sk -X POST https://localhost:9443/api/auth \ - -H 'Content-Type: application/json' \ - -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null | jq -r '.jwt // empty' -} - -wait_for_api() { - local code - for _ in $(seq 1 24); do - code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)" - [[ "${code}" == "200" ]] && return 0 - sleep 5 - done - return 1 -} - -# Echoes the HTTP status of an admin-init attempt. The setup token is minted -# afresh at every container start, so re-read it each time. -init_admin() { - local tok hdr="" - tok="$(vm "sudo podman logs ${CONTAINER} 2>&1 | grep setup_token | grep -oE '[0-9a-f]{64}' | tail -1" | tr -d '\r')" - [[ -n "${tok}" ]] && hdr="-H 'X-Setup-Token: ${tok}'" - vm "curl -sk -o /dev/null -w '%{http_code}' -X POST https://localhost:9443/api/users/admin/init \ - -H 'Content-Type: application/json' ${hdr} \ - -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null || echo 000 -} - -JWT="$(portainer_jwt)" -if [[ -z "${JWT}" ]]; then - info " Bootstrapping the break-glass admin account..." - init_code="$(init_admin)" - if [[ "${init_code}" == "303" ]]; then - # Portainer closes the admin-init window a few minutes after start - # (Redirect-Reason: AdminInitTimeout). Without this the module can never - # be bootstrapped again after any transient failure — restarting resets - # the timer and mints a new setup token. - info " admin-init window had closed — restarting ${CONTAINER} to reopen it" - vm "sudo podman restart ${CONTAINER} >/dev/null" || warn " restart failed" - wait_for_api && init_code="$(init_admin)" - fi - case "${init_code}" in - 200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;; - 409) warn " an admin exists but ${ADMIN_SECRET} does not match it — reset it by hand" ;; - *) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;; - esac - JWT="$(portainer_jwt)" -else - info " break-glass admin already provisioned" -fi - -# ── 5. Point Portainer at TAPPaaS identity (OIDC) ──────────────────── -# identity:identity wrote these three values; if they are missing the module is -# still usable with the local admin, so warn rather than fail. -if [[ -z "${JWT:-}" ]]; then - warn " no admin session — skipping OIDC configuration (see the admin init warning above)" -elif ! vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then - warn " ${SECRETS_ENV} missing — is identity:identity in dependsOn?" -else - info " Configuring OIDC login against Authentik..." - CLIENT_ID="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_ID\"'" | tr -d '\r')" - CLIENT_SECRET="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_SECRET\"'" | tr -d '\r')" - DISCOVERY="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_DISCOVERY_URI\"'" | tr -d '\r')" - - if [[ -n "${CLIENT_ID}" && -n "${CLIENT_SECRET}" && -n "${DISCOVERY}" ]]; then - # Portainer wants the three endpoints separately; the discovery document has them. - WELL_KNOWN="$(vm "curl -fsk ${DISCOVERY}" || true)" - AUTH_URI="$(jq -r '.authorization_endpoint // empty' <<<"${WELL_KNOWN}")" - TOKEN_URI="$(jq -r '.token_endpoint // empty' <<<"${WELL_KNOWN}")" - USER_URI="$(jq -r '.userinfo_endpoint // empty' <<<"${WELL_KNOWN}")" - - if [[ -n "${AUTH_URI}" && -n "${TOKEN_URI}" && -n "${USER_URI}" ]]; then - if [[ -n "${JWT}" ]]; then - # AuthenticationMethod 3 = OAuth. OAuthAutoCreateUsers lets a - # TAPPaaS identity log in without an admin pre-creating it; - # Authentik's group binding is the actual access gate. - SETTINGS="$(jq -n \ - --arg cid "${CLIENT_ID}" --arg csec "${CLIENT_SECRET}" \ - --arg auth "${AUTH_URI}" --arg tok "${TOKEN_URI}" --arg usr "${USER_URI}" \ - --arg redir "https://${PROXY_DOMAIN}/" \ - '{AuthenticationMethod: 3, - OAuthSettings: {ClientID: $cid, ClientSecret: $csec, - AuthorizationURI: $auth, AccessTokenURI: $tok, - ResourceURI: $usr, RedirectURI: $redir, - UserIdentifier: "preferred_username", - Scopes: "openid profile email", - OAuthAutoCreateUsers: true, SSO: true}}')" - set_code="$(vm "curl -sk -o /dev/null -w '%{http_code}' -X PUT https://localhost:9443/api/settings \ - -H 'Authorization: Bearer ${JWT}' -H 'Content-Type: application/json' \ - -d '$(printf '%s' "${SETTINGS}" | tr -d '\n')'" || echo 000)" - if [[ "${set_code}" == "200" ]]; then - info " ${GN}✓${CL} OIDC login configured" - else - warn " settings update returned HTTP ${set_code} — configure OAuth by hand at ${UI_URL}" - fi - else - warn " could not authenticate as the local admin — skipping OIDC configuration" - fi - else - warn " discovery document at ${DISCOVERY} lacked the endpoints — skipping OIDC configuration" - fi - else - warn " ${SECRETS_ENV} did not carry all three OIDC values — skipping OIDC configuration" - fi -fi - -echo "" -info "${GN}✓ Portainer CE installed${CL}" -echo "" -info "${BOLD}═══ Next steps ═══${CL}" -info " ${BOLD}Console:${CL} ${UI_URL} (direct: ${UI_DIRECT_URL})" -info " Sign in with your TAPPaaS identity. Break-glass admin password:" -info " ssh tappaas@${IP} 'sudo cat ${ADMIN_SECRET}'" -info " Add another host in the zone: run the Portainer agent there and add it" -info " under Environments — see INSTALL.md." diff --git a/src/module-catalog.json b/src/module-catalog.json index dfefa14..fa3f55c 100644 --- a/src/module-catalog.json +++ b/src/module-catalog.json @@ -11,15 +11,6 @@ "category": "containers", "status": "incomplete" }, - { - "moduleName": "portainer", - "repo": "community", - "moduleJson": "src/containers/portainer/portainer.json", - "vmid": 813, - "stack": "infrastructure", - "category": "containers", - "status": "incomplete" - }, { "moduleName": "komodo", "repo": "community",