Skip to main content

Gentian OS Commands

This document lists key cluster-admin commands for Gentian OS operations.

For environment mapping, promotion flows, and GitOps layout, see deployment.md.

For tenant-admin app lifecycle commands, see:

CLI entry points​

The Gentian CLI is a kubectl plugin (kubectl-gentian), installed with make install-plugin into ~/.local/bin, with a shorthand symlink gtnctl. It is a client of the director, like the admin console: sign in once with kubectl gentian login (a code confirmed in the browser), and every command asks the director as you, which checks, commits and records it. The commands about a person -- tenants activate-admin, and the look-up behind platform-roles -- ask the registrar, which checks the same way and reads or acts at Keycloak. It reaches the director and the registrar of the current kubectl context, so it needs no configuration.

People are invited, changed and removed in the Admin Console, Members; there is no command for it. Removing a person who has a mailbox on the cluster's own mail server asks there whether the mailbox is archived or deleted (mail.md §5c).

gtnctl tenants list # same as kubectl gentian tenants list

Use gtnctl at the terminal if you prefer a shorter command. All examples below use the canonical kubectl gentian form for consistency in docs and scripts. kubectl gentian --help prints every command; kubectl gentian whoami, logout and version complete the list below.

The examples use a tenant named demo. On a single-tenancy cluster (tenancyMode: single) the one tenant for users is named user: read --tenant user for --tenant demo.

1. Install the OS (Cluster Admin)​

Run the installer from a checkout of the OS repository:

./install.sh

It installs the kernel services, Argo CD, OpenBao, the operator and the director. GETTING-STARTED.md is the walkthrough and reference/install.md the reference.

2. Verify Core Health​

./install.sh --status
kubectl get applications -n kernel-gitops
kubectl get pods -n kernel-control
kubectl get tenants

3. Provision a Tenant​

Bringing a tenant on briefly disrupts the shared kernel. Provisioning adds listeners to the kernel Gateway, which reloads Envoy across every host it serves; expect transient 404s on kernel hosts while it happens. Treat creating and retiring tenants as maintenance-window operations on a cluster with live users.

A tenant is created through the director — the admin console's Tenants tab, or the CLI:

kubectl gentian tenants create demo --display-name "Demo AG" # --no-mfa: no second factor for its admin
kubectl gentian tenants list
kubectl gentian tenants show demo
kubectl get tenant demo -w

One thing about a tenant is the cluster's administrator's alone to decide, and it is off unless switched on: whether the tenant's own administrators may add catalogs to install from. It is stated when the tenant is created or changed afterwards, and tenants list and tenants show say what holds. Who approves what the tenant publishes to the internet and its egress is a role holder on the Cluster claim (perimeterApprover, see Cluster roles held by name below):

kubectl gentian tenants create demo --admins-add-catalogues
kubectl gentian tenants set demo --admins-add-catalogues=true

A second switch is chosen when the tenant is made and not changed afterwards: whether anybody may make an account in its realm themselves (--self-sign-up, spec.signUp.enabled). The command says what that opens before it asks the director: a registration page at the cluster's identity provider, an e-mail confirmation and a proof-of-work check, and accounts that are in none of the tenant's groups and so have nothing of the platform's. For a tenant whose apps serve the public, not for a workspace (iam.md §1.1b):

kubectl gentian tenants create customers --display-name "Customers" --self-sign-up

The country the tenant is in (--country, ISO 3166-1 alpha-2, spec.country) is optional. It is changed afterwards by whoever may set the tenant's policy, its own administrators too (PUT /v1/tenants/<t>/country, can_set_policy); "" removes it. A component whose profile names valueMapping.platform.countryKey is told it: the App Store app asks a store for the support offered there.

kubectl gentian tenants create acme --country CH
kubectl gentian tenants set acme --country DE
kubectl gentian tenants set acme --country ""

The name of a tenant this cluster retired is refused, for tenants create and tenants import alike: the rights and memberships that tenant left are still addressed by it. --reuse-name approves the reuse, to recover that tenant; the answer warns what the new tenant inherits and the commit records it.

kubectl gentian tenants import acme-nightly.gentian --identity-file acme-key.txt --reuse-name

Who the tenant is — legal name, currency, time zone, fiscal year start, address, website, email, phone, VAT and register numbers (spec.organization) and its display name — is filled in by its administrators under Tenant Settings in the admin console (GET/PUT /v1/tenants/<t>/organization, can_set_policy to write). A currency left empty is the country's. The answer names what is still missing of legal name, country, currency, time zone and languages; the App Store warns until nothing is. A component whose profile names valueMapping.platform.organizationKey is told all of it as one object, timeZoneKey the zone alone (unset while there is none); an app sets itself up from them.

A tenant's partners — realms of other clusters its realm links its people's accounts with (spec.partners, AD-17, identity-brokering.md). --issue: our people sign in there and our apps call it for them; --accept: their people arrive here, linked (link), made (create) or as an organization. Written by the tenant's administrators (can_administer), within the Cluster claim's spec.partners.allowedIssuers:

kubectl gentian tenants partners customers # list them
kubectl gentian tenants partners acme add shop --issuer https://id.shop.example/auth/realms/customers --issue
kubectl gentian tenants partners customers add acme --issuer https://id.acme.example/auth/realms/acme \
--accept organization --domain acme.example --display-name "ACME AG"
kubectl gentian tenants partners customers remove acme

The catalog switch is a line in the tenant's manifest in git (spec.catalogue.delegated), committed by the director as you (PUT/DELETE /v1/clusters/<c>/tenants/<t>/catalogue-delegation, can_configure on the cluster). A tenant's own administrator cannot set it. An imported tenant starts with it off, whatever held where its bundle came from.

The director commits clusters/<cluster>/tenants/demo/ as you; Argo CD syncs it and the operator provisions the realm, namespaces, database and desktop.

On a single-tenancy cluster (tenancyMode: single) there is exactly one tenant for users, named user, which the install creates after the handover (step E-04); tenants create with any other name is refused with a message that names the mode, and user itself is refused only because it exists. Nothing in this section has to be run there. tenants import follows the same rule.

A tenant's administrator — the tenant admin, admin@<tenant domain>; on a single-tenancy cluster the user admin, user-admin@<kernel-domain> — has no password. Hand the account over with a single-use, expiring activation link — mailed to a recovery address, or printed once without one:

kubectl gentian tenants activate-admin demo --recovery-email owner@demo.example
kubectl gentian tenants activate-admin demo # prints the link

It waits out provisioning, so it can run straight after create. Running it again issues a new link, which is also how a locked-out administrator gets back in. The recovery address is kept on the account in Keycloak, never in git.

Check reconciliation:

kubectl get tenant demo -o yaml
kubectl describe tenant demo

A domain of its own​

A tenant is served under the cluster's domain unless it is bound to a domain of its own. Binding one moves everything: its desktop to desktop.<domain>, its admin console to admin.<domain>, every app to <label>.<domain>, and with them its mail and the names its people sign in with.

kubectl gentian tenants domain demo # the domain git declares for it, if any
kubectl gentian tenants domain demo demo.example # bind it
kubectl gentian tenants domain demo --remove # back to its default address

The usage is tenants domain <name> [<domain> | --remove] [--yes]. Binding and removing are for whoever may configure the cluster. Each prints what moves and asks for a word typed out -- the domain to bind, the tenant's name to remove -- which --yes skips in a script. The director refuses a name that is not a hostname, a domain on or under the cluster's own, and one another tenant is bound to; its refusal is printed as it worded it.

Binding is for a cluster with many tenants. On a single-tenancy cluster the one tenant for users is on the cluster's own addresses, and the director refuses to bind any tenant there; it refuses the platform tenant on every cluster. Nothing is changed by a refused bind. --remove is never refused for either reason.

Two things have to be true first, and neither the director nor the command checks them:

  • DNS: *.<domain> resolves to this cluster. The platform publishes records in the DNS zone that holds the cluster's own domain and in the zones the cluster's settings list under certificates.dnsZones (§6c); list the domain's zone there, or make the wildcard record yourself.
  • Certificate: the cluster's DNS-01 issuer can write _acme-challenge records in the domain's zone, that is, its DNS token covers that zone. The tenant gets one wildcard certificate, *.<domain> (multi-tenancy.md).

The operator reports the outcome on the Tenant:

kubectl get tenant demo -o jsonpath='{.status.conditions[?(@.type=="DomainBound")].message}'

What the operator provisions for a tenant's mail is spec.mail.mode (mail.md §2), the cluster administrator's to set as the domain is (PUT /v1/clusters/<c>/tenants/<t>/mail-mode, can_configure):

kubectl gentian tenants mail-mode demo # what git declares, or the cluster's default
kubectl gentian tenants mail-mode demo disabled # one commit: selfhosted, external, transport-only or disabled

Decide it before binding a domain whose mail lives elsewhere: on a cluster with its own mail the operator otherwise publishes MX, SPF, DKIM and DMARC records for that domain, over the ones that are there. disabled publishes nothing.

The user tenant of a single-tenancy cluster needs none of this: it is served on the cluster's own domain (desktop.<kernel-domain>, admin.<kernel-domain>, <label>.<kernel-domain>), and binding it to another domain is refused, as said above: it would move the tenant off the cluster's addresses, and it could then hold no website at the cluster's main address.

4. Uninstall a Tenant​

kubectl gentian tenants retire demo # keeps its data
kubectl gentian tenants retire demo --purge # deletes its data
kubectl gentian tenants retire demo --purge --keep-bundles # deletes its data, keeps its backup bundles

or Retire in the console, which asks the same question. Both ask for the tenant's name typed out (--yes skips that in a script).

