Skip to content

Data storage and recovery design

This page describes the data stores used by the platform and the ownership and recovery rules for each store. The architecture page gives the system context; this page gives the data lifecycle. Job schedules and failure handling are in 15-jobs, the Site API contracts are in 07-site-api, and route-adjacent state is in 14-routing.

Store inventory

Store Contents Writers Recovery or retention
Site custom resource (CR) Site specification and status Hub and operator Platform backup exports the specification. Kubernetes owns the live object.
Site persistent volume claim (PVC) (named after the site id) Site content (/www/html), token database (/www/database), token-history backup Site applications and jobs Storage-system backup owns content recovery. Site deletion removes the PVC object; a retained persistent volume (PV) can be re-bound.
Platform data PVC (default nfs-data-sites-pvc) /sites-data/<namespace>/backups/, analytics/, templates/, secrets.json Hub, backup job, analytics job Backup files pruned by age; analytics files are never pruned; ad-hoc templates/ files removed only by explicit delete; secrets.json is rewritten by the Hub init container at every start.
Redis Site and status caches, directory-user cache, form-username matches, API specs, admin messages, dashboard statistics, OpenID Connect (OIDC) credentials, OIDC discovery metadata, locks Hub, operator, jobs, and services Entries expire by time to live (TTL) or LRU eviction. Volatile by design. A Redis outage is treated as a cache miss where the caller supports it.
sites-platform-state ConfigMap Read-only state and other runtime platform state Hub and routing helpers Not Helm-managed; conflict-safe updates preserve it across upgrades.
sites-config ConfigMap The rendered platform configuration (/sites-config/config.yaml) Helm; the Hub in-band editor rewrites it (drifts from Helm until the next upgrade) Helm owns the desired state (16).
sites-templates ConfigMap Platform-provided site creation templates, mounted read-only by the Hub at /sites-templates/ Helm (siteTemplates) Helm owns the content; the Hub reads the files on demand and never writes them.
Token-history database (inside the Site PVC) Site HTTP access token hashes and metadata Site API Gzipped JSON backup and restore are provided by the Site API. The backup sits on the same PVC.

Site storage layout

Site PVC. SiteExporter.to_volume_claim builds one claim per site, named after the site id, with the site labels, size {quota}Gi, storageClassName from kubernetes.storage_class_name (null uses the cluster default) and a single access mode from kubernetes.storage_access_mode (default ReadWriteMany). On update the claim is only resized when kubernetes.storage_expansion_supported is true; the form rejects a quota decrease while the cluster reports expansion support, and Kubernetes never shrinks a claim (09).

Site pod mounts. The StatefulSet mounts the claim as site-volume and the per-site files ConfigMap as site-config. Every container — proxy, API, admin, OAuth2, WebDAV, S3, custom, and the init containers — mounts site-volume at /www; there are no subPath splits inside /www and no emptyDir volumes in site pods. The layout under /www is by convention: html/ (content, also the S3 backend base directory), database/ (the token SQLite database and its backup). A disabled site scales the StatefulSet to 0 replicas; the PVC and its data are untouched (09-disable-and-archive).

Platform data PVC. The chart renders one ReadWriteMany PVC (default name nfs-data-sites-pvc, 100 Gi, class freenas-nfs-csi-retain in the shipped values). The Hub and the jobs mount it at /sites-data; the quota and retention jobs do not mount it by default (cron.<job>.mountsData). Under /sites-data/<namespace>/ live backups/ (platform backups), analytics/ (per-site report directories, keyed by site id), templates/ (ad-hoc site templates, written by the Hub template API and created on demand; the platform-provided site templates come from the read-only sites-templates ConfigMap instead), and secrets.json (the OIDC client-credentials and endpoints file, written by the Hub init container at every start, hub/.../initializer/oidc/ensurer.py).

Redis cache

The chart deploys Redis as a single-replica Deployment with the data on an emptyDir and persistence switched off (appendonly no, save ""), plus maxmemory 200mb with maxmemory-policy allkeys-lru. Redis is therefore volatile by design: it loses everything on restart, and entries can be evicted under memory pressure even before their TTL. Every caller must treat Redis as non-authoritative.

