diff --git a/.github/environments/enabled/config.yml b/.github/environments/enabled/config.yml index 9a9c2632..5e4ed747 100644 --- a/.github/environments/enabled/config.yml +++ b/.github/environments/enabled/config.yml @@ -27,6 +27,12 @@ DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_LMS_WORKER: 50 DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_CMS: 50 DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_CMS_WORKER: 50 DRYDOCK_MIGRATE_FROM: 13 +DRYDOCK_EDGE_PROXY_ENABLED: true +DRYDOCK_EDGE_PROXY_ROUTES: +- lms +- cms +DRYDOCK_EDGE_PROXY_ALLOWED_IPS: +- "10.0.0.0/8" LMS_HOST: local.edly.io CMS_HOST: studio.local.edly.io MFE_HOST: apps.local.edly.io diff --git a/.github/workflows/config-files-validation.yml b/.github/workflows/config-files-validation.yml index e7b6b05a..d410ccb9 100644 --- a/.github/workflows/config-files-validation.yml +++ b/.github/workflows/config-files-validation.yml @@ -56,7 +56,6 @@ jobs: - name: Setup Tutor environment run: | echo "TUTOR_ROOT=$GITHUB_WORKSPACE/.github/environments/${{ matrix.environment }}" >> $GITHUB_ENV - echo "TUTOR_PLUGINS_ROOT=$TUTOR_ROOT/plugins" >> $GITHUB_ENV - name: Tutor config save run: | @@ -72,6 +71,16 @@ jobs: caddy:2 \ caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + - name: Validate generated edge-proxy Caddyfile + run: | + EDGE_PROXY_CADDYFILE="$TUTOR_ROOT/env/plugins/drydock/maintenance/apps/caddy/Caddyfile" + if [ -f "$EDGE_PROXY_CADDYFILE" ]; then + docker run --rm \ + -v "$EDGE_PROXY_CADDYFILE:/etc/caddy/Caddyfile:ro" \ + caddy:2 \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + fi + - name: Print versions run: | echo "Kubectl version installed:" diff --git a/README.md b/README.md index 5fe18801..89caebc0 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,36 @@ The following configuration options are available: - `DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_CMS`: The minimum available percentage for the CMS's PodDisruptionBudget. To disable the PodDisruptionBudget, set `0`. Defaults to `0`. - `DRYDOCK_PDB_MINAVAILABLE_PERCENTAGE_CMS_WORKER`: The minimum available percentage for the worker's PodDisruptionBudget. To disable the PodDisruptionBudget, set `0`. Defaults to `0`. - `DRYDOCK_MIGRATE_FROM`: it allows defining the version of the OpenedX platform we are migrating from. It accepts the integer value mapping the origin release, for instance, `13`(maple) or `14`(nutmeg). When this variable is set, a group of `release-specific upgrade jobs` are added to the Kubernetes manifests. These jobs are applied to the cluster in a suitable order (thanks to the GitOps implementation with ArgoCD + sync waves) to guarantee the correct behavior of the platform in the new version. This brings the `tutor k8s upgrade `_ command to the GitOps pattern. The release-specific upgrade jobs are supported from release `13`(maple). Defaults to `0` (which disables release-specific upgrade jobs) +- `DRYDOCK_EDGE_PROXY_ENABLED`: Whether to deploy a dedicated edge proxy (Caddy) and redirect all Ingress backends to it. Defaults to `false`. +- `DRYDOCK_EDGE_PROXY_HTTP_STATUS`: HTTP status code returned with the maintenance HTML page. Defaults to `503`. +- `DRYDOCK_EDGE_PROXY_ALLOWED_IPS`: List of CIDR ranges allowed to bypass the edge proxy and reach the original caddy. Defaults to `[]`. > **_NOTE:_** You also need to set `DRYDOCK_INIT_JOBS` to `true` to enable the release-specific upgrade jobs in the case of a platform migration. +Edge Proxy +---------- + +When `DRYDOCK_EDGE_PROXY_ENABLED` is `true` and `DRYDOCK_INGRESS` is enabled, Drydock deploys an `edge-proxy` service (Caddy) and redirects **all** Ingress backends (LMS, Studio, MFE, Notes, Meilisearch, and extra hosts) to it. + +The edge proxy serves a default HTML page with the configured HTTP status code (`DRYDOCK_EDGE_PROXY_HTTP_STATUS`, default 503) to external clients. + +**Built-in IP allowlist**: Configure `DRYDOCK_EDGE_PROXY_ALLOWED_IPS` with a list of CIDR ranges (e.g., `["10.0.0.0/8", "192.168.1.100/32"]`) to allow specific IPs to bypass the edge proxy and reach the original Open edX Caddy directly. + +**Custom routing**: Additional Caddy configuration can be added through the `drydock-edge-proxy-caddyfile` Tutor patch. The `drydock-edge-proxy-caddyfile-global` patch is available for global Caddy options such as `trusted_proxies`. This keeps custom rules isolated from the main Open edX Caddyfile. + +**Example - Allow specific paths to bypass maintenance**: +```caddyfile +# In drydock-edge-proxy-caddyfile patch +@bypass { + path /health + path /api/v1/status +} +handle @bypass { + reverse_proxy caddy:80 +} +``` + + Job generation -------------- diff --git a/drydock/patches/drydock-edge-proxy-caddyfile b/drydock/patches/drydock-edge-proxy-caddyfile new file mode 100644 index 00000000..1617d9be --- /dev/null +++ b/drydock/patches/drydock-edge-proxy-caddyfile @@ -0,0 +1,24 @@ +# Example patch to allow specific paths to bypass edge-proxy and go directly to caddy +# Enable this patch by adding it to your tutor config: +# DRYDOCK_PATCHES: +# - drydock-edge-proxy-caddyfile +# +# This allows requests matching @bypass to be proxied to the main caddy service +# instead of showing the maintenance page. +# +# Usage examples: +# - Allow /health endpoint to pass through +# - Allow specific API paths during maintenance +# - IP-based bypass (combine with DRYDOCK_EDGE_PROXY_ALLOWED_IPS) + +@bypass { + # Example: Allow health checks to pass through + path /health + # Example: Allow specific paths during maintenance + # path /api/v1/status + # path /heartbeat +} + +handle @bypass { + reverse_proxy caddy:80 +} \ No newline at end of file diff --git a/drydock/patches/drydock-edge-proxy-caddyfile-global b/drydock/patches/drydock-edge-proxy-caddyfile-global new file mode 100644 index 00000000..aa28fb25 --- /dev/null +++ b/drydock/patches/drydock-edge-proxy-caddyfile-global @@ -0,0 +1,20 @@ +# Example global patch for edge-proxy Caddyfile +# Enable this patch by adding it to your tutor config: +# DRYDOCK_PATCHES: +# - drydock-edge-proxy-caddyfile-global +# +# This patch adds global Caddy configuration options for edge-proxy + +{ + # Trusted proxies for correct client IP detection behind load balancers + trusted_proxies private_ranges + + # Example: Custom log format + # log { + # output stdout + # format json + # } + + # Example: Admin API endpoint (disabled by default for security) + # admin off +} \ No newline at end of file diff --git a/drydock/patches/kustomization b/drydock/patches/kustomization new file mode 100644 index 00000000..e69de29b diff --git a/drydock/patches/kustomization-configmapgenerator b/drydock/patches/kustomization-configmapgenerator index 2dfa9d60..4fae1fa2 100644 --- a/drydock/patches/kustomization-configmapgenerator +++ b/drydock/patches/kustomization-configmapgenerator @@ -6,3 +6,12 @@ labels: app.kubernetes.io/name: openedx {% endif -%} +{%- if DRYDOCK_EDGE_PROXY_ENABLED %} +- name: edge-proxy-config + files: + - plugins/drydock/edge-proxy/apps/caddy/Caddyfile + - plugins/drydock/edge-proxy/static/maintenance.html + options: + labels: + app.kubernetes.io/name: edge-proxy +{%- endif %} diff --git a/drydock/patches/kustomization-resources b/drydock/patches/kustomization-resources index 05f39ba3..ae0fc647 100644 --- a/drydock/patches/kustomization-resources +++ b/drydock/patches/kustomization-resources @@ -15,3 +15,7 @@ {% if DRYDOCK_REGISTRY_CREDENTIALS -%} - plugins/drydock/k8s/secrets/image-pull-secret.yml {% endif -%} +{%- if DRYDOCK_EDGE_PROXY_ENABLED and DRYDOCK_INGRESS %} +- plugins/drydock/edge-proxy/k8s/deployment.yml +- plugins/drydock/edge-proxy/k8s/service.yml +{%- endif %} diff --git a/drydock/plugin.py b/drydock/plugin.py index e514e6dd..0743cb93 100644 --- a/drydock/plugin.py +++ b/drydock/plugin.py @@ -48,6 +48,9 @@ ["lms", "cms", "forum", "lms-worker", "cms-worker", "superset", "superset-worker", "superset-celery-beat"], ), ("DRYDOCK_REGISTRY_CREDENTIALS", ""), + ("DRYDOCK_EDGE_PROXY_ENABLED", False), + ("DRYDOCK_EDGE_PROXY_HTTP_STATUS", 503), + ("DRYDOCK_EDGE_PROXY_ALLOWED_IPS", []), ] ) @@ -186,6 +189,9 @@ def get_sync_waves_for_resource(resource_name: str) -> int: ("drydock/build", "plugins"), ("drydock/apps", "plugins"), ("drydock/k8s", "plugins"), + ("drydock/edge-proxy/k8s", "plugins"), + ("drydock/edge-proxy/apps", "plugins"), + ("drydock/edge-proxy/static", "plugins"), ], ) # Load all patches from the "patches" folder diff --git a/drydock/templates/drydock/edge-proxy/apps/caddy/Caddyfile b/drydock/templates/drydock/edge-proxy/apps/caddy/Caddyfile new file mode 100644 index 00000000..aeb3a900 --- /dev/null +++ b/drydock/templates/drydock/edge-proxy/apps/caddy/Caddyfile @@ -0,0 +1,34 @@ +{{ patch("drydock-edge-proxy-caddyfile-global") }} + +:80 { + handle /health { + respond "OK" 200 + } + + {{ patch("drydock-edge-proxy-caddyfile") | indent(4) }} + + {% if DRYDOCK_EDGE_PROXY_ALLOWED_IPS %} + @allowed_ips { + {% for ip in DRYDOCK_EDGE_PROXY_ALLOWED_IPS %} + remote_ip {{ ip }} + {% endfor %} + } + handle @allowed_ips { + reverse_proxy caddy:80 + } + {% endif %} + + handle { + error {{ DRYDOCK_EDGE_PROXY_HTTP_STATUS }} + } + + handle_errors { + @maintenance expression {http.error.status_code} == {{ DRYDOCK_EDGE_PROXY_HTTP_STATUS }} + handle @maintenance { + root * /srv/maintenance + rewrite * /maintenance.html + header Content-Type "text/html; charset=utf-8" + file_server + } + } +} diff --git a/drydock/templates/drydock/edge-proxy/k8s/deployment.yml b/drydock/templates/drydock/edge-proxy/k8s/deployment.yml new file mode 100644 index 00000000..d840f550 --- /dev/null +++ b/drydock/templates/drydock/edge-proxy/k8s/deployment.yml @@ -0,0 +1,51 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: edge-proxy + labels: + app.kubernetes.io/name: edge-proxy +spec: + selector: + matchLabels: + app.kubernetes.io/name: edge-proxy + template: + metadata: + labels: + app.kubernetes.io/name: edge-proxy + spec: + containers: + - name: caddy + image: {{ DOCKER_IMAGE_CADDY }} + env: + - name: default_site_port + value: ":80" + volumeMounts: + - mountPath: /etc/caddy/ + name: config + - mountPath: /srv/maintenance/ + name: html + ports: + - containerPort: 80 + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: edge-proxy-config + - name: html + configMap: + name: edge-proxy-config + items: + - key: maintenance.html + path: maintenance.html diff --git a/drydock/templates/drydock/edge-proxy/k8s/service.yml b/drydock/templates/drydock/edge-proxy/k8s/service.yml new file mode 100644 index 00000000..c9ecd502 --- /dev/null +++ b/drydock/templates/drydock/edge-proxy/k8s/service.yml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: edge-proxy + labels: + app.kubernetes.io/name: edge-proxy +spec: + type: ClusterIP + ports: + - port: 80 + name: http + selector: + app.kubernetes.io/name: edge-proxy diff --git a/drydock/templates/drydock/edge-proxy/static/maintenance.html b/drydock/templates/drydock/edge-proxy/static/maintenance.html new file mode 100644 index 00000000..a8f0cf13 --- /dev/null +++ b/drydock/templates/drydock/edge-proxy/static/maintenance.html @@ -0,0 +1,22 @@ + + + + + + Maintenance + + + +

