Skip to content

Site lifecycle and reconciliation

This page describes exactly what the operator does during the life of a site. The Site custom resource is the intent; the operator turns it into Kubernetes objects and reports pod state back into it. All handlers are in operator/src/ecmwf/sites/operator/site_handler.py. The image starts the process with kopf run --standalone --namespace "$NAMESPACE" --module ecmwf.sites.operator.site_handler (operator/Dockerfile), so the operator watches only the platform namespace. Module import loads ecmwf.sites.website.applications, which registers the built-in application classes the model needs.

Reconciler configuration

The startup handler (configure) and the chart set these values:

Setting Value Source
Peering standalone (also passed on the command line) settings.peering.standalone
Watch timeouts connect 60 s, server 600 s (watches re-establish every 10 minutes) settings.watching.*
Concurrency worker_limit = operator.worker_limit (default 5) — sites handled in parallel up to this number settings.queueing.worker_limit
Progress handler progress lives in the Site's own annotations (AnnotationsProgressStorage) settings.persistence.progress_storage
Update retries on_update retries = operator.retries (default 5); after that the handler stops until the next qualifying change kopf.on.update(retries=...)
Create retries on_create has no retry limit: a failed step raises kopf.TemporaryError (default delay 60 s) and the whole ordered sequence runs again kopf defaults
Deployment ServiceAccount sites, priority class high-priority, single replica helm/.../deployment-operator.yaml
role-based access control (RBAC) The namespaced Role for SA sites covers the Site Secret, ConfigMaps, persistent volume claim (PVC), StatefulSet, Service, Pods (including delete, for LIFE-15), and Site resources; the ClusterRole sites-application-resources adds persistentvolumes get/list/patch, which the volume-label steps need helm/.../rbac-admin.yaml

On resume (operator restart) the handler only logs (at debug) and records a trivial result. Progress annotations make kopf replay handlers not marked complete — in practice the create sequence, because update retries are capped; every create step tolerates HTTP 409, so replaying create is the recovery path. There is no separate converge loop and no periodic resync.

What triggers work

The operator acts on a Site update only when the kopf diff touches a restart-triggering property (operator/src/ecmwf/sites/operator/kopf_diff.py checks these prefixes):

  • spec.configuration.applications, .enabled, .headers, .quota, .retention_date, .share, .settings.resource_limits, .settings.error_handling, .space
  • spec.name, spec.owner, spec.type

The match is a substring test against the diff path, so a change to a deeper field such as spec.configuration.share.administrator_users triggers on the registered prefix spec.configuration.share. A code comment warns that a new prefix must not be a substring of an unrelated property path.

An added or changed, non-empty metadata.annotations["sites.ecmwf.int/upgrade-request"] also triggers the full update path. Removing the annotation does not trigger work. Unrelated annotations do not trigger work.

The comparison is order-insensitive: lists of application dicts are sorted by their name field before comparison, so a reorder without content change causes no work. There is no version comparison in the operator: any qualifying change re-renders every object and force-restarts the pods. A change to audit fields (description, updated_by, dates) causes no work.

Create flow

sequenceDiagram
    participant U as Admin
    participant H as Hub
    participant K as Kubernetes API
    participant O as Operator
    U->>H: Create site (UI or Hub API)
    H->>K: Create Site CR (id, owner, apps, quota...)
    K-->>O: on.create(Site)
    O->>O: Build model from spec
    O->>K: Secret, 2 ConfigMaps, PVC, StatefulSet, Service, Route (in order)
    Note over O: each step tolerates 409. A route owned by another site raises TemporaryError (retry in 60 s)
    O-->>K: background, after 20-40 s: label the bound PV
    K-->>O: on.field(Pod, status.conditions)
    O->>K: patch Site status

Existence gate. If both Secrets (<id> and <id>-oauth2), both ConfigMaps, PVC, StatefulSet, Service all exist and the route check passes, the create handler logs "already exists" and finishes without creating anything and without starting the persistent volume (PV) patch task. The route check treats a site with no public path as satisfied; a route of this site's name owned by another site raises TemporaryError (retry in 60 s) — the operator never overwrites a foreign route (14-route-ownership).

