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",