Skip to content

Commit 5b8c48b

Browse files
mjudeikisclaude
andauthored
fix(app-studio): address tenant workspace by DefaultCluster, not path (#357)
The app-studio tenant client built its kcp config as `<host>/clusters/<X-Kedge-Tenant>`, where X-Kedge-Tenant is the workspace *path* (root:kedge:tenants:<org>:<ws>) injected by the hub. Because the provider kubeconfig host points at the hub proxy, that request re-entered the hub's kcp proxy, whose cluster gate accepts only the caller's DefaultCluster *ID* (or ID:mount) — never a path — and 403'd with "cluster access denied". This broke every /api/projects and llm-settings call in prod. The tenant factory only ever acts on the caller's own workspace, so drop the /clusters/ segment and let the hub proxy scope the bare path to the token's DefaultCluster. tenantPath is retained as a cache key only. Also bundles in-progress infrastructure-provider instance-views work. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 69320bd commit 5b8c48b

17 files changed

Lines changed: 721 additions & 22 deletions

File tree

providers/app-studio/tenant/client.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ You may obtain a copy of the License at
1616
// The base kubeconfig (the provider's own kcp connection) supplies only the
1717
// front-proxy host + TLS; its credential is dropped so the factory can never
1818
// authenticate as the provider. Per request we build a config with that host
19-
// (cluster segment swapped for the tenant's path) and the caller's bearer token.
19+
// (no /clusters/ segment) and the caller's bearer token: the kedge hub proxy
20+
// scopes bare paths to the caller's own DefaultCluster, which is the only
21+
// workspace this factory ever talks to. Addressing by the tenant's workspace
22+
// *path* instead would be rejected — the proxy's cluster gate accepts only the
23+
// caller's DefaultCluster *ID* (or ID:mount), not a path.
2024
package tenant
2125

2226
import (
@@ -70,8 +74,14 @@ func NewClientFactory(base *rest.Config) *ClientFactory {
7074
}
7175
}
7276

73-
// For returns a dynamic client scoped to tenantPath, authenticating as the
74-
// caller via token. Cached per (tenant, token).
77+
// For returns a dynamic client scoped to the caller's own workspace,
78+
// authenticating as the caller via token. Cached per (tenant, token).
79+
//
80+
// The host carries no /clusters/ segment: the hub proxy scopes bare paths to
81+
// the token's DefaultCluster. tenantPath is retained only as a cache key — the
82+
// caller always acts within its own default workspace, so a request must never
83+
// be addressed by the workspace path (the proxy's gate rejects paths, accepting
84+
// only the DefaultCluster ID).
7585
func (f *ClientFactory) For(tenantPath, token string) (dynamic.Interface, error) {
7686
if token == "" {
7787
return nil, fmt.Errorf("no bearer token on request — cannot act on the tenant's behalf")
@@ -83,7 +93,7 @@ func (f *ClientFactory) For(tenantPath, token string) (dynamic.Interface, error)
8393
}
8494

8595
cfg := &rest.Config{
86-
Host: f.baseHost + "/clusters/" + tenantPath,
96+
Host: f.baseHost,
8797
BearerToken: token,
8898
TLSClientConfig: f.baseTLS,
8999
}

providers/infrastructure/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ helm CLI.
140140
| Operator | `operator/` + `apis/v1alpha1``InfrastructureProvider` CRD + reconciler |
141141
| Helm chart | `deploy/chart/` — operator + provider Deployment + CatalogEntry |
142142
| Per-cloud credential convention | [docs/credentials.md](docs/credentials.md) |
143+
| Template-defined instance rendering | [docs/instance-views.md](docs/instance-views.md) |
143144

144145
The CatalogEntry ships with `apiExport.schemas: []` (pure broker, no
145146
CRDs leak into tenant workspaces). The single `permissionClaim` is

providers/infrastructure/apis/v1alpha1/types_template.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ type TemplateSpec struct {
170170
// not rendered in the form.
171171
// +optional
172172
Agent *TemplateAgent `json:"agent,omitempty"`
173+
174+
// View is optional presentation metadata that tells the portal how to render
175+
// this template's instances — extra columns in the instance-list table and
176+
// grouped, typed fields on the instance detail page — instead of the default
177+
// raw-JSON dump. Authored by the template owner so each template controls its
178+
// own UX. Field values are dot-paths or ${…}-interpolated strings resolved
179+
// against the instance's spec/status/meta (see the portal's view resolver).
180+
// Stored as raw JSON (preserve-unknown-fields) and surfaced to the portal as
181+
// spec.view; opaque to the controller. Shape:
182+
//
183+
// columns: # extra instance-list columns
184+
// - header: Endpoint
185+
// value: "https://${spec.expose.fqdn}"
186+
// type: link # text | link | badge | code
187+
// detail: # detail-page field groups
188+
// - title: Access
189+
// fields:
190+
// - label: URL
191+
// value: "https://${status.url}"
192+
// type: link
193+
// - label: Region
194+
// path: spec.region
195+
//
196+
// +optional
197+
// +kubebuilder:pruning:PreserveUnknownFields
198+
// +kubebuilder:validation:XPreserveUnknownFields
199+
View *runtime.RawExtension `json:"view,omitempty"`
173200
}
174201

175202
// TemplateAgent is machine-facing guidance for LLM agents operating this

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

Lines changed: 5 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_templates.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,24 @@ spec:
235235
maxLength: 64
236236
pattern: ^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$
237237
type: string
238+
view:
239+
description: "View is optional presentation metadata that tells the
240+
portal how to render\nthis template's instances — extra columns
241+
in the instance-list table and\ngrouped, typed fields on the instance
242+
detail page — instead of the default\nraw-JSON dump. Authored by
243+
the template owner so each template controls its\nown UX. Field
244+
values are dot-paths or ${…}-interpolated strings resolved\nagainst
245+
the instance's spec/status/meta (see the portal's view resolver).\nStored
246+
as raw JSON (preserve-unknown-fields) and surfaced to the portal
247+
as\nspec.view; opaque to the controller. Shape:\n\n\tcolumns: #
248+
extra instance-list columns\n\t - header: Endpoint\n\t value:
249+
\"https://${spec.expose.fqdn}\"\n\t type: link #
250+
text | link | badge | code\n\tdetail: #
251+
detail-page field groups\n\t - title: Access\n\t fields:\n\t
252+
\ - label: URL\n\t value: \"https://${status.url}\"\n\t
253+
\ type: link\n\t - label: Region\n\t path: spec.region"
254+
type: object
255+
x-kubernetes-preserve-unknown-fields: true
238256
required:
239257
- backend
240258
- instanceCRD
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Instance views — template-defined rendering
2+
3+
By default the portal renders an instance's `My instances` row as
4+
Name / Template / Status / Age, and its detail page as a raw-JSON dump of the
5+
instance's `spec`. A template can override both by declaring a **view**, so each
6+
template controls how its own instances look — add an `Endpoint` column, turn a
7+
computed URL into a clickable link, group fields under headings, etc.
8+
9+
The view is authored **on the template** (`spec.view` on the `Template` CRD, or
10+
the `kedge.faros.sh/view` annotation on a kro RGD). It is opaque to the
11+
controller and interpreted entirely by the portal's view resolver
12+
(`portal/src/view.ts`).
13+
14+
## Shape
15+
16+
```yaml
17+
spec:
18+
view:
19+
columns: # extra instance-list columns (after Name/Template)
20+
- header: URL
21+
value: "${status.url}" # interpolated string
22+
type: link
23+
- header: Auth
24+
path: spec.oidc.mode # single dot-path
25+
type: badge
26+
detail: # detail-page field groups (replace the raw dump)
27+
- title: Access
28+
fields:
29+
- label: URL
30+
value: "${status.url}"
31+
type: link
32+
- label: Hostname
33+
path: status.host
34+
type: code
35+
```
36+
37+
A template with no `view` keeps the default rendering. `columns` and `detail`
38+
are independent — define either or both.
39+
40+
## Field values
41+
42+
Each column/field resolves a single value two ways (use one):
43+
44+
- `path:` — a dot-path, e.g. `status.host` or `spec.database.version`.
45+
- `value:` — a string with `${…}` tokens, e.g. `"https://${spec.expose.fqdn}/app"`.
46+
A token that doesn't resolve becomes empty, so partial templates degrade
47+
gracefully.
48+
49+
Three namespaces are available, matching the CR's own shape:
50+
51+
| Namespace | Source | Example |
52+
|-----------|--------|---------|
53+
| `spec.*` | the instance's input values | `spec.database.version` |
54+
| `status.*` | controller-computed outputs | `status.url`, `status.host` |
55+
| `meta.*` | `name`, `namespace`, `phase`, `template`, `createdAt` | `meta.phase` |
56+
57+
An **unqualified** first segment resolves against `spec`, so `expose.fqdn` is the
58+
same as `spec.expose.fqdn`.
59+
60+
> `status.*` only carries values your template's `status` mapping actually
61+
> projects onto the instance (the `conditions` and `children` arrays are excluded —
62+
> they have their own sections on the detail page). If a column referencing
63+
> `status.*`/`spec.*` shows `—`, the field isn't populated yet (still
64+
> provisioning) or isn't in the status mapping.
65+
66+
## Renderers (`type`)
67+
68+
| `type` | Renders as |
69+
|--------|------------|
70+
| `text` (default) | plain text |
71+
| `link` | clickable anchor opening in a new tab. The href is `href:` if set, else the resolved value; a bare host gets `https://` prepended. |
72+
| `code` | monospace pill with a copy button (good for secret names, hostnames) |
73+
| `badge` | neutral pill (good for enums like an auth mode or version) |
74+
75+
## Worked example
76+
77+
See [`install/templates/application.yaml`](../install/templates/application.yaml)
78+
for a full `spec.view` driving the URL column, an auth badge, and Access /
79+
Configuration / Readiness detail groups.
80+
81+
## Notes for authors
82+
83+
- Different templates may define different columns; the instance list shows the
84+
ordered union of all present templates' headers, and a row only fills the
85+
headers its own template defines.
86+
- When a template's columns reference `spec.*`/`status.*`, the portal fetches the
87+
full instance object per row to populate them — keep the column count modest.
88+
- Keep `spec.view` in lockstep with the embedded CRD: after editing the
89+
`Template` CRD's Go type, run `make codegen-infrastructure-provider` so the
90+
generated + embedded CRDs carry the `view` field (otherwise kcp prunes it).

