Skip to content

Commit b986679

Browse files
mjudeikisclaude
andauthored
feat(infrastructure): expose application ingress config via operator CR (#341)
In operator mode the serve Deployment is built by EnsureProviderServe, not the chart's deployment.yaml, so it previously never set KEDGE_APP_BASE_DOMAIN or KEDGE_INGRESS_CLASS — leaving the Application instance controller disabled and app exposure unconfigurable. Add an `application` block (baseDomain, ingressClass) to InfrastructureProviderSpec, render it from operator.application.* chart values into the CR, and stamp the two env vars onto the serve container. Empty ingressClass falls back to the in-binary "cloudflare" default; empty baseDomain keeps the feature off. Regenerated deepcopy + CRD via codegen; synced chart CRD copy. Docs in docs/application-template-architecture.md + README. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dbfe878 commit b986679

9 files changed

Lines changed: 320 additions & 0 deletions

providers/infrastructure/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ Values:
8787
`ghcr.io/faroshq/kro-multicluster/kro`).
8888
- `operator.provider.image.*` — the provider serve image (defaults to the chart
8989
image/appVersion).
90+
- `operator.application.*` — the `application` template's exposure layer:
91+
`baseDomain` (the zone apps are served under; **required to enable app
92+
exposure**) and `ingressClass` (defaults to `cloudflare`). These render into
93+
the CR's `spec.application` and become the serve container's
94+
`KEDGE_APP_BASE_DOMAIN` / `KEDGE_INGRESS_CLASS`. See
95+
[docs/application-template-architecture.md](docs/application-template-architecture.md).
9096

9197
### Verify
9298

providers/infrastructure/apis/v1alpha1/types_infrastructureprovider.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,34 @@ type InfrastructureProviderSpec struct {
8383
// Provider is the provider serve Deployment the operator owns on the runtime
8484
// cluster.
8585
Provider ProviderServeSpec `json:"provider"`
86+
87+
// Application configures the `application` template's exposure layer — the
88+
// public URL apps are served on and which ingress controller fulfils it.
89+
// Maps onto the serve container's KEDGE_APP_BASE_DOMAIN / KEDGE_INGRESS_CLASS
90+
// env vars. Optional; without it app exposure stays off (see ApplicationSpec).
91+
// +optional
92+
Application ApplicationSpec `json:"application,omitempty"`
93+
}
94+
95+
// ApplicationSpec configures the `application` template exposure layer. The two
96+
// fields map 1:1 to the serve container's KEDGE_APP_BASE_DOMAIN and
97+
// KEDGE_INGRESS_CLASS environment variables. See
98+
// docs/application-template-architecture.md.
99+
type ApplicationSpec struct {
100+
// BaseDomain is the DNS zone apps are served under, e.g. "apps.example.com".
101+
// REQUIRED to enable app exposure: the Application instance controller stays
102+
// disabled until this is set (it computes each app's host as
103+
// <prefix|name>-<tenantHash>.<baseDomain> and stamps it onto the instance).
104+
// Empty → the feature is off.
105+
// +optional
106+
BaseDomain string `json:"baseDomain,omitempty"`
107+
108+
// IngressClass is the ingress controller that fulfils the generated Ingress
109+
// (its spec.ingressClassName). It is substituted for the
110+
// ${kedge.ingressClass} token in a Template's backendConfig before the kro
111+
// RGD is authored. Empty → "cloudflare" (the in-binary default).
112+
// +optional
113+
IngressClass string `json:"ingressClass,omitempty"`
86114
}
87115

88116
// SecretKeyRef points at one key in a Secret in the operator's namespace.

providers/infrastructure/apis/v1alpha1/zz_generated.deepcopy.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

providers/infrastructure/config/crds/infrastructure.kedge.faros.sh_infrastructureproviders.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ spec:
6666
spec:
6767
description: InfrastructureProviderSpec is the operator's input.
6868
properties:
69+
application:
70+
description: |-
71+
Application configures the `application` template's exposure layer — the
72+
public URL apps are served on and which ingress controller fulfils it.
73+
Maps onto the serve container's KEDGE_APP_BASE_DOMAIN / KEDGE_INGRESS_CLASS
74+
env vars. Optional; without it app exposure stays off (see ApplicationSpec).
75+
properties:
76+
baseDomain:
77+
description: |-
78+
BaseDomain is the DNS zone apps are served under, e.g. "apps.example.com".
79+
REQUIRED to enable app exposure: the Application instance controller stays
80+
disabled until this is set (it computes each app's host as
81+
<prefix|name>-<tenantHash>.<baseDomain> and stamps it onto the instance).
82+
Empty → the feature is off.
83+
type: string
84+
ingressClass:
85+
description: |-
86+
IngressClass is the ingress controller that fulfils the generated Ingress
87+
(its spec.ingressClassName). It is substituted for the
88+
${kedge.ingressClass} token in a Template's backendConfig before the kro
89+
RGD is authored. Empty → "cloudflare" (the in-binary default).
90+
type: string
91+
type: object
6992
hub:
7093
description: Hub configures the provider's heartbeat target. Optional.
7194
properties:

