Add portainer and komodo as parallel container-management modules

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.
This commit is contained in:
Lars Rossen 2026-08-23 09:45:01 +02:00
parent 1ca204c405
commit bca395d7f9
10 changed files with 653 additions and 3 deletions

View file

@ -32,8 +32,16 @@ URL, and the clone is made over HTTPS.
## Modules ## 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 | | 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).

View file

@ -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.<domain>`, 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.

View file

@ -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
}
}
}

View file

@ -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 <env>
```
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.<domain>` 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 <user> --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
`<host>: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@<vm-ip> '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.

View file

@ -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.<domain>` |
| 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.<domain>`, 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 <env>` 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).

View file

@ -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 <module-name>
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/update.sh" "$@"

View file

@ -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
}
}
}

View file

@ -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 <module-name>
#
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 ]]

View file

@ -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 <module-name>
#
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."

View file

@ -1,5 +1,5 @@
{ {
"description": "MakerFLOSS DevOps Module Registry — experimental TAPPaaS modules", "description": "MakerFLOSS DevOps Module Registry \u2014 experimental TAPPaaS modules",
"foundationModules": [], "foundationModules": [],
"applicationModules": [ "applicationModules": [
{ {
@ -10,6 +10,24 @@
"stack": "infrastructure", "stack": "infrastructure",
"category": "containers", "category": "containers",
"status": "incomplete" "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": [], "proxmoxTemplates": [],