Retire removes the tenant's directory from git; Argo CD prunes the Tenant and the operator tears it down under the manifest's deletionPolicy, Retain unless edited, so the realm, databases and files stay. A retire of a tenant git does not describe asks the cluster whether it still runs it: an Argo CD sync that finished after the directory was removed can re-create the Tenant with no Application owning it, and such an orphan is deleted by the operator on that retire (and nothing else is — a Tenant made without Argo CD, or one whose Application exists, is refused with the reason). Purge first commits deletionPolicy: Delete (and the gentianos.io/purge-requested annotation), waits until the live Tenant carries it, then removes the directory; the tenant is listed as purging until then. A purge deletes the tenant's mailboxes too, where the cluster runs its own mail. Not kubectl delete tenant: git is what the cluster reconciles towards, so deleting the object just brings it back. The platform tenant can be neither retired nor purged.

5. Tenant App Store​

Tenant admins install apps from the App Store app or the CLI. On a cluster with no store the CLI is the only way: the cluster shows no catalog of its own.

The App Store app​

A person who may install apps in a tenant has an App Store tile whenever the Cluster claim names a store (catalogue.storeUrl) that is not the App Store app's own address on a cluster, and the cluster's usage statistics report is on (operations.md §6.2). The tile opens the App Store app, which runs on the cluster. It shows the data of the store outside the cluster — apps, descriptions, reviews, versions, prices — and installs through the director and the custodian as the person signed in. The store itself never calls the cluster. See store-contract.md.

Apps that are installed are administered in the admin console's Apps tab: state, access, integrations, privileges, uninstall and purge.

CLI​

kubectl gentian apps list --tenant demo # what the tenant has installed
kubectl gentian apps list --tenant demo --available # what the tenant's catalogues offer
kubectl gentian apps install xwiki-ce --tenant demo # the build the catalog lists, pinned by digest
kubectl gentian apps uninstall xwiki-ce --tenant demo

Catalogs​

A catalog is an https address apps are installed from, or, for profiles that must not be public, an OCI artifact read with the credential of a declared repository. A directory of the cluster's own deployments repository is still read, and deprecated. The cluster's administrator adds one for every tenant or for one tenant; a tenant's own administrator adds an address or an artifact for their tenant where the cluster's administrator delegated that, and never a directory.

kubectl gentian catalogues list # everything the cluster declares
kubectl gentian catalogues list --tenant demo # what demo installs from
kubectl gentian catalogues add acme https://acme.github.io/acme-catalogue # for every tenant
kubectl gentian catalogues add acme https://acme.github.io/acme-catalogue --tenant demo # for demo only
kubectl gentian catalogues add acme --oci ghcr.io/acme/catalogues/acme:1.0.0 --repository acme --tenant demo # an artifact, read with demo's repository acme
kubectl gentian catalogues add ours --oci ghcr.io/ours/catalogue:1 --repository ours # for every tenant, read with the cluster's repository ours
kubectl gentian catalogues remove acme # the cluster's
kubectl gentian catalogues remove acme --tenant demo
kubectl gentian catalogues rename acme acme-apps # the same catalog under another name; what was installed from it follows
kubectl gentian catalogues rename acme acme-apps --tenant demo
kubectl gentian tenants delegate-catalogues demo on # demo's administrators may add their own
kubectl gentian tenants delegate-catalogues demo off # the same switch as: tenants set demo --admins-add-catalogues=true|false
kubectl gentian catalogues residue # what the catalog left on the cluster
kubectl gentian catalogues residue remove OIDCPackCatalog/xwiki-ce-oidc # one object, name typed again
$ kubectl gentian catalogues list
CATALOGUE FOR ADDED BY ADDRESS
gentian every tenant the cluster https://gentian-org.github.io/gentian-apps
acme tenant demo the tenant https://acme.github.io/acme-catalogue
private tenant demo the tenant oci: ghcr.io/acme/catalogues/acme:1.0.0 (repository acme)
Tenants whose administrators may add catalogues of their own: demo.

With --tenant, add and remove are done as the cluster's administrator when the person is one, and otherwise as the tenant's administrator: that is refused unless the tenant is delegated, and removes only what the tenant added. Without --tenant, add and remove change the Cluster claim (spec.catalogue.sources), which is also where a catalog for the whole cluster can be written before the install, as a name and a url, or a name and an oci (repository, artifact). An address must be a public https one; anything else is refused. An --oci artifact is <registry host>/<repository>:<tag> or @sha256:<digest>, on a public registry, inside the address of the --repository named, which is declared for the tenant with --tenant and for the cluster without (custom-catalogs.md §4.4). A catalog is never a directory of the deployments repository, which holds the cluster's state only; --path is refused. custom-catalogs.md says how to build and publish a catalog.

rename is the cluster administrator's, with --tenant as without, and is one commit to everything that carries the name: the source entry, the catalogue: of every app entry and add-on pin installed from it (every tenant's, for a catalog of the cluster), and the origin of every profile materialized from it. No build moves, since no digest changes; the next install from the new name is the same profile again. Refused when the new name is taken — by the cluster, by that tenant, or for a catalog of the cluster by any tenant — and when the old one is not a catalog of that scope. Removing a catalog and adding it under another name is not the same thing: the profiles' origin would keep the old name, and an install from the new one would be refused as another origin's.

Nothing a profile's bundle brought is removed automatically: a piece a newer build drops stays, and so does a profile after its last uninstall. residue lists what is left, and residue remove deletes one object of the list — never anything a bundle on the cluster still brings.

$ kubectl gentian catalogues residue
KIND/NAME NAMESPACE PROFILE WHY CREATED IN EFFECT
ComponentProfile/acme-notes - acme-notes unused profile 2026-09-30 -
Composition/app-odoo - - unowned 2026-07-02 -
ConfigMap/element-ce.portal-bridge-sso kernel-provisioning element-ce dropped 2026-10-07 -
OIDCPackCatalog/gentian-element-oidc - element-ce dropped 2026-07-02 contested
OIDCPackCatalog/xwiki-ce-oidc - xwiki-ce orphaned 2026-08-14 no

ComponentProfile/acme-notes: unused profile: no tenant has it installed or switched on as an add-on, and no tenant retains data for it; its bundle brings Composition app-acme-notes, which stay
Composition/app-odoo: not owned by any bundle: it names no profile, and no bundle on this cluster brings it
ConfigMap/element-ce.portal-bridge-sso: it carries the label gentianos.io/profile-name: element-ce, and the bundle now materialised for element-ce does not bring it
OIDCPackCatalog/gentian-element-oidc: it carries the label gentianos.io/profile-name: element-ce, and the bundle now materialised for element-ce does not bring it; another catalog carries the same label, so these clients are refused until one is removed: element; read by the Composition of element-ce
OIDCPackCatalog/xwiki-ce-oidc: it carries the label gentianos.io/profile-name: xwiki-ce, and no profile xwiki-ce is on this cluster

Nothing here is removed automatically. To remove one:
kubectl gentian catalogues residue remove <kind>/<name>

$ kubectl gentian catalogues residue remove OIDCPackCatalog/xwiki-ce-oidc
Removing the OIDCPackCatalog xwiki-ce-oidc deletes it from the cluster. Nothing puts it back but
installing a build that brings it.
Type xwiki-ce-oidc to confirm: xwiki-ce-oidc
Deleted the OIDCPackCatalog xwiki-ce-oidc.
the OIDCPackCatalog xwiki-ce-oidc was deleted, asked for by ada@example.com

IN EFFECT says whether a leftover OIDC pack catalog is still read when a client is configured. The list is for whoever may audit the cluster; removing is for whoever may configure it. --yes takes the place of typing the name, and --namespace names the namespace of a ConfigMap or a customization record, which is the catalog's and no other. An unused profile is removed from the deployments repository first and deleted from the cluster once Argo CD has synced that; custom-catalogs.md §6 has the classes and the rules.

Guides:

Show all available kubectl gentian subcommands:

kubectl gentian --help

Private registries​

A catalog is public; the charts and images it names need not be. A tenant declares the registry they are in, and the token is set afterwards in the Admin Console of the tenant, under Credentials, as the name the command prints. A chart whose address lies inside the declared one is pulled with it.

kubectl gentian repositories declare acme --tenant demo --url oci://ghcr.io/acme # then: Credentials, repository-acme
kubectl gentian repositories remove acme --tenant demo

6. Install and Uninstall Apps​

Apps are installed by the director adding them to the tenant's manifest in git, as the person who asked; the operator reconciles them. The tenant's administrator usually does this from the App Store app; the CLI does the same:

kubectl gentian apps list --tenant demo # what the tenant has installed
kubectl gentian apps list --tenant demo --available # what the tenant's catalogues offer
kubectl gentian apps install xwiki-ce --tenant demo # the build the catalog lists
kubectl gentian apps install xwiki-ce --tenant demo --from in-house # when several catalogues serve the name
kubectl gentian apps install xwiki-ce --tenant demo --digest sha256:<64 hex> # a build you name yourself
kubectl gentian apps install xwiki-ce --tenant demo --for-everyone # and grants it to every member
kubectl gentian apps uninstall xwiki-ce --tenant demo # removes the app, keeps its data
kubectl gentian apps uninstall xwiki-ce --tenant demo --purge # removes the app, then destroys its data
kubectl gentian apps outdated [--tenant demo] # which installs are behind their catalog, and why they stay
kubectl gentian apps move-outdated [--tenant demo] [--app xwiki-ce] # move them to the newest build, retry rolled-back moves (cluster administrator)
kubectl gentian apps update-mode xwiki-ce --tenant demo --mode tenant # who moves this app: latest | manual | tenant | default

The command line installs a pinned build. An app is installed from one of the catalogs the tenant sees — the cluster's and the tenant's own (kubectl gentian catalogues list --tenant demo) — and install sends the director the entry's coordinate and digest. Nothing is on a cluster ahead of an install: the director fetches that one profile, checks it against the digest and commits it.