RedisCacheService uses the configured host, port, database, and password values (cache.*, 16). It reconnects when Redis is unavailable; cache callers receive a miss instead of a cache-service failure when the connection cannot be established.

The Redis NetworkPolicy admits only the Hub and Operator pods. When CACHE_REDIS_PASSWORD is present in sites-secrets, the Redis container start script also sets requirepass from it. The script passes the value on standard input, not on the command line, and it refuses a value with ", \ or a line break. Redis adds standard input to the end of the configuration file with no line break, so the script starts standard input with an empty line. Then requirepass stays on its own line also when the redisConfig value has no final line break. The readiness probe authenticates through REDISCLI_AUTH. Without the key, Redis runs without a password.

Values are stored with a namespace prefix and expiry (default cache.timeout_cold, 7 days). Integers use a plain ASCII representation. Other values use a signed serialized payload:

  1. Pickle serializes the value.
  2. An HMAC-SHA-256 signature is added. The signing key is itself derived: HMAC(session_secret_key, "sites-redis-cache") with SHA-256. All components share the same session secret, so they can read each other's entries and an attacker without the secret cannot forge one. The configuration refuses a session secret shorter than 32 bytes, and the cache refuses to sign or verify with an empty key.
  3. The marker, signature, and payload are stored with the configured TTL.
  4. Reads verify the signature with hmac.compare_digest before using the payload.
  5. A restricted unpickler allows only these names:
  6. safe built-in primitives (no type, object, eval, open or __import__);
  7. the datetime classes datetime, date, time, timedelta and timezone, and the enum base classes;
  8. classes that an ecmwf.sites.* module defines, including nested classes such as SiteStatus.Condition.

Pickle protocol 4 accepts dotted names. Thus each part of a dotted name must be a class that the named module defines, and a part must not start with __. A name that reaches a function, an imported module or an imported class (for example pickle.loads through an ecmwf.sites module) is rejected. The module must already be loaded in the process, so an entry cannot make the process import a module. Any other reference raises before an object is built.

The unpickler cannot check the BUILD opcode, so a signed entry can still set attributes on an allowed class. This is an accepted risk: only a holder of the session secret key can sign an entry.

An unsigned, incorrectly signed, foreign-signed, or unpicklable payload is ignored — the caller sees a miss. Cache data is not authoritative data; callers must work when an entry is missing or expired.

Key space

Prefixes are defined in core/src/ecmwf/sites/core/config/redis.py:

Prefix Contents Written by Deleted by
sites_ Site models, site lists, site status (hot TTL 300 s), read-only flag, public ingress host Hub service decorators (service/sites.py) @clear_cache on every site mutation, the operator's pod conditions and container-statuses watchers, the Hub cache-clear action, retention-job actions, backup restore
users_ Form autocomplete results for username lookups (warmish TTL) Core form utilities TTL only
ad_ The directory (LDAP/AD) user list (warmish TTL) UsersService users_service.refresh() (cache-clear action)
specs_ Rendered Hub and per-site OpenAPI specs Hub views Cache-clear action, TTL
messages_ Platform-wide admin announcements flashed to users MessageService Explicit clears (for example after backup restore)
stats_ Dashboard service/system metric summaries (hot TTL) Hub dashboard views Cache-clear action, TTL
oidc_ Per-user OpenID credential blobs keyed by subject (cold TTL) OIDC login path Logout/revocation paths delete entries; the Hub logout flow does not — see 10-security
oidc-discovery_ Discovery metadata under a SHA-256 key of (URL, issuer) Discovery service TTL only; the prefix deliberately cannot be matched by oidc_* wildcard deletes (redis.py comment, asserted in test_oidc_discovery.py)
lock_ Distributed lock markers Lock service Unlock or TTL
hub-token-grant-ip: Password-grant attempt counters per client IP (plain integer, fixed 60 s window, key a SHA-256 of the address) _token_grant_rate_limited (hub/.../app/api/common.py) through is_rate_limited TTL only
hub-token-grant-ip-user: Password-grant attempt counters per client IP and username (plain integer, fixed 60 s window, key a SHA-256 of the pair) _token_grant_rate_limited (hub/.../app/api/common.py) through is_rate_limited TTL only

