Merge portainer into podman: engine plus GUI in one module

Podman is the container foundation, Portainer CE is the web interface onto it,
running as a container on the very engine it manages. This is what the separate
portainer module already did, so it is retired rather than duplicated.

Two sockets on purpose: the rootful one is the Docker-compatible API Portainer
drives (it does not support rootless), the rootless user socket is for people
who ssh in and run containers by hand — which was the original podman module's
point and is worth keeping.

An explicit proxyDomain lets the module publish at the environment's own domain
instead of <module>.<environment-domain>, making it that environment's gateway.

DESIGN.md keeps the Cockpit identity analysis: it is the reason for the change,
and anyone proposing 'just put Cockpit behind forward-auth' should read it.
This commit is contained in:
Lars Rossen 2026-08-25 09:30:11 +02:00
parent ffc9e12526
commit 04b4437c95
15 changed files with 389 additions and 793 deletions

View file

@ -32,16 +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 Two takes on the same job — run containers on a lab host, let registered people manage them,
manage them, reach the other hosts in the zone. They exist side by side on purpose. reach the other hosts in the zone.
| Module | What it is | Status | | 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 | | [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 `podman` absorbed the former standalone `portainer` module: Podman is the container
cannot be made one, and its multi-host switcher is deprecated and disabled by default because foundation, Portainer is the GUI onto it, one VM. Cockpit was dropped because it fits neither
it "cannot be secure". The reasoning is written up in 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). [podman/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).

View file

