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:
parent
ffc9e12526
commit
04b4437c95
15 changed files with 389 additions and 793 deletions
14
README.md
14
README.md
|
|
@ -32,16 +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.
|
||||
Two takes on the same job — run containers on a lab host, let registered people manage them,
|
||||
reach the other hosts in the zone.
|
||||
|
||||
| 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 |
|
||||
| [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
|
||||
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` absorbed the former standalone `portainer` module: Podman is the container
|
||||
foundation, Portainer is the GUI onto it, one VM. Cockpit was dropped because it fits 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 in
|
||||
[podman/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
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.
|
||||
Note that the direct `:9090` URL stays open under option 1 — the gate is on the friendly
|
||||
name only, which is honest but worth saying out loud in `README.md`.
|
||||
**None of the three.** All of them are workarounds for a console that cannot take an OIDC
|
||||
login, and the requirement had grown: registered people managing containers *across the hosts
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,110 +1,95 @@
|
|||
# Podman container host — Installation
|
||||
# Podman + Portainer — Installation
|
||||
|
||||
Primary audience: TAPPaaS admin. Steps the scripts cannot automate.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access.
|
||||
- The dependencies are pulled in automatically (`cluster:vm`, `templates:debian`,
|
||||
`backup:vm`, `network:proxy`).
|
||||
- For the friendly HTTPS name to present a valid certificate, the TAPPaaS wildcard must be in
|
||||
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).
|
||||
- Dependencies resolve automatically (`cluster:vm`, `templates:debian`, `backup:vm`,
|
||||
`network:proxy`, `identity:identity`).
|
||||
- The identity foundation must be up — the login depends on it.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd /home/tappaas/Community/src/larsrossen/containers/podman
|
||||
install-module.sh podman
|
||||
# Published at <vmname>.<environment-domain>:
|
||||
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:
|
||||
- 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**
|
||||
(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
|
||||
therefore its associated zone — pass `--environment` at install time:
|
||||
## Post-install (manual)
|
||||
|
||||
### 1. Who can log in
|
||||
|
||||
Authentik binds the application to the allowed groups at install. To let the hackerspace in:
|
||||
|
||||
```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)
|
||||
|
||||
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:**
|
||||
On each additional VM or physical machine:
|
||||
|
||||
```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
|
||||
ssh tappaas@<vm-ip>
|
||||
sudo useradd -m -s /bin/bash -G sudo podadmin && sudo passwd podadmin
|
||||
sudo loginctl enable-linger podadmin # keep its rootless podman socket alive
|
||||
ssh tappaas@<vm-ip> 'sudo cat /etc/secrets/podman-admin'
|
||||
```
|
||||
|
||||
Then open **`https://podman.<domain>`** (or `https://<vm-ip>:9090`), log in with that account,
|
||||
and choose **Podman containers** in the left menu (Cockpit starts the user's podman service on
|
||||
first use).
|
||||
Username `admin`. This is the way back in when Authentik is unavailable — keep it.
|
||||
|
||||
## Using it
|
||||
## Notes from the live installs (lab1, 2026-08-24/25)
|
||||
|
||||
- CLI: `ssh tappaas@<vm-ip>` then `podman run …`, `podman ps`, `podman images`.
|
||||
- Compose files: `podman-compose up -d` in a directory with a `compose.yaml`.
|
||||
- Web: manage/start/stop containers, pull images and inspect logs from the Cockpit
|
||||
**Podman containers** page.
|
||||
Four things bit during the first real install; all are handled in `update.sh` now, but they
|
||||
are worth knowing when debugging:
|
||||
|
||||
## 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
|
||||
test-module.sh podman
|
||||
module-manager module test podman-lab1 # effective name outside the default environment
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
|
||||
| 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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
rootless-capable container engine (a drop-in for `docker`). On top of the engine it adds the
|
||||
**Cockpit web console** with the **`cockpit-podman`** plugin, so containers, images and pods
|
||||
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`.
|
||||
A **Debian 13 (trixie) VM running [Podman](https://podman.io)** as the container engine, with
|
||||
**[Portainer CE](https://www.portainer.io)** as its web interface. Portainer runs *as a
|
||||
container on the very engine it manages*: Podman is the foundation, Portainer is the GUI.
|
||||
|
||||
## 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 |
|
||||
|------------|-------------|-----|
|
||||
| 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` |
|
||||
## Two engines, on purpose
|
||||
|
||||
## 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.
|
||||
- **No internet exposure.** The friendly name is reachable only from the `mgmt` zone; it is
|
||||
not published to the internet (no inbound NAT/port-forward).
|
||||
- **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).
|
||||
Portainer's access to the rootful socket is **root-equivalent on this VM**. That is inherent to
|
||||
every container manager with socket access — treat the VM as a lab host and put nothing
|
||||
precious on it.
|
||||
|
||||
## Access (internal only)
|
||||
## Why Portainer and not Cockpit
|
||||
|
||||
`network:proxy` publishes **`https://podman.<domain>`** (split-horizon DNS → Caddy → the VM's
|
||||
`:9090`, HTTPS upstream), with an access-list permitting only the **`mgmt`** zone — **not
|
||||
reachable from the internet**. Cockpit rides WebSockets, so the route is created with
|
||||
`proxyUpstreamHttp1: true` (Caddy talks HTTP/1.1 to the upstream); the direct
|
||||
`https://<vm-ip>:9090` always works too.
|
||||
The first version of this module shipped Cockpit with `cockpit-podman`. It cannot meet either
|
||||
requirement: Cockpit is not an OIDC client and cannot be configured into one, it always needs
|
||||
a local Unix account (`cockpit-bridge` runs in a PAM session as the user), and its multi-host
|
||||
switcher is deprecated and disabled by default — the project states it "cannot be secure".
|
||||
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
|
||||
|
||||
|
|
@ -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 |
|
||||
| `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) |
|
||||
| `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
|
||||
|
||||
The module sets no explicit `zone0`, so the VM is placed in the **default environment's zone**;
|
||||
install it with `--environment <env>` to put it in a specific environment and its associated
|
||||
zone instead (see [INSTALL.md](./INSTALL.md#placement--zone)).
|
||||
No `zone0` is set, so the VM lands in the environment's zone (ADR-007 P5).
|
||||
`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 reach the console from a client zone while Authentik decides who gets in.
|
||||
|
||||
## Sizing
|
||||
|
||||
2 vCPU / **2 GB** RAM / 20 GB disk — enough for the engine, Cockpit and a handful of light
|
||||
containers. Bump memory/disk to match whatever you actually run on it (container images and
|
||||
volumes land on the VM disk, thin-provisioned on ZFS).
|
||||
2 vCPU / 2 GB RAM / 32 GB disk. Podman and Portainer are small; the disk is for the images and
|
||||
volumes of whatever gets run here.
|
||||
|
||||
For installation steps see [INSTALL.md](./INSTALL.md).
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
#
|
||||
# podman module install — thin wrapper.
|
||||
#
|
||||
# The VM itself is created by the cluster:vm provider (Debian 13 cloud image),
|
||||
# then OS-prepped by the templates:debian provider (apt update + qemu-guest-agent
|
||||
# via update-os.sh). This script applies the module-specific step: installing
|
||||
# Podman + the Cockpit web console onto the Debian VM. All real work lives in
|
||||
# update.sh (install == update for this module).
|
||||
# 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>
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"version": "0.1.0",
|
||||
"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.2.0",
|
||||
"appVersion": "5.4",
|
||||
"releaseDate": "2026-08-18",
|
||||
"releaseDate": "2026-08-25",
|
||||
"maintainer": "@larsrossen",
|
||||
"status": "Development",
|
||||
"vmname": "podman",
|
||||
"vmid": 812,
|
||||
"vmtag": "TAPPaaS,Community,Containers",
|
||||
"vmtag": "TAPPaaS,MakerFLOSS,Containers",
|
||||
"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": [],
|
||||
"identity": {
|
||||
"oidcRedirectPaths": ["/"],
|
||||
"secretsEnv": "/etc/secrets/podman.env"
|
||||
},
|
||||
"config": {
|
||||
"cluster:vm": {
|
||||
"bios": "seabios",
|
||||
"ostype": "l26",
|
||||
"cores": 2,
|
||||
"memory": "2048",
|
||||
"diskSize": "20G",
|
||||
"diskSize": "32G",
|
||||
"storage": "tanka1",
|
||||
"imageType": "img",
|
||||
"image": "debian-13-generic-amd64.qcow2",
|
||||
|
|
@ -28,10 +33,8 @@
|
|||
"bridge0": "lan"
|
||||
},
|
||||
"network:proxy": {
|
||||
"proxyPort": 9090,
|
||||
"proxyUpstreamTls": true,
|
||||
"proxyUpstreamHttp1": true,
|
||||
"proxyAllowedZones": ["mgmt"]
|
||||
"proxyPort": 9443,
|
||||
"proxyUpstreamTls": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
#!/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):
|
||||
# - the VM is reachable
|
||||
# - podman is installed (version marker + working binary)
|
||||
# - the cockpit-podman plugin is present
|
||||
# - the Cockpit web console answers (HTTPS, :9090)
|
||||
# - 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>
|
||||
#
|
||||
|
|
@ -47,35 +48,48 @@ ok "VM IP resolved (${IP})"
|
|||
|
||||
vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; }
|
||||
|
||||
# Version marker + working podman binary
|
||||
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
|
||||
# The engine itself, and the Docker-compatible API Portainer drives.
|
||||
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
|
||||
no "podman binary not working"
|
||||
fi
|
||||
|
||||
# cockpit-podman plugin installed
|
||||
if vm "dpkg -s cockpit-podman >/dev/null 2>&1"; then
|
||||
ok "cockpit-podman plugin installed"
|
||||
if vm "systemctl is-active --quiet podman.socket"; then
|
||||
ok "rootful podman.socket active (Portainer's container API)"
|
||||
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
|
||||
|
||||
# Cockpit web console answers on :9090 (login page pre-auth is a pass).
|
||||
# Plain -s (no --fail): --fail exits 22 on 4xx and would corrupt the -w code.
|
||||
code="$(vm "curl -sk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)"
|
||||
if [[ "${code}" =~ ^(200|302|401|403)$ ]]; then
|
||||
ok "Cockpit console answers on :9090 (HTTP ${code})"
|
||||
# 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 "Cockpit console did not answer on :9090 (HTTP ${code})"
|
||||
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 ]]
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# podman module update — install/upgrade Podman + the Cockpit web console on the
|
||||
# Debian 13 VM.
|
||||
# podman module update — a Podman container host with Portainer CE as its GUI.
|
||||
#
|
||||
# Podman is the daemonless, rootless-capable container engine; it ships in the
|
||||
# Debian 13 (trixie) main repo, so no external download is needed. To give it the
|
||||
# browser interface the module promises, we also install Cockpit and its
|
||||
# cockpit-podman plugin — Cockpit serves an HTTPS admin console with a PAM LOGIN
|
||||
# SCREEN on :9090, and cockpit-podman adds the "Podman containers" page for
|
||||
# managing containers, images and pods. network:proxy publishes that console as
|
||||
# https://podman.<domain> (internal, mgmt zone only).
|
||||
# Podman is the engine; Portainer is the web interface onto it. Both live on the
|
||||
# same VM: Portainer runs AS a container on the very engine it manages.
|
||||
#
|
||||
# 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 over SSH installs the packages from apt. Idempotent: it re-runs apt every
|
||||
# time (apt is a no-op when already current) and records a version marker.
|
||||
# then drives the VM over SSH. Idempotent throughout.
|
||||
#
|
||||
# Usage: update.sh <module-name>
|
||||
#
|
||||
|
|
@ -24,12 +28,15 @@ set -euo pipefail
|
|||
|
||||
MODULE="${1:-podman}"
|
||||
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}")"
|
||||
VMID="$(get_config_value 'vmid')"
|
||||
SECRETS_ENV="$(get_config_value 'secretsEnv' '/etc/secrets/podman.env')"
|
||||
[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}"
|
||||
|
||||
# ── Locate the node hosting the VM (HA-safe) and resolve its IP ───────
|
||||
|
|
@ -47,78 +54,214 @@ get_vm_ip() {
|
|||
| 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=""
|
||||
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}"
|
||||
|
||||
# SSH helper: run a command on the VM as the tappaas user.
|
||||
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
|
||||
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)"
|
||||
# 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}}")"
|
||||
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
|
||||
COCKPIT_URL="https://${PROXY_DOMAIN}"
|
||||
UI_URL="https://${PROXY_DOMAIN}"
|
||||
else
|
||||
[[ -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
|
||||
|
||||
# ── 1. Install podman + cockpit + cockpit-podman (apt) ───────────────
|
||||
# podman-compose/slirp4netns give rootless networking + compose-file support;
|
||||
# cockpit + cockpit-podman provide the web console with a login screen on :9090.
|
||||
info " Installing podman, podman-compose, cockpit, cockpit-podman (apt)..."
|
||||
# ── 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 podman-compose slirp4netns uidmap \
|
||||
cockpit cockpit-podman \
|
||||
curl ca-certificates" \
|
||||
|| die "failed to install podman/cockpit packages"
|
||||
podman podman-compose slirp4netns uidmap curl jq ca-certificates" \
|
||||
|| die "failed to install podman"
|
||||
|
||||
PODMAN_VER="$(vm "podman --version 2>/dev/null | awk '{print \$3}'" || true)"
|
||||
[[ -n "${PODMAN_VER}" ]] || die "podman did not install correctly (no version reported)"
|
||||
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"
|
||||
|
||||
# ── 3. Wait for the Cockpit console to answer (port 9090) ────────────
|
||||
info " Waiting for the Cockpit console to come up (https://${IP}:9090)..."
|
||||
# Two engines on one host, on purpose:
|
||||
# - 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
|
||||
for _ in $(seq 1 18); do
|
||||
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)"
|
||||
[[ "${code}" =~ ^(200|302|401|403)$ ]] && { UP=1; break; }
|
||||
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"
|
||||
|
||||
echo ""
|
||||
if [[ "${UP}" -eq 1 ]]; then
|
||||
info "${GN}✓ Podman ${PODMAN_VER} + Cockpit console installed${CL}"
|
||||
# ── 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
|
||||
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
|
||||
|
||||
echo ""
|
||||
info "${GN}✓ Podman + Portainer installed${CL}"
|
||||
echo ""
|
||||
info "${BOLD}═══ Next steps ═══${CL}"
|
||||
info " ${BOLD}Console:${CL} ${COCKPIT_URL} (direct: https://${IP}:9090)"
|
||||
info " Log in with a Linux account on the VM. The cloud-init ${BOLD}tappaas${CL} user has"
|
||||
info " SSH-key auth and no password, so set one first for the web login:"
|
||||
info " ssh tappaas@${IP} 'sudo passwd tappaas'"
|
||||
info " Then open the console and pick ${BOLD}Podman containers${CL} in the left menu."
|
||||
info " See INSTALL.md for the details (and how to add a dedicated admin user instead)."
|
||||
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 " Containers on this host: ssh tappaas@${IP} and run podman (rootless)."
|
||||
info " Other hosts in the zone: run the Portainer agent there, add it under"
|
||||
info " Environments — see INSTALL.md."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ]]
|
||||
|
|
@ -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."
|
||||
|
|
@ -11,15 +11,6 @@
|
|||
"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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue