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
|
## Modules
|
||||||
|
|
||||||
|
Three parallel takes on the same job — run containers on a lab host, let registered people
|
||||||
|
manage them, reach the other hosts in the zone. They exist side by side on purpose.
|
||||||
|
|
||||||
| Module | What it is | Status |
|
| Module | What it is | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [podman](src/containers/podman) | Debian 13 VM with rootless Podman + the Cockpit web console | incomplete |
|
| [portainer](src/containers/portainer) | Portainer CE on rootful Podman; OIDC login, agents on other hosts | incomplete — **the one the session uses** |
|
||||||
|
| [komodo](src/containers/komodo) | GPL Core + Periphery alternative; scaffold, scripts specified but not written | scaffold |
|
||||||
|
| [podman](src/containers/podman) | Plain Podman host with the Cockpit console; single host, local login | incomplete |
|
||||||
|
|
||||||
Open work on `podman`: identity integration — see its [DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).
|
Why three: Cockpit turned out to fit neither requirement — it is not an OIDC client and
|
||||||
|
cannot be made one, and its multi-host switcher is deprecated and disabled by default because
|
||||||
|
it "cannot be secure". The reasoning is written up in
|
||||||
|
[podman/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).
|
||||||
|
|
|
||||||
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": [],
|
"foundationModules": [],
|
||||||
"applicationModules": [
|
"applicationModules": [
|
||||||
{
|
{
|
||||||
|
|
@ -10,6 +10,24 @@
|
||||||
"stack": "infrastructure",
|
"stack": "infrastructure",
|
||||||
"category": "containers",
|
"category": "containers",
|
||||||
"status": "incomplete"
|
"status": "incomplete"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "portainer",
|
||||||
|
"repo": "community",
|
||||||
|
"moduleJson": "src/containers/portainer/portainer.json",
|
||||||
|
"vmid": 813,
|
||||||
|
"stack": "infrastructure",
|
||||||
|
"category": "containers",
|
||||||
|
"status": "incomplete"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "komodo",
|
||||||
|
"repo": "community",
|
||||||
|
"moduleJson": "src/containers/komodo/komodo.json",
|
||||||
|
"vmid": 814,
|
||||||
|
"stack": "infrastructure",
|
||||||
|
"category": "containers",
|
||||||
|
"status": "incomplete"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"proxmoxTemplates": [],
|
"proxmoxTemplates": [],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue