Skip to content

Architecture

This page describes the implemented system: what runs, how the pieces connect, and what the Helm chart deploys. Detailed designs are linked from each section.

System context

graph LR
    Browser["Browser"]
    Client["API client / sitesctl / SDK"]
    IdP["OIDC identity provider"]
    subgraph Cluster["Kubernetes namespace"]
        subgraph HubPod["Hub + platform"]
            HUB["sites-hub"]
            OP["sites-operator"]
            REDIS["redis"]
            JOBS["CronJobs: backup, quota, retention, analytics"]
        end
        SITE["Site StatefulSet (oauth2, proxy, api, optional apps; initializer, mirror)"]
        CRD[("Site CR")]
        DATA[("/sites-data PVC")]
    end
    GW["Ingress controller or Gateway"]

    Browser --> GW --> HubPod
    Browser --> GW --> SITE
    Client --> GW --> SITE
    HUB -- "writes/updates" --> CRD
    OP -- "watches + reconciles" --> CRD
    OP -- "creates/updates" --> SITE
    HUB --- REDIS
    OP --- REDIS
    JOBS --- REDIS
    HUB --> DATA
    JOBS --> DATA
    HUB -- "OIDC" --> IdP
    SITE -- "token + session auth" --> IdP

The Hub and the operator never proxy site content. The platform is control-plane only: the Hub records intent in the Site resource, and the operator turns that intent into running objects (lifecycle and reconciliation).

Per-Site runtime

One Site is one StatefulSet. Its replica count is the platform setting site.replicas (default 1; 0 while the Site is disabled). The selected applications run as containers of the same Pod, so they share one network namespace, and calls between them use 127.0.0.1. All replicas mount the same content persistent volume claim (PVC), which is a single object named after the Site id (data storage and recovery). site-oauth2 is the front door for browser traffic; it passes requests to site-proxy, which routes by path to the other containers.

graph LR
    IN["Route (Ingress or HTTPRoute)"] --> O2["site-oauth2 :4180"]
    O2 --> PX["site-proxy :8080 (OpenResty + Lua)"]
    PX -->|"/s/api"| API["site-api :10000 (Go)"]
    PX -->|"/s/admin"| AD["site-admin :9000 (File Browser)"]
    PX -->|"/s/dav"| DAV["site-webdav :7000"]
    PX -->|"/s/s3"| S3["site-s3 :6000"]
    PX -->|content| APP["site-custom :8000 (optional) or local files"]
    INIT["site-initializer (init)"] --> VOL[("site PVC")]
    MIRROR["site-mirror (no listener)"] --> VOL
    PX --> VOL

Facts that matter to developers:

  • The Go API has no authentication of its own: its route middleware is a pass-through, and the service performs no credential check (site/src/ecmwf/sites/website/applications/api/main.go, api/api/parse_middleware.go). Authorization comes from the proxy: the authenticated API locations run admin_auth.lua (the session-files location runs session_files_auth.lua) before proxy_pass to http://127.0.0.1:10000; the public read-only files location carries no access module by design (site/.../applications/proxy/nginx/api.conf). The API listens only on 127.0.0.1:10000 in the shared Pod network namespace, and gin trusts X-Forwarded-* headers only from the loopback peer (SetTrustedProxies(["127.0.0.1"])). The application contract, and the limits of this isolation, are in Site API and security.
  • The per-site Service exposes a single port: site-oauth2 on 4180. Every other application returns no Service port and no route path (site application architecture).
  • When networkPolicy.enabled is true, the site-internal-traffic policy limits ingress to Site Pods: port 4180 from the routing namespace (ingressNamespace in ingress mode, gatewayNamespace in gateway mode), and all ports from the monitoring and metrics-gateway namespaces; Pods inside the platform namespace match every rule. It does not filter traffic inside one Pod (that is the shared loopback namespace).
  • When networkPolicy.enabled is true, the hub-internal-traffic policy limits ingress to the Hub Pods: port 8080 from the routing namespace and from the monitoring and metrics-gateway namespaces. It does not filter egress, because the Hub reaches Redis, the Kubernetes API, the identity provider, and the other integrations. The policy keeps the unauthenticated /hub/metrics/ route and the rest of the Hub away from other Pods (SEC-29).
  • site-oauth2, site-proxy, site-api, and site-initializer are essential applications: the platform always configures them. site-mirror is platform-controlled (migration.enabled). The other applications are enabled per site through spec.configuration.applications (03-domain-model).
  • site-initializer runs before the application containers. site-mirror is a migration container in the same Pod; it has no listener, Service port, or route. See content mirror.
  • Every container receives the per-site Secret <id> (holds MASTER_TOKEN) and the per-site environment ConfigMap <id> through envFrom. Rendered files arrive through the files ConfigMap <id>-files, mounted as the site-config volume (shared container contract).

