Skip to content

Site application architecture

This page is the index for the applications that compose a Site pod. It defines the registry contract and the shared container contract: what each class must provide, and what the exporter renders from it.

The applications:

Path shortcuts: site/.../applications/ is site/src/ecmwf/sites/website/applications/; Lua and nginx paths are relative to site/.../applications/proxy/.

The model side of the registry — class flags, application selection from spec.configuration.applications, image override, and upgrade behaviour — is in domain model. This page covers the deployment contract: containers, Service ports, and route paths.

Registry contract

Each application module under site/src/ecmwf/sites/website/applications/<name>/app_config.py registers a SiteApplicationConfiguration subclass in the singleton SiteApplicationRegistry (core/src/ecmwf/sites/core/model/site_apps_registry.py). Base-class attribute defaults matter: a new built-in application is a basic application (IS_BASIC = True) unless it says otherwise.

Attribute Default Meaning
NAME — Container name and application-entry key
PORT — Container listener port. The initializer and the mirror define none
CONTAINER_IMAGE — Built-in image: <registry>/<project>/<NAME>:<VERSION>
VERSION site.version config Tag in the built-in image formula
IMAGE_PULL_POLICY kubernetes.image_pull_policy config Default pull policy for every container
IS_INIT False Render into the init-container list
IS_ESSENTIAL False The normal Site path needs the application
IS_BASIC True Include in the default application list for a new Site
IS_OPTIONAL False Shown as removable in the interface
RESOURCE_LIMIT base tiers (see 03) Per tier MEMORY/CPU request (MIN) and limit (MAX) values; MAX: None means "no limit". Every current module overrides the base values; see built-in Site applications, custom applications, and content mirror

Each application implements three abstract methods, and may override the rest:

Method Contract
get_container() Return the Kubernetes container definition
get_service_port() Return a V1ServicePort for the per-site Service, or None when the application must not be reachable cluster-wide
get_ingress_path() Return a V1HTTPIngressPath for the public route, or None when the application gets no route path
get_config_map_values(data) Receive the per-Site render data and return this application's own entries; the builder merges all applications' entries into the files ConfigMap <id>-files, never the environment ConfigMap <id>
get_extra_env_var_list() Decode the application's extra_env entries into container environment variables
is_to_expose_clusterwide() True when get_service_port() returns a port
is_to_expose_worldwide() True when get_ingress_path() returns a path

For each Site, ConfiguredSiteApplications aggregates the selected instances:

  • get_init_containers() — containers of the init applications.
  • get_containers() — containers of the selected normal applications.
  • get_service_ports() — ports of the applications where is_to_expose_clusterwide() is true.
  • get_ingress_path() — the ingress path of the first application where is_to_expose_worldwide() is true. Today only site-oauth2 qualifies.

A new built-in application must register its class and provide the three abstract methods. Display helpers (pretty_name, get_urls, get_hub_view_url, to_dict) build on NAME, DESCRIPTION, and the application path attribute.

Application matrix

Application Kind Port Own path (app.path) Service port Route
site-initializer Essential init container — Site path — —
site-oauth2 Essential, basic 4180 /s/oauth2 yes yes (Site path, owns the route)
site-proxy Essential, basic 8080 Site path — —
site-api Essential, basic 10000 <site>s/api/v2/ — —
site-admin Basic, optional 9000 <site>s/admin/ — —
site-webdav Optional 7000 <site>s/dav/ — —
site-s3 Optional 6000 <site>s/s3/ — —
site-custom Site-supplied 8000 Site path — —
site-mirror Migration container (essential while migration.enabled) — Site path — —

The "own path" column is the path attribute each application class sets. For site-api, site-admin, site-webdav, and site-s3 it is the location the proxy serves under the Site URL (<site> is the Site path); those applications are reached through the proxy, not through a route or Service path of their own. Only site-oauth2 provides a route path, which is the Site path itself. The proxy location configuration is in Site proxy.

Shared container contract

