skip to content
docsspec
github

Installing addons

Installing an addon is pull, not push. The addon never registers itself and needs no identity to appear. It declares what it contributes in its capability manifest, and an admin adds it in the portal's settings by its in-cluster address. portal-api fetches the manifest and creates what it declares — an app, launchpad tiles, slot rows — and records the addon with the components it consists of, all owned by the addon's key. Removing the addon deletes everything by that key. The core learns nothing about the addon except what the manifest said.

There are two ways to get an addon's containers running, and the install is the same once they run:

From a chartFrom an address
Who deploys the containersThe platform's operator, from the addon's Helm chart, after an admin confirms its planYou, through your own deployment channel
HowSettings → addons → +, or zae addon add — see addon chartsStep 1 below, then install by the primary's address — step 2
RemoveDeletes the containers tooLeaves them for you to delete

For a chart addon, portal-api runs the install below by itself once the addon is Ready (ADR-0011), provided the chart's name — the addon's name — is the manifest's service. One key is one addon: a chart addon and an addon installed from an address cannot share it, and each install path answers 409 while the other holds it (see moving an addon to its chart). This page describes the install from an address and everything both ways share: what the platform creates from the manifest, what install refuses, and what settings → addons shows.

sequenceDiagram
    participant Admin
    participant Settings as portal settings
    participant API as portal-api
    participant Addon as addon primary (in-cluster)
    Admin->>Settings: addons → address http://my-addon → install
    Settings->>API: POST /api/portal/addons {proxyUrl}
    API->>Addon: GET /.well-known/zaentrum-capability.json
    Addon-->>API: manifest (commands, checks, topics, ui, components, setup)
    API->>API: validate · check ownership
    API->>API: create app, tiles, slot rows, addon + components — one transaction
    API-->>Settings: what was created

Two steps, and the second one is a click. An addon is often several containers — one primary that serves the manifest and the console, plus the components it declares — and all of them are deployed in step 1 the way you deploy anything else. The portal shows them and whether the addon is configured (ADR-0010); for an addon installed from an address, it never deploys them.

Plain Kubernetes, applied next to the platform through your own deployment channel. The operator never touches workloads it did not render, so an addon deployed into the platform namespace survives every reconcile, is never pruned, and does not cascade-delete with the CR. No Ingress or Route: the portal proxy is the addon's front door.

PieceWho provides itNotes
Deployment + Service per addon componentthe addon's manifestsBoth named by the component's workload; applied into the platform namespace
The grouping labelsthe addon's manifests (or you, via kustomize)See below — they drive the operator console grouping
Secrets the addon itself needs (DB URL, an encryption key for its settings)youAddon manifests reference them by name; settings an admin edits later live in the addon, see configuration
A database/schema, if the addon has stateyouAddons own their schema; the platform DB server may host it
Media volume mountyouRequired if the addon produces files for ingest
A service accountonly if the addon calls platform APIsSee identity — not needed to install

Every Deployment of an addon carries two labels in its metadata:

apiVersion: apps/v1
kind: Deployment
metadata:
name: example-worker # = the component's workload
labels:
zaentrum.io/addon: example # the addon's key — its manifest's service
zaentrum.io/component: worker # the component's name in the manifest
spec:
selector:
matchLabels:
app: example-worker # selectors stay the addon's own

They are metadata only — never in spec.selector, a pod template's matching labels or a Service selector. Selectors are immutable: a label added to one makes an already-deployed addon fail to apply, and nothing that groups workloads reads a selector. The addon's own manifests should carry both labels, since both are facts about the addon rather than the instance.

The namespace-wide addons label from earlier installs keeps working — stamp it from your kustomization, not by hand per file:

kustomization.yaml
namespace: <platform-namespace>
labels:
- pairs:
app.kubernetes.io/part-of: <platform-namespace>-addons
# default includeSelectors: false — selectors are immutable; adding to
# them would make an already-installed addon fail to apply
resources:
- my-addon.yaml

The portal's operator console groups workloads with them:

  1. platform services — what the operator renders;
  2. one section per installed addon, listing its declared components in order, each matched to the Deployment whose name equals its workload; a component with no such Deployment shows as not deployed;
  3. workloads labelled as addon workloads (zaentrum.io/addon, or the -addons part-of label) that no installed addon declares — deployed ahead of the install, or dropped from a manifest;
  4. everything else, under unclaimed.

Labels group what is shown; the manifest is what claims a workload. An addon that skips the labels still shows its declared components, and its undeclared workloads look like something nobody owns.

Settings → addons → the address of the addon's primary component (its Service name, e.g. http://my-addon) → check, then install. Optionally pick the space its tiles go into; the default is the first space, and an addon may bring its own.

check runs the whole install without writing anything: it shows what would be created, each declared component with the live state of its workload — a worker you forgot to deploy shows up here as not deployed — and whatever install would refuse.

