Skip to content

Commit 8a84d58

Browse files
Merge branch 'main' into chore/nso-node-taint-honor
2 parents 8b936c4 + d228eeb commit 8a84d58

13 files changed

Lines changed: 931 additions & 6 deletions

File tree

config/extension-server/deployment.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ spec:
124124
- name: tls-ca
125125
mountPath: /tls-ca
126126
readOnly: true
127+
# Branded data-plane error pages. Backed by an OPTIONAL ConfigMap so
128+
# the pod starts even when the ConfigMap is absent — the extension
129+
# server then serves the page compiled into the operator image.
130+
# The operator config's gateway.errorPage.bodyPath points here.
131+
- name: error-pages
132+
mountPath: /etc/datum/error-pages
133+
readOnly: true
127134
terminationGracePeriodSeconds: 20
128135
volumes:
129136
# issuer-name and dns-names are placeholders — an overlay must patch them.
@@ -145,3 +152,14 @@ spec:
145152
items:
146153
- key: ca.crt
147154
path: ca.crt
155+
# Optional branded error-page content. The ConfigMap is created in the
156+
# infrastructure (GitOps) repo and carries the key error-5xx.html. With
157+
# optional: true the pod starts cleanly when it is absent, in which case
158+
# the extension server serves the embedded default page.
159+
- name: error-pages
160+
configMap:
161+
name: envoy-error-pages
162+
optional: true
163+
items:
164+
- key: error-5xx.html
165+
path: error-5xx.html