Steps. Otherwise the full sequence runs inside kopf.execute, in this order. Each step is idempotent: HTTP 409 logs "already exists... might be a RESUME event" and continues; any other API error aborts the sequence and retries it from the start after 60 s.

Step Behaviour notes
create_secret The per-site Secret (Site API tokens and secrets, 10-security).
create_oauth2_secret The Secret <id>-oauth2 with the derived OAuth2 Proxy cookie secret (10-security). It is adopted by the Site.
create_config_maps Two ConfigMaps: the environment map and the -files map (nginx configuration, error pages). The files map needs the platform public host; a missing host raises TemporaryError.
create_volume_claim First looks for an existing PersistentVolume by site labels or the old claim names (account or site id). When found, the claim is created with volumeName pinned to it and the PV's labels and claim reference are patched (recreation of a deleted site re-attaches its data, 13-DAT-10). Without a PV, the claim is created normally.
create_stateful_set Replicas = site.replicas (default 1) for an enabled site, 0 for a disabled one. Priority class low-priority, or medium-priority for monitored sites.
create_service The cluster-internal Service in front of the pods.
create_routes Builds the route for the active routing mode; a site with no public path creates no route. The route and, in gateway mode, the controller policies are adopted with kopf.adopt. A build error raises TemporaryError.

Background PV patch. After the sequence, an un-awaited task sleeps a random 20–40 s (jitter lets the PVC bind, and spreads the load when many sites are created at once), then labels the bound PV with the site labels. The PV is found by claim → volume name, then by site labels, then by the old claim names; a not-yet-bound volume logs "not found" and is skipped. A failure in this background task is logged by a done-callback — nothing else awaits it. The same patch runs after updates, with a fixed 5 s delay.

Update flow

On a qualifying update (see "What triggers work") the operator runs the update wrapper, in order:

  1. update_secret, update_oauth2_secret, update_config_map — both ConfigMaps re-render from scratch (fresh host, branding, OpenID Connect (OIDC) endpoints, nginx configuration). update_oauth2_secret creates <id>-oauth2 when it does not exist (a site from an earlier release), else writes the cookie secret again from the current session.secret_key.
  2. update_volume_claim — refreshes the claim labels and annotations; the storage request is the only spec change, and it applies only when kubernetes.storage_expansion_supported is true.
  3. update_stateful_set(force_restart=True) — sets the kubectl.kubernetes.io/restartedAt pod-template annotation to the current UTC time, so every qualifying change rolls the pods. For an enabled site, it then deletes each site pod that is not Running and Ready and is not terminating (replace_not_ready_pods). The StatefulSet uses the default OrderedReady pod management policy. With this policy, the controller does not replace a pod that never becomes ready: it waits for that pod and stops the rollout (the Kubernetes "forced rollback" limitation). For example, a custom application that does not accept connections on port 8000 fails its readiness check. If the user then removes or changes the image, the old pod stays and the Site status stays not running. The deletion removes this wait, and the controller creates the pod again from the updated template. A failure to list or delete pods is logged as a warning and does not stop the update.
  4. update_service, update_routes — the route is rebuilt for the active routing mode, and any route of the other mode owned by this site (sites.ecmwf.int/id label) is deleted (14-route-ownership).
  5. A background task re-patches the PV label after 5 s.

Implemented details:

  • Self-healing. Every update step first reads the object; a missing object (for example deleted by hand) is recreated through the create function instead of failing.
  • Owner references are cleared and re-appended on every updated object. A code comment explains why: kopf.adopt does not refresh the apiVersion of existing references, and UID-based removal fails after a site restore gives the Site a new UID. The code clears all references and appends the current one — safe because kopf is the only controller that owns these objects.
  • Route update: an API error on the route read is treated as "missing" (recreate); a route owned by another site logs an error and raises TemporaryError (retry in 60 s); a site that lost its public path has its route deleted. The replacement reuses the existing object, then sync_site_policies reconciles the gateway policies (14-routing).
  • Other-mode cleanup deletes only routes owned by this site. A foreign resource of the other mode stays: the routing backend checks ownership before deleting and returns silently when it does not own the object; the handler additionally keeps and warns if a backend raises RouteOwnershipError. A delete API error other than 404 raises TemporaryError (retry in 30 s).
  • If no diff qualifies, the handler returns without touching anything.