SiteExporter renders one StatefulSet per Site. The Pod carries three volumes: site-volume (the Site persistent volume claim (PVC), read-write), site-config (the files ConfigMap <id>-files), and oidc-client-secret (only the OIDC_CLIENT_SECRET key of oidc-secrets, as the file client-secret, mode 0440). Seven applications build their container through the shared _container() helper in core/src/ecmwf/sites/core/model/site_app_config.py. What it sets:

  • Configuration sources (envFrom): the per-site Secret <id> and the per-site environment ConfigMap <id>. No container gets the platform Secret oidc-secrets through envFrom. The chart creates oidc-secrets from secrets.oidcSecrets values (platform configuration).
  • Environment: the decoded extra_env entries, then PORT (the container port), then the variables that the application gives in the extra_env parameter of _container(), then the masked variables. Only site-proxy gives such variables: OIDC_CLIENT_ID and OIDC_CLIENT_SECRET, each a secretKeyRef to one key of oidc-secrets, and OIDC_EXTRA_AUDIENCES, a plain value from oidc.extra_audiences (not a Secret). The Lua code of the proxy reads them. Because they come after the application extra_env entries, a site value with the same name does not replace them (site/tests/test_oidc_secret_scope.py). The masked variables (SITE_MASKED_ENVIRONMENT_NAMES, core/.../config/constants.py) are NAMESPACE and the KUBERNETES_* variables of the kubernetes Service, each with an empty value. Kubelet always injects the KUBERNETES_* variables, also with enableServiceLinks: false. A pod cannot remove them, but a container variable with the same name replaces them. The masked variables come after extra_env, so that an extra_env entry cannot set them. The initializer and the mirror build their own containers, and they also get the masked variables.
  • Volume mounts: site-volume at /www, plus each application's own site-config subPath mounts (for example the proxy mounts admin.conf; the OAuth2 application mounts its two configuration files and the error page).
  • OAuth2 Proxy secrets: only site-oauth2 mounts oidc-client-secret, read-only, at /opt/sites/sites/site/oauth2/secrets (clientSecretFile in alpha-oauth2.cfg). It also gets OAUTH2_PROXY_COOKIE_SECRET from the key cookie-secret of the Secret <id>-oauth2, as its last environment entry, so that an extra_env entry cannot replace it. The custom application gets neither.
  • Security context: allowPrivilegeEscalation=false, runAsNonRoot=true, privileged=false. The helper accepts read_only_root_filesystem (default false); no built-in application enables it. The Pod runs as user/group 65534 and mounts no ServiceAccount token (03).
  • Resources: memory limit always set, CPU request always set, CPU limit only when the tier value sets it explicitly. This follows the _limit_range best practice: CPU is request-only unless a limit is named.
  • Image pull policy: the class default IMAGE_PULL_POLICY from kubernetes.image_pull_policy. site-custom overrides it to Always, because user images are mutable tags.

Liveness probes only: no application defines a readiness or startup probe.

Application Liveness probe Timing (initial delay / period / failure threshold)
site-oauth2 HTTP GET /ping on its port 10 s / 60 s / 3
site-proxy TCP socket on its port Kubernetes defaults
site-api, site-admin, site-webdav, site-s3 TCP socket on their port 10 s / 60 s / 3
site-custom TCP socket on its port 30 s / 60 s / 10 (tolerant of slow user applications)

The four Go applications (site-oauth2, site-api, site-admin, site-webdav) also set GOGC=50 and GOMEMLIMIT to 90 % of the effective tier memory maximum, so the garbage collector stops before the container hits its cgroup limit.

Two applications bypass _container() and build the container directly:

  • site-initializer: environment sources are the Secret <id> and the ConfigMap <id> — no OpenID Connect (OIDC) Secret, no ports, no environment variables, no probes, and no security context (the Pod-level settings still apply). It mounts the Site volume.
  • site-mirror: a normal container with no listener. It sets its own security context (the same three flags, root filesystem writable), mounts the Site volume, takes the Secret <id> and ConfigMap <id> through envFrom, and receives its MIRROR_* settings as direct env entries rendered from the platform migration settings (empty when migration.enabled is false). The same values are also added to the files ConfigMap by get_config_map_values; the mirror test asserts both copies agree. See content mirror.
