Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
29 changes: 29 additions & 0 deletions changelog.d/20260924_163352_piotr_maintenance_page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!--
A new scriv changelog fragment.

Uncomment the section that is right (remove the HTML comment wrapper).
For top level release notes, leave all the headers commented out.
-->

### Added

- Maintenance page.

<!--
### Changed

- A bullet item for the Changed category.

-->
<!--
### Fixed

- A bullet item for the Fixed category.

-->
<!--
### Removed

- A bullet item for the Removed category.

-->
1 change: 1 addition & 0 deletions drydock/patches/caddyfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{% if DRYDOCK_ENABLE_MULTITENANCY %}
{$default_site_port:80} {
{% include "drydock/caddy/_maintenance.caddy" %}
@favicon_matcher {
path_regexp ^/favicon.ico$
}
Expand Down
1 change: 1 addition & 0 deletions drydock/patches/caddyfile-cms
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% include "drydock/caddy/_maintenance.caddy" %}
{% if DRYDOCK_ENABLE_SCORM and MINIO_HOST is defined %}
@scorm_matcher {
path /scorm-proxy/*
Expand Down
1 change: 1 addition & 0 deletions drydock/patches/caddyfile-lms
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% include "drydock/caddy/_maintenance.caddy" %}
{% if DRYDOCK_ENABLE_SCORM and MINIO_HOST is defined %}
@scorm_matcher {
path /scorm-proxy/*
Expand Down
1 change: 1 addition & 0 deletions drydock/patches/caddyfile-mfe-proxy
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% include "drydock/caddy/_maintenance.caddy" %}
6 changes: 6 additions & 0 deletions drydock/patches/kustomization-patches
Original file line number Diff line number Diff line change
Expand Up @@ -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 -%}
3 changes: 3 additions & 0 deletions drydock/patches/kustomization-resources
Original file line number Diff line number Diff line change
Expand Up @@ -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 -%}
78 changes: 78 additions & 0 deletions drydock/plugin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import base64
import functools
import hashlib
import importlib.resources
import os
import typing as t
Expand All @@ -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

Expand Down Expand Up @@ -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", ""),
]
)

Expand Down Expand Up @@ -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 :)

Expand All @@ -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),
]
)

Expand Down
33 changes: 33 additions & 0 deletions drydock/templates/drydock/caddy/_maintenance.caddy
Original file line number Diff line number Diff line change
@@ -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 %}
26 changes: 26 additions & 0 deletions drydock/templates/drydock/k8s/maintenance/configmap.yml
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
@@ -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 -%}
50 changes: 50 additions & 0 deletions drydock/templates/drydock/maintenance-default/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>We'll be right back</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 1.5rem;
}
.card {
max-width: 32rem;
text-align: center;
line-height: 1.6;
}
.badge {
display: inline-block;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #38bdf8;
border: 1px solid #38bdf8;
border-radius: 999px;
padding: 0.25rem 0.75rem;
margin-bottom: 1.5rem;
}
h1 { font-size: 1.75rem; margin: 0 0 1rem; }
p { margin: 0 0 0.75rem; color: #94a3b8; }
</style>
</head>
<body>
<main class="card">
<span class="badge">Scheduled maintenance</span>
<h1>We&rsquo;ll be right back</h1>
<p>The platform is temporarily unavailable while we perform scheduled maintenance.</p>
<p>Thank you for your patience &mdash; please check back again shortly.</p>
</main>
</body>
</html>