The Hub cache-clear action (misc_clear_cache) removes the site, spec, and stats caches by wildcard and refreshes the directory-user cache. It does not touch OIDC credentials, discovery metadata, or locks. Site templates are not cached; the Hub reads them from disk on demand.

The distributed lock service (core/src/ecmwf/sites/core/service/lock_service.py) provides two primitives: try_lock (atomic Redis set-if-absent with a TTL, falling back to a process-local lock when Redis is unavailable — cross-replica exclusion is then lost) and lock (an unconditional marker write, used as a "generation in progress" flag). Users: the Hub bulk-upgrade lock (sites-bulk-upgrade, TTL 1800 s, 12) and analytics report generation (TTL 180 s, 15).

OIDC discovery adds a second validation layer: it checks the issuer and the endpoint URLs when it reads a Redis entry, before it returns the metadata.

Platform state

sites-platform-state is deliberately not Helm-managed. The Hub creates it when needed and updates it with resource-version conflict retries (up to five). A value of None removes a key.

The read-only key is used by the Hub and routing integrations. It survives a Helm upgrade and a workload restart. The Hub has access only to this ConfigMap and the named platform configuration objects described in the role-based access control (RBAC) design. Enforcement per routing mode is in 14-routing.

Platform backup

The backup system writes one dated YAML file under /sites-data/<namespace>/backups/. It lists all Site resources and removes status and resourceVersion from each exported object. It does not copy Site PVC content. Pruning is by modification time only (jobs.backup_keep_files days); there is no size cap, and subdirectories are kept. The schedule, the failure email, and the failure semantics are in 15. Note that the backups live on the platform PVC: a platform-PVC loss takes the content definitions' backup with it, so the storage system must back this PVC up as well.

Restoring Site definitions is a Hub administration operation. Restoring the backing storage is an operation of the Kubernetes storage system and is not performed by the platform backup job.

Token history

The Go Site API stores HTTP access token history in SQLite on the Site volume: /www/database/tokens.db, opened with WAL mode, a shared cache, and a 5 s busy timeout. The database stores token hashes and metadata such as identifier, permission, expiration, last use, and revocation state. Raw access tokens are not stored as token-history records.

History writes use a bounded asynchronous worker queue of 256 entries. A full queue or an unavailable database is logged and does not block the token request. Cleanup removes expired history entries after a 7-day grace, daily. Revocation updates the history record and uses the token hash. Every token decode first checks an in-memory Bloom filter of revoked hashes, seeded from the database at startup (api/token_revocation_bloom.go); the filter is memory-only and rebuilt on every restart. A hash that the loaded filter does not contain is not revoked, so the decode accepts it without a database read. Otherwise the database gives the authoritative answer, and a decode fails closed when the database is unavailable, so a revoked token cannot be used during the outage. When the database does not open, the API retries it in the background every 30 s and rebuilds the filter and the history endpoints when it opens, so revocation recovers without a restart; a snapshot is also written to the backup file at startup and once per day. The API contract (create/decode/revoke/backup/restore shapes) is in 07-site-api.

The API can export token history to a gzipped JSON backup with a SHA-256 checksum (/www/database/token-history-backup.json.gz) and restore it after validating the backup. Normal restore and the database recovery path are both reported in the API logs. The backup sits on the same PVC as the database: it survives a corrupted database file, but not a lost volume. Losing the PVC loses both copies.

Deletion and recovery boundaries

Deleting a Site resource deletes the namespaced objects through Kubernetes owner references; there is no delete handler and no finalizer (09). The owned set: Secret, both ConfigMaps, PVC, StatefulSet, Service, route, and gateway policy objects. The backing PersistentVolume keeps the reclaim policy of the Site storage class (kubernetes.storage_class_name; the shipped default is null, so the cluster default class decides), and can remain Released for an administrator to clean up. The class name freenas-nfs-csi-retain in the shipped values belongs to the platform data PVC, not to site volumes: site volumes accumulate only when their own class retains.