@ -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* full bypass if the port is reachable any other way, and this module currently *advertises*
direct `https://<vm-ip>:9090` access as a feature. direct `https://<vm-ip>: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. **None of the three.** All of them are workarounds for a console that cannot take an OIDC
Note that the direct `:9090` URL stays open under option 1 — the gate is on the friendly login, and the requirement had grown: registered people managing containers *across the hosts
name only, which is honest but worth saying out loud in `README.md`. 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 ### One thing to verify on a live system first

View file

@ -1,110 +1,95 @@
# Podman container host — Installation # Podman + Portainer — Installation
Primary audience: TAPPaaS admin. Steps the scripts cannot automate. Primary audience: TAPPaaS admin. Steps the scripts cannot automate.
## Prerequisites ## Prerequisites
- `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access. - `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access.
- The dependencies are pulled in automatically (`cluster:vm`, `templates:debian`, - Dependencies resolve automatically (`cluster:vm`, `templates:debian`, `backup:vm`,
`backup:vm`, `network:proxy`). `network:proxy`, `identity:identity`).
- For the friendly HTTPS name to present a valid certificate, the TAPPaaS wildcard must be in - The identity foundation must be up — the login depends on it.
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).
## Install ## Install
```bash ```bash
cd /home/tappaas/Community/src/larsrossen/containers/podman # Published at <vmname>.<environment-domain>:
install-module.sh podman 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: 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.<domain>` (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** ## Post-install (manual)
(ADR-007 P5: with `zone0` unset, install resolves the zone from
`config/environments/<env>.json → network.zone`). To place it in a specific environment — and ### 1. Who can log in
therefore its associated zone — pass `--environment` at install time:
Authentik binds the application to the allowed groups at install. To let the hackerspace in:
```bash ```bash
install-module.sh podman --environment <env> # VM joins <env>'s network.zone people-manager user modify <user> --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) On each additional VM or physical machine:
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:**
```bash ```bash
ssh tappaas@<vm-ip> '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 `<host>:9001`.
Same-zone traffic is allowed by default; another zone needs a pinhole into `:9001`.
### 3. Break-glass access
```bash ```bash
ssh tappaas@<vm-ip> ssh tappaas@<vm-ip> 'sudo cat /etc/secrets/podman-admin'
sudo useradd -m -s /bin/bash -G sudo podadmin && sudo passwd podadmin
sudo loginctl enable-linger podadmin # keep its rootless podman socket alive
``` ```
Then open **`https://podman.<domain>`** (or `https://<vm-ip>:9090`), log in with that account, Username `admin`. This is the way back in when Authentik is unavailable — keep it.
and choose **Podman containers** in the left menu (Cockpit starts the user's podman service on
first use).
## Using it ## Notes from the live installs (lab1, 2026-08-24/25)
- CLI: `ssh tappaas@<vm-ip>` then `podman run …`, `podman ps`, `podman images`. Four things bit during the first real install; all are handled in `update.sh` now, but they
- Compose files: `podman-compose up -d` in a directory with a `compose.yaml`. are worth knowing when debugging:
- Web: manage/start/stop containers, pull images and inspect logs from the Cockpit
**Podman containers** page.
## 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 ```bash
test-module.sh podman module-manager module test podman-lab1 # effective name outside the default environment
``` ```
Manual checks: 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.
| Check | Expected |
|-------|----------|
| `ssh tappaas@<vm-ip> podman --version` | prints the Podman version |
| `https://podman.<domain>` 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 <user>`
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://<vm-ip>: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.

View file

@ -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, A **Debian 13 (trixie) VM running [Podman](https://podman.io)** as the container engine, with
rootless-capable container engine (a drop-in for `docker`). On top of the engine it adds the **[Portainer CE](https://www.portainer.io)** as its web interface. Portainer runs *as a
**Cockpit web console** with the **`cockpit-podman`** plugin, so containers, images and pods container on the very engine it manages*: Podman is the foundation, Portainer is the GUI.
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.<domain>` via
`network:proxy`.
## 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 | ## Two engines, on purpose
|------------|-------------|-----|
| Podman container engine (rootless + CLI) | on the VM | `ssh tappaas@<vm-ip>` then `podman …` |
| Cockpit web console (login + Podman page) | `mgmt` zone | `https://podman.<domain>` (internal) |
| Direct console | LAN reachable from `mgmt` | `https://<vm-ip>:9090` |
| `podman-compose` for compose files | on the VM | `podman-compose up -d` |
## 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. Portainer's access to the rootful socket is **root-equivalent on this VM**. That is inherent to
- **No internet exposure.** The friendly name is reachable only from the `mgmt` zone; it is every container manager with socket access — treat the VM as a lab host and put nothing
not published to the internet (no inbound NAT/port-forward). precious on it.
- **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).
## Access (internal only) ## Why Portainer and not Cockpit
`network:proxy` publishes **`https://podman.<domain>`** (split-horizon DNS → Caddy → the VM's The first version of this module shipped Cockpit with `cockpit-podman`. It cannot meet either
`:9090`, HTTPS upstream), with an access-list permitting only the **`mgmt`** zone — **not requirement: Cockpit is not an OIDC client and cannot be configured into one, it always needs
reachable from the internet**. Cockpit rides WebSockets, so the route is created with a local Unix account (`cockpit-bridge` runs in a PAM session as the user), and its multi-host
`proxyUpstreamHttp1: true` (Caddy talks HTTP/1.1 to the upstream); the direct switcher is deprecated and disabled by default — the project states it "cannot be secure".
`https://<vm-ip>:9090` always works too. 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 `<module>.<environment-domain>`. 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 ## 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 | | `cluster:vm` | Creates the Debian 13 VM from the cloud image |
| `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) | | `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) |
| `backup:vm` | Scheduled VM backup to PBS | | `backup:vm` | Scheduled VM backup to PBS |
| `network:proxy` | Internal-only reverse proxy for `podman.<domain>` | | `network:proxy` | The published HTTPS name, upstream on `:9443` |
| `identity:identity` | OIDC application, group binding, client credentials |
## Placement ## Placement
The module sets no explicit `zone0`, so the VM is placed in the **default environment's zone**; No `zone0` is set, so the VM lands in the environment's zone (ADR-007 P5).
install it with `--environment <env>` to put it in a specific environment and its associated `proxyAllowedZones` is deliberately unset, which gives the internal default — every Active
zone instead (see [INSTALL.md](./INSTALL.md#placement--zone)). 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 ## Sizing
2 vCPU / **2 GB** RAM / 20 GB disk — enough for the engine, Cockpit and a handful of light 2 vCPU / 2 GB RAM / 32 GB disk. Podman and Portainer are small; the disk is for the images and
containers. Bump memory/disk to match whatever you actually run on it (container images and volumes of whatever gets run here.
volumes land on the VM disk, thin-provisioned on ZFS).
For installation steps see [INSTALL.md](./INSTALL.md). For installation steps see [INSTALL.md](./INSTALL.md).

View file

@ -2,11 +2,12 @@
# #
# podman module install — thin wrapper. # podman module install — thin wrapper.
# #
# The VM itself is created by the cluster:vm provider (Debian 13 cloud image), # The VM is created by the cluster:vm provider (Debian 13 cloud image) and
# then OS-prepped by the templates:debian provider (apt update + qemu-guest-agent # OS-prepped by templates:debian. identity:identity has, by this point, created
# via update-os.sh). This script applies the module-specific step: installing # the OIDC application in Authentik and written OIDC_CLIENT_ID /
# Podman + the Cockpit web console onto the Debian VM. All real work lives in # OIDC_CLIENT_SECRET / OIDC_DISCOVERY_URI to the VM's secrets env file. This
# update.sh (install == update for this module). # script applies the module-specific step; all real work lives in update.sh
# (install == update for this module).
# #
# Usage: install.sh <module-name> # Usage: install.sh <module-name>
# #

View file

@ -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", "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.1.0", "version": "0.2.0",
"appVersion": "5.4", "appVersion": "5.4",
"releaseDate": "2026-08-18", "releaseDate": "2026-08-25",
"maintainer": "@larsrossen", "maintainer": "@larsrossen",
"status": "Development", "status": "Development",
"vmname": "podman", "vmname": "podman",
"vmid": 812, "vmid": 812,
"vmtag": "TAPPaaS,Community,Containers", "vmtag": "TAPPaaS,MakerFLOSS,Containers",
"ports": [ "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": [], "provides": [],
"identity": {
"oidcRedirectPaths": ["/"],
"secretsEnv": "/etc/secrets/podman.env"
},
"config": { "config": {
"cluster:vm": { "cluster:vm": {
"bios": "seabios", "bios": "seabios",
"ostype": "l26", "ostype": "l26",
"cores": 2, "cores": 2,
"memory": "2048", "memory": "2048",
"diskSize": "20G", "diskSize": "32G",
"storage": "tanka1", "storage": "tanka1",
"imageType": "img", "imageType": "img",
"image": "debian-13-generic-amd64.qcow2", "image": "debian-13-generic-amd64.qcow2",
@ -28,10 +33,8 @@
"bridge0": "lan" "bridge0": "lan"
}, },
"network:proxy": { "network:proxy": {
"proxyPort": 9090, "proxyPort": 9443,
"proxyUpstreamTls": true, "proxyUpstreamTls": true
"proxyUpstreamHttp1": true,
"proxyAllowedZones": ["mgmt"]
} }
} }
} }

View file

@ -1,12 +1,13 @@
#!/usr/bin/env bash #!/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): # Verifies (from tappaas-cicd, over the Proxmox guest agent + SSH):
# - the VM is reachable # - the VM is reachable
# - podman is installed (version marker + working binary) # - the rootful podman socket is active (this is Portainer's Docker API)
# - the cockpit-podman plugin is present # - the portainer container is running
# - the Cockpit web console answers (HTTPS, :9090) # - the API answers on :9443
# - authentication is set to OAuth, i.e. identity wiring survived
# #
# Usage: test.sh <module-name> # Usage: test.sh <module-name>
# #
@ -47,35 +48,48 @@ ok "VM IP resolved (${IP})"
vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; } vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; }
# Version marker + working podman binary # The engine itself, and the Docker-compatible API Portainer drives.
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
if vm "command -v podman >/dev/null 2>&1 && podman --version >/dev/null 2>&1"; then 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 else
no "podman binary not working" no "podman binary not working"
fi fi
if vm "systemctl is-active --quiet podman.socket"; then
# cockpit-podman plugin installed ok "rootful podman.socket active (Portainer's container API)"
if vm "dpkg -s cockpit-podman >/dev/null 2>&1"; then
ok "cockpit-podman plugin installed"
else 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 fi
# Cockpit web console answers on :9090 (login page pre-auth is a pass). # Container up and healthy.
# Plain -s (no --fail): --fail exits 22 on 4xx and would corrupt the -w code. STATE="$(vm "sudo podman container inspect --format '{{.State.Status}}' portainer 2>/dev/null" | tr -d '\r' || true)"
code="$(vm "curl -sk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)" if [[ "${STATE}" == "running" ]]; then
if [[ "${code}" =~ ^(200|302|401|403)$ ]]; then ok "portainer container running"
ok "Cockpit console answers on :9090 (HTTP ${code})"
else else
no "Cockpit console did not answer on :9090 (HTTP ${code})" no "portainer container not running (state: ${STATE:-absent})"
fi 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 "" echo ""
info "Result: ${PASS} passed, ${FAIL} failed" info "Result: ${PASS} passed, ${FAIL} failed"
[[ "${FAIL}" -eq 0 ]] [[ "${FAIL}" -eq 0 ]]