What the platform creates from the manifest:

ManifestCreatedOwned by
service (always)An app with key service, proxyUrl = the address you typed, base URL /portal/app/<service>the app key
ui.appThe app's title, description and icon
ui.console: trueA tile addon.<service> in the chosen space, opening the hosted consolethe addon. prefix
ui.spaceA launchpad section of the addon's own, which its tiles go into instead of the chosen spaceit is removed with the addon, once empty
ui.tiles[]One tile per entry, keyed addon.<service>.<key>, each opening a path inside the addon's console. Replaces the single console tilethe addon.<service>. prefix
ui.slots[]One slot row per entry, keyed <service>.<key>, addon = servicethe addon column
components[]An addon record — address, version, the manifest it was installed from — and one row per component: name, workload, role, summary. Without components, one implicit primary at the addressthe app key
setupNothing but the declaration, kept with the manifest. The checklist is read live from the addon
commands[], checks[]Nothing to create — registering the app is what puts the addon on the CLI discovery list

All of it is written in one transaction: an install that fails leaves nothing half-created. No configuration value is stored anywhere in the portal — the addon record holds the manifest, not what an admin later enters in the addon's console.

Relative slot URLs (/portal/app/my-addon?q={q}) are absolutised against the portal's public origin at install time, because product apps may live on other hosts and render rows as plain links. An addon does not need to know where it is installed.

Scripted, with an admin bearer:

Terminal window
# check: the plan and each component's workload state, nothing written
curl -X POST https://<instance>/api/portal/addons \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"proxyUrl":"http://my-addon","dryRun":true}'
curl -X POST https://<instance>/api/portal/addons \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"proxyUrl":"http://my-addon"}'
# {"key":"my-addon","app":{…},"space":null,"tiles":1,"slots":1,"commands":2,"checks":1,
# "version":"0.4.0","components":[{"name":"my-addon","workload":"my-addon","role":"primary",
# "summary":"","phase":"ready","ready":1,"desired":1,"restarts":0,"reason":""}],
# "setup":null,"refresh":false,"adopt":false,"previousAddress":"","dryRun":false}

space, publicBase, dryRun and replaceAddress are optional fields of that body; publicBase overrides the origin used for absolutising when the call is made from inside the cluster rather than through the public route, and replaceAddress confirms moving an installed addon to a new address (see below). A check answers with previousAddress when the install would move the addon, and adopt when it records an app from an earlier install as the addon.

The address is validated the same way the embed proxy validates its targets: in-cluster names only. portal-api will not fetch a manifest from the internet. Its host must also name the primary's Service — http://my-addon, http://my-addon:8080 or http://my-addon.<namespace>.svc.cluster.local — because that name is the workload the portal matches; an IP address is refused with 422.

Beyond a manifest that breaks the component and setup rules, install answers 409 when the addon would take something it does not own. check shows the same, except where a case below says otherwise:

  • The service name is an app nobody installed. An admin registered an app with that key by hand under settings → apps; installing would take it over. Remove or rename that app first. The one app that is not refused is what an earlier install left behind: an app with the base URL /portal/app/<service> whose proxy address is the address you install from. Installing adopts it as the addon, and check says so.
  • The addon moves to another address. The addon is installed, but from a different address than the one you typed. Installing would point its proxy, and every token it forwards, at the new address, so it needs your confirmation. check shows the old and new address, and its install button confirms the move. A scripted install sends "replaceAddress": true. refresh uses the recorded address and never moves anything.
  • A component's workload is a platform service. An addon cannot claim a Deployment the operator renders. The portal can only check this when it can list the namespace's workloads. If the apiserver refuses that list, install answers 503 and writes nothing. check still shows the plan, with every component unknown. Outside a cluster there are no platform workloads, and the check does not apply.
  • A component's workload belongs to another addon. One workload, one owner: two installed manifests cannot both declare it.
  • The key belongs to a chart addon. An addon the platform installs from a chart is upgraded and reconfigured as a chart addon, never over by address.

Each installed addon — including one with no console, tiles or slot rows — is a row with its version, its containers (running against declared, e.g. 2/2 running) and its setup state. Expanding the row shows:

  • Components — name, role and workload, with the workload's phase, ready and desired replicas, restarts and the reason when something is wrong. not deployed when no Deployment has that name; unknown when the operator service cannot be reached.
  • Setup checklist — the manifest's sections in order, each with the state and summary the addon reports. The browser fetches /api/portal/apps/<key><setup.path> with the admin's own token; the status document is the addon's, and a failed call shows the checklist as unknown. Summaries are rendered as plain text. configure opens /portal/app/<key><target> — the view in the addon's console where that section is edited.

GET /api/portal/addons returns the same: version, components with phase, ready, desired, restarts and reason (null when not deployed), setup, and refreshAvailable.

A chart addon's row also shows its chart and version and the phase of its ZaentrumAddon, with upgrade, values and remove (addon charts); the list adds chart, phase and suspended for it.

