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 runadmin_auth.lua(the session-files location runssession_files_auth.lua) beforeproxy_passtohttp://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 on127.0.0.1:10000in the shared Pod network namespace, and gin trustsX-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-oauth2on 4180. Every other application returns no Service port and no route path (site application architecture). - When
networkPolicy.enabledis true, thesite-internal-trafficpolicy limits ingress to Site Pods: port 4180 from the routing namespace (ingressNamespacein ingress mode,gatewayNamespacein 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.enabledis true, thehub-internal-trafficpolicy 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, andsite-initializerare essential applications: the platform always configures them.site-mirroris platform-controlled (migration.enabled). The other applications are enabled per site throughspec.configuration.applications(03-domain-model).site-initializerruns before the application containers.site-mirroris 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>(holdsMASTER_TOKEN) and the per-site environment ConfigMap<id>throughenvFrom. Rendered files arrive through the files ConfigMap<id>-files, mounted as thesite-configvolume (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 theSiteExporterthat 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 |
— |