MakerFLOSS/slides/tappaas/how-to/new-module/index.md
Lars Rossen 87d6d83ff3
All checks were successful
Build slides / build (push) Successful in 1m22s
Build docs site / build (push) Successful in 1m26s
slides(tappaas): the session builds the portainer module
Retargets the deck from podman to portainer: the contract, the three script
slides, both demo slides and the repo-location slide. Demo 3 is now an actual
single sign-on — 'no login form, you are already you' — because Portainer is an
OIDC client and Cockpit never could be.

The test.sh slide keeps the check worth stealing: /api/settings/public reporting
method 3, which catches identity silently falling back to local accounts.
2026-08-23 09:46:59 +02:00

11 KiB

marp theme class paginate title description
true gaia invert true How to implement a TAPPaaS module Live build of a Portainer module — OrangeMaker, 24 August

Getting a module onto TAPPaaS

Live build of a Portainer container console

OrangeMaker · 24 August


The plan

  1. What TAPPaaS is — a few diagrams, ten minutes, no deeper
  2. What a module actually is — a json contract and three scripts
  3. Build one live — the lab1 environment, then portainer into it
  4. Prove it works — tests, then the web console
  5. You get a login — your own identity on this system

You leave with: a mental model, a module you watched get built, and an account.


The shape of a TAPPaaS site

flowchart LR
  sat["satellite VPS<br/>public IP · ingress<br/>off-site backup"]
  subgraph z0["mgmt · zone mgmt"]
    cicd["tappaas-cicd"]
    net["network"]
    clu["cluster"]
    bak["backup"]
    idp["identity"]
  end
  subgraph z1["default · zone srv"]
    nc["nextcloud"]
    ha["home-assistant"]
  end
  subgraph z2["lab1 · zone lab1"]
    pod["portainer — today"]
  end
  sat --> net
  cicd --> nc
  cicd --> pod

The words we will use

Word Meaning
Module The smallest deployable unit — its own VM, its own json contract. Foundation and apps are modules.
Environment A tenant. mgmt and one named after the site always exist; add more freely. Today we add lab1.
Zone A VLAN with a firewall policy. Modules land in their environment's zone.
Mothership tappaas-cicd — where the managers run and where you type.
Satellite Optional VPS, for sites with no usable public IP.

Everything is a module. There is one model to learn, not eight.


Developing modules: dev instance, private branch

flowchart RL
  subgraph Development["Development repository"]
    dev_main["main"]
  end
  subgraph Upstream["Upstream repository"]
    up_main["main"]
    up_stable["stable"]
    up_main -->|merge| up_stable
  end
  subgraph InstanceDev["TAPPaaS instance · dev"]
    localDev["clone of Upstream"]
    localDev_dev["clone of Development"]
  end
  subgraph InstanceProd["TAPPaaS instance · prod"]
    localProd["clone of Upstream"]
  end
  localProd -->|pull| up_stable
  localDev -->|pull| up_main
  localDev_dev -->|push| dev_main
  dev_main -->|PR| up_main

Our setup tonight

flowchart RL
  subgraph t["TAPPaaS · codeberg"]
    stable["stable"]
    main["main"]
  end
  subgraph c["Community · codeberg"]
    cm["main"]
  end
  subgraph f["forgejo.makerfloss.eu"]
    fm["main"]
  end
  subgraph inst["Our TAPPaaS · tappaas-cicd"]
    lt["clone of TAPPaaS"]
    lc["clone of Community"]
    lf["clone of MakerFLOSS"]
  end
  lt -->|pull| main
  lc -->|pull| cm
  lf -->|pull| fm
  classDef tracked stroke:#ff8a80,stroke-width:3px
  class main tracked

We track main, not stable — this is a lab, we want the new things.


A module is a contract and three scripts

portainer/
├── portainer.json  # the contract — what it is, needs, provides
├── install.sh      # put the software in the VM        (once)
├── update.sh       # keep it patched                   (on schedule)
├── test.sh         # prove it still works              (gates updates)
├── README.md       # what it is
└── INSTALL.md      # what automation cannot do for you

That is the whole surface. Everything else is the platform's job.


The contract, for real

{
  "description": "Portainer CE — container console with TAPPaaS login",
  "vmname": "portainer", "vmid": 813,
  "dependsOn": ["cluster:vm", "templates:debian", "backup:vm",
                "network:proxy", "identity:identity"],
  "identity": { "oidcRedirectPaths": ["/"],
                "secretsEnv": "/etc/secrets/portainer.env" },
  "config": {
    "cluster:vm": { "cores": 2, "memory": "2048", "diskSize": "32G",
                    "image": "debian-13-generic-amd64.qcow2" },
    "network:proxy": { "proxyPort": 9443, "proxyUpstreamTls": true }
  }
}