providers/infrastructure/deploy/chart/crds/infrastructure.kedge.faros.sh_infrastructureproviders.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ spec:
6666
spec:
6767
description: InfrastructureProviderSpec is the operator's input.
6868
properties:
69+
application:
70+
description: |-
71+
Application configures the `application` template's exposure layer — the
72+
public URL apps are served on and which ingress controller fulfils it.
73+
Maps onto the serve container's KEDGE_APP_BASE_DOMAIN / KEDGE_INGRESS_CLASS
74+
env vars. Optional; without it app exposure stays off (see ApplicationSpec).
75+
properties:
76+
baseDomain:
77+
description: |-
78+
BaseDomain is the DNS zone apps are served under, e.g. "apps.example.com".
79+
REQUIRED to enable app exposure: the Application instance controller stays
80+
disabled until this is set (it computes each app's host as
81+
<prefix|name>-<tenantHash>.<baseDomain> and stamps it onto the instance).
82+
Empty → the feature is off.
83+
type: string
84+
ingressClass:
85+
description: |-
86+
IngressClass is the ingress controller that fulfils the generated Ingress
87+
(its spec.ingressClassName). It is substituted for the
88+
${kedge.ingressClass} token in a Template's backendConfig before the kro
89+
RGD is authored. Empty → "cloudflare" (the in-binary default).
90+
type: string
91+
type: object
6992
hub:
7093
description: Hub configures the provider's heartbeat target. Optional.
7194
properties:

providers/infrastructure/deploy/chart/templates/operator-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,15 @@ spec:
8383
tag: {{ .Values.operator.provider.image.tag | default .Values.image.tag | default .Chart.AppVersion | quote }}
8484
replicas: {{ .Values.operator.provider.replicas }}
8585
port: {{ .Values.operator.provider.port }}
86+
{{- if or .Values.operator.application.baseDomain .Values.operator.application.ingressClass }}
87+
# Exposure layer for the `application` template (serve container's
88+
# KEDGE_APP_BASE_DOMAIN / KEDGE_INGRESS_CLASS). baseDomain gates the feature.
89+
application:
90+
{{- with .Values.operator.application.baseDomain }}
91+
baseDomain: {{ . | quote }}
92+
{{- end }}
93+
{{- with .Values.operator.application.ingressClass }}
94+
ingressClass: {{ . | quote }}
95+
{{- end }}
96+
{{- end }}
8697
{{- end }}