View file

@ -1,19 +1,23 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# podman module update — install/upgrade Podman + the Cockpit web console on the # podman module update — a Podman container host with Portainer CE as its GUI.
# Debian 13 VM.
# #
# Podman is the daemonless, rootless-capable container engine; it ships in the # Podman is the engine; Portainer is the web interface onto it. Both live on the
# Debian 13 (trixie) main repo, so no external download is needed. To give it the # same VM: Portainer runs AS a container on the very engine it manages.
# browser interface the module promises, we also install Cockpit and its #
# cockpit-podman plugin — Cockpit serves an HTTPS admin console with a PAM LOGIN # Portainer manages containers through the Docker-compatible API. Podman exposes
# SCREEN on :9090, and cockpit-podman adds the "Podman containers" page for # exactly that API on its ROOTFUL socket (/run/podman/podman.sock), which is what
# managing containers, images and pods. network:proxy publishes that console as # Portainer's own Podman install guide uses — rootless Podman is explicitly not
# https://podman.<domain> (internal, mgmt zone only). # 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, # 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 # then drives the VM over SSH. Idempotent throughout.
# time (apt is a no-op when already current) and records a version marker.
# #
# Usage: update.sh <module-name> # Usage: update.sh <module-name>
# #
@ -24,12 +28,15 @@ set -euo pipefail
MODULE="${1:-podman}" MODULE="${1:-podman}"
readonly MGMT="mgmt" 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}")" VMNAME="$(get_config_value 'vmname' "${MODULE}")"
VMID="$(get_config_value 'vmid')" VMID="$(get_config_value 'vmid')"
SECRETS_ENV="$(get_config_value 'secretsEnv' '/etc/secrets/podman.env')"
[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}" [[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}"
# ── Locate the node hosting the VM (HA-safe) and resolve its IP ─────── # ── Locate the node hosting the VM (HA-safe) and resolve its IP ───────
@ -47,78 +54,214 @@ get_vm_ip() {
| grep -v '^127\.' | head -1 | 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="" IP=""
for _ in $(seq 1 18); do IP="$(get_vm_ip)"; [[ -n "${IP}" ]] && break; sleep 10; done 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)" [[ -n "${IP}" ]] || die "could not resolve ${VMNAME} IP via guest agent (is qemu-guest-agent up? templates:debian installs it)"
info " VM IP: ${IP}" 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}" "$@"; } 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 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" vm "exit 0" 2>/dev/null || die "SSH to tappaas@${IP} not available"
# ── Friendly URL for the operator ──────────────────────────────────── # ── 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}}")" 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 if [[ -n "${PROXY_DOMAIN}" ]] && getent hosts "${PROXY_DOMAIN}" >/dev/null 2>&1; then
COCKPIT_URL="https://${PROXY_DOMAIN}" UI_URL="https://${PROXY_DOMAIN}"
else else
[[ -n "${PROXY_DOMAIN}" ]] && info " ${PROXY_DOMAIN} does not resolve yet — using the direct URL" [[ -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 fi
# ── 1. Install podman + cockpit + cockpit-podman (apt) ─────────────── # ── 1. Podman engine + the rootful API socket ────────────────────────
# podman-compose/slirp4netns give rootless networking + compose-file support; info " Installing podman + tooling (apt)..."
# cockpit + cockpit-podman provide the web console with a login screen on :9090.
info " Installing podman, podman-compose, cockpit, cockpit-podman (apt)..."
vm "sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq" || die "apt-get update failed" vm "sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq" || die "apt-get update failed"
vm "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ vm "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
podman podman-compose slirp4netns uidmap \ podman podman-compose slirp4netns uidmap curl jq ca-certificates" \
cockpit cockpit-podman \ || die "failed to install podman"
curl ca-certificates" \
|| die "failed to install podman/cockpit packages"
PODMAN_VER="$(vm "podman --version 2>/dev/null | awk '{print \$3}'" || true)" PODMAN_VER="$(vm "podman --version 2>/dev/null | awk '{print \$3}'" || true)"
[[ -n "${PODMAN_VER}" ]] || die "podman did not install correctly (no version reported)" [[ -n "${PODMAN_VER}" ]] || die "podman did not install correctly (no version reported)"
info " podman version: ${PODMAN_VER}" 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" 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) ──────────── # Two engines on one host, on purpose:
info " Waiting for the Cockpit console to come up (https://${IP}:9090)..." # - 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 UP=0
for _ in $(seq 1 18); do for _ in $(seq 1 24); do
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)" code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)"
[[ "${code}" =~ ^(200|302|401|403)$ ]] && { UP=1; break; } [[ "${code}" == "200" ]] && { UP=1; break; }
sleep 5 sleep 5
done done
[[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443"
echo "" # ── 4. Bootstrap the local admin ─────────────────────────────────────
if [[ "${UP}" -eq 1 ]]; then # The password is kept on the VM for break-glass access — OIDC is the everyday
info "${GN}✓ Podman ${PODMAN_VER} + Cockpit console installed${CL}" # 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 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 fi
echo ""
info "${GN}✓ Podman + Portainer installed${CL}"
echo "" echo ""
info "${BOLD}═══ Next steps ═══${CL}" info "${BOLD}═══ Next steps ═══${CL}"
info " ${BOLD}Console:${CL} ${COCKPIT_URL} (direct: https://${IP}:9090)" info " ${BOLD}Console:${CL} ${UI_URL} (direct: ${UI_DIRECT_URL})"
info " Log in with a Linux account on the VM. The cloud-init ${BOLD}tappaas${CL} user has" info " Sign in with your TAPPaaS identity. Break-glass admin password:"
info " SSH-key auth and no password, so set one first for the web login:" info " ssh tappaas@${IP} 'sudo cat ${ADMIN_SECRET}'"
info " ssh tappaas@${IP} 'sudo passwd tappaas'" info " Containers on this host: ssh tappaas@${IP} and run podman (rootless)."
info " Then open the console and pick ${BOLD}Podman containers${CL} in the left menu." info " Other hosts in the zone: run the Portainer agent there, add it under"
info " See INSTALL.md for the details (and how to add a dedicated admin user instead)." info " Environments — see INSTALL.md."

View file

@ -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 <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.
## 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 `<effective-module-name>.<environment-domain>`, 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.

View file

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

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

View file

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

View file

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

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

View file

@ -11,15 +11,6 @@
"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", "moduleName": "komodo",
"repo": "community", "repo": "community",