Under maintenance

+

We will be back shortly.

+ + diff --git a/drydock/templates/drydock/k8s/ingress/cms.yml b/drydock/templates/drydock/k8s/ingress/cms.yml index 61bec919..5c493b13 100644 --- a/drydock/templates/drydock/k8s/ingress/cms.yml +++ b/drydock/templates/drydock/k8s/ingress/cms.yml @@ -21,14 +21,14 @@ spec: path: "/course-authoring" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 - pathType: Prefix path: "/{{app_name}}" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {%- endif %} @@ -38,10 +38,10 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 - {%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} +{%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} tls: - hosts: - {{ CMS_HOST }} @@ -50,4 +50,4 @@ spec: {%- else %} secretName: cms-host-tls {%- endif %} - {%- endif %} +{%- endif %} diff --git a/drydock/templates/drydock/k8s/ingress/extra-hosts.yml b/drydock/templates/drydock/k8s/ingress/extra-hosts.yml index a1950734..c02852b5 100644 --- a/drydock/templates/drydock/k8s/ingress/extra-hosts.yml +++ b/drydock/templates/drydock/k8s/ingress/extra-hosts.yml @@ -19,10 +19,10 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 - {%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} +{%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} tls: - hosts: - {{ host }} @@ -31,5 +31,5 @@ spec: {%- else %} secretName: {{ host|replace(".", "-") }}-tls {%- endif %} - {%- endif %} +{%- endif %} {%- endfor %} diff --git a/drydock/templates/drydock/k8s/ingress/lms.yml b/drydock/templates/drydock/k8s/ingress/lms.yml index df273a12..65ae1e3d 100644 --- a/drydock/templates/drydock/k8s/ingress/lms.yml +++ b/drydock/templates/drydock/k8s/ingress/lms.yml @@ -18,7 +18,7 @@ spec: path: "/learning" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {%- if MFE_DOCKER_IMAGE is defined %} @@ -28,7 +28,7 @@ spec: path: "/{{app_name}}" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {%- endif %} @@ -38,11 +38,11 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {{ patch("drydock-lms-extra-paths")|indent(6) }} - {%- for host in DRYDOCK_INGRESS_LMS_EXTRA_HOSTS %} +{%- for host in DRYDOCK_INGRESS_LMS_EXTRA_HOSTS %} - host: {{ host }} http: paths: @@ -50,7 +50,7 @@ spec: path: "/learning" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {%- if MFE_DOCKER_IMAGE is defined %} @@ -59,7 +59,7 @@ spec: path: "/{{app_name}}" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {%- endfor %} @@ -68,12 +68,12 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 {{ patch("drydock-lms-extra-paths")|indent(6) }} - {%- endfor %} - {%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} +{%- endfor %} +{%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} tls: - hosts: - {{ LMS_HOST }} @@ -85,4 +85,4 @@ spec: {%- else %} secretName: lms-host-tls {%- endif %} - {%- endif %} +{%- endif %} diff --git a/drydock/templates/drydock/k8s/ingress/meilisearch.yml b/drydock/templates/drydock/k8s/ingress/meilisearch.yml index c7e84ec4..2c43fcf5 100644 --- a/drydock/templates/drydock/k8s/ingress/meilisearch.yml +++ b/drydock/templates/drydock/k8s/ingress/meilisearch.yml @@ -18,10 +18,10 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 - {%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} +{%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} tls: - hosts: - {{ MEILISEARCH_HOST }} @@ -30,5 +30,5 @@ spec: {%- else %} secretName: meilisearch-host-tls {%- endif %} - {%- endif %} +{%- endif %} {%- endif %} diff --git a/drydock/templates/drydock/k8s/ingress/mfe.yml b/drydock/templates/drydock/k8s/ingress/mfe.yml index 9b65bc1a..c4db4850 100644 --- a/drydock/templates/drydock/k8s/ingress/mfe.yml +++ b/drydock/templates/drydock/k8s/ingress/mfe.yml @@ -19,10 +19,10 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 - {%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} +{%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} tls: - hosts: - {{ MFE_HOST }} @@ -31,5 +31,5 @@ spec: {%- else %} secretName: mfe-host-tls {%- endif %} - {%- endif %} +{%- endif %} {%- endif %} diff --git a/drydock/templates/drydock/k8s/ingress/notes.yml b/drydock/templates/drydock/k8s/ingress/notes.yml index a4550d65..41b71b6e 100644 --- a/drydock/templates/drydock/k8s/ingress/notes.yml +++ b/drydock/templates/drydock/k8s/ingress/notes.yml @@ -18,10 +18,10 @@ spec: path: "/" backend: service: - name: caddy + name: {{ "edge-proxy" if DRYDOCK_EDGE_PROXY_ENABLED else "caddy" }} port: number: 80 - {%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} +{%- if DRYDOCK_AUTO_TLS or DRYDOCK_CUSTOM_CERTS %} tls: - hosts: - {{ NOTES_HOST }} @@ -30,5 +30,5 @@ spec: {%- else %} secretName: notes-host-tls {%- endif %} - {%- endif %} +{%- endif %} {%- endif %} diff --git a/pyproject.toml b/pyproject.toml index b53fcc1a..cb6ea31a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tutor-contrib-drydock" -version = "22.1.0" +version = "22.2.0" description = "A Tutor plugin to manage our opinionated Open edX operations" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index b606ff07..82ba949a 100644 --- a/uv.lock +++ b/uv.lock @@ -1398,7 +1398,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/d7/6c/f58136bcbc099fb97 [[package]] name = "tutor-contrib-drydock" -version = "22.1.0" +version = "22.2.0" source = { editable = "." } dependencies = [ { name = "tutor" },