Addons installed before component groups existed are listed with one implicit primary at their address. refresh reads what their manifest declares now. The exception is an addon that created neither a tile nor a slot row. Nothing in the portal identifies such an app as an addon, so it is not listed after the upgrade. Install it again from the same address: install adopts the existing app instead of refusing it.

An addon with real internal structure — a backlog, a queue, a settings area — can place several tiles instead of one, in a section of its own:

"ui": {
"app": { "title": "acquire", "icon": "download" },
"console": true,
"space": { "key": "acquire", "title": "acquire", "ord": 30 },
"tiles": [
{ "key": "requests", "title": "requests", "description": "who asked for what", "icon": "download", "target": "#/requests", "ord": 10 },
{ "key": "downloads", "title": "downloads", "description": "the queue, live", "icon": "gauge", "target": "#/downloads", "ord": 20 }
]
}

A tile target opens a view inside the addon's own console — a hash route or a path, never another origin, and never a path that climbs out with .., whether spelled out or percent-encoded. That keeps this a curated set of entry points rather than a second navigation model competing with the addon's own: place the few views an operator starts from, not every tab the console has. Setup section targets follow the same rule.

Tiles without an icon inherit the app's. Without a space, they land in the space the admin picked at install.

A chart addon is upgraded through its chart: a new version, planned with its changes, then installed (addon charts). The refresh below then runs by itself, because portal-api installs a Ready chart addon again whenever its manifest changes.

For an addon installed from an address, deploy the new version through your channel first, then refresh. The portal keeps the manifest it installed from; when the descriptor CLI discovery last read from the addon differs — the normal state shortly after a deploy that changed it — the addon's row shows refresh available.

Settings → addons → refresh is the same call as install. Tiles, slot rows and components are replaced, not merged, so a card, button or component the addon dropped disappears instead of lingering, and the ownership checks run again. Commands and checks need nothing — the CLI reads the live descriptor on every discovery.

A chart addon goes in one step: settings → addons → remove, or zae addon remove, deletes its ZaentrumAddon, everything the chart applied, its values Secrets and generated values, and the registry rows — or, with keep values, keeps both Secrets for a later install (addon charts).

For an addon installed from an address, subtraction, in order:

  1. Settings → addons → remove (or DELETE /api/portal/addons/<key>): deletes the slot rows, every tile the addon owns, the addon record with its components, and the app by key — plus the addon's own space, once nothing else is left in it. The core shows no trace; zae discover no longer lists it. The answer says what was removed — {"removed":{"tiles":1,"rows":1,"space":""},"remainingWorkloads":["my-addon"]}, where space names the removed spaces, comma-separated, or is empty — and remainingWorkloads lists the addon's workloads that are still deployed (every declared one when the portal cannot list workloads), which the remove dialog names. The portal does not delete them — it did not create them.
  2. Delete those workloads and the addon's other Kubernetes objects through the channel that deployed them; the zaentrum.io/addon label finds them.
  3. Drop its database if you are done with the data. The addon's configuration lives there, and nowhere else.

Moving an addon from its address to its chart

Section titled “Moving an addon from its address to its chart”

An addon deployed through your channel and installed from its address can become a chart addon once it ships a chart. The two cannot hold the same key, and the operator never takes over a Deployment or Service it did not create, so the move is a removal and an install, with a gap while the chart's workloads start:

  1. Collect the inputs. What the running deployment gets from your channel — a database address, the key that encrypts the addon's settings — becomes the chart's inputs. Give an existing encryption key as a secret input rather than letting the operator generate a new one: data encrypted with the old key could not be read with a new one.
  2. Check the name. The chart's name must be the manifest's service — the key the addon is registered under now.
  3. Remove the address install in settings → addons. Its registry rows go; its workloads keep running.
  4. Add the chart under that name — settings → addons → +, or zae addon add — with the inputs from step 1, and read the plan. It lists every object the old workloads still hold as already exists and is not owned by this addon.
  5. Delete the old workloads and the addon's other objects through the channel that deployed them; the zaentrum.io/addon label finds them. The next plan is clean.
  6. Install. The portal registers the addon again once it is Ready.

The addon's database and the configuration in it stay where they are when the chart points at the same database; tiles, slot rows and the setup checklist come back from the same manifest.

🔶 Platform-managed deploys are decided and being built: the operator installs an addon's Helm chart from one ZaentrumAddon resource per addon, after an admin confirms the plan, and portal-api runs the install on this page once the addon is Ready (ADR-0011). Addon charts is the contract. Components and setup stay as they are.

🔶 A declarative install is a committed ZaentrumAddon and its values Secret (addon charts), one resource per addon rather than a list on the Zaentrum CR. It arrives with addon charts.

The runtime write API on /api/portal/extensions remains for addons that change their contributions while running (see identity). It is no longer the install path.