Site status monitoring

The operator watches pods, not just Sites. Every pod handler acts only on pods that carry the sites.ecmwf.int/id label and the sites.ecmwf.int/operator_bound annotation. Pods inherit both from the StatefulSet pod template, so the operator reconciles only pods it created. No pod handler returns a result: kopf writes handler results into the watched object's status, and the operator must not write status fields on pods.

  • status.conditions or status.containerStatuses change: the handler finds the Site from the pod labels, maps the pod status with resolve_pod_status, and patches the Site's status subresource. The container-statuses watcher exists because the conditions stop changing while a container keeps failing its readiness check: a container that starts and never becomes ready leaves Ready and ContainersReady False from the first kubelet update, and the readiness flag moves only in status.containerStatuses. Without that watcher the stored status would keep the state the last condition flip computed. After patching, the handler deletes the cached site entries (13-key-space): a pod's phase does not change when a probe starts or stops failing, so the pages cannot rely on the phase watcher alone to clear them. If the Site was deleted meanwhile the handler logs a warning and returns. Each write logs one INFO line: the pod, the field that triggered it, the resolved phase, and the container problems now stored on the Site. Cost of the two watchers: kopf keeps the watched fields in the kopf.zalando.org/last-handled-configuration annotation of each site pod, so it patches that annotation when status.containerStatuses changes. When one pod event changes both fields, both handlers run, each with one Site read, one status patch, one INFO line, and one cache delete (a Redis SCAN on the site key pattern). After an operator upgrade that adds a watched field, the first event of each pod runs the handler once.
  • status.phase change: the handler logs the new phase, and when the phase is not Running it clears the site's status-cache entries so the Hub and health views fetch fresh data (13-key-space).

resolve_pod_status (core/.../kubernetes/utils.py) builds the Site status:

  • Conditions are ordered by lastTransitionTime; the first failed one wins, otherwise the last successful one.
  • When the winning condition is not successful, the container statuses are scanned for waiting or terminated entries, and for running containers that report ready: false (recorded as kind running, reason NotReady). Any problem stores the phase as Pending plus a plain list of per-container problems (name, kind, reason, message) in the Site status — the Hub renders it.
  • A disabled site always reports phase Disabled, whatever the pod says.
  • Otherwise the pod phase is used, defaulting to Pending.
  • all_steps_started is true only when the phase is Running and the selected condition is successful.

Disable and archive

Disabling a site (Hub toggle "Archive Site", API POST /hub/api/v2/sites/<space>/<site>/toggle/) flips spec.configuration.enabled to false. It is a qualifying change, so the update flow runs. Effects:

  • StatefulSet replicas become 0 (re-render sets site.replicas only for enabled sites): no pods, content unmodifiable.
  • The route is kept. Requests redirect with HTTP 302 to /hub/error/page/410/ (NGINX location snippet in ingress mode, RequestRedirect filter in gateway mode).
  • Data on the PVC is untouched. Restarting a disabled site is a no-op.
  • The retention job "archives" a site by performing the same toggle (15-retention-job).

Upgrade flow

Upgrades are Hub-side. POST /hub/api/v2/sites/upgrade/ ("Upgrade All Sites", platform admin) or the per-site variant rewrites the resource:

sequenceDiagram
    participant A as Platform admin
    participant H as Hub
    participant K as Kubernetes API
    participant O as Operator
    A->>H: POST /hub/api/v2/sites/upgrade/
    H->>H: per site: rebuild model with upgrade=true (rewrite built-in images to site.version, keep custom images)
    H->>K: Patch Site CR (thread pool)
    K-->>O: on.update: applications diff
    O->>K: Re-render ConfigMaps + StatefulSet (forced restart)

The Hub replies while the work continues per site. No version marker is stored in status; the applications list itself is the record.

For a same-version update, use Force Upgrade All Sites or POST /hub/api/v2/sites/upgrade/?force=true. Force defaults to false. For each forced request, the Hub writes a new UUID to the sites.ecmwf.int/upgrade-request Site annotation in the same write as the application updates. Kopf tracks the annotation and the existing update handler regenerates all resources and restarts active site pods. The annotation is not part of the Site model. The Sites data access object (DAO) adds it to the custom object only for a forced upgrade. Site writes use JSON merge patches, so later edits do not remove it. It is not a completion marker. Each later forced request uses a different UUID. Archived sites are included and remain at zero replicas. Custom images, site settings, and data are preserved. No Custom Resource Definition (CRD) schema change is required.

Migration mirror

When migration.enabled is true, the platform adds site-mirror to every site. The Hub and operator must restart to load the setting, and the sites must then be upgraded so their StatefulSets include the new container. The mirror writes to the local Site PVC and has no Service, route, or source-side write operation. It runs independently of the operator reconcile handler. Disable the setting, restart the Hub and operator, and force an upgrade of all sites again. Sites whose resources already contain a site-mirror application entry keep that entry and need it removed manually. The complete cycle algorithm, deletion safeguards, retry behaviour, and rollout procedure are in content mirror.

Delete flow

There is no delete handler and no finalizer. Every namespaced object was created with an owner reference to the Site resource, so kubectl delete site/<id> (or the Hub API) cascades through Kubernetes garbage collection: Secret, both ConfigMaps, PVC, StatefulSet, Service, route, and gateway policy objects.

The PVC is deleted with the site. When the Site StorageClass has the Retain reclaim policy — the convention the platform recovery design assumes — the backing PersistentVolume becomes Released, and an administrator cleans it up from the Hub administration pages (the Hub ServiceAccount may list and delete released PVs). A site recreated with the same identity re-binds the retained volume (13-DAT-10).

Site state machine

stateDiagram-v2
    [*] --> Provisioning: create Site CR
    Provisioning --> Running: operator created objects, pods ready
    Running --> Running: qualifying change (re-render + restart)
    Running --> Archived: disable (Hub toggle, or the retention job archives the site)
    Archived --> Running: enable
    Archived --> [*]: the retention job deletes an archived site
    Running --> [*]: delete (CR removed, objects cascade)
    Archived --> [*]: delete

The archive and delete delays are configuration settings, jobs.retention_expired_archive_after_days and jobs.retention_expired_delete_after_days; 15 lists the defaults.

Monitored sites

spec.configuration.settings.monitored (set by Hub administrators only) does two things: pods use priority class medium-priority instead of low-priority, and the Hub health report includes the site when it is also enabled. It defines no service-level objective.

Operator logging

  • operator.dependency_log_level (default INFO) sets the level of the kopf logger. The HTTP client loggers (urllib3, kubernetes) stay at WARNING unless this level is DEBUG. Sites loggers are unchanged.
  • The startup log line reports the Python, kopf, and Sites package versions; the full installed-package list is logged at debug level.
  • Update logs list the changed field paths at info level and old/new values at debug level. Values of fields whose names look like secrets are replaced with a mask, and long values are truncated to 120 characters before logging.

Behaviour requirements