Site recreation re-binds a retained volume: on create, the operator looks for a PersistentVolume matching the site metadata or the old claim names (account or site id). When it finds one, the new PVC is created with volumeName pinned to that PV, and the operator patches the PV's labels and claim reference. This is how a restored Site definition picks up its old data.

The platform therefore has separate recovery boundaries:

  • Site definitions: platform backup and Hub restore.
  • Site content: storage-system backup and PVC/PV re-binding.
  • Token history: Site API JSON backup and restore (same-PVC caveat above).
  • Analytics: weekly preloader recomputation and on-demand generation. Report files are never pruned — every generated weekly period accumulates on the platform PVC — and deleting a Site does not remove its /sites-data/<namespace>/analytics/<site-id>/ directory.
  • Templates: explicit Hub template deletes only; Site deletion leaves template files and the site's own /www content to the PV lifecycle.
  • Redis: cache rebuild from authoritative data; no recovery operation exists or is needed.

Behaviour requirements

ID Requirement Implemented in Verified by
DAT-01 Redis cache entries use namespace prefixes and configured TTLs. core/.../service/redis_cache.py, core/.../misc/sites_cache_utils.py core/tests/test_redis_config.py (prefix constants); the decorator TTL mapping is not tested
DAT-02 Non-integer Redis payloads require a valid HMAC-SHA-256 signature (key derived from the session secret) before deserialisation through a class-allowlisting unpickler; unsigned, tampered, and foreign-signed payloads are ignored. core/.../service/redis_cache.py core/tests/test_redis_cache_load_object.py (unsigned, tampered, wrong-key, short-signature, and os.system payload cases; dotted-name, function, re-exported class, dunder, type/object, module-not-loaded, and stdlib allow-list cases; int-as-ASCII, nested class, and site model round trips)
DAT-03 Redis or cache failures produce cache misses where the caller supports degraded operation. core/.../service/redis_cache.py (check_redis_cli) core/tests/test_redis_cache_load_object.py (corrupted and missing values), core/tests/test_lock_service.py (lock fallback without Redis)
DAT-04 Platform state is stored in a non-Helm-managed ConfigMap and updates retry resource-version conflicts. core/.../kubernetes/platform_state.py core/tests/test_routing.py::TestPlatformState (read-missing, create, conflict-retry with key removal)
DAT-05 Platform backups contain Site definitions without status or resource-version fields and are pruned by modification time after the configured period. core/.../backup/system.py core/tests/test_backups.py (backup flow and pruning; the export stripping itself is not asserted)
DAT-06 Token history stores hashes and metadata, writes asynchronously, cleans expired entries, and supports validated backup and restore. site/.../api/api/token_storage_sqlite.go, token_utils.go Go API tests (token_utils_test.go history persistence; the backup/restore handlers are not tested, see 11)
DAT-07 Site deletion cascades through owner references only (no finalizer); the PVC object is removed while the backing PV follows the storage retention policy. operator/.../site_create.py (kopf.adopt), Helm storage values operator/tests/test_site_create.py, 09
DAT-08 The lock service serializes bulk work with a Redis set-if-absent lock and TTL, and falls back to a process-local lock when Redis is unavailable. core/.../service/lock_service.py core/tests/test_lock_service.py
DAT-09 A site quota increase changes the PVC request only when kubernetes.storage_expansion_supported is true; a quota decrease is rejected by the form and never applied to the claim. core/.../model/site_exporter.py (to_volume_claim), core/.../service/sites.py core/tests/test_site_exporter_volume_claim.py (both directions), core/tests/test_form_fixes.py (form-level up/down-scale rules)
DAT-10 When a matching PersistentVolume exists (for example a retained volume of a deleted site), site creation binds the new claim to that volume and patches its labels and claim reference. operator/.../site_create.py (__handle_existing_volume) operator/tests/test_site_create.py (TestCreateVolumeClaimExistingVolume: adopt-and-patch, create-new when absent)
DAT-11 Redis is volatile by design: single replica, emptyDir, persistence off, LRU eviction under maxmemory. helm/.../deployment-redis.yaml, chart Redis config values — (render values are not asserted)