$ kubectl gentian apps list --tenant demo --available
SOURCE APP VERSION EDITION DIGEST INSTALLED
gentian nextcloud-base-ce 31.0.4 ce sha256:2f1c0d9a77b3 yes
gentian xwiki-ce 16.4.0 ce sha256:9b41e6c05d12 -
in-house xwiki-ce 16.5.0 ie sha256:c07a1be4403f -

$ kubectl gentian apps install xwiki-ce --tenant demo --from in-house
Pinned in-house/xwiki-ce version 16.5.0 sha256:c07a1be4403f
Installing xwiki-ce into demo: committed (4e1f9a2c); the platform installs it.

INSTALLED says yes for the build the tenant has, other build when the tenant has the app at another digest, and unpinned when it was installed with none. The listing is the director's reading of each source's index, the ce, me and ie entries only.

One source serves the nameit is used
Several do--from <source> says which; without it nothing is installed
None doesnothing is installed, and the sources are listed. With both --from and --digest an entry the listing does not show can still be asked for
--digestthe build to install instead of the one the listing states. The director fetches the bundle from the source and installs nothing unless it hashes to the digest sent
The tenant has no catalognothing can be installed by command; the cluster's administrator adds one (kubectl gentian catalogues add)
The name is taken by a profile from another catalognothing is installed, and the refusal says who has to rename (custom-catalogs.md §7)
An entry of the profile would answer on a name the platform keepsnothing is installed, and the refusal lists the names. In every tenant: desktop, admin, store, console, platform, id, auth, login, signin, sign-in, sso, account, accounts. For the user tenant of a single-tenancy cluster also argocd, corp, headlamp, imap, llm, mail, mail-egress, www

The director verifies the bundle against the digest before it commits, and the operator verifies it again at rollout (store-contract.md §3, §4). Installing an app the tenant already has at another build moves it: who may is the app's update mode's to say, and the move exports first, verifies after and rolls back by itself (custom-catalogs.md §6). Installing the build the app is already pinned to changes nothing, unless the move to it was rolled back or did not start: then the director asks for the move once more, under the same rights as a move, and says why the last one ended (retrying the move that was rolled back: …). apps outdated prints the command for every such app, and apps move-outdated retries them all, an app the catalogs leave to the store too. Each retry exports afresh. The platform never retries a move by itself. The PLATFORM column of --available is the build's floor, spec.requires.platform; one marked (not met) needs a newer platform than this cluster runs.

Uninstalling and purging are two different acts, and the CLI says which one it did.

Uninstalling removes the app from the tenant: its workloads and its sign-in client go, and everything it stored stays — its database, its object storage, its cache user, its files, its stored credentials, and the access group with everybody who is in it. The app is then retained: uninstalled, data retained. Installing it again in the same tenant finds all of it. A backup taken after the uninstall goes on holding that data, as an uninstalled app's; a backup asked for some apps only names the ones it left out (status.notIncluded).

Purging destroys what uninstalling kept, and cannot be undone. It is refused while the app is still the tenant's. --purge therefore does both, in order: it uninstalls the app (or finds it already uninstalled), waits until the app is gone from the cluster, and then purges it. "Gone" is the platform's own answer — the tenant no longer names the app, its Component is gone, and Helm has finished uninstalling its release. The wait is bounded by GENTIAN_UNINSTALL_WAIT seconds (default 900); when it runs out the command says so, nothing has been purged, and the same command can be run again. What every act does with every kind of data is one table in data-lifecycle.md.

A purge is refused, with nothing destroyed, when the app's profile is not on the cluster — what the app owns cannot be determined without it; installing the app again from a catalog source puts the profile back — and when the vault, the identity provider or the database it will need does not answer.

A release that is already out of the tenant and only waits for its provider to uninstall it -- a provider still waiting out an upgrade of a broken release gets there minutes later -- does not hold the purge back: the purge destroys the data, waits for the release to go, deletes the app's volume claims once more (a rollback or a kept claim puts one back), and answers only when nothing of the app is left.

The purge itself is one request that is answered when it is over. If a step fails, the command ends with which step failed, what had already been destroyed and what was not attempted; nothing is rolled back, every step is safe to repeat, and running the command again continues with what is left. What each act keeps and destroys, kind by kind, is in store-contract.md §8.

--for-everyone writes defaultGrant: true on the app's entry in the same commit. Every member of the tenant then has the app by default: the people who are members when the app's group first exists are added to it once, and somebody invited later has it preselected. It needs the right to grant in the tenant as well as to install. Without the flag, access is given per person (store-contract.md §3).

Inspect app reconciliation:

kubectl get component -A | grep xwiki # Ready when it runs
kubectl get pods -n tenant-demo | grep xwiki

A cluster's stage (dev, staging, prod) is fixed at bootstrap via GENTIAN_DEPLOYMENTS_STAGE in install.env (see deployment.md §1) — apps/tenants commands don't take a --env/--stage flag; they always target the one cluster selected by GENTIAN_DEPLOYMENTS_CLUSTER_ID.

Groups, and people of another tenant signing in​

A tenant's own groups are declared in git, made by the platform and filled in the Admin Console. People of another tenant of the cluster may sign in to a tenant with their account there, given some of its groups (members, or its own); see identity-brokering.md.

kubectl gentian tenants groups customers website-editors store-editors
kubectl gentian tenants sign-in customers --from team --groups website-editors,members
kubectl gentian tenants sign-in customers # what it declares

Credentials a person supplies​

A registry's token, an app's API keys: what a profile asks a person for is set at the custodian, which checks each value before it stores it. A value is read from a file or from standard input, never from the command line, and a file's trailing newline is dropped.

kubectl gentian credentials list --tenant customers # each requirement, its fields, and whether it is set
kubectl gentian credentials set app-customers-aluvian-website-stripe \
--field secret_key=@stripe-restricted.txt --field publishable_key=@stripe-publishable.txt
openssl rand -hex 32 | kubectl gentian credentials set app-customers-aluvian-website-intake --field token=-

What an app asks for beyond the default​

An app whose profile asks for more than a tenant's apps get by default -- a way out to an address (egress), a relaxed pod security (podSecurity), cluster roles (clusterRoles), types of its own (apiExtensions) -- waits until a person answers each request; its component reads waiting for approval of <kind>/<name>. The answer is a commit through the director, with you as the approver and your reason in it. An egress request is the tenant's perimeter approver's to answer (and the cluster's administrator's, in a tenant the cluster operates); a podSecurity request and a cluster role that reads are the cluster's security officer's; an apiExtensions request and a cluster role that writes are break-glass's. The Admin Console's Apps page offers the same as a button, to whoever holds the right the request needs.

kubectl gentian apps approve aluvian-commerce --tenant team # what it waits for
kubectl gentian apps approve aluvian-commerce egress/intake --tenant team \
--reason "reads the website's and the store's order requests over https"

An apiExtensions approval names the definitions it approves: the command sends the digest the cluster reports for the pending request, and a later change to them waits for a new approval.

--expires <RFC 3339> bounds the approval. Removing the entry from the tenant's privileges.yaml in git withdraws it, and the app waits again.

A request the tenant cannot answer itself is the security officer's or break-glass's, so the App Store's install step asks for it (the director's POST /v1/tenants/{t}/apps/{p}/actions/request-privilege, recorded on the app's Component) and the platform console's Approvals page lists what was asked. The same list, from the command line, carries every tenant's pending privileges, each with who answers it (APPROVER: security-officer, break-glass, or perimeter-approver for egress):

kubectl gentian approvals list # every tenant's waiting privileges: who answers, who asked, when, why, and what was declined

A cluster role is approved only for a profile the cluster permits it to. Break-glass changes that list:

kubectl gentian approvals cluster-roles # what is permitted, per profile
kubectl gentian approvals cluster-roles allow backup-scheduler-ee tenant-exports
kubectl gentian approvals cluster-roles disallow backup-scheduler-ee tenant-exports

It is read with cluster#can_approve, can_approve_perimeter or can_configure (the usher's GET /v1/clusters/{c}/approvals, each entry with approvedBy); the answer is apps approve as above, with the request's tenant -- or a no:

kubectl gentian apps decline aluvian-commerce podSecurity/run-as-root --tenant team \
--reason "no root on shared nodes; the rootless build of the image runs without it"

Whoever may approve a request of the kind may decline it, and nobody else. A decline commits nothing: the request stays pending and the app keeps waiting, but the tenant's console and the App Store's install step show who declined, when and why, and offer to ask again. Asking again clears the decline, as does an approval made after it.

What was approved, for every tenant at once, is read with cluster#can_audit from the director's GET /v1/clusters/{c}/privileges -- 200 {"cluster", "privileges": [{"tenant","install","privilege","approver","approvedAt", "reason","expiresAt"}]}, each tenant's privileges.yaml in one list:

kubectl gentian approvals granted # every tenant's approved exceptions: app, privilege, who approved, when, until when

What a tenant has on the internet​

An entry of a profile that the public reaches (surface: perimeter) answers only once the tenant's perimeter approver has approved it (app-customization.md §2.10). Installing the app publishes nothing; the director lists what the tenant's installed apps ask for, and keeps the registry of what was approved:

kubectl gentian exposures requests --tenant demo
kubectl gentian exposures approve cloud share --tenant demo # <app instance> <entry>
kubectl gentian exposures approve cloud share --tenant demo --expires 2027-03-31 --reason "shared calendars"
kubectl gentian exposures list --tenant demo
kubectl gentian exposures withdraw cloud share --tenant demo

