From 45510586da8fd7ef958248109fd4ca6a5f81e740 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Tue, 28 Jul 2026 17:26:29 +0200 Subject: [PATCH] feat: implement maintenance page --- README.md | 3 + .../20260924_163352_piotr_maintenance_page.md | 29 +++++++ drydock/patches/caddyfile | 1 + drydock/patches/caddyfile-cms | 1 + drydock/patches/caddyfile-lms | 1 + drydock/patches/caddyfile-mfe-proxy | 1 + drydock/patches/kustomization-patches | 6 ++ drydock/patches/kustomization-resources | 3 + drydock/plugin.py | 78 +++++++++++++++++++ .../drydock/caddy/_maintenance.caddy | 33 ++++++++ .../drydock/k8s/maintenance/configmap.yml | 26 +++++++ .../k8s/patches/caddy-maintenance-volume.yml | 22 ++++++ .../drydock/maintenance-default/index.html | 50 ++++++++++++ 13 files changed, 254 insertions(+) create mode 100644 changelog.d/20260924_163352_piotr_maintenance_page.md create mode 100644 drydock/patches/caddyfile-mfe-proxy create mode 100644 drydock/templates/drydock/caddy/_maintenance.caddy create mode 100644 drydock/templates/drydock/k8s/maintenance/configmap.yml create mode 100644 drydock/templates/drydock/k8s/patches/caddy-maintenance-volume.yml create mode 100644 drydock/templates/drydock/maintenance-default/index.html diff --git a/README.md b/README.md index 5fe18801..f06ab3c5 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ The following configuration options are available: - `DRYDOCK_ENABLE_SCORM`: Whether to enable scorm. Defaults to `true`. - `DRYDOCK_POD_LIFECYCLE`: Whether to enable pod lifecycle. Defaults to `true`. - `DRYDOCK_REGISTRY_CREDENTIALS`: A string with the credentials to access the private registry. The format should follow the [kubernetes config.json interpretation](https://kubernetes.io/docs/concepts/containers/images/#config-json). Defaults to `""`. +- `DRYDOCK_MAINTENANCE_ENABLED`: Whether to enable maintenance mode. When enabled, every external visitor whose IP is not in the allowlist is served a maintenance page (HTTP 503) by Caddy, while the original URL is preserved (no redirect). Private IP ranges always bypass the gate, so in-cluster traffic and Kubernetes health probes are never affected. This covers typical Open edX hosts (LMS, CMS and MFE), which lets allowlisted operators QA the platform before going live. Defaults to `false`. **Note:** the gate matches the client IP that Caddy derives from `X-Forwarded-For`, which requires `ENABLE_WEB_PROXY: false` (the standard setup when Caddy runs behind an ingress, as with Drydock-managed ingresses). Since the forwarded chain is trusted, a client that forges `X-Forwarded-For` can bypass the gate. Treat this as a QA convenience, not a security boundary. +- `DRYDOCK_MAINTENANCE_ALLOWED_IPS`: A list of IPs/CIDRs that bypass maintenance mode and reach the real services, eg.: `["203.0.113.10", "198.51.100.0/24"]`. Private ranges are always allowed in addition to this list. Defaults to `[]`. +- `DRYDOCK_MAINTENANCE_PAGE_DIR`: Path to a directory holding the maintenance page assets. The directory must contain an `index.html` (the page entry point). Only top-level files are used (no subdirectories), and the page must reference its assets relatively (eg. `./style.css`). Relative paths are resolved against the Tutor root. When empty, a built-in default page is used. Defaults to `""`. **Note:** the assets are rendered into a ConfigMap, which is capped at ~1 MiB, so keep the page lightweight. - `DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_MFE`: The minimum available percentage for the MFE's PodDisruptionBudget. To disable the PodDisruptionBudget, set `0`. Defaults to `0`. - `DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_FORUM`: The minimum available percentage for the FORUM's PodDisruptionBudget. To disable the PodDisruptionBudget, set `0`. Defaults to `0`. - `DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_CADDY`: The minimum available percentage for the CADDY's PodDisruptionBudget. To disable the PodDisruptionBudget, set `0`. Defaults to `0`. diff --git a/changelog.d/20260924_163352_piotr_maintenance_page.md b/changelog.d/20260924_163352_piotr_maintenance_page.md new file mode 100644 index 00000000..9ea88c1d --- /dev/null +++ b/changelog.d/20260924_163352_piotr_maintenance_page.md @@ -0,0 +1,29 @@ + + +### Added + +- Maintenance page. + + + + diff --git a/drydock/patches/caddyfile b/drydock/patches/caddyfile index d2709530..d869c17c 100644 --- a/drydock/patches/caddyfile +++ b/drydock/patches/caddyfile @@ -1,5 +1,6 @@ {% if DRYDOCK_ENABLE_MULTITENANCY %} {$default_site_port:80} { +{% include "drydock/caddy/_maintenance.caddy" %} @favicon_matcher { path_regexp ^/favicon.ico$ } diff --git a/drydock/patches/caddyfile-cms b/drydock/patches/caddyfile-cms index b6a51624..c1c56755 100644 --- a/drydock/patches/caddyfile-cms +++ b/drydock/patches/caddyfile-cms @@ -1,3 +1,4 @@ +{% include "drydock/caddy/_maintenance.caddy" %} {% if DRYDOCK_ENABLE_SCORM and MINIO_HOST is defined %} @scorm_matcher { path /scorm-proxy/* diff --git a/drydock/patches/caddyfile-lms b/drydock/patches/caddyfile-lms index b6a51624..c1c56755 100644 --- a/drydock/patches/caddyfile-lms +++ b/drydock/patches/caddyfile-lms @@ -1,3 +1,4 @@ +{% include "drydock/caddy/_maintenance.caddy" %} {% if DRYDOCK_ENABLE_SCORM and MINIO_HOST is defined %} @scorm_matcher { path /scorm-proxy/* diff --git a/drydock/patches/caddyfile-mfe-proxy b/drydock/patches/caddyfile-mfe-proxy new file mode 100644 index 00000000..577b213c --- /dev/null +++ b/drydock/patches/caddyfile-mfe-proxy @@ -0,0 +1 @@ +{% include "drydock/caddy/_maintenance.caddy" %} diff --git a/drydock/patches/kustomization-patches b/drydock/patches/kustomization-patches index 26fca338..750f3adf 100644 --- a/drydock/patches/kustomization-patches +++ b/drydock/patches/kustomization-patches @@ -30,3 +30,9 @@ kind: Deployment name: '{% for name in DRYDOCK_POST_INIT_DEPLOYMENTS %}{{ name }}{% if not loop.last %}|{% endif %}{% endfor %}' path: plugins/drydock/k8s/patches/post-init-deployments-sync-wave.yml +{% if DRYDOCK_MAINTENANCE_ENABLED -%} +- target: + kind: Deployment + name: caddy + path: plugins/drydock/k8s/patches/caddy-maintenance-volume.yml +{% endif -%} diff --git a/drydock/patches/kustomization-resources b/drydock/patches/kustomization-resources index 05f39ba3..6729c839 100644 --- a/drydock/patches/kustomization-resources +++ b/drydock/patches/kustomization-resources @@ -15,3 +15,6 @@ {% if DRYDOCK_REGISTRY_CREDENTIALS -%} - plugins/drydock/k8s/secrets/image-pull-secret.yml {% endif -%} +{% if DRYDOCK_MAINTENANCE_ENABLED -%} +- plugins/drydock/k8s/maintenance/configmap.yml +{% endif -%} diff --git a/drydock/plugin.py b/drydock/plugin.py index e514e6dd..d5dd133f 100644 --- a/drydock/plugin.py +++ b/drydock/plugin.py @@ -1,4 +1,6 @@ +import base64 import functools +import hashlib import importlib.resources import os import typing as t @@ -8,6 +10,7 @@ from tutor import env as tutor_env from tutor import hooks as tutor_hooks from tutor import serialize, types +from tutor.exceptions import TutorError from tutor.commands.jobs import do_callback from tutor.commands.k8s import k8s @@ -48,6 +51,9 @@ ["lms", "cms", "forum", "lms-worker", "cms-worker", "superset", "superset-worker", "superset-celery-beat"], ), ("DRYDOCK_REGISTRY_CREDENTIALS", ""), + ("DRYDOCK_MAINTENANCE_ENABLED", False), + ("DRYDOCK_MAINTENANCE_ALLOWED_IPS", []), + ("DRYDOCK_MAINTENANCE_PAGE_DIR", ""), ] ) @@ -176,6 +182,76 @@ def get_sync_waves_for_resource(resource_name: str) -> int: return get_sync_waves_order().get(resource_name, 0) +DEFAULT_MAINTENANCE_PAGE_DIR = str( + importlib.resources.files("drydock") / "templates" / "drydock" / "maintenance-default" +) + + +def _resolve_maintenance_dir(page_dir: str) -> str: + """ + Resolve the directory holding the maintenance page assets. + + An empty ``page_dir`` falls back to the plugin's built-in default page. + Relative paths are resolved against the Tutor root when it is available. + """ + if not page_dir: + return DEFAULT_MAINTENANCE_PAGE_DIR + if os.path.isabs(page_dir): + return page_dir + try: + root = click.get_current_context().obj.root + return os.path.join(root, page_dir) + except (RuntimeError, AttributeError): + return os.path.abspath(page_dir) + + +def get_maintenance_files(page_dir: str = "") -> list[dict[str, t.Any]]: + """ + Return the top-level files of the maintenance page directory. + + Only top-level files are returned because ConfigMap keys cannot contain + ``/``. Text files go into ``data``; binary files are base64-encoded into + ``binaryData``. The maintenance page should reference its assets relatively + (e.g. ``./style.css``), since they all land in the same served directory. + """ + base = _resolve_maintenance_dir(page_dir) + if not os.path.isdir(base): + raise TutorError(f"DRYDOCK_MAINTENANCE_PAGE_DIR: directory does not exist: {base}") + files: list[dict[str, t.Any]] = [] + for name in sorted(os.listdir(base)): + full_path = os.path.join(base, name) + if not os.path.isfile(full_path): + continue + with open(full_path, "rb") as asset: + raw = asset.read() + try: + files.append({"name": name, "binary": False, "data": raw.decode("utf-8")}) + except UnicodeDecodeError: + files.append({"name": name, "binary": True, "data": base64.b64encode(raw).decode("ascii")}) + if not any(asset["name"] == "index.html" for asset in files): + raise TutorError( + f"DRYDOCK_MAINTENANCE_PAGE_DIR: no index.html found in {base}. " + "The maintenance page entry point must be named index.html." + ) + return files + + +def maintenance_assets_checksum(page_dir: str = "") -> str: + """ + Short content hash of the maintenance assets. + + Used to suffix the ConfigMap name and annotate the Caddy pod template, so + that updating the page triggers a rollout. + """ + digest = hashlib.sha256() + for asset in get_maintenance_files(page_dir): + digest.update(asset["name"].encode("utf-8")) + digest.update(b"\0") + digest.update(asset["data"].encode("utf-8")) + digest.update(b"\0") + return digest.hexdigest()[:12] + + ################# You don't really have to bother about what's below this line, ################# except maybe for educational purposes :) @@ -202,6 +278,8 @@ def get_sync_waves_for_resource(resource_name: str) -> int: ("get_init_tasks", get_init_tasks), ("iter_sync_waves_order", iter_sync_waves_order), ("get_sync_waves_for_resource", get_sync_waves_for_resource), + ("get_maintenance_files", get_maintenance_files), + ("maintenance_assets_checksum", maintenance_assets_checksum), ] ) diff --git a/drydock/templates/drydock/caddy/_maintenance.caddy b/drydock/templates/drydock/caddy/_maintenance.caddy new file mode 100644 index 00000000..01008b3d --- /dev/null +++ b/drydock/templates/drydock/caddy/_maintenance.caddy @@ -0,0 +1,33 @@ +{% if DRYDOCK_MAINTENANCE_ENABLED %} +# Drydock maintenance mode: serve a maintenance page to every external visitor +# whose IP is not in the allowlist. Private ranges always bypass so in-cluster +# traffic and kubelet health probes are never gated. +# This must be a `route`, not a `handle`: handles in a site block are mutually +# exclusive (first match wins), so an earlier non-terminal handle_path (eg. the +# profile-image upload limit in the base LMS block) would let matching requests +# fall through to reverse_proxy and bypass the gate. +@drydock_blocked not client_ip private_ranges {{ DRYDOCK_MAINTENANCE_ALLOWED_IPS | join(" ") }} +route @drydock_blocked { + root * /var/www/drydock-maintenance + # Serve referenced assets (css/img) as-is; fall back to the page otherwise. + @drydock_asset file + handle @drydock_asset { + header Cache-Control "no-store" + file_server + } + handle { + header { + Cache-Control "no-store" + Retry-After "3600" + } + # file_server would answer non-GET/HEAD requests (API calls, form + # submissions) with 405; keep the maintenance semantics instead. + @drydock_nonget not method GET HEAD + respond @drydock_nonget 503 + rewrite * /index.html + file_server { + status 503 + } + } +} +{% endif %} diff --git a/drydock/templates/drydock/k8s/maintenance/configmap.yml b/drydock/templates/drydock/k8s/maintenance/configmap.yml new file mode 100644 index 00000000..a223d658 --- /dev/null +++ b/drydock/templates/drydock/k8s/maintenance/configmap.yml @@ -0,0 +1,26 @@ +{% if DRYDOCK_MAINTENANCE_ENABLED -%} +{% set maintenance_files = get_maintenance_files(DRYDOCK_MAINTENANCE_PAGE_DIR) -%} +apiVersion: v1 +kind: ConfigMap +metadata: + name: drydock-maintenance-page-{{ maintenance_assets_checksum(DRYDOCK_MAINTENANCE_PAGE_DIR) }} + namespace: {{ K8S_NAMESPACE }} + labels: + app.kubernetes.io/name: openedx + app.kubernetes.io/component: drydock-maintenance +{% set text_files = maintenance_files | rejectattr("binary") | list -%} +{% set binary_files = maintenance_files | selectattr("binary") | list -%} +{% if text_files %} +data: +{% for asset in text_files %} + {{ asset.name }}: | +{{ asset.data | indent(4, first=True) }} +{% endfor %} +{%- endif %} +{% if binary_files %} +binaryData: +{% for asset in binary_files %} + {{ asset.name }}: {{ asset.data }} +{% endfor %} +{%- endif %} +{%- endif %} diff --git a/drydock/templates/drydock/k8s/patches/caddy-maintenance-volume.yml b/drydock/templates/drydock/k8s/patches/caddy-maintenance-volume.yml new file mode 100644 index 00000000..f2481d5a --- /dev/null +++ b/drydock/templates/drydock/k8s/patches/caddy-maintenance-volume.yml @@ -0,0 +1,22 @@ +{% if DRYDOCK_MAINTENANCE_ENABLED -%} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: caddy +spec: + template: + metadata: + annotations: + drydock.io/maintenance-checksum: "{{ maintenance_assets_checksum(DRYDOCK_MAINTENANCE_PAGE_DIR) }}" + spec: + volumes: + - name: drydock-maintenance + configMap: + name: drydock-maintenance-page-{{ maintenance_assets_checksum(DRYDOCK_MAINTENANCE_PAGE_DIR) }} + containers: + - name: caddy + volumeMounts: + - name: drydock-maintenance + mountPath: /var/www/drydock-maintenance + readOnly: true +{% endif -%} diff --git a/drydock/templates/drydock/maintenance-default/index.html b/drydock/templates/drydock/maintenance-default/index.html new file mode 100644 index 00000000..b818fcd3 --- /dev/null +++ b/drydock/templates/drydock/maintenance-default/index.html @@ -0,0 +1,50 @@ + + + + + + + We'll be right back + + + +
+ Scheduled maintenance +

We’ll be right back

+

The platform is temporarily unavailable while we perform scheduled maintenance.

+

Thank you for your patience — please check back again shortly.

+
+ +