providers/infrastructure/deploy/chart/values.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,18 @@ operator:
197197
tag: "" # empty → chart image.tag / appVersion
198198
replicas: 2
199199
port: 8081
200+
201+
# Application-template exposure layer (the `application` template's public URL
202+
# + ingress controller). This is the operator-mode equivalent of the top-level
203+
# `application.*` values: the operator owns the serve Deployment, so these land
204+
# on the InfrastructureProvider CR (KEDGE_APP_BASE_DOMAIN / KEDGE_INGRESS_CLASS)
205+
# rather than via the chart's deployment.yaml. See
206+
# docs/application-template-architecture.md.
207+
application:
208+
# DNS zone apps are served under, e.g. "apps.example.com". REQUIRED to enable
209+
# app exposure — the Application instance controller stays disabled until this
210+
# is set. Empty → feature off.
211+
baseDomain: ""
212+
# Ingress controller that fulfils the generated Ingress. Empty → "cloudflare"
213+
# (the in-binary default).
214+
ingressClass: ""
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Application template exposure & ingress wiring
2+
3+
The `application` template provisions a 3-tier app (frontend + backend +
4+
Postgres) and exposes **only the frontend**, behind oauth2-proxy, on a public
5+
URL. This doc explains how that URL is wired to your cluster's ingress
6+
controller and how to configure it.
7+
8+
See also: [credentials.md](credentials.md) (the `cloud-credentials` Secret the
9+
OIDC client secret is bridged from).
10+
11+
## The exposure chain
12+
13+
For each `Application` instance the kro RGD materializes (see
14+
[install/templates/application.yaml](../install/templates/application.yaml)):
15+
16+
```
17+
public host (fqdn)
18+
19+
20+
Ingress ── ingressClassName: <KEDGE_INGRESS_CLASS> (host = expose.fqdn)
21+
22+
23+
Service <name>-oauth : 4180
24+
25+
26+
oauth2-proxy ── OIDC gate (authenticated users only)
27+
│ --redirect-url=https://<fqdn>/oauth2/callback
28+
29+
Service <name>-frontend : <frontendPort>
30+
```
31+
32+
The backend and Postgres tiers are **never** exposed — they are reachable only
33+
via in-cluster DNS.
34+
35+
There is **no `tls:` block** on the generated Ingress. The default design
36+
terminates TLS at the edge (Cloudflare Tunnel): the `cloudflare` ingress
37+
controller reads the Ingress `host` and creates the DNS record + tunnel route
38+
itself. oauth2-proxy runs with `--reverse-proxy=true --cookie-secure=true` and
39+
trusts `X-Forwarded-*` from that edge. If you front this with a different
40+
controller you own TLS/DNS yourself — see "Using a non-Cloudflare controller".
41+
42+
## The two knobs
43+
44+
Both are environment variables on the **provider serve** container.
45+
46+
| Env var | Default | What it does |
47+
|---|---|---|
48+
| `KEDGE_INGRESS_CLASS` | `cloudflare` | Substituted for the reserved `${kedge.ingressClass}` token in a Template's `backendConfig` before the kro RGD is authored. Ends up as the Ingress `spec.ingressClassName`. kro never sees the token — substitution happens in [backend/kro/rgd.go](../backend/kro/rgd.go) (`substituteTokens`). |
49+
| `KEDGE_APP_BASE_DOMAIN` | _(unset)_ | The DNS zone apps are served under, e.g. `apps.example.com`. **Also gates the Application instance controller** — see below. |
50+
51+
### `KEDGE_APP_BASE_DOMAIN` gates the whole feature
52+
53+
The Application instance controller is **opt-in**. It starts only when BOTH
54+
`KEDGE_APP_BASE_DOMAIN` and a runtime kubeconfig (`KRO_KUBECONFIG`, or the
55+
in-cluster runtime) are present
56+
([application_controller.go:37](../application_controller.go#L37)). Without the
57+
base domain it logs `application controller: disabled` and Application
58+
instances never get an fqdn.
59+
60+
The controller computes the public hostname as
61+
([apps/host.go:58](../apps/host.go#L58)):
62+
63+
```
64+
<hostnamePrefix | name>-<tenantHash>.<KEDGE_APP_BASE_DOMAIN>
65+
```
66+
67+
and stamps it onto `spec.expose.fqdn`. The RGD then reads
68+
`${schema.spec.expose.fqdn}` for the Ingress `host`, the oauth2-proxy
69+
`--redirect-url`, and the reported `status.url`. The tenant must NOT set `fqdn`
70+
or `credentialsSecretName` by hand — the controller owns both.
71+
72+
So to turn on app exposure you must set **`KEDGE_APP_BASE_DOMAIN`**;
73+
`KEDGE_INGRESS_CLASS` only changes *which* controller fulfils the Ingress.
74+
75+
A wildcard DNS record `*.<KEDGE_APP_BASE_DOMAIN>` (or per-app records created by
76+
your controller, as Cloudflare Tunnel does) must resolve to your ingress edge.
77+
78+
## Configuring it — by deploy mode
79+
80+
### Legacy chart mode (`operator.enabled=false`)
81+
82+
Set the two values; the chart's
83+
[deployment.yaml](../deploy/chart/templates/deployment.yaml) renders them onto
84+
the serve container as `KEDGE_APP_BASE_DOMAIN` / `KEDGE_INGRESS_CLASS`:
85+
86+
```yaml
87+
# values.yaml
88+
application:
89+
baseDomain: apps.example.com # empty → feature disabled
90+
ingressClass: cloudflare # swap to your controller's class
91+
```
92+
93+
```sh
94+
helm upgrade --install infrastructure \
95+
oci://ghcr.io/faroshq/charts/kedge-infrastructure-provider --version 0.0.13 \
96+
--set application.baseDomain=apps.example.com \
97+
--set application.ingressClass=nginx \
98+
...
99+
```
100+
101+
### Operator mode (`operator.enabled=true`)
102+
103+
**This is the mode the production install runs in.** In operator mode the serve
104+
Deployment is built in code by `EnsureProviderServe`
105+
([operator/serve.go](../operator/serve.go)), not by the chart's
106+
`deployment.yaml`, so the exposure config travels through the
107+
`InfrastructureProvider` CR. Set the `operator.application.*` values; the chart's
108+
[operator-config.yaml](../deploy/chart/templates/operator-config.yaml) renders
109+
them into `spec.application` on the CR, and the operator stamps the two env vars
110+
onto the serve container:
111+
112+
```yaml
113+
# values.yaml
114+
operator:
115+
enabled: true
116+
application:
117+
baseDomain: apps.example.com # empty → feature disabled
118+
ingressClass: cloudflare # empty → in-binary default "cloudflare"
119+
```
120+
121+
```sh
122+
helm upgrade --install infrastructure \
123+
oci://ghcr.io/faroshq/charts/kedge-infrastructure-provider --version 0.0.13 \
124+
-n kedge-prod-infrastructure-operator --create-namespace \
125+
--set operator.enabled=true \
126+
--set-file operator.providerKubeconfig=./kedge/provider-infrastructure.kubeconfig \
127+
--set operator.application.baseDomain=apps.example.com \
128+
--set operator.application.ingressClass=cloudflare \
129+
...
130+
```
131+
132+
Equivalently, edit the CR directly:
133+
134+
```yaml
135+
apiVersion: infrastructure.kedge.faros.sh/v1alpha1
136+
kind: InfrastructureProvider
137+
spec:
138+
application:
139+
baseDomain: apps.example.com
140+
ingressClass: cloudflare
141+
```
142+
143+
The operator re-reconciles the serve Deployment on the next pass (≤2 min), which
144+
sets `KEDGE_APP_BASE_DOMAIN` / `KEDGE_INGRESS_CLASS` and rolls the pods. Leaving
145+
`baseDomain` empty keeps the Application controller disabled; leaving
146+
`ingressClass` empty falls back to the in-binary `cloudflare` default.
147+
148+
> Do **not** `kubectl set env` the serve Deployment by hand — the operator
149+
> overwrites its `spec` on every reconcile (`existing.Spec = want.Spec`), so the
150+
> change is reverted. Configure it on the CR instead.
151+
152+
## Writing exposure into your own templates
153+
154+
Any Template's `backendConfig` can defer the controller choice to platform
155+
config by writing the reserved token in the Ingress:
156+
157+
```yaml
158+
- id: ingress
159+
template:
160+
apiVersion: networking.k8s.io/v1
161+
kind: Ingress
162+
spec:
163+
ingressClassName: ${kedge.ingressClass} # ← substituted at RGD-author time
164+
rules:
165+
- host: ${schema.spec.expose.fqdn} # ← stamped by the controller
166+
...
167+
```
168+
169+
`${kedge.ingressClass}` is the only `${kedge.*}` token today. It is replaced by
170+
a plain string substitution on the backendConfig JSON before kro parses it, so
171+
it is safe to use anywhere a class name is valid. kro's own `${...}` references
172+
(`${schema.spec.*}`, `${someResource.metadata.name}`) pass through untouched.
173+
174+
## Using a non-Cloudflare controller
175+
176+
`KEDGE_INGRESS_CLASS=nginx` (or `traefik`, etc.) only changes
177+
`spec.ingressClassName`. The shipped `application` template assumes edge TLS and
178+
edge-managed DNS (the Cloudflare model), so for a generic in-cluster controller
179+
you also need to:
180+
181+
- Provide DNS for `*.<baseDomain>` → your ingress LB.
182+
- Terminate TLS yourself — add a `tls:` block to the Ingress (e.g. cert-manager
183+
with an `cert-manager.io/cluster-issuer` annotation). The shipped template has
184+
none; fork `install/templates/application.yaml` to add it, or author your own
185+
template.
186+
187+
oauth2-proxy already trusts forwarded headers (`--reverse-proxy=true`) and sets
188+
secure cookies, so it works behind any TLS-terminating ingress.
189+
</content>

providers/infrastructure/operator/serve.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ func EnsureProviderServe(
8282
if cr.Spec.Hub.Insecure {
8383
env = append(env, corev1.EnvVar{Name: "KEDGE_HUB_INSECURE", Value: "true"})
8484
}
85+
// Application-template exposure layer. KEDGE_APP_BASE_DOMAIN also gates the
86+
// Application instance controller (it stays disabled when unset); KEDGE_INGRESS_CLASS
87+
// falls back to the in-binary "cloudflare" default when left empty.
88+
if cr.Spec.Application.BaseDomain != "" {
89+
env = append(env, corev1.EnvVar{Name: "KEDGE_APP_BASE_DOMAIN", Value: cr.Spec.Application.BaseDomain})
90+
}
91+
if cr.Spec.Application.IngressClass != "" {
92+
env = append(env, corev1.EnvVar{Name: "KEDGE_INGRESS_CLASS", Value: cr.Spec.Application.IngressClass})
93+
}
8594
volMounts := []corev1.VolumeMount{
8695
{Name: "provider-kubeconfig", MountPath: "/var/run/secrets/kedge/provider", ReadOnly: true},
8796
}

0 commit comments

Comments
 (0)