requests shows every entry an installed app declares for the internet: its kind, the address it is published at, its paths, who can reach it, and its state. The same list carries the one request that is not a public address: an entry behind sign-in that asks to keep the app's own Authorization header (KIND "Behind sign-in: keeps the app's own Authorization header"). It is approved, reviewed and withdrawn with the same commands, and nothing is published for it. requested is declared and not approved, so nothing is published. approved, review due and expired are as in list, with who approved it and until when. unmatched is an entry in the registry that matches no entry of an installed app; nothing is published for it, and withdraw clears it. Whoever may see the tenant may read the list.

Under SIGN-IN, none: anyone means anyone on the internet reaches the paths without signing in, and no credential is passed to the app. none: the app checks the credential is a public address whose callers' Authorization header is passed to the app (authMode: app). The platform checks nobody at a published address in either case. required is an entry behind sign-in. KIND, and everything approve prints about an entry, are the director's own words.

approve is for whoever may publish in the tenant (can_expose):

  • the tenant's perimeter approver: an administrator of the tenant the cluster's administrator named (platform-roles holders add perimeterApprover \<tenant> \<login> --reason \<why>);
  • the cluster's perimeter approver, a person of the platform tenant named the same way, in every tenant;
  • the cluster's administrator, in every tenant the cluster operates — not in a tenant that withdrew the operator.

Anybody else is answered 403. It reads the entry from the director and prints its kind, the address, the paths, who can reach it and the limit per client address, then asks you to type <app instance>/<entry> (or pass --yes). Without --expires the entry stays until it is withdrawn; a date means the end of that day, UTC. It is reviewed after a year at the latest. Approving an entry that is approved already is its review, and replaces its expiry and reason. For a public address that passes the caller's credential to the app, what it prints includes that the platform does not know or check who calls, and that an app password or token of a person removed from the tenant keeps working until the app itself revokes it. For an entry behind sign-in it says that nothing is put on the internet and what changes instead. The command sends the kind it showed with the approval, and the director refuses it (409) if the app's entry has come to declare another meanwhile. The command sends nothing for an entry the director does not list, and the director itself refuses an app instance that is not installed in the tenant and an entry that asks for nothing an approver decides. It also refuses an approval that asks for the main address for an entry not declared for it, or that does not ask for it for one that is; the command sends what the entry is, and prints the director's refusal as it came.

An entry for the cluster's main address (apex: true in the profile) is approved only with --acknowledge-main-address-rule. The command first prints the director's rule for a website there in full, and the name of whoever acknowledged it is recorded with the entry. --yes does not acknowledge it, and the flag is refused for an entry that is not for the main address. The director approves such an entry only for the one tenant that may hold the main address: the user tenant of a single-tenancy cluster (--tenant user), or the tenant the Cluster claim names for it (mainAddress.tenant, under either mode); the website then answers at https://<kernel-domain>/ and www..

kubectl gentian exposures approve <app instance> <entry> --tenant user --acknowledge-main-address-rule

An alias. A published entry answers under its tenant's domain. Whoever may configure the cluster can give it a second name directly under the cluster's own domain, and take it away again:

kubectl gentian exposures alias <app instance> <entry> --tenant <tenant> --host <name>.<kernel-domain>
kubectl gentian exposures alias <app instance> <entry> --tenant <tenant> --remove

The entry has to be published first, by the tenant's approver; the alias publishes nothing by itself and survives the approver's reviews. The director refuses a name the platform keeps, a name a tenant is called, a name another entry holds, and any alias on a single-tenancy cluster; exposures list shows the alias in its own column. What is refused and why: routing.md §7.

list shows each entry with who published it, when it is reviewed and when it ends: published is in force, review due is in force and past its review date, expired answers no longer and stays as the record. withdraw takes one down, for whoever may publish in the tenant; it is one commit, and the address stops answering once the cluster has taken it in.

6a. Resource Plans and Usage​

A tenant's resource ceiling is chosen from a priced catalog of ResourcePlan objects, never typed as a quantity — see resource-plans.md. The commands below read the operator's answers — the same ones the Admin Console's Resources screen shows — so the rules (the downgrade guard, the cap on what a tenant may choose) are enforced once. Choosing a plan is not a command here: it is a commit the director makes as the person who asked, from the console or from its API with that person's token.

List the catalog as it applies to one tenant:

kubectl gentian resources plans --tenant corp

The tenant's current plan is marked *.

Show a tenant's ceiling and what is committed under it:

kubectl gentian resources show --tenant corp

Move a tenant to a plan — the cluster's administrator's, through the director, as yourself:

kubectl gentian resources set --tenant corp --plan nodes-2 # [--force]: see below

A plan change carries who chose it and which decision allowed it. The director commits clusters/<cluster>/tenants/corp/resource-plan.yaml as the caller; ArgoCD applies it on the next sync, the operator reconciles the tenant-quota ResourceQuota and records the change in the tenant's usage history. It is refused (409) when the plan is smaller than what the tenant is using:

{"error": "plan small is smaller than what the tenant is using (limits.cpu: using 34, plan allows 32)"}

Kubernetes does not evict pods to fit a shrunken quota — it refuses the next create — so shrinking a tenant too far would otherwise appear to work and fail hours later at the next restart. --force overrides the guard, for an administrator who has accepted that cost. A retired plan is refused either way.

What a window resolves to for invoicing:

kubectl gentian resources report --tenant corp \
--from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z
PLAN DAYS FROM TO SKU
base 17.05 2026-01-01T00:00:00 2026-01-18T01:12:00 sku-1node
nodes-2 13.95 2026-01-18T01:12:00 2026-02-01T00:00:00 sku-2node

Every tenant's intervals over the same window, for invoicing a cluster's tenants together:

kubectl gentian resources report --all --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z

The cluster's plans, and changing them (resource-plans.md §2.4):

kubectl gentian resources catalog
kubectl gentian resources catalog set nodes-3 --file nodes-3.json
kubectl gentian resources catalog remove nodes-3

Where a tenant orders another plan — the notice its Admin Console shows in place of a choice (resource-plans.md §5):

echo '{"text":{"en":"Ask us for more capacity"},"email":"sales@example.com"}' > notice.json
kubectl gentian resources ordering set --file notice.json
kubectl gentian resources ordering # what is set
kubectl gentian resources ordering clear

plans, show and report are read from the usher; set, catalog and ordering go to the director, each through a port-forward the plugin establishes and tears down per invocation.

Live consumption (as opposed to what is committed) needs metrics-server, which the install brings with step A-07-metrics-server:

./install.sh --only A-07

6b. Models​

The models the cluster's model gateway offers are the Cluster claim's (spec.llm), and the cluster's administrator changes them: on the Models tab of the admin console, or here. Both ask the director, which commits the claim. llms.md §5 has the rules.

kubectl gentian models list # each model, and what the claim says of it
kubectl gentian models show > models.json # the settings: enabled, gpuAcceleration, console, instances, providers
kubectl gentian models set -f models.json # make the claim declare exactly these
$ kubectl gentian models list
MODEL FROM STATE NOTE
qwen-qwen2.5-7b-instruct instance qwen declared served by vllm-qwen-inference
infomaniak/gemma-4-31b provider infomaniak declared token: credential llm-provider-infomaniak, property infomaniak_api_key
Model serving is on, GPU serving on.

set sends the whole of the settings: a model the file does not name is removed from the gateway. The director refuses a provider whose apiBase is not a public https address (no port but 443, no credentials, nothing inside the cluster or a private network), and one whose apiKeyProperty is not its own: <name>_api_key, with - as _. console.enabled serves the gateway's own console at llm.<kernel domain>, a public address; left out of the file, it stays as it is.

A provider's token is never in the file; it is entered in the admin console under Credentials, as llm-provider-<name>, which the cluster declares for every provider on the claim once it has applied the commit. Whether it has been supplied is shown on the console's Models tab, not here. A model the cluster serves itself names its instance; whether it has finished loading the model is not shown.

6c. Cluster settings​

The Cluster claim's scalar settings — tenancyMode, mainAddress.tenant, mail.serviceMode, the metrics and certificate switches — are changed the same way: the director commits the claim, and the cluster takes the change once Argo CD has synced. The same as editing the claim in the deployments checkout and running ./install.sh, without the checkout.

kubectl gentian settings show # the claim's settings, as JSON
kubectl gentian settings set tenancyMode=multi mainAddress.tenant=customers # one commit
kubectl gentian settings set certificates.dnsZones=gentian.org # a list: comma-separated
kubectl gentian settings set "certificates.dnsRecords=docs.gentian.org CNAME gentian-org.github.io" # quoted: an item holds spaces
kubectl gentian settings set logForwarding.destination.endpoint=https://evidence.example.org/ingest # empty removes the forwarder

The director holds the list of what may be set and refuses anything else, and refuses tenancyMode=single on a cluster carrying more than the one user tenant a single-tenancy cluster admits. A change of tenancyMode moves the tenants' addresses (§3); nothing else here restarts anything.

Two settings are the installer's rather than the operator's: certificates.dnsZones, the zones external-dns may write besides the kernel domain's, for tenants served on a domain of their own (§3), and certificates.dnsRecords, records with nothing on the cluster behind them, such as a CNAME to a site hosted elsewhere. A record is <name> <A|CNAME> <target>, published unproxied, and its name lies in a zone external-dns may write; the installer refuses one that does not. Removing an entry removes the record. The commit lands like any other, and the cluster takes it when the step that installs external-dns runs again, which the command says: ./install.sh --only B-01 on the install host. The DNS credential has to cover every zone listed.

external-dns creates nothing at a name that already holds a record it does not own, and says so only at debug level. A record declared here for a name made by hand stays unpublished until the hand-made one is removed.

7. Administrator Accounts​

