Writing rewriters¶
Every ingress controller invented its own annotation language. HAProxy puts path rewrites in haproxy.org/path-rewrite. Nginx puts them in nginx.ingress.kubernetes.io/rewrite-target. Traefik decided annotations were beneath it and uses CRDs instead (but also supports annotations, because consistency is for the weak). A rewriter translates one controller's dialect into provider-agnostic ingress entries that any IngressProvider can consume.
The gatekeepers of old each bore a different sigil, yet all opened the same threshold. The pilgrim need not know the sigil — only declare which gate he approaches, and the keeper shall answer in kind.
— Cultes des Goules, On the Many Gates (on good authority)
The contract is small — three methods, one name — because the hard part isn't the interface. The hard part is reading the annotations of an ingress controller you didn't choose, translating semantics that were never designed to be portable, and producing something that a completely different reverse proxy can understand. The contract just gives you a place to put that suffering.
The contract¶
A rewriter class must have:
name— a string identifying the rewriter (e.g."haproxy","nginx"). Used for override matching: an external rewriter with the samenameas a built-in one replaces it.match(manifest, ctx)— returnTrueif this rewriter handles this Ingress manifest. Typically checksingressClassName(resolved throughingress_typesconfig) or annotation prefixes.rewrite(manifest, ctx)— convert one Ingress manifest to a list of entry dicts (see entry format below).priority(optional) — integer, default1000. Lower = checked earlier. External and built-in rewriters are sorted together; on a tie, an external rewriter is checked before a built-in one. The built-in HAProxy rewriter sits at1100so that annotation-specific rewriters at the default1000get first pick.
from dekube import IngressRewriter, get_ingress_class, resolve_backend
class NginxRewriter(IngressRewriter):
name = "nginx"
def match(self, manifest, ctx):
ingress_types = ctx.config.get("ingress_types") or {}
cls = get_ingress_class(manifest, ingress_types)
if cls == "nginx":
return True
if cls in ("haproxy", "traefik"): # another known controller's class wins
return False
annotations = (manifest.get("metadata") or {}).get("annotations") or {}
return any(k.startswith("nginx.ingress.kubernetes.io/") for k in annotations)
def rewrite(self, manifest, ctx):
entries = []
for rule in (manifest.get("spec") or {}).get("rules") or []:
if not rule:
continue
host = rule.get("host") or ""
if not host:
continue
for path_entry in (rule.get("http") or {}).get("paths") or []:
if not path_entry:
continue
backend = resolve_backend(path_entry, manifest, ctx)
entries.append({
"host": host,
"path": path_entry.get("path") or "/",
"upstream": backend["upstream"],
"scheme": "http",
})
return entries
Entry format¶
Each entry dict returned by rewrite() must have:
| Key | Type | Required | Description |
|---|---|---|---|
host |
str |
yes | The hostname (e.g. app.example.com) |
path |
str |
yes | The path (/ for catch-all, /api for specific) |
upstream |
str |
yes | The upstream address (host:port) |
scheme |
str |
yes | http or https |
server_ca_secret |
str |
no | Secret name containing CA cert for backend TLS |
server_sni |
str |
no | SNI server name for backend TLS |
strip_prefix |
str |
no | Path prefix to strip before proxying. On multi-path rules, scope it to the matching path — don't apply a global annotation blindly to every entry. |
response_headers |
dict[str, str] |
no | Headers to add to responses (e.g. CORS headers, security headers) |
max_body_size |
str |
no | Max client body size (e.g. "100M") |
extra_directives |
list[str] |
no | Deprecated. Provider-specific raw directives. See below. |
Structured fields vs extra_directives¶
Prefer response_headers and max_body_size over extra_directives. Structured fields are provider-agnostic — every IngressProvider (Caddy, Nginx, future providers) can consume them. extra_directives was Caddy-specific syntax that other providers couldn't interpret.
entries.append({
"host": "app.example.com",
"path": "/",
"upstream": "app:8080",
"scheme": "http",
"response_headers": {
"X-Frame-Options": "DENY",
"Access-Control-Allow-Origin": "*",
},
"max_body_size": "100M",
})
extra_directives still works — the CaddyProvider reads it as a fallback for third-party rewriters that haven't migrated. New rewriters should use structured fields exclusively.
How dispatch works¶
When the IngressProvider processes Ingress manifests, each manifest is dispatched to the first matching rewriter:
- Rewriters with
extensions.<name>.enabled: falseare dropped (Rewriter disabled: <name>) - The rest are checked in priority order, external and built-in together (ties: external first). Each sees its own
extensions.<name>block asctx.extension_configduringmatch(), and the winner keeps it duringrewrite() - If no rewriter matches, a warning is emitted and the manifest is skipped
The built-in HAProxyRewriter (priority 1100, checked last) matches:
ingressClassName: haproxyor empty/absent class (acts as default fallback)- Any manifest with
haproxy.org/*annotations
The official nginx and traefik rewriters claim their own class, refuse another known class (haproxy, nginx, traefik) — as the real controller would — and otherwise (classless or an unmapped custom class) claim any Ingress carrying their annotation prefix. So a classless Ingress with nginx annotations goes to nginx when it's loaded, and to the HAProxy fallback when it isn't.
Custom ingress class names (ingress_types)¶
When clusters use custom ingressClassName values (e.g. haproxy-controller-internal, nginx-external), add an ingress_types mapping in dekube.yaml to resolve them to canonical rewriter names:
ingress_types:
haproxy-controller-internal: haproxy
haproxy-controller-external: haproxy
nginx-internal: nginx
The mapping is applied before rewriter dispatch — rewriters see the canonical name. Without it, a custom class name only matches through a rewriter's annotation heuristic (nginx/traefik annotations, haproxy.org/*); an Ingress with neither is skipped with a warning.
Inside your rewriter, use get_ingress_class(manifest, ctx.config.get("ingress_types") or {}) to get the resolved class name. Both get_ingress_class and resolve_backend are part of the public interface — import them from dekube.
For building a complete reverse proxy backend (not just an annotation translator), see Writing ingress providers.
Override mechanism¶
An external rewriter with the same name as a built-in one replaces it entirely. For example, a custom HAProxyRewriter with name = "haproxy" would replace the built-in HAProxy handling.
When an override occurs, dekube prints:
See Writing extensions for testing, repo structure, publishing, and available imports.