dependsOn is the whole trick: five services, declared — not scripted.


install.sh — called once, at add

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/update.sh" "$@"

By the time it runs: cluster:vm built the VM, templates:debian apt-upgraded it, network:proxy published the name — and identity:identity already created the OIDC application and dropped its client credentials on the VM.

Nothing here is install-only, so install is update. Five lines is a perfectly good install.sh.


update.sh — where the work is

Runs on the mothership, not on the VM. It:

  1. finds the node hosting the VM via pvesh, resolves its IP through the guest agent
  2. apt install podman, then enables the rootful podman.socket — that socket is the Docker-compatible API Portainer speaks
  3. pulls portainer-ce:lts and runs it against the socket + a named volume
  4. creates a break-glass admin, password into /etc/secrets/portainer-admin
  5. reads /etc/secrets/portainer.env, fetches the OIDC discovery document, and PUTs the three endpoints into /api/settings → login becomes OAuth

Re-runnable throughout: the container is recreated only when the image digest actually changed, and an existing admin password is never regenerated.


test.sh — the one that earns trust

Check How
VM reachable IP resolves via the guest agent
container API rootful podman.socket is active
app running podman container inspect says running
API alive /api/status returns a version
identity intact /api/settings/public reports method 3 = OAuth

That last one is the check worth stealing. It catches the failure nobody notices: identity quietly falling back to local accounts, so the app still works while no longer being the login you thought it was.

Run on demand, and again before every update is merged. If test.sh is honest, unattended updates are safe — that is the entire deal.


What you did not have to write

  • A VM — cluster:vm built it from the Debian 13 cloud image
  • OS prep — templates:debian did apt + guest agent
  • A VLAN, an interface, DHCP, firewall rules — the zone came with the environment
  • https://portainer.lab1.makerfloss.eu with a real certificate — network:proxy
  • An OIDC application, a group binding and client credentials — identity:identity
  • Nightly backup to PBS, scheduled updates, health reporting

A few lines of json bought all of it.


Demo 1 — an environment of our own

network-manager zone add lab1 --from-zone srv
network-manager show lab1              # vlan tag, subnet, access-to
network-manager reconcile              # dry-run: drift on all 4 planes
network-manager reconcile --apply      # converge OPNsense, Proxmox, switch, AP

environment-manager add lab1 --display "MakerFLOSS lab" \
    --zone lab1 --domain lab1.makerfloss.eu
environment-manager list
environment-manager show lab1

A tenant with its own VLAN, firewall posture and DNS names — lab1.makerfloss.eu outside, lab1.internal inside.


Demo 2 — install the module

module-manager module add portainer --environment lab1

module-manager module list             # what is deployed
module-manager module show portainer   # the resolved config, cascade applied

Watch the order: dependencies resolve first, then the VM, then the network, then install.sh. Install order is computed from every module's dependsOn — nobody maintains a list.


Demo 3 — prove it, then look at it

module-manager module test portainer

Then open https://portainer.lab1.makerfloss.eu and press Sign in.

  • No login form. You are already you — Authentik, via OIDC.
  • Not in devops? No console. The group binding is the access gate.
  • Other machines in lab1 join as Environments: one agent container on :9001.

Create a container. Start it. Read its logs. From a browser, as yourself.


Where this module lives

makerfloss/src/containers/portainer/

Two siblings next to it: podman/ (one host, local login) and komodo/ (GPL, scripts still to write). Same job, three answers.

Three homes for a module, all first-class:

Home For
TAPPaaS repo, by pull request modules the whole project should carry
Community repo yours, shared, no gatekeeping
Private repo — forgejo.makerfloss.eu/TAPPaaS/makerfloss ours, today's portainer

Adding a repository is one site-manager repository add.


Your turn — identity on this TAPPaaS

people-manager user add <you> --email <you>@example.org \
    --roles user --groups devops
people-manager reconcile          # preview
people-manager reconcile --apply  # push to Authentik

authentik-manager user-recovery-link <you>   # one-time URL: set your own password

One login per person, roles via groups. Nobody types a password into a chat window.


Take it further

  • Develop a module — tappaas.org/generated/develop-a-module
  • Every json field — tappaas.org/generated/schemas
  • Zones and the access model — tappaas.org/generated/zones
  • Repository topology — tappaas.org/generated/design/cicd-git
  • This deck — slides.makerfloss.eu/tappaas/how-to/new-module

Pick an app you want on a sovereign box. Open an issue first — someone may already be packaging it.