No administrator is given a password. The platform admin (admin@<kernel-domain>, who signs in at https://platform.<kernel-domain>/) and every tenant admin — on a single-tenancy cluster, the one user admin, user-admin@<kernel-domain> — are created without one and handed over through a single-use, expiring link that sets it — and enrols a second factor unless that was switched off — mailed to a recovery address or shown once to whoever issued it. There is nothing to retrieve.

A new link, for a lost password or a link that expired:

kubectl gentian tenants activate-admin <tenant> [--recovery-email <address>]
./install.sh --activate-admin # the platform admin

The first asks the registrar, which changes nothing about a member of a group that holds a platform role or a person of the platform tenant who holds one by name, and answers 403 for the platform admin's account. That account's link comes from the install host, with the installer's own credential — which is also the way in when nobody can sign in.

Cluster roles held by name​

admin and breakGlass are held through a group of the kernel realm (spec.platformRoles). Every other cluster role is held by a person the cluster's administrator names on the Cluster claim, who keeps their own account: an entry of spec.platformAccess.roleHolders, with a reason and an optional expiry (iam.md §1.1c). The role is held only with a token of that person's realm, and used from their tenant's console.

RoleWho may be namedScope
securityOfficer -- approving what escapes the default posture: a pod-security waiver, a cluster role, trustTier: platform (cluster#can_approve)a person of the platform tenant, or an administrator of another tenantcluster
auditor -- reading across the cluster (cluster#can_audit)the samecluster
serviceAdmin -- the claim's system section (can_operate_system)a person of the platform tenantcluster
sharedAppsAdmin -- shared apps (can_install_shared, can_grant_shared)a person of the platform tenantcluster
perimeterApprover -- public addresses and egress beyond the baseline (can_expose, can_approve_privilege)a person of the platform tenant, for every tenant; an administrator of a tenant, for that tenantcluster; tenant:<t>
tenantAuditor -- reading one tenant's audit evidence from the platform console (tenant#can_read_evidence)a person of the platform tenant, for the one tenant --scope namestenant:<t>
kubectl gentian platform-roles candidates # who could hold one: the platform tenant's people, every other tenant's administrators
kubectl gentian platform-roles holders # ROLE SCOPE TENANT LOGIN EMAIL EXPIRES REASON IN EFFECT, as the claim declares
kubectl gentian platform-roles members # who holds admin and breakGlass through their kernel Keycloak group
kubectl gentian platform-roles review [--next <RFC 3339>] # show both, type "reviewed", and the review is one commit
kubectl gentian platform-roles holders add securityOfficer acme it@acme.example --reason "acme's CISO approves waivers" # one commit
kubectl gentian platform-roles holders add perimeterApprover acme it@acme.example --reason "acme publishes its shop" --expires 2027-06-30T00:00:00Z
kubectl gentian platform-roles holders add tenantAuditor platform acme-audit@<kernel-domain> --reason "acme reads its own records" --scope tenant:acme
kubectl gentian platform-roles holders remove securityOfficer acme it@acme.example

add looks the login up among the candidates through the registrar and commits the account's subject, with the scope that follows from role and tenant; --reason is required. Once Argo CD has synced, the operator writes the role into OpenFGA for that account of that realm. An entry whose --expires has passed grants nothing: the operator drops its tuple at that moment, and the director refuses to add one already expired. remove takes the entry away, and the role with it.

The director's routes, all on the Cluster claim:

RouteRelationBodyAnswer
GET /v1/clusters/{c}/platform-roles/holderscan_audit200 {"cluster", "roles": ["auditor","perimeterApprover","securityOfficer","serviceAdmin","sharedAppsAdmin","tenantAuditor"], "roleHolders": [{"role","scope","tenant","subject","username","email","reason","expiresAt"}]}
PUT /v1/clusters/{c}/platform-roles/holders/{role}/{tenant}/{subject}can_configure{"username", "email", "reason", "expiresAt"?, "scope"?} -- scope tenant:<t> required for tenantAuditor202 {"status":"updated","commit"}, 200 {"status":"unchanged"}, 400 without a reason, 422 for a role, tenant or subject that cannot hold one -- a person of another tenant who is not its administrator, or an entry already expired
DELETE /v1/clusters/{c}/platform-roles/holders/{role}/{tenant}/{subject}can_configure202 or 200 unchanged, as above

Who holds a role over the cluster is reviewed on an interval. review shows the group members and the holders, refuses a list the registrar could not read in full, and records the review once you type reviewed: the claim's spec.platformAccess.accessReview keeps who reviewed, when, and when the next one is due (a quarter later unless --next names a date within the year), and the commit lists what was shown. Take away what nobody should hold before you confirm. holders says when a review is due, and so does the director's answer (reviewDue), which is true until the first review.

RouteRelationBodyAnswer
POST /v1/clusters/{c}/actions/review-accesscan_audit{"members": [the registrar's platform-role-members roles], "nextReviewAt"?}202 {"status":"updated","commit"}, 400 without members or with a date not within the coming year

The holders answer carries "accessReview": {"reviewedAt","reviewedBy","nextReviewAt"} (null before the first) and "reviewDue".

The candidates are the registrar's: GET /v1/clusters/{c}/role-candidates, can_configure, answers 200 {"cluster", "complete", "tenants": [{"tenant","displayName","realm","platform","people": [...],"unavailable"}]}.

The members of the admin and breakGlass groups are the registrar's to read, from the kernel realm, and Keycloak's to change: GET /v1/clusters/{c}/platform-role-members, can_audit, answers 200 {"cluster", "complete", "roles": [{"role","group","members": [{"id","username","email",...}], "unavailable"}]}; a group that could not be read has members: null, unavailable: true and makes complete false.

The other way round, a tenant's administrators may let the platform's administrators sign in to their tenant: the switch is the tenant's own, off by default, and it is not the tenant's operated_by consent (that one covers the director, this one the sign-in).

kubectl gentian tenants platform-access acme on # members of gentian:platform:admin may enter acme as its administrators
kubectl gentian tenants platform-access acme off

The way in is Enter tenant in the platform console, which is https://desktop.acme.<kernel-domain>/?kc_idp_hint=kernel; the tenant's own sign-in page shows no button for it.

Keycloak master-realm admin:

kubectl get secret keycloak-admin -n kernel-authentication \
-o jsonpath='{.data.password}' | base64 -d && echo

ArgoCD has no local admin: D-03 sets admin.enabled: "false" and deletes argocd-initial-admin-secret. Sign in through Keycloak as a member of gentian:platform:admin. Break glass, when Keycloak itself is down, is kubectl in the gitops namespace: set a new password, switch the account on, and reach the server by port-forward (the edge needs Keycloak too):

kubectl -n kernel-gitops patch secret argocd-secret -p \
"{\"stringData\":{\"admin.password\":\"$(argocd account bcrypt --password '<new>')\",\"admin.passwordMtime\":\"$(date -u +%FT%TZ)\"}}"
kubectl -n kernel-gitops patch configmap argocd-cm --type merge -p '{"data":{"admin.enabled":"true"}}'
kubectl -n kernel-gitops port-forward svc/argocd-server 8080:443
# afterwards: ./install.sh --force --only D-03 switches it off again

8. Key URLs​

Given KERNEL_DOMAIN, the main URLs are:

9. Useful Troubleshooting Commands​

kubectl get events -A --sort-by=.lastTimestamp | tail -n 50
kubectl logs -n kernel-control deploy/gentian-os -f
kubectl get integrationbindings -A
kubectl describe application -n kernel-gitops gentian-os

Reaching OpenBao with the bao CLI​

OpenBao has no public address. From a machine with a kubeconfig for the cluster, forward its port and sign in as the platform admin:

kubectl port-forward -n kernel-secrets svc/openbao 8200:8200 &
export BAO_ADDR=https://localhost:8200 # localhost is one of the certificate's names
export BAO_SKIP_VERIFY=true # the certificate is self-signed; or BAO_CACERT=<ca.crt of the Secret openbao-tls there>
bao login -method=oidc -path=oidc role=cluster-admin # opens a browser; sign in as admin@<kernel-domain>
export BAO_TOKEN="$(bao print token)"

The cluster-admin role admits the platform administrators' group and may read and write secret/gentian-os/kernel/*. After the handover this is the only way in: the installer's own token is revoked by E-03. scripts/recovery.sh --from-vault and the bao kv get examples in §11 and in reference/install.md §4 assume BAO_ADDR and BAO_TOKEN are set this way.

Gateway API edge routing​

# Platform Gateways and routes
kubectl get gatewayclass gentian-envoy
kubectl get gateway -A
kubectl get httproute -A -l app.kubernetes.io/managed-by=gentian-os
kubectl describe gateway authenticated -n kernel-edge # and: perimeter

# Envoy Gateway and its data plane
kubectl get pods -n kernel-edge
kubectl get svc -n kernel-edge

# Tenant edge status
kubectl get tenant -o custom-columns=NAME:.metadata.name,GATEWAY:.status.conditions[?(@.type==\"GatewayReady\")].status,TUNNEL:.status.conditions[?(@.type==\"TunnelIngressReady\")].status

# Envoy policies attached to routes
kubectl get backendtrafficpolicy -n tenant-demo
kubectl get backendtrafficpolicy -n kernel-edge -l app.kubernetes.io/managed-by=gentian-os

On networkMode: tunnel, Gateway.status.conditions[Programmed] may be False (AddressNotAssigned) while listener conditions are Programmed=True and traffic reaches Envoy via Cloudflare tunnel. Check TunnelIngressReady on the Tenant and external curl to the public hostname.

OIDC pack catalog​

An app whose sign-in client is configured from an OIDC pack depends on the cluster-scoped OIDCPackCatalog its profile bundle brings (a bundle from a catalog added for the whole cluster; one added for a single tenant brings profiles alone). Check that the pack is on the cluster before debugging pack Jobs or missing client scopes:

kubectl get oidcpackcatalog -l gentianos.io/profile-name=demo-app -o yaml

List pack keys and confirm a profile's clientId is present:

kubectl get oidcpackcatalog -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.packs}{"\n"}{end}'

An app whose client the operator renders with its release (Odoo, for one) needs no pack entry. See app-profile-guide.md §5.

10. Kernel Mail Stack (Dovecot + Postfix)​

Two knobs: spec.mail.serviceMode on the Cluster claim (clusters/<cluster>/kernel/claims/cluster.yaml in the deployments repository) says whether the cluster runs its own mail servers (system) or relays through a provider (external, the default). Tenant.spec.mail.mode controls what the operator provisions per tenant. See mail.md §0.

Tunnel clusters: mail.serviceMode: system is refused with networkMode: tunnel, at pre-flight. A tunnel carries HTTP and HTTPS only; use external there.

What runs where under system​

No installer step deploys mail. The gentian-mail ApplicationSet exists only while the claim says system, and syncs three charts:

ApplicationNamespaceWhat it is
postfix-<stage>system-mailthe mail server and DKIM signer; no load balancer
dovecot-<stage>system-mailthe mailbox store; no load balancer
mail-edge-<stage>system-mail-dmzthe mail edge: an HAProxy that takes ports 25, 587 and 993 on one load balancer and passes each connection on. It holds no mail, no user, no certificate and no key; TLS ends at the servers

The proxy hands the sender's address to Postfix and Dovecot in a PROXY-protocol header, on ports of their own (10025, 10587, 10993) that admit the proxy alone. The load balancer in front of it has to hand the sender's address on as well: by forwarding packets (externalTrafficPolicy: Local, which the Service sets), or, where it opens its own connection, with the PROXY protocol switched on at both ends (loadBalancer.proxyProtocol in kernel/services/mail-edge, mail.md §9b).

Switch a cluster to its own mail​

# clusters/<cluster>/kernel/claims/cluster.yaml
spec:
networkMode: static-ip
mail:
serviceMode: system

Edit the claim in the deployments checkout and run ./install.sh: step 0 commits the edit signed and pushes it, and the cluster reconciles.

Check mail component health​

kubectl get application -n kernel-gitops | grep -E 'postfix|dovecot|mail-edge'
kubectl get pods -n system-mail
kubectl get pods,svc -n system-mail-dmz # the Service has the mail address under EXTERNAL-IP
kubectl logs -n system-mail-dmz -l app.kubernetes.io/name=mail-edge --tail=20
kubectl get dnsendpoint -n system-mail-dmz # mail.<domain>, imap.<domain>, MX, SPF, DKIM, DMARC
kubectl get tenant <tenant> -o jsonpath='{.status.conditions[?(@.type=="MailReady")]}'

With a DNS provider the operator publishes mail.<kernel-domain> and imap.<kernel-domain> from the load balancer's address, and the MX, SPF, DKIM and DMARC records; it publishes no address record before the load balancer has one. The PTR of the address mail leaves from is set at the provider (mail.md §10).

Switch back to external relay mode​

# clusters/<cluster>/kernel/claims/cluster.yaml
spec:
mail:
serviceMode: external
host: smtp.example.com
port: 587
ssl: false
starttls: true
# username/password go through the custodian, not the claim —
# see the "smtp-relay" CredentialRequirement (credentials.yaml)

Run ./install.sh as above, then supply the relay's credentials in the admin console's Credentials tab.

11. Tenant Backup (Export)​

Tenant admins take exports from the Admin Console → Export tab; the guide written for them is tenant-backup.md, and §1 of it is the key-setup procedure they will forward to you. An export captures the workspace's databases, buckets, volumes and Keycloak realm into one encrypted bundle, pausing each app in turn so its data is internally consistent.

Encryption — choose before you need it​

ChoiceWho can decryptUse for
Platform key (default)whoever holds the identity for backupRecipients — in the recovery kit, and in OpenBao when the cluster escrows itroutine and scheduled backups; the only mode support can help restore
My passphraseonly the passphrase holdera bundle the platform must not be able to read

Both produce ordinary age files. A lost passphrase means a lost bundle — there is no recovery path, by design.

A cluster needs a key pair before the default mode works, and ./install.sh --export-recovery-kit makes one on a cluster that has none: the public half goes to OpenBao at gentian-os/kernel/backup/recipients, the private half into the kit and, unless spec.backup.escrowIdentity is false, to gentian-os/kernel/backup/identity as well. It never regenerates — a second pair would orphan every bundle written to the first.

Pin the public half in git so the operator prefers a value the cluster cannot rewrite:

# gentian-deployments/clusters/<cluster>/kernel/values.yaml
backupRecipients:
- age1... # public key; safe to commit

With no recipient anywhere, an export fails rather than writing a tenant's data unencrypted.

From the CLI​

kubectl apply -f - <<'EOF'
apiVersion: gentianos.io/v1alpha1
kind: TenantExport
metadata:
name: nightly-2026-08-18
namespace: tenant-demo
spec: {} # every installed app; platform-key encryption
EOF

kubectl get tenantexports -n tenant-demo
kubectl describe tenantexport nightly-2026-08-18 -n tenant-demo

To one of the tenant's own stores (§12a), whose keys are set:

spec:
destination:
mode: source
source: demo.offsite # <tenant>.<name>; another tenant's or the cluster's is refused

A store may declare the age keys its bundles are read by (recipients on the store); an export to it that names none of its own is encrypted to them in place of the cluster's.

Watching one​

# Phase, bundle location, and which apps are paused right now
kubectl get tenantexport nightly-2026-08-18 -n tenant-demo \
-o custom-columns=PHASE:.status.phase,BUNDLE:.status.bundle.prefix,PAUSED:.status.quiesced

# The capture Jobs themselves. Each runs where its credential is: a database
# dump beside its database, the realm export in the identity namespace, a
# bucket and the manifest beside the object store, a volume archive in the
# tenant's namespace (docs/concepts/operations.md §9.4a).
for ns in system-postgresql system-mariadb system-s3 kernel-authentication tenant-demo; do
kubectl get jobs -n "$ns" -l gentianos.io/tenant-export=nightly-2026-08-18,gentianos.io/tenant=demo
done

An app listed in .status.quiesced is offline right now. That is normal mid-export and worth investigating if it persists: the operator resumes an app as soon as its capture finishes, and resumes anything it finds paused on every reconcile, including after a restart.

Deleting one​

Tenant admins delete exports from the Export tab; this is the same operation:

kubectl delete tenantexport nightly-2026-08-18 -n tenant-demo

A finalizer holds the resource until a cleanup Job has removed the bundle's objects from the bucket — a deleted backup is gone from storage, not merely from this list. Deleting a Running export is the abort mechanism: paused apps are resumed and outstanding capture Jobs are stopped first. If the cleanup Job itself fails, the export is released anyway and the operator log names the bucket and prefix left behind.

Retiring a tenant keeps its bundles; deleting it destroys them, unless it was told to keep them. These resources live in the tenant's namespace, so a teardown removes them. The operator recognizes a teardown and does not delete a bundle because its TenantExport went. What happens to the bundles is then the deletion policy's to say: with Retain the backup bucket stays; with Delete the bucket is destroyed with the tenant's other stores, unless the purge was asked for with keepBundles (or the bundles are on external storage, which the platform never deletes). A bundle you want to outlive its tenant has to be downloaded, or kept that way (data-lifecycle.md §2).

Bundles a retired tenant left behind stay until they are removed by hand. On an erasure request, or simply to reclaim the space, remove them deliberately once you are sure:

# What a deleted tenant left behind
rclone lsf gentian:demo-gentian-backup/

# Removing it is not reversible and no backup remains afterwards
rclone purge gentian:demo-gentian-backup

gentian: is an rclone remote for the platform's own object store, set up from its administrator's Secret and a port-forward to its Service:

kubectl port-forward -n system-s3 svc/s3 19000:9000 &
export RCLONE_CONFIG_GENTIAN_TYPE=s3 RCLONE_CONFIG_GENTIAN_PROVIDER=Other \
RCLONE_CONFIG_GENTIAN_ENDPOINT=http://localhost:19000 RCLONE_S3_NO_CHECK_BUCKET=true
export RCLONE_CONFIG_GENTIAN_ACCESS_KEY_ID="$(kubectl get secret s3-admin -n system-s3 -o jsonpath='{.data.accessKey}' | base64 -d)"
export RCLONE_CONFIG_GENTIAN_SECRET_ACCESS_KEY="$(kubectl get secret s3-admin -n system-s3 -o jsonpath='{.data.secretKey}' | base64 -d)"

Reading a bundle​

The bundle is a prefix in the tenant's backup bucket. Every artifact is encrypted; bundle-info.json is deliberately not, and names the command that opens the rest:

rclone cat gentian:demo-gentian-backup/nightly-2026-08-18/bundle-info.json

# Platform key
age -d -i /path/to/identity manifest.json.age > manifest.json
# Your passphrase
age -d manifest.json.age > manifest.json

manifest.json lists what was captured per app, the chart versions at capture time, and the pause window each app saw. It is what a restore goes by. Since format 2 (schemaVersion: 2) each app's stores has one entry per artifact with its kind, the name of what it was captured from and its path in the bundle, and the app carries its digest, databaseEngine and releases; a format 1 manifest names apps and kinds only and still restores (operations.md §9.4). Format 3 (schemaVersion: 3) adds retained on an app that was uninstalled with its data kept, the tenant's mailboxes where the cluster runs its own mail server, and rights: the entries of the rights store that follow from nothing else. A format 2 bundle still restores. Format 4 (schemaVersion: 4) adds, among an app's stores, an entry of kind: secrets at secrets/<app>.json: the app's own secrets, encrypted like every artifact. A format 3 bundle has none and still restores. Format 4 is what an export writes now.

When the bundle is on external storage​

gentian: above is the platform's own object store. A tenant whose policy names an external endpoint writes there instead, with a credential the operator keeps in the kernel namespace rather than the tenant's — so reading such a bundle means a remote for that endpoint first:

ns=system-s3 # not the tenant namespace
sec=backup-destination-<tenant> # from status.bundle.credentialSecret
ak=$(kubectl get secret "$sec" -n "$ns" -o jsonpath='{.data.accessKey}' | base64 -d)
sk=$(kubectl get secret "$sec" -n "$ns" -o jsonpath='{.data.secretKey}' | base64 -d)

export RCLONE_CONFIG_EXT_TYPE=s3 RCLONE_CONFIG_EXT_PROVIDER=Other \
RCLONE_CONFIG_EXT_ENDPOINT=https://sos-ch-dk-2.exo.io RCLONE_CONFIG_EXT_REGION=ch-dk-2 \
RCLONE_CONFIG_EXT_ACCESS_KEY_ID="$ak" RCLONE_CONFIG_EXT_SECRET_ACCESS_KEY="$sk" \
RCLONE_S3_NO_CHECK_BUCKET=true
# a bundle with status.bundle.addressingStyle: virtualHost also needs
# export RCLONE_CONFIG_EXT_FORCE_PATH_STYLE=false
rclone lsf ext:<bucket>/<prefix>/
rclone cat ext:<bucket>/<prefix>/bundle-info.json

kubectl get tenantexport <name> -n tenant-<t> -o jsonpath='{.status.bundle}' names the endpoint, bucket, prefix and credential for any given export, so nothing here has to be guessed.

The identity to decrypt with comes from the recovery kit, from the printed QR, or — on a cluster that escrows it — straight out of OpenBao:

bao kv get -mount=secret -field=identity gentian-os/kernel/backup/identity > id.txt
rclone cat ext:<bucket>/<prefix>/manifest.json.age | age -d -i id.txt

12. Tenant Restore​

A restore replaces live data with what a bundle recorded. Anything written since is gone. It is deliberately awkward to trigger by accident.

kubectl apply -f - <<'EOF'
apiVersion: gentianos.io/v1alpha1
kind: TenantRestore
metadata:
name: restore-2026-08-18
namespace: tenant-demo
spec:
exportRef: nightly-2026-08-18 # or bundle: {bucket, prefix}
confirmTenant: demo # must equal the tenant, or it refuses
decryption:
identitySecretRef: # platform-key bundle
name: backup-identity
# passphraseSecretRef: # passphrase bundle
# name: my-passphrase
EOF

Where the identity comes from depends on spec.backup.escrowIdentity. With it true (the default) OpenBao holds a copy at gentian-os/kernel/backup/identity, readable by cluster-admin and denied to External Secrets — so a cluster administrator can restore without the kit. With it false the kit is the only copy, and a restore is where you prove you still have it:

kubectl create secret generic backup-identity -n tenant-demo \
--from-file=identity=/path/to/age-identity.txt

Delete that Secret once the restore is done.

spec.intoNewTenant: true says the tenant was made new for this bundle. An import (§12a) sets it; a restore into the tenant the bundle was taken of leaves it out. With it, the data of an app the bundle holds as uninstalled with its data kept is brought with stores made for it; without it such data is put back only where the tenant still holds it, so an app purged since stays purged. The access rights the bundle records as granted, a person's among them, are granted with or without the field only from a bundle this cluster signed and taken on it; from any other they are named in the result, and status.rights.provenance says why (security/design.md §6.4).

From a console, or directly against the director​

A console restores through the director, as the person signed in. The tenant's administrators restore their own tenant; the cluster's administrator any tenant:

POST /v1/tenants/{t}/bundles body: the .gentian bytes -> {bundle}
POST /v1/tenants/{t}/bundles/inspect {backup | bundle | source, decryption} -> manifest
POST /v1/tenants/{t}/actions/restore {backup | bundle | source, decryption, apps?} -> 202 {name, ...}
POST /v1/clusters/{c}/tenants/{t}/bundles/inspect the same, as the cluster's administrator
POST /v1/clusters/{c}/tenants/{t}/actions/restore the same, as the cluster's administrator
GET /v1/tenants/{t}/restores/{name} the status (the usher)

The tenant routes ask can_set_policy on the tenant, the cluster routes can_configure on the cluster; the cluster's administrator uploads at POST /v1/clusters/{c}/bundles. backup is the name of one of the tenant's backups, wherever its policy wrote it; bundle is what an upload answered, and nothing else: a reference with an endpoint or a credential of its own is refused. source is a bundle where the tenant's backups are written that the cluster may no longer list as a backup, {"name": "storage"|"destination", "prefix": "..."}, as GET /v1/tenants/{t}/bundle-sources/{name}/bundles lists them (§12a). The director opens the manifest with the key first and refuses a bundle of another tenant with 422 (bringing one here is an import, §12a). The key goes to the operator in the request and into a Secret the restore owns; it is never committed or logged. The platform's version line, which a console compares a bundle's with, is in GET /v1/tenants/{t}/me (platformVersionLine) for whoever may enter the tenant.

What it does, in order​

  1. Preflight — confirmation matches, no export or restore already running, bundle exists and is Ready, decryption key present. Nothing is touched until all of these pass.
  2. Plan — the operator opens the bundle's manifest with the key and decides, against the tenant as it stands, what it puts back. A bundle of another tenant is refused (OtherTenant): a restore does not rename; rename the bundle with a tool that rewrites it first. Only apps the manifest lists are touched. An app is restored whole or not at all; one that is not installed, runs an older build than wrote the data (unless spec.skipVersionCheck), or has a store the bundle's does not match, is named in status.notRestored with the reason. With spec.apps, an app named that cannot be restored refuses the whole restore instead. Still nothing has been touched.
  3. Per app — pause, load the database (on PostgreSQL also every database the app's role owned), make the bucket with its user and policy and load it, unpack volumes, run the profile's restore.post hooks, run restore.verify, resume. One app at a time.
  4. Tenant-wide, last — Keycloak realm and the desktop's database. Last deliberately: restoring identity earlier would let members sign in to half-restored data.
kubectl get tenantrestore restore-2026-08-18 -n tenant-demo -o jsonpath='{.status}' | jq \
'{phase, complete, notRestored, nameDerivation, bundleSchemaVersion, notes}'

phase: Ready with complete: false is a restore that left something out; notRestored says what and why. notes is what no restore brings back (data-lifecycle.md §5).

After a restore, members cannot sign in​

Keycloak's export carries no password hashes, so accounts come back without credentials. status.passwordResetRequired says so. Send members through a reset from Admin Console → Members.

After a restore, credentials somebody entered are missing​

Of the stored credentials a bundle holds each app's own secrets and no other. The platform's other credentials were made for the tenant when it was provisioned and are unchanged; anything a person typed in — a repository's password, an SMTP relay's, an API key — is not in the bundle and has to be entered again. Data an app sealed with a secret the platform generated for it reads wherever the restore set that secret from the bundle; status.notes says for each app whether it did. A bundle older than format 4 holds no secrets, and such data then reads only where the app's secrets are the ones it was written with.

12a. Tenant Import​

Create plus Restore, from a bundle, on this cluster or another one (data-lifecycle.md §4). The bundle is the .gentian file Download produced, or a bucket prefix the cluster can reach.

kubectl gentian tenants import acme-nightly.gentian --identity-file acme-key.txt
kubectl gentian tenants import acme-nightly.gentian --passphrase # prompts
kubectl gentian tenants import --bucket acme-gentian-backup --prefix nightly-20261001 --identity-file k.txt
kubectl gentian tenants import --bucket b --prefix p --endpoint https://s3.example.org --addressing-style virtualHost --identity-file k.txt
kubectl gentian tenants import acme-nightly.gentian --identity-file acme-key.txt --drop-custom-domain

What happens: the file is uploaded to the cluster's own storage; the director opens the manifest with the key and refuses a name the cluster already has. It then makes sure of the definition of every app the bundle's tenant lists: on the cluster already at the build the bundle records, or fetched at that build from one of the cluster's catalogs and committed. If one cannot be had, the import is refused with 422, names each, and has changed nothing. A platform component of an earlier release that this one no longer has (app-store-me) is left out instead: the command prints it as skipped, with the reason, and its data is not restored. It commits the tenant from the manifest's settings (deletionPolicy Retain, whatever the bundle said), waits until the operator reports the tenant Ready with its apps up, and starts a TenantRestore. An app that asks for a credential only a person can provide waits for it, since no bundle carries one; the status names the app and the credential, which is set under Credentials in the tenant's Admin Console. The wait ends after two hours. The command follows the import and prints each phase: declared, provisioning, restoring, ready.

The tenant is made under the name the bundle carries; an import does not rename, and a request that names another is refused with 422. A bundle meant for another name is rewritten into it, by a tool that does so, first. Its realm, database prefix and bucket prefix follow from that name, whatever the bundle states. A tenant whose realm or prefixes are already another tenant's is refused with 409, on import and on create.

The import is recorded in git beside the tenant (import.json) until it has finished, so it survives a restart of the director. The record holds no key. If the director restarts before the restore has started, the status says awaiting-key: run the same command again, with the same bundle and key, and the import goes on. If the restore was already running, nothing is needed.

Directly against the director:

POST /v1/clusters/{c}/bundles body: the .gentian bytes -> {bundle}
POST /v1/clusters/{c}/bundles/uploads an empty upload -> {bundle}
POST /v1/clusters/{c}/bundles?into={prefix} body: the .gentian bytes, or those not yet there -> {bundle}
GET /v1/clusters/{c}/bundles/{prefix}/files what an upload holds -> {files: {name: size}, complete, refused?, refusedAt?}
POST /v1/clusters/{c}/bundles/inspect {bundle, decryption} -> manifest
POST /v1/clusters/{c}/tenants/import {bundle, decryption, keepCustomDomain?} -> 202 status
GET /v1/clusters/{c}/tenants/{t}/import the status

A large bundle is uploaded in steps, so that a transfer that stops part-way is continued rather than begun again: start an upload, fill it with ?into=, and after a stop send the files files does not list into the same upload. What arrived stays until the upload is complete or swept; nothing restores from an upload not yet complete. A fill the platform refuses (the import storage full, the tenant's limit, not a bundle) answers with the reason, and the reason is also kept on the upload as refused until a fill completes it, for a sender whose connection broke before the answer arrived. The tenant routes take the same three, under /v1/tenants/{t}/bundles.

A bundle in storage outside the cluster is read from a bundle source the cluster's administrator declares, never with an address and keys typed at the import. The address is a commit (BundleSource, in clusters/<c>/kernel/claims/bundle-source-<name>.yaml); the keys are set at the custodian as bundle-source-<name> (Admin Console, Credentials), stored at gentian-os/kernel/backup/sources/<name>, and read only by the platform. Reading needs only s3:ListBucket and s3:GetObject. A tenant's own sources are where its backups go: storage (its bucket here), destination (its backup policy's, when it names one), and the stores its administrator declares. A store is a BundleSource named <tenant>.<name> with spec.tenant, in clusters/<c>/tenants/<t>/bundle-source-<name>.yaml; its keys are set at the custodian as bundle-source-<tenant>.<name>, stored at gentian-os/tenants/<t>/backup/sources/<name>, and need writing and deleting as well as reading. An export writes to one with destination: {mode: source, source: <tenant>.<name>}.

GET /v1/clusters/{c}/bundle-sources declared, with whether the keys are there
PUT /v1/clusters/{c}/bundle-sources/{name} {endpoint, bucket, prefix?, region?, addressingStyle?, description?}
DELETE /v1/clusters/{c}/bundle-sources/{name} removed from git and from the cluster
GET /v1/clusters/{c}/bundle-sources/{name}/bundles [{prefix, info}]: each bundle's plaintext header
POST /v1/clusters/{c}/bundle-sources/{name}/actions/fetch {prefix} -> {name, bundle}: copied into gentian-imports
GET /v1/clusters/{c}/tenants/{t}/bundle-sources[/{name}/bundles] a tenant's own (also POST .../{name}/actions/fetch)
GET /v1/clusters/{c}/bundle-fetches/{f} running | ready | failed
GET /v1/clusters/{c}/bundles/{prefix}/download a fetched or uploaded bundle, as one file
GET /v1/tenants/{t}/bundle-sources[/{name}/bundles] the tenant's own, under can_set_policy
PUT /v1/tenants/{t}/bundle-sources/{name} a store of the tenant's own, as <t>.<name>; the cluster's body, and recipients?
DELETE /v1/tenants/{t}/bundle-sources/{name} removed from git, and so from the cluster
POST /v1/tenants/{t}/bundle-sources/{name}/actions/fetch {prefix}: the tenant's administrator's fetch
GET /v1/tenants/{t}/bundle-fetches/{f} a fetch made on the tenant's route
GET /v1/tenants/{t}/bundles/{prefix}/download its copy, or the tenant's upload

A tenant's administrator fetches from the tenant's own sources alone, only a bundle whose header names the tenant (422 otherwise), and the copy counts towards the tenant's limits in gentian-imports, as its uploads do: one bundle at most 20 GiB, 50 GiB at once (413). Everything there goes once it is restored or imported, or after seven days (data-lifecycle.md §4).

All of the cluster's are can_configure. An import names a bundle at a source instead of a bucket: {"source": {"name": "old", "prefix": "nightly-20261001"}, "decryption": ...}, read where it is. A fetched bundle is in gentian-imports like an upload, for a console that rewrites it before importing it.

decryption is {"passphrase": "..."} or {"identity": "AGE-SECRET-KEY-1..."}. The key is written into a Secret the restore owns and goes with it. Members come back without credentials (no bundle carries them); the status says passwordResetRequired, and carries the restore's complete, notRestored and notes, and outsideGuarantee when the bundle is of an older platform version line, or records none, or the tenant's domain here is another (the command prints it). A bundle of a newer version line is refused. The cluster's own version line is in the answer to GET /v1/clusters/{c}/settings (whoever may audit the cluster): platformVersionLine, with build, the commit the operator's image was built from, when the build recorded one.

A custom domain the bundle's tenant had (customDomain in the manifest inspect answers) is kept by default: the TenantDomain is committed with the tenant, under the rules and the right (can_configure on the cluster) of tenants domain, and the restore starts only once the operator has put the tenant on it, so nothing is moved. A domain this cluster has bound to another tenant is refused with 409, one that cannot be bound here (on the kernel domain, a single-tenancy cluster) with 422; both before anything is written. The status carries customDomain; pointing its DNS at the cluster is yours. keepCustomDomain: false (--drop-custom-domain) makes the tenant at <tenant>.<kernel-domain> instead: a move, as above, after which the domain can be declared again with tenants domain <name> <domain>.

The uploaded file is kept in the cluster's gentian-imports bucket until a restore of it has run to its end, restored or failed, and is then removed. A restore refused before it changed anything leaves it, so the import can be asked for again without uploading twice; an upload that is never restored stays until it is removed by hand (rclone purge gentian:gentian-imports/\<prefix>). App grants are not in a bundle: they are declared in git, and a tenant imported into another cluster has them to set again. An import brings the data of apps that were uninstalled with their data kept, as uninstalled apps, when this cluster has their definitions. It grants the access rights granted in the tenant the bundle was taken of only when the bundle is this cluster's own, of a tenant of the same name; any other bundle's are each named in notes, to be granted again here.

13. Restore Drill​

Run this on a scratch tenant before you need it. An untested backup is a hypothesis. For the procedures themselves — rebuilding a cluster, restoring a tenant with the cluster key, or with a tenant's own — see recovery.md, or run scripts/recovery.sh, which performs them.

# 1. Take a bundle
kubectl apply -f - <<'EOF'
apiVersion: gentianos.io/v1alpha1
kind: TenantExport
metadata: {name: drill-before, namespace: tenant-demo}
spec: {}
EOF
kubectl wait --for=jsonpath='{.status.phase}'=Ready \
tenantexport/drill-before -n tenant-demo --timeout=30m

# 2. Change something you can recognise — a file, a user, a project.

# 3. Put it back
kubectl apply -f - <<'EOF'
apiVersion: gentianos.io/v1alpha1
kind: TenantRestore
metadata: {name: drill-restore, namespace: tenant-demo}
spec:
exportRef: drill-before
confirmTenant: demo
decryption:
identitySecretRef: {name: backup-identity}
EOF
kubectl wait --for=jsonpath='{.status.phase}'=Ready \
tenantrestore/drill-restore -n tenant-demo --timeout=60m

# 4. Check the change is gone, other tenants are untouched, and time it.
kubectl get tenantrestore drill-restore -n tenant-demo \
-o jsonpath='{.status.startedAt} -> {.status.completedAt}{"\n"}'

The measured time is the RTO. Publish it rather than assuming one.

The other recovery: the kit​

A tenant bundle restores a tenant into a working cluster. It cannot rebuild the cluster — the derived credentials every kernel service authenticates with come from the master password and the derivation salt, which live in OpenBao, and a disaster that loses OpenBao's storage loses them. That is what the recovery kit carries, and it is worth proving it round-trips before trusting it:

./install.sh --export-recovery-kit # writes an encrypted kit, mode 0600
make verify-recovery-kit # export, load back, compare byte-exact

verify-recovery-kit needs no cluster. It exports a kit from known values and reads it back in a clean shell, asserting the salt and every credential return unaltered through newlines, quotes and shell metacharacters, and that a tampered key name, a file that is not a kit, and a missing identity are each refused rather than partially applied. A salt that comes back wrong by one byte reproduces a whole cluster of wrong passwords, and the symptom is an admin login that fails for no visible reason.

The kit also carries the cluster's bundle signing key (BUNDLE_SIGNING_SEED), and the export seeds one on a cluster that has none. A cluster rebuilt with --recover from the kit and the same deployments repository keeps the key and its id (spec.clusterUID of the Cluster claim), so the bundles it wrote before still bring back the rights granted in their tenants (security/design.md §6.4).

Storing the kit in the cluster it protects defeats it. It belongs wherever the organization keeps break-glass material, beside the backup identity.

If a drill wedges: an app stuck in .status.quiesced is offline. The operator resumes anything it finds paused on the next reconcile, including after a restart, so deleting the stuck TenantExport/TenantRestore is the recovery — the workload's gentianos.io/pre-export-replicas annotation records what it should be scaled back to.

Both of those depend on the operator still reconciling, so establish that first. Every step above is a reconcile away from working, and a controller that has stopped will not take the delete either: a TenantExport carries a finalizer, and deleting it hangs rather than resuming anything.

# The export controller's own log. Silence since the last "paused app for
# capture", while other controllers keep logging, is a stopped worker.
kubectl -n kernel-control logs deploy/gentian-os | grep tenantexport | tail

# What a stopped worker usually is: a cache that cannot sync, because the
# ServiceAccount may not list the type something read through it.
kubectl -n kernel-control logs deploy/gentian-os | grep -i forbidden

A restart clears the wedge and the resume follows from it — the operator resumes anything it finds paused. It does not clear the cause: if the log names a Forbidden, the missing rule is a marker and a make gen-all away, and the export will wedge again on the next attempt without it.

kubectl -n kernel-control rollout restart deploy/gentian-os