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:
parent
1ca204c405
commit
bca395d7f9
10 changed files with 653 additions and 3 deletions
12
README.md
12
README.md
|
|
@ -32,8 +32,16 @@ URL, and the clone is made over HTTPS.
|
|||
|
||||
## Modules
|
||||
|
||||
Three parallel takes on the same job — run containers on a lab host, let registered people
|
||||
manage them, reach the other hosts in the zone. They exist side by side on purpose.
|
||||
|
||||
| Module | What it is | Status |
|
||||
| --- | --- | --- |
|
||||
| [podman](src/containers/podman) | Debian 13 VM with rootless Podman + the Cockpit web console | incomplete |
|
||||
| [portainer](src/containers/portainer) | Portainer CE on rootful Podman; OIDC login, agents on other hosts | incomplete — **the one the session uses** |
|
||||
| [komodo](src/containers/komodo) | GPL Core + Periphery alternative; scaffold, scripts specified but not written | scaffold |
|
||||
| [podman](src/containers/podman) | Plain Podman host with the Cockpit console; single host, local login | incomplete |
|
||||
|
||||
Open work on `podman`: identity integration — see its [DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).
|
||||
Why three: Cockpit turned out to fit neither requirement — it is not an OIDC client and
|
||||
cannot be made one, and its multi-host switcher is deprecated and disabled by default because
|
||||
it "cannot be secure". The reasoning is written up in
|
||||
[podman/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).
|
||||
|
|
|
|||
71
src/containers/komodo/README.md
Normal file
71
src/containers/komodo/README.md
Normal 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.
|
||||
38
src/containers/komodo/komodo.json
Normal file
38
src/containers/komodo/komodo.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/containers/portainer/INSTALL.md
Normal file
79
src/containers/portainer/INSTALL.md
Normal 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.
|
||||
84
src/containers/portainer/README.md
Normal file
84
src/containers/portainer/README.md
Normal 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).
|
||||
18
src/containers/portainer/install.sh
Executable file
18
src/containers/portainer/install.sh
Executable 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" "$@"
|
||||
40
src/containers/portainer/portainer.json
Normal file
40
src/containers/portainer/portainer.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/containers/portainer/test.sh
Executable file
85
src/containers/portainer/test.sh
Executable 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 ]]
|
||||
209
src/containers/portainer/update.sh
Executable file
209
src/containers/portainer/update.sh
Executable 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."
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"description": "MakerFLOSS DevOps Module Registry — experimental TAPPaaS modules",
|
||||
"description": "MakerFLOSS DevOps Module Registry \u2014 experimental TAPPaaS modules",
|
||||
"foundationModules": [],
|
||||
"applicationModules": [
|
||||
{
|
||||
|
|
@ -10,6 +10,24 @@
|
|||
"stack": "infrastructure",
|
||||
"category": "containers",
|
||||
"status": "incomplete"
|
||||
},
|
||||
{
|
||||
"moduleName": "portainer",
|
||||
"repo": "community",
|
||||
"moduleJson": "src/containers/portainer/portainer.json",
|
||||
"vmid": 813,
|
||||
"stack": "infrastructure",
|
||||
"category": "containers",
|
||||
"status": "incomplete"
|
||||
},
|
||||
{
|
||||
"moduleName": "komodo",
|
||||
"repo": "community",
|
||||
"moduleJson": "src/containers/komodo/komodo.json",
|
||||
"vmid": 814,
|
||||
"stack": "infrastructure",
|
||||
"category": "containers",
|
||||
"status": "incomplete"
|
||||
}
|
||||
],
|
||||
"proxmoxTemplates": [],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue