Skip to content

Commit 9bacbf6

Browse files
mjudeikisclaude
andcommitted
fix(infrastructure): make development previews reachable from the local portal
Local kind serves the apps Gateway on a forwarded port (10443), but the synthesized status.url implied 443 and the hub CSP still allowed only the deleted preview-gateway origin — so the portal preview iframe pointed at a dead port AND was blocked from framing. - New ${kedge.appPublicPort} token (KEDGE_APP_PUBLIC_PORT, ":<port>" suffix, empty in prod): application template appends it to status.url/redirectURL. - Tiltfile.cluster: hub portalFrameSources now allows the local apps domain (https://*.apps.127.0.0.1.sslip.io:10443) instead of the dead *.preview.localhost origin; infra provider gets KEDGE_APP_PUBLIC_PORT. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8ab07a1 commit 9bacbf6

6 files changed

Lines changed: 58 additions & 5 deletions

File tree

Tiltfile.cluster

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@ k8s_yaml(helm(
422422
# /bonkers admin access for the dev static token (resolves to
423423
# static-dev-toke@kedge.local — see proxy.ensureStaticTokenUserOnce).
424424
'hub.adminUsers={static-dev-toke@kedge.local}',
425-
'hub.portalFrameSources={https://*.%s:%s}' % (app_studio_preview_base_domain, app_studio_preview_public_port),
425+
# Development previews render the app's own exposure URL (status.url)
426+
# in the portal iframe — allow the local apps domain on the envoy
427+
# port-forward. (preview.localhost was the deleted preview-gateway.)
428+
'hub.portalFrameSources={https://*.%s:%s}' % (app_base_domain, app_studio_preview_public_port),
426429
# Make *.kcp.localhost resolve to the Envoy gateway from the hub pod,
427430
# so the operator-issued kubeconfigs work in-cluster unchanged.
428431
'hostAliases[0].ip=%s' % gateway_ip,
@@ -824,10 +827,15 @@ local_resource(
824827
# cloudflare-tunnel (the in-binary default).
825828
'KEDGE_GATEWAY_NAME={gateway} ' +
826829
'KEDGE_GATEWAY_NAMESPACE=envoy-gateway-system ' +
830+
# Locally the Gateway is only reachable via the envoy
831+
# port-forward — templates append this to status.url
832+
# (${kedge.appPublicPort}); prod leaves it unset (443).
833+
'KEDGE_APP_PUBLIC_PORT={port} ' +
827834
'make run-provider-infrastructure').format(
828835
kro=KIND_HOST_KUBECONFIG, kc=KCP_ADMIN_KUBECONFIG,
829836
apps_domain=app_base_domain,
830-
gateway=app_studio_preview_parent_gateway_name),
837+
gateway=app_studio_preview_parent_gateway_name,
838+
port=app_studio_preview_public_port),
831839
deps=[
832840
'providers/infrastructure/main.go',
833841
'providers/infrastructure/heartbeat.go',

providers/infrastructure/backend/kro/backend.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ const (
8888
// - KEDGE_GATEWAY_NAME / KEDGE_GATEWAY_NAMESPACE — the exposure-layer Gateway
8989
// parent every template's HTTPRoutes (apps AND sandbox previews) attach to
9090
// (defaults "cloudflare-tunnel" / "cfgate-system").
91+
// - KEDGE_APP_PUBLIC_PORT — bare port number appended (as ":<port>") to
92+
// synthesized exposure URLs via ${kedge.appPublicPort}. Unset in
93+
// production (443 implied); local kind sets 10443 (the envoy
94+
// port-forward).
9195
//
9296
// Per-instance inputs (container images, etc.) are NOT env tokens — templates
9397
// declare them as schema fields with sane defaults (e.g. simple-webapp's
@@ -102,9 +106,14 @@ func New(runtime dynamic.Interface) *Backend {
102106
if gatewayNamespace == "" {
103107
gatewayNamespace = DefaultGatewayNamespace
104108
}
109+
appPublicPort := ""
110+
if port := strings.TrimSpace(os.Getenv("KEDGE_APP_PUBLIC_PORT")); port != "" {
111+
appPublicPort = ":" + port
112+
}
105113
tokens := map[string]string{
106114
gatewayNameToken: gatewayName,
107115
gatewayNamespaceToken: gatewayNamespace,
116+
appPublicPortToken: appPublicPort,
108117
}
109118
maps.Copy(tokens, devImageTokens())
110119
return &Backend{runtime: runtime, tokens: tokens}

providers/infrastructure/backend/kro/backend_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,25 @@ func TestSubstituteTokensLeavesKroRefs(t *testing.T) {
163163
}
164164
}
165165

166+
func TestSubstituteTokensAppPublicPort(t *testing.T) {
167+
// The status.url CEL embeds the token inside a quoted CEL string; both
168+
// values must yield a valid expression.
169+
in := []byte(`{"url":"${\"https://\" + httpRoute.spec.hostnames[0] + \"${kedge.appPublicPort}\"}"}`)
170+
171+
// Local kind: KEDGE_APP_PUBLIC_PORT=10443 → ":10443" suffix.
172+
out := string(substituteTokens(in, map[string]string{appPublicPortToken: ":10443"}))
173+
if want := `{"url":"${\"https://\" + httpRoute.spec.hostnames[0] + \":10443\"}"}`; out != want {
174+
t.Errorf("with port: substituteTokens = %s, want %s", out, want)
175+
}
176+
177+
// Production (token unset in the caller's map): the placeholder must not
178+
// leak — it resolves to the empty string.
179+
out = string(substituteTokens(in, map[string]string{}))
180+
if want := `{"url":"${\"https://\" + httpRoute.spec.hostnames[0] + \"\"}"}`; out != want {
181+
t.Errorf("without port: substituteTokens = %s, want %s", out, want)
182+
}
183+
}
184+
166185
// testTokens is the platform-config token map the backend builds from env (the
167186
// exposure-layer Gateway parent + the dev-overlay images), for buildRGD tests.
168187
func testTokens() map[string]string {

providers/infrastructure/backend/kro/rgd.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ const (
4141
// kedge-dev-agent binary every dev-mode component runs
4242
// (KEDGE_DEV_AGENT_IMAGE).
4343
devAgentImageToken = "${kedge.devAgentImage}"
44+
45+
// appPublicPortToken is the ":<port>" suffix templates append to
46+
// synthesized exposure URLs (status.url). Empty in production — the
47+
// Gateway serves on 443 and the URL implies it — and ":10443" style when
48+
// the Gateway is only reachable on a forwarded port (local kind).
49+
// Resolved from KEDGE_APP_PUBLIC_PORT (the bare port number).
50+
appPublicPortToken = "${kedge.appPublicPort}"
4451
)
4552

4653
const (
@@ -170,6 +177,12 @@ func substituteTokens(raw []byte, tokens map[string]string) []byte {
170177
if resolved[gatewayNamespaceToken] == "" {
171178
resolved[gatewayNamespaceToken] = DefaultGatewayNamespace
172179
}
180+
// Unlike the tokens above, empty is the CORRECT production value here
181+
// (no port suffix). Force the key so the placeholder never leaks into
182+
// the RGD when a caller's token map omits it.
183+
if _, ok := resolved[appPublicPortToken]; !ok {
184+
resolved[appPublicPortToken] = ""
185+
}
173186
for token, value := range resolved {
174187
raw = bytes.ReplaceAll(raw, []byte(token), []byte(value))
175188
}

providers/infrastructure/docs/template-conventions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ invalid field into a materialized resource.
1717
|---|---|---|
1818
| **Per-instance, configurable** (image, version, size, replicas) | `spec.schema` field **with a `default`**; the resource references `${schema.spec.<field>}` | `simple-webapp.spec.image` (`default: "nginx:latest"`); `postgres-database.spec.version` |
1919
| **Fixed sidecar / tooling image** (not user-facing) | **hardcoded literal** in the resource | the control-token `bitnami/kubectl` job (database, redis, application); `quay.io/oauth2-proxy/oauth2-proxy:v7.6.0` |
20-
| **Platform-global, no universal default** | a reserved `${kedge.*}` substitution token, resolved by the kro backend from env | the exposure Gateway parent: `${kedge.gatewayName}` / `${kedge.gatewayNamespace}` (the **only** tokens that exist) |
20+
| **Platform-global, no universal default** | a reserved `${kedge.*}` substitution token, resolved by the kro backend from env | the exposure Gateway parent `${kedge.gatewayName}` / `${kedge.gatewayNamespace}`; the dev-overlay images `${kedge.devImage.<toolchain>}` / `${kedge.devAgentImage}`; the exposure-URL port suffix `${kedge.appPublicPort}` (empty in prod, `:10443` on local kind) |
2121

2222
## Why not `${kedge.*}` env tokens for images?
2323

providers/infrastructure/install/templates/application.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -849,10 +849,14 @@ spec:
849849
# The HTTPRoute is always created (only its backend changes by mode).
850850
# Per-tier readiness lets the portal show progress. No secret values are
851851
# surfaced.
852+
# ${kedge.appPublicPort} is substituted by the kro backend BEFORE kro sees
853+
# this CEL: "" in production (443 implied), ":<port>" when the Gateway is
854+
# only reachable on a forwarded port (local kind). The empty case leaves a
855+
# harmless `+ ""` in the expression.
852856
status:
853-
url: ${"https://" + httpRoute.spec.hostnames[0]}
857+
url: ${"https://" + httpRoute.spec.hostnames[0] + "${kedge.appPublicPort}"}
854858
host: ${httpRoute.spec.hostnames[0]}
855-
redirectURL: ${"https://" + httpRoute.spec.hostnames[0] + "/oauth2/callback"}
859+
redirectURL: ${"https://" + httpRoute.spec.hostnames[0] + "${kedge.appPublicPort}" + "/oauth2/callback"}
856860
frontendReady: ${frontendDeployment.status.readyReplicas}
857861
backendReady: ${backendDeployment.status.readyReplicas}
858862
databaseReady: ${dbStatefulSet.status.readyReplicas}

0 commit comments

Comments
 (0)