config/manager/config.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,18 @@ gateway:
1313
#
1414
# Default: false. Set to true in environments where dns-operator is deployed.
1515
enableDNSIntegration: false
16+
# errorPage configures the branded data-plane error page served by the
17+
# extension server for edge-generated 5xx responses on the downstream /
18+
# Connector data plane (e.g. an offline Connector tunnel). When enabled, the
19+
# extension server attaches an Envoy local_reply_config to every customer-
20+
# facing HCM so visitors see a branded "temporarily unavailable" page instead
21+
# of a raw body like "no healthy upstream".
22+
#
23+
# bodyPath points at the optional ConfigMap mount (volume "error-pages" in
24+
# config/extension-server/deployment.yaml). When the file is absent or empty,
25+
# the page compiled into the operator image is used as the fallback — content
26+
# problems never block xDS or fail startup.
27+
errorPage:
28+
enabled: true
29+
bodyPath: /etc/datum/error-pages/error-5xx.html
30+
minStatusCode: 500
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
---
2+
status: provisional
3+
stage: alpha
4+
---
5+
6+
# Branded Data-Plane Error Pages
7+
8+
- [Summary](#summary)
9+
- [Motivation](#motivation)
10+
- [Goals](#goals)
11+
- [Non-Goals](#non-goals)
12+
- [Proposal](#proposal)
13+
- [User Stories](#user-stories)
14+
- [Risks and Mitigations](#risks-and-mitigations)
15+
- [Design Details](#design-details)
16+
- [Where the page is served](#where-the-page-is-served)
17+
- [Which responses get the page](#which-responses-get-the-page)
18+
- [Content ownership: a ConfigMap, with a baked-in fallback](#content-ownership-a-configmap-with-a-baked-in-fallback)
19+
- [Updating the page](#updating-the-page)
20+
- [Interaction with the Connector "tunnel offline" message](#interaction-with-the-connector-tunnel-offline-message)
21+
- [Verifying the experience](#verifying-the-experience)
22+
- [Rollout](#rollout)
23+
- [Drawbacks](#drawbacks)
24+
- [Alternatives](#alternatives)
25+
26+
## Summary
27+
28+
When a visitor reaches a customer's site through Datum's edge — a Connector-backed
29+
proxy, or any Gateway on the downstream data plane — and something goes wrong
30+
(the tunnel is offline, the origin is down, a request times out), the response is
31+
generated by Envoy, not by the customer's application. By default that response
32+
is a **raw, unstyled error**: an HTTP 503 with a bare body like
33+
`no healthy upstream`, with no indication of where it came from or what to do
34+
next.
35+
36+
This enhancement delivers a **Datum-branded HTML error page** for those
37+
edge-generated errors on the downstream / Connector data plane. The page is
38+
rendered by the **extension server** — the component that already customizes this
39+
data plane during Envoy Gateway translation — and its **content is sourced from a
40+
mounted `ConfigMap`**, with a copy compiled into the operator as an
41+
always-valid fallback. That makes the page editable through GitOps without an
42+
operator release, while guaranteeing a valid page even if the override is absent.
43+
44+
## Motivation
45+
46+
Datum's network services route a visitor's request through edge Envoy to a
47+
customer's backend — either directly, or, for the **Connector**, through an
48+
outbound tunnel to a private origin. When that path can't be completed, Envoy
49+
itself returns the response. Common cases on the Connector data plane:
50+
51+
- The Connector tunnel is **offline** (agent not running / not yet connected).
52+
- The origin behind the tunnel or backend is **down or unreachable**.
53+
- The request **times out** or trips a circuit breaker.
54+
55+
In all of these, the visitor never reaches the customer's app, so the customer's
56+
own error page can't be shown. The edge is the only thing that can speak — and
57+
what it says, and how it looks, is Datum's responsibility. A blank `503` with
58+
`no healthy upstream` reads like a broken platform; a branded page that says
59+
"this site is temporarily unavailable" with Datum's identity reads like a
60+
platform that's working as intended and tells you what's happening.
61+
62+
This is a **trust and brand-perception** surface. It is the page a customer's
63+
*own* end-users see when the customer is mid-incident — precisely when the
64+
experience matters most.
65+
66+
### Goals
67+
68+
- Serve a **branded HTML error page** for edge-generated 5xx responses on the
69+
downstream / Connector data plane.
70+
- Cover **all** customer-facing listeners on that data plane, including HTTP/3 /
71+
QUIC, so the experience is consistent regardless of how the browser connects.
72+
- Let the page's **content be updated independently of code** — a content edit
73+
should be a GitOps change, not an operator image build and release.
74+
- **Fail safe**: a missing or malformed page must never take the data plane down
75+
or block configuration updates.
76+
77+
### Non-Goals
78+
79+
- Changing **which** status codes are considered errors, or the HTTP status code
80+
returned. We brand the body; we do not alter routing or response codes.
81+
- Per-customer or per-Gateway custom error pages. This proposal delivers one
82+
Datum-branded page across the data plane. Per-tenant theming is possible future
83+
work and is called out as a non-goal here.
84+
- The **standard** (non-downstream) gateways, which already serve a branded page
85+
and are out of scope.
86+
- Replacing application-level error pages. When the customer's origin is
87+
reachable, its own responses are returned unchanged.
88+
89+
## Proposal
90+
91+
Deliver the branded error page through the extension server's existing
92+
per-listener mutation pass. During Envoy Gateway's translation, the extension
93+
server already rewrites listeners (for the WAF) and routes and clusters (for the
94+
Connector); we add one more listener mutation that attaches a **local reply
95+
configuration** carrying the branded HTML. The mechanics — which responses are
96+
branded, where the content lives, and how updates propagate — are covered under
97+
[Design Details](#design-details).
98+
99+
### User Stories
100+
101+
- **As a visitor to a customer's site that is temporarily unavailable**, I see a
102+
clear, branded "temporarily unavailable" page instead of a blank or cryptic
103+
error, so I understand the site (not my browser) has a problem and that it's
104+
likely transient.
105+
- **As a Datum customer whose Connector is offline**, when my users hit my proxy
106+
they get a polished, on-brand page, so an outage on my side doesn't look like a
107+
broken platform.
108+
- **As Datum's brand/design owner**, I can update the wording and styling of the
109+
edge error page by changing a single content artifact in Git, and have it roll
110+
out across the edge without waiting on an operator release.
111+
- **As a Datum operator**, I can confirm the branded page is actually being
112+
served, and I'm confident that a bad content edit can't break traffic — the
113+
edge falls back to a known-good page.
114+
115+
### Risks and Mitigations
116+
117+
Each risk is mitigated by a design choice detailed in the linked section below.
118+
119+
| Risk | Mitigation |
120+
| --- | --- |
121+
| A missing or malformed page removes the experience or, worse, blocks config updates (the downstream extension hook is fail-closed). | Embedded fallback + `optional` mount; content is an override only. See [Content ownership](#content-ownership-a-configmap-with-a-baked-in-fallback). |
122+
| A content edit appears not to take effect. | Content changes roll the extension server to force a fresh translation. See [Updating the page](#updating-the-page). |
123+
| The branded page overrides the Connector's terse tunnel-control signal. | Visitor traffic and tunnel-control traffic are distinct; handling is a deliberate decision. See [Interaction with the Connector "tunnel offline" message](#interaction-with-the-connector-tunnel-offline-message). |
124+
125+
## Design Details
126+
127+
> [!NOTE]
128+
> This section stays at the product/architecture level. The translation hook,
129+
> security, and re-translation machinery are described in detail in the
130+
> [Envoy Gateway Extension Server](../envoy-gateway-extension-server/README.md)
131+
> enhancement; this proposal reuses them.
132+
133+
### Where the page is served
134+
135+
The extension server already participates in every Envoy Gateway translation for
136+
the downstream data plane and already mutates listeners. We add a per-listener
137+
step that attaches Envoy's **local reply** configuration to each customer-facing
138+
HTTP connection manager. "Local reply" is Envoy's term for a response Envoy
139+
generates itself (an upstream failure, a timeout, a direct response) — exactly
140+
the responses a customer's app can't answer. Attaching the branded body there
141+
means every such response on that listener renders the page.
142+
143+
Because the step runs over **all** listeners the translation produces, it
144+
covers both the TLS (HTTP/1.1 + HTTP/2) and the QUIC / HTTP/3 listeners
145+
uniformly, with no per-listener targeting to maintain and no class of "we forgot
146+
a listener" bug.
147+
148+
Internal, non-customer listeners (for example Envoy Gateway's own readiness
149+
endpoint) are **excluded** by construction, so health checks and infrastructure
150+
endpoints are never branded.
151+
152+
### Which responses get the page
153+
154+
**Any edge-generated response with status ≥ 500** is rendered as the branded page
155+
(configurable threshold). The original HTTP status code is preserved — a 503
156+
stays a 503 — only the body and content-type are replaced. A runtime switch is
157+
retained so the behavior can be disabled in an emergency without a redeploy.
158+
159+
### Content ownership: a ConfigMap, with a baked-in fallback
160+
161+
The page is **content**, owned by brand/design, not by operator code. So:
162+
163+
- The HTML lives in a **`ConfigMap`** managed in the infrastructure (GitOps)
164+
repository.
165+
- The extension server **mounts** that `ConfigMap` to disk (an `optional`
166+
volume) and reads the file at startup.
167+
- The operator also carries a **compiled-in default** page. If the mounted
168+
override is missing or unreadable, the default is used.
169+
170+
This split gives us the best of both: content is editable by GitOps without an
171+
operator release, and there is always a valid page even if the override is
172+
absent — which is essential because the downstream extension hook is fail-closed
173+
(a hard error would otherwise stall configuration for the whole data plane).
174+
175+
A minimal operator configuration block selects the behavior; the bytes come from
176+
the mount or the embedded fallback:
177+
178+
```yaml
179+
gateway:
180+
errorPage:
181+
enabled: true
182+
bodyPath: /etc/datum/error-pages/error-5xx.html # optional; falls back to embedded default
183+
minStatusCode: 500
184+
```
185+
186+
### Updating the page
187+
188+
A `ConfigMap` edit lands on disk but does **not** automatically reach Envoy: the
189+
extension server only influences Envoy during a translation, and Envoy Gateway
190+
does not watch the extension server's mounted files. So a content change is only
191+
applied on the **next** translation.
192+
193+
To make updates deterministic, a content change **rolls the extension server**
194+
(via a content checksum on the Deployment or a reload controller). The restart
195+
re-reads the file and the extension server's reconnection drives a fresh
196+
translation, so the new page propagates predictably across the edge. This mirrors
197+
the broader re-translation considerations covered in the extension-server
198+
enhancement.
199+
200+
### Interaction with the Connector "tunnel offline" message
201+
202+
The Connector path has a second, separate signal: a terse `503 "Tunnel not
203+
online"` returned on the tunnel-control (HTTP `CONNECT`) path, used by tunnel
204+
clients rather than browsers. Normal end-users never traverse that path — their
205+
browser requests are ordinary `GET`s — so the **visitor-facing** experience is
206+
governed entirely by the branded page described here.
207+
208+
Because the branded page matches any response ≥ 500, it would, if applied
209+
indiscriminately, also rewrite that tunnel-control 503. Two acceptable options:
210+
211+
1. **Brand everything ≥ 500** (simplest; one consistent rule). Tunnel clients are
212+
robust to body changes and key on status, not body.
213+
2. **Preserve the terse tunnel-control body** by scoping the page to
214+
visitor-facing responses.
215+
216+
We default to (1) for simplicity and revisit only if a tunnel client needs the
217+
terse body. Either way, the visitor experience — the thing this proposal is
218+
about — is the branded page.
219+
220+
### Verifying the experience
221+
222+
- A browser request to a proxy whose Connector is **offline** returns a `503`
223+
whose body is the **branded HTML** (correct `content-type: text/html`), not
224+
`no healthy upstream`.
225+
- The same holds for an **origin-down** backend and for a **timeout**.
226+
- Envoy Gateway's own **readiness** endpoint and other internal listeners are
227+
**not** branded.
228+
- Editing the `ConfigMap` and letting the rollout complete changes the served
229+
page; removing the `ConfigMap` falls back to the embedded default with no
230+
traffic impact.
231+
- HTTP/3 / QUIC clients get the same branded page as HTTP/2 clients.
232+
233+
### Rollout
234+
235+
1. Ship the extension-server delivery and embedded fallback — the page renders
236+
from the baked-in default even before any `ConfigMap` exists.
237+
2. Add the `ConfigMap` with the page content and wire the mount and the
238+
content-change rollout.
239+
3. Verify the branded page on the downstream data plane in staging, then
240+
production, using the checks above.
241+
242+
## Drawbacks
243+
244+
- It adds one more responsibility to the extension server, which is on the
245+
critical translation path for the data plane — though the work is small and
246+
isolated.
247+
- Content updates are not instantaneous: they require a translation, which we
248+
trigger via a controlled rollout. This is inherent to the extension-server
249+
model and is an acceptable trade for not coupling content to code releases.
250+
- Two copies of the page exist (the live `ConfigMap` and the embedded fallback).
251+
The fallback is intentionally minimal to keep this cost low.
252+
253+
## Alternatives
254+
255+
- **Bake the page only into the operator image (no `ConfigMap`).** Simpler, and a
256+
reasonable starting point, but every wording or styling tweak becomes an
257+
operator release. The `ConfigMap` keeps content a GitOps concern; the embedded
258+
copy is retained as a fallback, not as the primary source.
259+
- **Serve the error page from a separate service** the edge routes to on failure.
260+
More moving parts, a new failure domain on the unhappy path, and added latency
261+
exactly when the system is already degraded. The local-reply approach keeps the
262+
page entirely within the edge proxy.
263+
- **Do nothing.** Visitors continue to see raw `no healthy upstream` bodies on
264+
customer outages, undercutting the trust and brand experience this proposal
265+
delivers.

internal/config/config.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,11 @@ type GatewayConfig struct {
633633
// Coraza specifies configuration for the Coraza WAF.
634634
Coraza CorazaConfig `json:"coraza,omitempty"`
635635

636+
// ErrorPage specifies configuration for the branded data-plane error page
637+
// served for edge-generated 5xx responses on the downstream / Connector
638+
// data plane.
639+
ErrorPage ErrorPageConfig `json:"errorPage,omitempty"`
640+
636641
// ValidPortNumbers is a list of port numbers that are permitted on gateway
637642
// listeners.
638643
//
@@ -790,6 +795,45 @@ type CorazaConfig struct {
790795

791796
// +k8s:deepcopy-gen=true
792797

798+
// ErrorPageConfig configures the branded data-plane error page. When enabled,
799+
// the extension server attaches an Envoy local_reply_config to every
800+
// customer-facing HCM so edge-generated 5xx responses render a branded HTML
801+
// page instead of a raw body like "no healthy upstream".
802+
//
803+
// The page content is sourced from BodyPath (a mounted ConfigMap) when present
804+
// and readable, otherwise from the page compiled into the operator image. A
805+
// missing or unreadable override never fails startup and never blocks xDS — it
806+
// falls back to the embedded default.
807+
type ErrorPageConfig struct {
808+
// Enabled toggles branded error-page injection. Defaults to false; the
809+
// extension server only attaches local_reply_config when this is true.
810+
Enabled bool `json:"enabled,omitempty"`
811+
812+
// BodyPath is an optional path to a file (typically a mounted ConfigMap key)
813+
// containing the branded HTML. When empty, unreadable, or empty-on-disk, the
814+
// embedded default page is used instead.
815+
BodyPath string `json:"bodyPath,omitempty"`
816+
817+
// MinStatusCode is the inclusive lower bound for response status codes that
818+
// receive the branded body. The original status code is always preserved.
819+
//
820+
// +default=500
821+
MinStatusCode uint32 `json:"minStatusCode,omitempty"`
822+
823+
// RuntimeKey is the Envoy runtime key gating the branded reply, allowing it
824+
// to be disabled at runtime without a redeploy.
825+
//
826+
// +default="local_reply_5xx"
827+
RuntimeKey string `json:"runtimeKey,omitempty"`
828+
829+
// ContentType is the Content-Type set on the branded response body.
830+
//
831+
// +default="text/html; charset=UTF-8"
832+
ContentType string `json:"contentType,omitempty"`
833+
}
834+
835+
// +k8s:deepcopy-gen=true
836+
793837
type ExtensionAPIValidationOptions struct {
794838
// BackendTrafficPolicies specifies validation options for BackendTrafficPolicy resources.
795839
BackendTrafficPolicies BackendTrafficPolicyValidationOptions `json:"backendTrafficPolicies"`

0 commit comments

Comments
 (0)