From bca395d7f98348d8c7492ce95ef7cc19e6f20f36 Mon Sep 17 00:00:00 2001 From: Lars Rossen Date: Sun, 23 Aug 2026 09:45:01 +0200 Subject: [PATCH] Add portainer and komodo as parallel container-management modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit portainer: Portainer CE on rootful podman (Portainer drives the Docker-compatible API, and rootless is not supported upstream), published via network:proxy with proxyAllowedZones left unset so members reach it from a client zone but the internet does not. Login is OIDC: identity:identity writes the client credentials, update.sh resolves the endpoints from the discovery document and PUTs them into /api/settings. A break-glass local admin stays for when Authentik is down. Multi-host is the agent on :9001 per host. komodo: scaffold only — komodo.json plus a README that specifies what install.sh and update.sh must do. GPL, no edition split, but it needs a database and models builds and stacks, so it is the alternative rather than the teaching example. Neither has been run on a live TAPPaaS yet; both are catalogued as incomplete. --- README.md | 12 +- src/containers/komodo/README.md | 71 ++++++++ src/containers/komodo/komodo.json | 38 +++++ src/containers/portainer/INSTALL.md | 79 +++++++++ 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 | 209 ++++++++++++++++++++++++ src/module-catalog.json | 20 ++- 10 files changed, 653 insertions(+), 3 deletions(-) create mode 100644 src/containers/komodo/README.md create mode 100644 src/containers/komodo/komodo.json create mode 100644 src/containers/portainer/INSTALL.md create mode 100644 src/containers/portainer/README.md create mode 100755 src/containers/portainer/install.sh create mode 100644 src/containers/portainer/portainer.json create mode 100755 src/containers/portainer/test.sh create mode 100755 src/containers/portainer/update.sh diff --git a/README.md b/README.md index 9d8d783..e77cb84 100644 --- a/README.md +++ b/README.md @@ -32,8 +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. + | Module | What it is | Status | | --- | --- | --- | -| [podman](src/containers/podman) | Debian 13 VM with rootless Podman + the Cockpit web console | incomplete | +| [portainer](src/containers/portainer) | Portainer CE on rootful Podman; 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 | -Open work on `podman`: identity integration — see its [DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis). +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/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis). diff --git a/src/containers/komodo/README.md b/src/containers/komodo/README.md new file mode 100644 index 0000000..ea946a0 --- /dev/null +++ b/src/containers/komodo/README.md @@ -0,0 +1,71 @@ +# Komodo — build and deployment system across the lab hosts + +Primary audience: TAPPaaS operator comparing container managers. + +[Komodo](https://komo.do) is a **GPL-3.0** build-and-deploy system for containers: a central +**Core** (web UI, API, database) plus a stateless **Periphery** agent on every managed host. +It does what [portainer](../portainer) does — create, start, stop, inspect containers across +several machines — and adds git-driven builds, stacks and procedures. Its documentation is +pointed about the licensing difference: no node limit, no API limit, no business edition. + +> **Status: scaffold.** `komodo.json` is authored; **`install.sh`, `update.sh` and `test.sh` +> are not written yet** — this file specifies what they must do. The module is here so the +> two candidates can be compared honestly, not because it is ready to install. + +## Why it is a parallel module and not the one in the deck + +Komodo wins on licensing and loses on size. It needs a **database** (MongoDB, or FerretDB as +the FOSS-native stand-in) alongside Core, and it models builds, stacks, repos and procedures +— a lot of surface to walk a room through in one evening. [portainer](../portainer) is one +container against a socket, which is why the teaching session uses that. If the CE/BE split +ever bites — most likely over group-to-team mapping — this is the way out. + +## Shape + +| Piece | Where | Port | +|-------|-------|------| +| Komodo Core (UI + API) | this VM, container | `9120` | +| Database (FerretDB or Mongo) | this VM, container | internal only | +| Periphery agent | every managed host | `8120` | + +Komodo's own docs warn that Periphery must be **restricted to the Core's address** rather +than accepting connections from anywhere — that is a firewall rule, not a default. + +## `install.sh` — what it must do + +- Thin wrapper, exactly as in [podman](../podman) and [portainer](../portainer): `exec update.sh "$@"` +- Nothing is install-only for this module — every step below is idempotent and belongs in the update path +- By the time it runs: `cluster:vm` has built the Debian 13 VM, `templates:debian` has apt-upgraded it and installed the guest agent, `network:proxy` has published `https://komodo.`, and `identity:identity` has created the OIDC application and written `/etc/secrets/komodo.env` + +## `update.sh` — what it must do + +- **Locate the VM** — find the hosting node via `pvesh` (HA-safe), resolve the IP through the Proxmox guest agent, wait for SSH; identical to the other two modules +- **Install the engine** — `podman`, `podman-compose`, `curl`, `jq`; enable the **rootful** `podman.socket` (Periphery needs the Docker-compatible API) and `podman-restart.service` +- **Lay down the compose file** — Komodo ships `compose/ferretdb.compose.yaml`; pin the image tags rather than tracking `latest`, and keep FerretDB over MongoDB so the stack stays FOSS-licensed end to end +- **Generate the secrets once** — `KOMODO_PASSKEY` (shared with every Periphery agent) and the database credentials, written to `/etc/secrets/komodo-core.env` at `0600` and never regenerated on a later run +- **Write `/config/config.toml`** — the Core config the container mounts; this is where OIDC lives: + - read `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_DISCOVERY_URI` from `/etc/secrets/komodo.env` (put there by `identity:identity`) + - set `oidc_enabled`, the provider URL, client id and secret, and the redirect that matches `oidcRedirectPaths` in `komodo.json` + - disable local password login **only after** a successful OIDC sign-in has been confirmed, so a misconfiguration cannot lock everyone out +- **Bring the stack up** — `podman-compose up -d`, then wait for `:9120` to answer +- **Install Periphery locally** — the Core VM manages itself as the first server, agent bound to `127.0.0.1:8120` +- **Be idempotent** — recreate containers only when a pinned image digest actually changed; never rewrite an existing passkey or database credential; re-running must be a no-op on an unchanged system +- **Print next steps** — the URL, where the passkey lives, and the one-liner for adding a Periphery agent on another lab host + +## `test.sh` — what it must do + +- VM reachable via the guest agent +- rootful `podman.socket` active +- Core and database containers running +- `:9120` answers, and the API reports a version +- OIDC is the configured login method, not local passwords + +## Dependencies + +Same as [portainer](../portainer): `cluster:vm`, `templates:debian`, `backup:vm`, +`network:proxy`, `identity:identity`. + +## Sizing + +2 vCPU / **4 GB** RAM / 40 GB disk — more than Portainer, because of the database and because +Komodo builds images as well as running them. diff --git a/src/containers/komodo/komodo.json b/src/containers/komodo/komodo.json new file mode 100644 index 0000000..9b51cf6 --- /dev/null +++ b/src/containers/komodo/komodo.json @@ -0,0 +1,38 @@ +{ + "description": "Komodo — GPL container build and deployment system (Core + Periphery agents) for managing containers across the lab hosts, with OIDC login against TAPPaaS identity", + "version": "0.0.1", + "appVersion": "latest", + "releaseDate": "2026-08-23", + "maintainer": "@larsrossen", + "status": "Development", + "vmname": "komodo", + "vmid": 814, + "vmtag": "TAPPaaS,MakerFLOSS,Containers", + "ports": [ + { "port": 9120, "protocol": "TCP", "description": "Komodo Core web UI + API (HTTP)" } + ], + "dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy", "identity:identity"], + "provides": [], + "identity": { + "oidcRedirectPaths": ["/auth/oidc/callback"], + "secretsEnv": "/etc/secrets/komodo.env" + }, + "config": { + "cluster:vm": { + "bios": "seabios", + "ostype": "l26", + "cores": 2, + "memory": "4096", + "diskSize": "40G", + "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": 9120 + } + } +} diff --git a/src/containers/portainer/INSTALL.md b/src/containers/portainer/INSTALL.md new file mode 100644 index 0000000..ed2a1b2 --- /dev/null +++ b/src/containers/portainer/INSTALL.md @@ -0,0 +1,79 @@ +# 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. + +## 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 new file mode 100644 index 0000000..45cbe26 --- /dev/null +++ b/src/containers/portainer/README.md @@ -0,0 +1,84 @@ +# 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 new file mode 100755 index 0000000..1e81802 --- /dev/null +++ b/src/containers/portainer/install.sh @@ -0,0 +1,18 @@ +#!/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 new file mode 100644 index 0000000..e0acf64 --- /dev/null +++ b/src/containers/portainer/portainer.json @@ -0,0 +1,40 @@ +{ + "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 new file mode 100755 index 0000000..7c93a6f --- /dev/null +++ b/src/containers/portainer/test.sh @@ -0,0 +1,85 @@ +#!/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 new file mode 100755 index 0000000..51b5789 --- /dev/null +++ b/src/containers/portainer/update.sh @@ -0,0 +1,209 @@ +#!/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 ──────────────────────────────────── +DOMAIN="$(get_variant_config "" 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 (once) ────────────────────────────── +# Portainer refuses admin creation after a timeout window, so this must happen +# promptly after first start. 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. +if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then + info " Bootstrapping the break-glass admin account..." + 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}" + ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')" + init_code="$(vm "curl -sk -o /dev/null -w '%{http_code}' -X POST https://localhost:9443/api/users/admin/init \ + -H 'Content-Type: application/json' \ + -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" || echo 000)" + case "${init_code}" in + 200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;; + 409) info " admin already existed — keeping it" ;; + *) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;; + esac +else + ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')" + 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 [[ -n "${ADMIN_PW:-}" ]] && vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then + 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 + JWT="$(vm "curl -sk -X POST https://localhost:9443/api/auth \ + -H 'Content-Type: application/json' \ + -d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" | jq -r '.jwt // empty')" + 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 +else + warn " ${SECRETS_ENV} missing — is identity:identity in dependsOn? Local admin login still works." +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 1e1b2dd..dfefa14 100644 --- a/src/module-catalog.json +++ b/src/module-catalog.json @@ -1,5 +1,5 @@ { - "description": "MakerFLOSS DevOps Module Registry — experimental TAPPaaS modules", + "description": "MakerFLOSS DevOps Module Registry \u2014 experimental TAPPaaS modules", "foundationModules": [], "applicationModules": [ { @@ -10,6 +10,24 @@ "stack": "infrastructure", "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", + "moduleJson": "src/containers/komodo/komodo.json", + "vmid": 814, + "stack": "infrastructure", + "category": "containers", + "status": "incomplete" } ], "proxmoxTemplates": [],