providers/infrastructure/install/crds/infrastructure.kedge.faros.sh_templates.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,24 @@ spec:
235235
maxLength: 64
236236
pattern: ^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$
237237
type: string
238+
view:
239+
description: "View is optional presentation metadata that tells the
240+
portal how to render\nthis template's instances — extra columns
241+
in the instance-list table and\ngrouped, typed fields on the instance
242+
detail page — instead of the default\nraw-JSON dump. Authored by
243+
the template owner so each template controls its\nown UX. Field
244+
values are dot-paths or ${…}-interpolated strings resolved\nagainst
245+
the instance's spec/status/meta (see the portal's view resolver).\nStored
246+
as raw JSON (preserve-unknown-fields) and surfaced to the portal
247+
as\nspec.view; opaque to the controller. Shape:\n\n\tcolumns: #
248+
extra instance-list columns\n\t - header: Endpoint\n\t value:
249+
\"https://${spec.expose.fqdn}\"\n\t type: link #
250+
text | link | badge | code\n\tdetail: #
251+
detail-page field groups\n\t - title: Access\n\t fields:\n\t
252+
\ - label: URL\n\t value: \"https://${status.url}\"\n\t
253+
\ type: link\n\t - label: Region\n\t path: spec.region"
254+
type: object
255+
x-kubernetes-preserve-unknown-fields: true
238256
required:
239257
- backend
240258
- instanceCRD

