Versioning and updates
How Gentian OS and the apps on it move from one version to the next. The platform knows which release it runs, every app says which platform it needs, the cluster administrator decides how each app moves, a move is one guarded operation that can go back, and one tenant's move never touches another tenant.
1. Three versions
An installed app carries three versions, and each answers one question.
| Version | Names | Answers |
|---|---|---|
| the upstream version | the application itself: Nextcloud 33, Odoo 18 | whether data written by one build can be read by another |
| the profile build | the bundle: its digest, and spec.version, bumped on every change to the bundle | what a tenant runs, what the store and every listing show |
| the platform version line | the OS: 0.5, 0.6, … | whether a build can run on this cluster |
Compatibility with the OS is a property of the profile build, because the profile is what the operator reads. The upstream version has nothing to say about it.
The operator does not know the upstream version: it sits inside the chart.
A restore therefore judges the data by the chart version the bundle's
manifest records for each app against the chart version installed
(buildRule, internal/controller/tenantrestore_plan.go). The same version
is restored; a newer one installed is restored too, because an app upgrades
older data when it starts; an older one, or a version that cannot be
compared, is refused for that app unless the restore sets
spec.skipVersionCheck (operations.md §9.4). An import is
a restore into a new tenant and is judged the same way.
1.1 Numbering
How the platform's chart and a profile's spec.version are numbered between
releases is in deployment.md §5.5.
2. The platform knows its version; a build declares its floor
The Cluster claim's status (status.platform) carries the platform version
line and the build the operator runs, written by the operator from its own
binary, and the newest release the platform's feed publishes
(GENTIAN_RELEASE_FEED_URL). The director reads it from there and answers it
on the cluster's settings, the administration console shows it, and every
check below compares against it. The version line is derived from the
chart's version (internal/platformversion, generated by
scripts/gen/gen-platform-version.py), so the two cannot disagree.
A profile declares the minimum platform it needs:
spec:
requires:
platform: ">=0.5"
The director refuses an install whose floor the cluster does not meet and
lists the entry with the reason ("needs platform 0.5"). A profile without the
field is read as >=0.0.
No profile declares an upper bound. An author cannot know what a later platform drops, so the platform carries that half of the contract: a field or behavior is read for one more version line after its replacement ships, and the release notes of each line list what the next one removes.
CI holds both sides to it. The apps repository validates every bundle against
the CRD schema of each version line its floor admits
(scripts/validate-platform-schema.py there). This repository fetches the
released and the development catalog and runs every bundle through the
operator's own bundle check
(internal/profilebundle, TestThePublishedCataloguesConform), so a
platform change that breaks a published profile fails the platform's pull
request, not a tenant's install.
3. One build object per build
The ComponentProfile is one object per name, the newest build any tenant
installed, because the app's identity (its group, its routes, its
authorization objects) is spelled from that name. Beside it the cluster holds
one ProfileBuild per build any tenant installed, named by the profile and a
prefix of its digest: the bundle at that digest, verified by the operator, its
profile's spec written on its status for a Composition to read. A tenant's
Component pins a build; one pinned to a build that is not the profile's
current one is rendered from its ProfileBuild. Two tenants on two builds of
one app share nothing but their companions, which are one object per name
like the profile; a build whose companions differ from the current build's is
held (CompanionMismatch) until the tenant moves.
A build is its digest: the object's profile and digest cannot change. It
stays on the cluster while any Component pins it. The director and the
installer write it beside the profile into the catalog directory of the
deployments repository (builds/<profile>-<12 hex of the digest>.yaml), and a
build the cluster holds is installed again without fetching it, which is what
gives rollback (§5).
4. Who moves an app
The cluster administrator sets a mode per app, and may override it per tenant. A tenant administrator cannot move an app unless its mode says so.
| Mode | Who moves the app | To what |
|---|---|---|
latest (the default) | the platform | the newest build on the app's catalog that the platform's version line admits, inside the maintenance window |
manual | the cluster administrator | a build they name, for the whole cluster or for one tenant |
tenant | the tenant administrator | a build the catalog offers |
The cluster's policy is on the Cluster claim (spec.catalogue.updates):
mode for every app, apps[] for the apps whose mode differs. The per-tenant
override is Tenant.spec.apps[].updateMode, written through the director
under can_configure, so by the cluster administrator alone
(PUT /v1/clusters/{c}/tenants/{t}/apps/{p}/update-mode,
kubectl gentian apps update-mode). The answer for one app is the tenant's
override, else the cluster's answer for the app, else a customization hold,
else the cluster's mode. An add-on has no mode of its own: it follows its
base and moves with it.
An app with a Customization record at L3 or above that carries
upgradeRisk is in manual mode for the tenants the record applies to,
unless an override names another mode. The ladder exists to say what the
next upstream release breaks; latest does not walk past it.
4.1 The maintenance window and the staged rollout
latest moves apps inside a window set on the Cluster claim
(spec.catalogue.updates.window: a daily start and end in a timezone;
absent, any time). The mover runs inside the director every five minutes and
reads the update report (§4.2); nothing is stored between runs, so what it
acts on is what a person reads. Within the window it moves one tenant first,
waits for that move to verify (§5), then the rest in batches of five per run.
A move that fails or is rolled back holds every other tenant's move to that
build: the report says so, and a person decides. The commits name
policy:latest as the mover.
4.2 The update report
There is no floor in tenant mode: a tenant may stay where it is. Instead
the director computes, for every installed app, whether a newer build the
platform admits exists on its catalog, and what keeps it where it is: its
mode, a Customization with upgradeRisk, a move that was rolled back. The
report reads three things together and stores nothing: what git says each
tenant has installed and at which build, what the cluster runs and how the
last move went (the operator's rollouts), and what the catalog each install
came from offers now. A pinned add-on is listed under its app, at its own
build. The director serves the report for the cluster
(GET /v1/clusters/{c}/updates, can_audit) and per tenant
(GET /v1/tenants/{t}/updates); the administration console shows it under
the cluster's apps with one action, "move to the newest build", for all
listed or for a selection (POST /v1/clusters/{c}/actions/move-outdated);
kubectl gentian apps outdated prints the same report.
The OS appears in the same place: the operator reads the release feed and writes the newest release, with the address of its notes, on the cluster's status beside the version it runs (§2).
5. A move is one guarded operation
Every move, whatever its mode and whoever asked, is the one director
operation POST /v1/tenants/{t}/apps/{p} with a digest. The director writes
the pin, with movedAt and movedBy (a person's id, or policy:latest),
and the operator carries the move out (internal/controller/component_move.go):
- Refuse when the cluster does not meet the build's floor, or the bundle does not hash to the digest.
- Export the app's data to the platform's own storage, as a manual export of that app; the instance keeps running the build it ran until the export is ready, and an export that fails ends the move with nothing changed.
- Materialize the new build beside the old one and repoint the
Component. - Verify: wait for the release, run the profile's
spec.backup.restore.verifycommand in the app's pod. A build that does not come up within fifteen minutes, or does not verify, is rolled back: the instance is rendered from the build it ran, which the cluster still holds as aProfileBuild, and the reason is recorded. - Record who or what moved it. The Component's
status.rolloutsays the build the instance runs, the target, the phase and why; the pin says what was asked.
rollbackLimit stays in the rendered release's values
(internal/apprender/values_release.go), for the chart's own history; the
move's rollback replaces it as the mechanism and does not uninstall a release
at revision 1.
A move that was rolled back, or did not start because its export failed,
stays where it ended until it is asked for again. Asking again is installing
the pinned build once more, or "move to the newest build"
(kubectl gentian apps move-outdated), which takes in every such move: the
director writes a fresh movedAt and movedBy on the entry, with the pin
unchanged, and the operator starts the move from step 2. Who may ask is who
may move the app (§4). The platform's latest mover never asks again by
itself, so a build that keeps failing is not tried over and over; the report
lists it as rolled back.