flowchart LR
    R["Ingress or HTTPRoute"] --> O["site-oauth2"]
    O --> P["site-proxy"]
    P --> A["site-api"]
    P --> B["optional or custom backend"]
    I["init containers"] --> V[("Site PVC /www")]
    P --> V

The OAuth2 Proxy upstream is the proxy on 127.0.0.1:8080, rendered into alpha-oauth2.cfg. Browser traffic therefore always passes authentication (site-oauth2), then the proxy location and auth chain (site-proxy), before it reaches a backend application.

Image and selection rules

Built-in images use:

<kubernetes.private_registry>/<kubernetes.private_registry_project>/<application>:<site.version>

An image in spec.configuration.applications overrides the built-in image. A normal application is selected only when its entry supplies both a name and a non-empty image (03). An upgrade rewrites built-in image versions. It preserves the image of site-custom because the platform cannot determine its compatible version.

The application list is part of the Site resource. Adding or removing an application is therefore a Site update and follows the operator update flow in site lifecycle and reconciliation.

Behaviour requirements

ID Requirement Implemented in Verified by
APP-01 The registry creates init applications separately from normal containers and selects normal containers only when their effective image is available. core/.../model/site_apps_registry.py site/tests/test_site_apps_registry.py (test_only_the_initializer_is_an_init_application, test_init_applications_are_configured_without_a_spec_entry, test_application_entry_with_image_selects_the_application, test_application_entry_without_image_does_not_select_the_application)
APP-02 Browser traffic enters through OAuth2 Proxy and the Site proxy before it reaches a backend application. site/.../applications/oauth2/oauth2-proxy/alpha-oauth2.cfg (upstream 127.0.0.1:8080) site/tests/test_oauth_app_config.py (test_get_config_map_values), site/tests/test_proxy_lua_auth_chain.py
APP-03 The mirror has no listener, writes only to the local Site volume, and receives the source URL, interval, and deletion settings from the platform migration configuration. site/.../applications/mirror/app_config.py site/tests/test_mirror_app_config.py (test_container_has_no_listener_or_probe, test_container_mounts_site_volume_read_write, test_container_receives_mirror_environment_when_enabled, test_container_has_no_mirror_environment_when_disabled)
APP-04 Mirror downloads use a work directory and preserve the existing file when a download fails. site/.../applications/mirror/bin/mirror.sh site/tests/test_mirror_script.py
APP-05 Mirror deletion is disabled for an empty source or when the stale-file safety limit is exceeded. site/.../applications/mirror/bin/mirror.sh site/tests/test_mirror_script.py
APP-06 A custom application image must come from an allowed registry (prefix match; an empty allow-list permits any registry), and a public Site may carry a custom application only when site.allow_public_custom_application is true. core/.../form/utils.py (is_image_from_allowed_registry), core/.../form/forms.py core/tests/test_add_edit_site_form.py (test_custom_application_validation, test_custom_application_any_registry_by_default, test_unchanged_custom_image_skips_registry_check, test_custom_application_on_public_site)
APP-07 Every normal Site container receives the shared volume, the required configuration sources, and a non-privileged security context; the port and namespace environment is added except for site-mirror and site-initializer. core/.../model/site_app_config.py (_container), core/.../model/site_exporter.py site/tests/test_mirror_app_config.py (test_container_reads_site_secret_and_config_map, test_container_runs_as_non_root) — the shared _container() builder itself has no direct test
APP-08 Only site-oauth2 returns a Service port and a route path; every other application returns None for both, so the per-site Service has a single port and the route has a single backend. site/.../applications/*/app_config.py, core/.../model/site_apps_registry.py (get_service_ports, get_ingress_path) site/tests/test_site_apps_registry.py (test_only_oauth2_has_a_service_port_and_a_route_path, for every registered application), site/tests/test_oauth_app_config.py (test_get_service_port, test_get_ingress_path)
APP-09 Application data reaches containers as files in the per-site files ConfigMap (<id>-files); the environment ConfigMap (<id>) carries only the fixed site keys. core/.../model/site_exporter.py (to_config_map, to_files_config_map) site/tests/test_oauth_app_config.py (test_get_config_map_values), site/tests/test_proxy_app_config.py (test_get_config_map_values), site/tests/test_mirror_app_config.py (test_environment_and_config_map_values_agree)
BIA-01 The initializer runs before normal Site containers and has no Service port or route. site/.../applications/initializer/app_config.py, core/.../model/site_exporter.py site/tests/test_site_apps_registry.py (test_only_the_initializer_is_an_init_application; test_only_oauth2_has_a_service_port_and_a_route_path: no Service port or route; the start order itself is not asserted)
BIA-02 OAuth2 Proxy receives browser traffic, uses the rendered Site configuration, and forwards authenticated requests to the Site proxy. site/.../applications/oauth2/app_config.py site/tests/test_oauth_app_config.py
BIA-03 The admin, WebDAV, and S3 applications are selected from the Site application list and are reached through proxy-owned paths. site/.../applications/admin/, webdav/, s3/, proxy/ site/tests/test_admin_app_config.py, site/tests/test_webdav_app_config.py, site/tests/test_s3_app_config.py
BIA-04 The Site API is reachable through the loopback network and exposes no independent external route. site/.../applications/api/app_config.py, site/.../applications/proxy/nginx/api.conf, site/.../applications/proxy/lua/, core/.../model/site_exporter.py site/tests/test_api_app_config.py (test_container_uses_port_10000, test_proxy_reaches_the_api_on_its_port)
BIA-05 Every Site keeps the essential set (site-initializer, site-oauth2, site-api, site-proxy); the application list re-adds it on every change, and site-admin is the only removable default application. core/.../model/site.py (set_applications), core/.../model/site_apps_registry.py site/tests/test_site_apps_registry.py
BIA-06 A normal application creates a container only when the Site specification lists it with an image. core/.../model/site_apps_registry.py (image_available()) site/tests/test_site_apps_registry.py
BIA-07 The proxy renders the admin, API, WebDAV, and S3 locations only when the application is configured for the Site. site/.../applications/proxy/app_config.py (is_application_configured filter), proxy/nginx/*.conf —
BIA-08 WebDAV BasicAuth accepts the master token (Hex64) or a Branca token only; on public Sites, read-only methods on directories without .htpasswd run as anonymous. proxy/lua/basic.lua site/tests/test_proxy_lua_basic.py (anonymous fallback and Hex64 path; the Branca branch is not asserted)
BIA-09 The S3 container receives no authentication material: the proxy authenticates the X-Amz-Security-Token header and clears it and Authorization before forwarding. proxy/nginx/s3.conf, proxy/lua/s3_auth.lua —
BIA-10 The Site Service exposes exactly one port, site-oauth2 on 4180, with sessionAffinity: ClientIP. core/.../model/site_exporter.py (to_service), per-app get_service_port() site/tests/test_oauth_app_config.py (port and route present), site/tests/test_site_apps_registry.py (test_only_oauth2_has_a_service_port_and_a_route_path: absent for all other applications); the Service object itself is not asserted
BIA-11 Every Site image sets IMAGE_TAG from the mandatory TAG build argument; all images except the Go API also record /etc/build-date. site/*.Dockerfile site/tests/test_dockerfile_licences.py, utilities/tests/dockerfile-policy.test.sh
PRX-01 The proxy renders NGINX and listing-template files from the Site and platform configuration. site/.../applications/proxy/app_config.py, proxy/nginx/, proxy/html/ site/tests/test_proxy_app_config.py
PRX-02 Invalid values that would become NGINX settings are removed before configuration is rendered. proxy/app_config.py (remove_unsafe_nginx_settings) site/tests/test_proxy_app_config.py
PRX-03 Public and private Sites use different request maps for .htpasswd, signed URLs, session URLs, and anonymous methods. proxy/nginx/, proxy/lua/ Lua tests (public_auth.lua itself is not covered)
PRX-04 API, admin, WebDAV, S3, health, and metrics requests use separate locations and authentication handlers. proxy/nginx/*.conf, proxy/lua/*.lua site/tests/test_proxy_app_config.py, Lua tests (per-location wiring is not asserted)
PRX-05 The default content backend serves /www/html, while custom applications select proxy or FastCGI integration. proxy/nginx/location.conf, proxy/nginx/*-app.conf —
PRX-06 The health location allows the pod loopback address and trusted_ips; the metrics location allows trusted_ips only, evaluated against the real IP; an empty list denies all metrics requests. proxy/nginx/health.conf, metrics.conf site/tests/test_proxy_app_config.py (health render)
PRX-07 CORS, Content Security Policy (CSP), cache, robots, custom error, and retention settings change only the generated locations that use them. proxy/nginx/*.conf, proxy/app_config.py site/tests/test_proxy_app_config.py (CORS/CSP and unsafe settings; the X-Robots-Tag effect is not asserted)
PRX-08 The listing UI adds administrator-only Edit Mode, dropzone uploads to the Site API, and in-browser signed and session URL generation. proxy/html/js/, proxy/html/template/ —
PRX-09 Authentication failures answer as JSON on the /s/api/vN, /s/_m2m, and /s/s3 paths, and with the HTML or redirect treatment elsewhere. proxy/nginx/nginx.conf (map $want_json), proxy/lua/auth_utils.lua —
PRX-10 Content authentication paths always set X-Site-Retention-Date-Expired; an expired retention date injects the branded banner via sub_filter; a missing banner or an invalid RETENTION_DATE logs and degrades without injecting. proxy/lua/retention_date_notification.lua, proxy/nginx/server.conf, proxy/nginx/location.conf site/tests/test_proxy_lua_retention_date.py
PRX-11 When the metrics integration is active, the proxy collects counters in the log phase and serves /s/metrics from the shared dictionary; no metrics code is rendered when it is inactive. proxy/lua/metrics.lua, proxy/nginx/nginx.conf, server.conf, metrics.conf site/tests/test_proxy_app_config.py (render wiring on and off; metrics.lua logic is not covered, see 21)
CUS-01 The site-custom container exists only when the Site configuration carries a site-custom entry with a non-empty image; the form creates the entry only when an image is set. core/.../model/site_apps_registry.py, core/.../model/site.py (SiteApplicationsBuilder.from_form) site/tests/test_site_apps_registry.py (test_application_entry_with_image_selects_the_application, test_application_entry_without_image_does_not_select_the_application) — the image-selection rule is exercised with site-s3; core/tests/test_site_applications_validation.py (TestSiteApplicationFromFormEntry) covers the form-side entry creation
CUS-02 Custom image, proxy type, context root, and environment values use the Custom Resource Definition (CRD) and Hub validation rules; the form uses the same image regex as the CRD. helm/.../crds/site-crd.yaml, core/.../form/forms.py, core/.../config/constants.py (IMAGES_REGEX) core/tests/test_add_edit_site_form.py (custom application validation), hub/tests/test_api_request_validation.py (empty fields), hub/tests/test_validation_constants.py (the browser constants equal IMAGES_REGEX, SITE_CUSTOM_ERROR_REGEX, and the extra_env name rule), core/tests/test_site_applications_validation.py (the model rejects an invalid enum)
CUS-03 New or changed custom images follow the configured registry policy and public-site policy; an unchanged image on an already-public Site reverts to private, and the Hub API holds that revert until the caller confirms it. core/.../form/forms.py, core/.../form/utils.py (is_image_from_allowed_registry), hub/.../app/api/common.py (edit_site) core/tests/test_add_edit_site_form.py (test_custom_application_validation, test_custom_application_any_registry_by_default, test_unchanged_custom_image_skips_registry_check, test_custom_application_on_public_site), hub/tests/test_edit_site_reverted.py (hold, confirm, raw-client behaviour)
CUS-04 The custom container uses port 8000, TCP liveness and readiness probes, the Site volume, the Site configuration sources, the Always pull policy, and no OIDC Secret. site/.../applications/custom/app_config.py, core/.../model/site_app_config.py (_container) site/tests/test_custom_app_config.py (test_container_port_and_probe_and_pull_policy, test_container_has_a_tcp_readiness_probe_on_the_service_port, test_container_uses_the_site_configuration_sources_without_the_oidc_secret, test_container_mounts_the_site_volume, test_container_passes_the_custom_environment_variables)
CUS-05 The proxy includes proxy-app.conf or fastcgi-app.conf according to proxy_type, defaults to the static default-app.conf without a custom image, and rewrites the upstream path only in proxy mode with site_context. proxy/nginx/location.conf, proxy-app.conf, fastcgi-app.conf, fastcgi.conf, default-app.conf —
CUS-06 Site upgrades preserve the custom image while rewriting built-in application images. core/.../model/site.py (from_json upgrade path) hub/tests/test_sites_upgrade.py (test_force_same_version_preserves_spec_and_changes_request_each_time)
CUS-07 An environment entry needs a shell-style name, must not contain a comma in its value, and must not use a name the platform injects; the form lists every reserved name it finds. core/.../config/utils.py (parse_extra_env, invalid_extra_env_entries), core/.../form/utils.py (ExtraEnvEntriesValid), core/.../model/site_exporter.py (SITE_PLATFORM_ENVIRONMENT_NAMES) core/tests/test_add_edit_site_form.py (test_custom_application_extra_env_*), core/tests/test_site_applications_validation.py (TestSitePlatformEnvironmentNames)
CUS-08 The Hub sends no request to the registry of a custom application image; an image that cannot be pulled shows on the Settings tab status line after the restart. hub/.../app/hub/custom_application.py (custom_application_status), hub/.../templates/hub/view-tab-settings.html hub/tests/test_custom_application_status.py
CUS-09 The Settings tab reports the custom application restart state from the Site system status and lets an administrator restart the Site from the error line. hub/.../app/hub/custom_application.py (custom_application_status), hub/.../templates/hub/view-tab-settings.html hub/tests/test_custom_application_status.py
CUS-10 The Settings tab page carries the environment variable names only; the dialog reads the values from an endpoint that requires Site administrator access, and leaves the field out of the submission until that read succeeds. hub/.../template_utils.py, hub/.../app/api/common.py (custom_application_environment), hub/.../static/js/list-editor.js hub/tests/test_custom_application_environment.py
CUS-11 The chart rejects an invalid custom-application configuration before install or upgrade. The registry list is the only control over where custom images come from, and kubernetes.private_registry does not restrict them. helm/ecmwf-sites/templates/_helpers.tpl (sites.customApplicationSettings.validate) utilities/tests/helm-custom-applications.test.sh
CUS-12 The status line treats a waiting ContainerCreating or PodInitializing as preparing, gives a pull, crash, or out-of-memory problem a short label and a one-sentence advice, keeps an unknown reason as the raw error, and never shows the container text unescaped. hub/.../app/hub/custom_application.py (classify_custom_application_container), hub/.../templates/hub/view-tab-settings.html hub/tests/test_custom_application_status.py (TestCustomApplicationContainerClassification, the label and tooltip template tests)
CUS-13 The site-custom container carries a TCP readiness probe on port 8000 (15 s initial delay, 30 s period, failure threshold 3). The readiness state only reports acceptance through the Pod condition: the site Service publishes not-ready addresses, so it never removes the pod from routing. site/.../applications/custom/app_config.py, core/.../model/site_exporter.py (to_service) site/tests/test_custom_app_config.py (test_container_has_a_tcp_readiness_probe_on_the_service_port), core/tests/test_site_exporter_service.py
CUS-14 The Add control of a row-list editor creates a row only when every existing row holds a value in each of its required fields (an environment variable value is optional); otherwise it focuses the first required field left empty and flashes the reason in the hint, keeps one pending hint restore per editor, and never changes whether the rows may be submitted. hub/.../static/js/list-editor.js (firstBlankSegmentIndex, firstIncompleteRow, the data-list-role="add" handler) hub/tests/test_list_editor.py (test_add_creates_a_row_only_when_the_others_are_complete, test_add_accepts_an_empty_environment_variable_value)
CUS-15 A running site-custom container that fails its readiness check is recorded as NotReady in the Site status, and the status line keeps preparing with the label waiting for the application, an advice that says this is normal at start-up and lists the possible causes when it continues, and the Restart now link. A Site update deletes the pod that is not ready, so removing or changing the image does not leave the Site stuck (LIFE-15). core/.../kubernetes/utils.py (resolve_pod_status), hub/.../app/hub/custom_application.py (classify_custom_application_container), hub/.../templates/hub/view-tab-settings.html, operator/.../site_update.py (replace_not_ready_pods) core/tests/test_kubernetes_utils.py (test_running_not_ready_container_is_reported, test_ready_or_unknown_running_containers_add_no_entry), hub/tests/test_custom_application_status.py (TestCustomApplicationContainerClassification, the pending-label template test, test_not_ready_status_line_offers_restart), operator/tests/test_site_update.py::TestReplaceNotReadyPods
MIR-01 The mirror is added only when migration is enabled and has no listener, probe, Service port, or route. mirror/app_config.py, core/.../model/site_apps_registry.py site/tests/test_mirror_app_config.py (test_is_not_enabled_by_default, test_container_has_no_listener_or_probe)
MIR-02 The source URL combines the platform scheme and the configured source domain; sitesctl is configured with it and with the Site master token. mirror/app_config.py (get_environment), mirror/bin/mirror.sh site/tests/test_mirror_script.py (test_sets_the_source_url), site/tests/test_mirror_app_config.py (test_container_receives_mirror_environment_when_enabled)
MIR-03 Changed files are downloaded to a work directory and replace the existing file only after a successful download, with the source modification time restored. mirror/bin/mirror.sh (download_file) site/tests/test_mirror_script.py (test_no_partial_file_is_left_in_the_local_directory, test_retry_exhaustion_keeps_old_file_and_continues, test_local_mtime_is_set_to_source_mtime)
MIR-04 Retry, delay, dry-run, checksum, and deletion settings control the cycle behaviour. mirror/bin/mirror.sh, mirror/app_config.py site/tests/test_mirror_script.py (test_download_retry_succeeds_on_third_attempt, test_zero_retries_make_one_attempt, test_zero_delay_does_not_sleep, test_dry_run_changes_nothing, test_verify_checksum_downloads_a_different_file, test_verify_checksum_skips_a_matching_file, test_delete_false_keeps_stale_files)
MIR-05 An empty source, unreachable source, failed download, or unsafe stale-file percentage does not remove local content. mirror/bin/mirror.sh (cycle) site/tests/test_mirror_script.py (test_unavailable_source_skips_the_cycle, test_delete_is_skipped_when_source_is_empty, test_delete_is_skipped_above_the_percent_limit, test_failed_download_is_retried_in_the_next_cycle)
MIR-06 The operator renders the migration settings into the mirror container environment; the files ConfigMap copy agrees with it; both are absent when migration is disabled. mirror/app_config.py (get_environment, get_config_map_values) site/tests/test_mirror_app_config.py (test_container_receives_mirror_environment_when_enabled, test_container_has_no_mirror_environment_when_disabled, test_environment_and_config_map_values_agree)
MIR-07 A missing or invalid configuration setting is reported to stderr and never stops the mirror container; it keeps reporting until the configuration is fixed. mirror/bin/mirror.sh (configuration-error loop) site/tests/test_mirror_script.py (test_invalid_environment_is_rejected, test_missing_environment_is_rejected, test_invalid_configuration_keeps_running_and_reports)
MIR-08 The mirror performs only read operations against the source platform. mirror/bin/mirror.sh (content list, content checksum, content download only) site/tests/test_mirror_script.py (test_script_only_reads_from_the_source)
MIR-09 Change detection compares type, size, and modification time; source paths with spaces, UTC offsets, and fractional seconds parse correctly; symlinked source content arrives as regular files and directories. mirror/bin/mirror.sh (source_list, comparison awk) site/tests/test_mirror_script.py (test_second_cycle_skips_up_to_date_files, test_download_when_size_differs, test_download_when_source_is_newer, test_no_download_when_local_is_newer_with_same_size, test_names_with_spaces, test_modification_time_with_any_utc_offset, test_fractional_seconds_in_modification_time, test_symlinked_source_content_is_copied_as_regular_files)