providers/infrastructure/install/templates/application.yaml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,52 @@ spec:
8585
- "status.redirectURL — the OIDC redirect/callback URL to register with your IdP (byo mode)."
8686
- "Per-tier readiness: status.frontendReady / backendReady / databaseReady / oauthReady (ready replica counts)."
8787
- "The backend's DATABASE_URL env is sourced from Secret '<name>-db-credentials' (key 'uri') in the instance namespace — informational; the image just reads the DATABASE_URL env, it never reads the Secret."
88+
# ── Portal presentation ───────────────────────────────────────────────
89+
# Tells the portal how to render this template's instances instead of the
90+
# default raw-JSON values dump. Field values are dot-paths or ${…}-interpolated
91+
# strings resolved against the instance's spec.* (inputs), status.* (computed
92+
# outputs), and meta.* (name/phase/…). type ∈ text|link|badge|code.
93+
view:
94+
# Extra columns in the "My instances" table (after Name/Template).
95+
columns:
96+
- header: URL
97+
value: "${status.url}"
98+
type: link
99+
- header: Auth
100+
path: spec.oidc.mode
101+
type: badge
102+
# Field groups on the instance detail page (replace the raw values dump).
103+
detail:
104+
- title: Access
105+
fields:
106+
- label: URL
107+
value: "${status.url}"
108+
type: link
109+
- label: Hostname
110+
path: status.host
111+
type: code
112+
- label: Redirect URL
113+
value: "${status.redirectURL}"
114+
type: link
115+
- title: Configuration
116+
fields:
117+
- label: Auth mode
118+
path: spec.oidc.mode
119+
type: badge
120+
- label: Postgres version
121+
path: spec.database.version
122+
type: badge
123+
- label: DB secret
124+
path: status.dbConnectionSecretRef.name
125+
type: code
126+
- title: Readiness
127+
fields:
128+
- label: Frontend
129+
path: status.frontendReady
130+
- label: Backend
131+
path: status.backendReady
132+
- label: Database
133+
path: status.databaseReady
88134
instanceCRD:
89135
group: infrastructure.kedge.faros.sh
90136
version: v1alpha1

providers/infrastructure/kro/instance.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ func unstructuredToInstance(obj *unstructured.Unstructured, templateName string)
244244
if spec, _, _ := unstructured.NestedMap(obj.Object, "spec"); spec != nil {
245245
inst.Values = spec
246246
}
247+
if status, _, _ := unstructured.NestedMap(obj.Object, "status"); status != nil {
248+
// Surface controller-computed outputs (url, fqdn, secret names, …)
249+
// for a template's View to reference as status.*. Drop the bulky
250+
// conditions/children arrays — already promoted to typed fields.
251+
delete(status, "conditions")
252+
delete(status, "children")
253+
if len(status) > 0 {
254+
inst.Status = status
255+
}
256+
}
247257
if phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase"); phase != "" {
248258
inst.Phase = phase
249259
}

providers/infrastructure/kro/templates.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,11 @@ func rgdToTemplate(obj *unstructured.Unstructured) (*Template, error) {
161161
t.SampleValues = parsed
162162
}
163163
}
164+
if raw := annotations[AnnotationView]; raw != "" {
165+
var view TemplateView
166+
if err := json.Unmarshal([]byte(raw), &view); err == nil {
167+
t.View = &view
168+
}
169+
}
164170
return t, nil
165171
}

0 commit comments

Comments
 (0)