ID Requirement Implemented in Verified by
LIFE-01 The operator creates the per-site objects in a fixed order (Secret, ConfigMaps, PVC, StatefulSet, Service, route) and tolerates already-existing objects. operator/.../site_handler.py, site_create.py operator/tests/test_site_create.py (409 tolerance for Secret, ConfigMaps, PVC, and route — StatefulSet and Service creation has no test; config-map pair, route skip and build errors; the step order itself is not asserted)
LIFE-02 The operator acts on updates only when a restart-triggering property changed. operator/.../kopf_diff.py, site.py:SITE_PROPERTIES_REQUIRING_RESTART operator/tests/test_diff.py (test_diff, test_removed_twofa_setting_does_not_restart_site)
LIFE-03 Every qualifying update re-renders all configuration and restarts the site pods. operator/.../site_update.py, site_exporter.py (restartedAt) operator/tests/test_site_update.py, operator/tests/test_forced_upgrade.py (annotation-only update runs the full path)
LIFE-04 Disabling a site scales it to zero and redirects requests to the Hub 410 error page; data is preserved. site_exporter.py, ingress_utils.py, gateway_utils.py core/tests/test_site_exporter_ingress.py::TestSiteExporterIngress::test_to_ingress_adds_archive_snippet_when_site_is_disabled, core/tests/test_routing.py::TestGatewayExport::test_rules_for_disabled_site, operator/tests/test_forced_upgrade.py (archived replicas stay 0)
LIFE-05 Deleting the Site resource cascades to all namespaced objects through owner references; no operator code runs on delete. operator/.../site_create.py (kopf.adopt), site_update.py operator/tests/test_site_create.py
LIFE-06 An upgrade rewrites built-in application images to the platform version and preserves custom images. core/.../model/site.py, hub/.../api/common.py hub/tests/test_sites_upgrade.py
LIFE-07 On a route-ownership conflict the operator retries after 60 s instead of stealing the route. operator/.../site_handler.py (__has_route), site_update.py (_update_route) operator/tests/test_site_update.py (test_foreign_route_raises_temporary_error; the delay value is not asserted)
LIFE-08 An operator restart replays create handlers safely; there is no separate converge timer. site_handler.py (on_resume) —
LIFE-09 A forced upgrade writes a new UUID to the sites.ecmwf.int/upgrade-request annotation, and that annotation alone triggers a full re-render and restart of active pods. hub/.../api/common.py, hub/.../api/v2/views.py, operator/.../kopf_diff.py, site_handler.py hub/tests/test_sites_upgrade.py, operator/tests/test_forced_upgrade.py
LIFE-10 Pod condition and container-status changes are mapped into the Site status: a disabled site reports Disabled, a failed condition with waiting, terminated, or unready running containers reports Pending plus a per-container problem list, and all_steps_started is true only for a Running pod with a successful condition. operator/.../site_handler.py, core/.../kubernetes/utils.py (resolve_pod_status) core/tests/test_kubernetes_utils.py (phase-preservation cases, the NotReady cases), operator/tests/test_pod_status_handlers.py
LIFE-11 Every update step recreates a missing owned object through the create path instead of failing. operator/.../site_update.py operator/tests/test_site_update.py (fallback tests for secret, StatefulSet, Service, PVC, ConfigMaps, route)
LIFE-12 Each update clears all owner references on the updated object and re-appends the current Site reference. operator/.../site_update.py (update_owner_references) operator/tests/test_site_update.py::TestUpdateOwnerReferences
LIFE-13 After a routing-mode switch the update deletes the route of the other mode only when this site owns it; a foreign resource is kept, and a non-404 delete error retries after 30 s. operator/.../site_update.py (_delete_other_routes) operator/tests/test_site_update.py (test_deletes_routes_of_other_modes, test_ignores_missing_or_foreign_routes_of_other_modes, test_other_mode_delete_error_raises_temporary_error)
LIFE-14 A pod phase change to anything other than Running clears that site's status cache so the interfaces show fresh state. operator/.../site_handler.py (pod_status_phase) —
LIFE-15 After a StatefulSet update of an enabled site, the operator deletes the site pods that are not Running and Ready and not terminating, so a pod that never becomes ready does not stop the rollout. List and delete errors do not stop the update. The operator Role sites-application grants delete on Pods, and a 403 answer logs an ERROR message. operator/.../site_update.py (update_stateful_set, replace_not_ready_pods), helm/.../rbac-admin.yaml operator/tests/test_site_update.py::TestReplaceNotReadyPods, core/tests/test_helm_configuration.py::test_operator_can_delete_pods_but_not_volumes