What the Helm chart deploys

One Helm release installs the whole platform into one namespace. The chart (helm/ecmwf-sites/) renders:

Object Notes
sites-hub Deployment + Service + Horizontal Pod Autoscaler (HPA) Init step runs the Hub initializer. With the HPA enabled, the Deployment still renders the configured hub.replicas, so a later helm upgrade resets the autoscaler's count.
sites-operator Deployment Fixed replicas: 1; ServiceAccount sites
redis Deployment + Service + redis-config ConfigMap Cache, site list, OpenID Connect (OIDC) discovery copy; automountServiceAccountToken: false
sites-config ConfigMap The platform configuration (see 16)
Routing Ingress mode: master sites-ingress-master + Hub minion sites-ingress (per-site minion Ingresses are operator-built); gateway mode: sites-hub HTTPRoute, plus a Gateway (default name sites-gateway) only when routing.gateway.create=true — that template also renders the sites-http-redirect HTTPRoute when TLS and its redirect are on with no custom listeners (14)
CronJobs sites-backup-system (daily 01:00), sites-quota-notification (daily 06:00), sites-retention-notification (Mon 09:00), sites-analytics-preloader (Mon 06:00, only with an analytics provider) — see 15
Data PVC (/sites-data) Mounted by the Hub and the jobs that need it
Secrets sites-secrets and oidc-secrets are referenced; not created by default, optionally created through the secrets.*.create values. The optional image-pull secret is attached to the ServiceAccounts when configured
ServiceAccounts sites, sites-hub, sites-jobs + Roles/Bindings Fixed name list; see the role-based access control (RBAC) table below
NetworkPolicies site-internal-traffic, hub-internal-traffic, and sites-redis-internal-traffic, gated by networkPolicy.enabled. Namespace selectors: ingressNamespace, gatewayNamespace, monitoringNamespace, metricsGatewayNamespace. Site egress: always HTTPS/443 and DNS/53; networkPolicy.egressCIDRs adds a CIDR allow-list with database-style ports (443, 5432, 5433, 3306, 6443, 8083, 27017, 27018) — an empty list omits that rule only
PriorityClasses, ResourceQuota, ServiceMonitors From chart values
Site Custom Resource Definition (CRD) Lives in helm/ecmwf-sites/crds/, installed on first install, not upgraded — apply CRD changes explicitly (helm show crds)

The sites-platform-state ConfigMap is deliberately not Helm-managed. It stores read-only platform state so it survives upgrades (core/src/ecmwf/sites/core/kubernetes/platform_state.py).

RBAC model

The chart creates three ServiceAccounts with fixed names.

ServiceAccount Role (bound by) Used by Namespaced permissions Cluster permissions
sites sites-application (rbac-admin.yaml) Operator Pods get/list/watch/patch/delete; Events create; Secrets get/list/create/update/delete; Services, PVCs, and ConfigMaps get/list/create/update/delete; StatefulSets get/list/create/update/delete; Ingresses get/list/create/update/delete; HTTPRoutes get/create/update/delete; Sites get/list/watch/patch; Sites/status get/patch sites-application-resources ClusterRole: PersistentVolumes get/list/patch; Namespaces and CustomResourceDefinitions list/watch
sites-hub sites-hub (rbac-hub.yaml) Hub sites-secrets Secret (by name) get/update; sites-config and sites-platform-state ConfigMaps (by name) get/update plus ConfigMap create; Pods get/list/delete and pods/log get; ResourceQuotas get/list; Deployments list and the named sites-hub patch; Ingresses get/list and the named sites-ingress-master update; Sites get/list/create/patch/delete; Sites/status get; metrics.k8s.io pods list sites-hub-resources ClusterRole: Nodes get/list; metrics.k8s.io nodes list; PersistentVolumes list/delete (released-volume cleanup; the sites-hub-volume-deletion admission policy limits delete to Released Site volumes of the release namespace)
sites-jobs sites-jobs (rbac-jobs.yaml) The four CronJobs Sites get/list/patch/delete; Ingresses get/list —

