--- marp: true theme: gaia class: invert paginate: true title: How to implement a TAPPaaS module description: 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 ```mermaid flowchart LR sat["satellite VPS
public IP · ingress
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 ```mermaid 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 ```mermaid 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 ```text 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 ```json { "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 ```bash #!/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 `PUT`s 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.lab1.makerfloss.eu`, valid cert — `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 ```bash network-manager add lab1 --from-zone makerfloss # --check first for a dry run network-manager show lab1 # vlan 299, 10.2.99.0/24, access-to network-manager reconcile # dry-run: drift on all 4 planes environment-manager add lab1 --display "MakerFLOSS lab" --owner makerfloss \ --zone lab1 --domain lab1.makerfloss.eu environment-manager show lab1 ``` `add` reconciles every plane itself. A tenant with its own VLAN, firewall posture and DNS names — `lab1.makerfloss.eu` outside, `lab1.internal` inside. --- ## Demo 2 — install the module ```bash site-manager repository add forgejo.makerfloss.eu/TAPPaaS/makerfloss --branch main module-manager module add portainer --environment lab1 module-manager module list # what is deployed module-manager module show portainer-lab1 # note the -lab1 suffix ``` Outside the default environment the module is **`portainer-lab1`** — `add` takes the base name, everything after it takes the effective one. 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 ```bash module-manager module test portainer-lab1 ``` Then open **`https://portainer-lab1.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 ```text 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 ```bash people-manager user add --email @example.org \ --roles user --groups devops people-manager reconcile # preview people-manager reconcile --apply # push to Authentik authentik-manager user-recovery-link # 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.