The operator Role has no wildcard resource or verb. Gateway policy providers can add rules to the operator Role through routing.gateway.policyRbacRules. The Redis Pod mounts no ServiceAccount token. The Hub cannot read per-Site Secrets (its Secret rule is restricted by name) and cannot create workloads; only the operator ServiceAccount can.

Kopf also makes Kubernetes API calls. It patches Pod annotations for handler progress and change tracking, posts Events, and observes Namespaces and CustomResourceDefinitions — these match the ClusterRole comments and rules. These permissions are required in addition to the calls made directly by application code.

The Hub uses sites-hub, not the operator account sites. Released-volume cleanup uses the Hub ClusterRole's PersistentVolume list and delete permissions. RBAC cannot limit delete by label, so the chart also creates the ValidatingAdmissionPolicy sites-hub-volume-deletion when the cluster serves that API. It denies a Hub deletion unless the volume has the label sites.ecmwf.int/app: site, its claimRef namespace is the release namespace, and its phase is Released. The operator can read and patch volumes for site creation and recovery; it cannot delete them.

Data stores

The complete ownership and recovery rules for these stores are in data storage and recovery.

Store Contents Written by
Site PVC (<site-id>, size = quota in Gi) Site content The Site Pod
/sites-data/<namespace>/backups/ Daily YYYYMMDD.yaml exports of all Site resources Backup job
/sites-data/<namespace>/analytics/ Precomputed analytics reports Analytics preloader
/sites-data/<namespace>/templates/ Ad-hoc site creation templates (Hub API) Hub
/sites-templates/ (read-only sites-templates ConfigMap) Platform site creation templates (Helm siteTemplates) Hub
Redis Site list cache, OIDC discovery metadata (oidc-discovery_*), dashboard statistics cache Hub, operator, jobs

Each Site's content stays on its own PVC. The platform backup never copies site content.

Shared Python core

All four Python packages build on sites-core:

  • core/.../config/: the configuration schema, loader, and consistency checks (16).
  • core/.../model/: the Site model, applications registry, and the SiteExporter that renders every per-site Kubernetes object and ConfigMap (03).
  • core/.../kubernetes/: the data access object (DAO) layer and the routing backends (14).
  • core/.../integrations/: analytics, metrics, directory, notifications (17).
  • core/.../notifications/, core/.../backup/, core/.../analytics/: the CronJob implementations (15).

The Hub, the operator, and the jobs share this code. No Site container runs Python from core: the Go API, the Lua proxy, the Alpine shell initializer, and the shell-based mirror are independent implementations of their protocols, built from site/*.Dockerfile. The mirror's sitesctl comes from the separate Sites CLI repository (content mirror).

Behaviour requirements

ID Requirement Implemented in Verified by
ARC-01 The Go Site API registers no authentication or authorization; every API route is guarded by the proxy's admin_auth or private_auth module before the proxy_pass over loopback — only the public read-only files location is unauthenticated by design — and the Site Service exposes only the OAuth2 port. site/.../applications/api/main.go (RouteMiddleware pass-through), site/.../applications/proxy/nginx/api.conf, core/.../model/site_exporter.py —
ARC-02 The Hub cannot read site Secrets and cannot create workloads; only the operator ServiceAccount can. helm/ecmwf-sites/templates/rbac-hub.yaml (Secret rule restricted by resourceNames), rbac-admin.yaml utilities/tests/helm-routing-and-secrets.test.sh
ARC-03 Read-only platform state survives Helm upgrades because the state ConfigMap is not Helm-managed. core/.../kubernetes/platform_state.py core/tests/test_routing.py (test_read_missing_state, test_create_state)
ARC-04 Every per-site object carries the site labels and is adopted into the Site's ownership, so deletion cascades. core/.../model/site_exporter.py (labels), operator/.../site_create.py (kopf.adopt), operator/.../site_update.py (update_owner_references) operator/tests/test_site_create.py (test_existing_volume_adopts_and_patches, adopt assertions), operator/tests/test_site_update.py
ARC-05 The platform and its sites share one namespace; the operator watches only that namespace. operator/Dockerfile (kopf run --standalone --namespace "$NAMESPACE"), helm/.../deployment-operator.yaml —
ARC-06 Site Pod egress is limited to HTTPS and DNS, plus the database-style port list toward the configured networkPolicy.egressCIDRs. helm/ecmwf-sites/templates/networkpolicy-site.yaml —