Skip to content

Commit ba4d009

Browse files
mjudeikisclaude
andcommitted
feat(app-studio): route tenant access through the GraphQL gateway
Activate the GraphQL path: clientFor now builds a tenant.Scope from the X-Kedge-Cluster header (the hub-injected workspace cluster ID) and the caller token, so App Studio reaches its workspace through the hub's gateway — which serves any workspace the caller has access to, unlike the DefaultCluster-gated kcp user-proxy. - http.go reads X-Kedge-Cluster into identity.clusterID - server.go/main.go construct the GraphQL client from KEDGE_HUB_URL - all ~18 c.Dynamic().Resource(gvr) call sites move to c.Resource(descriptor), backed by the GraphQL Scope (prod) or a dynamic client (tests) - tenant_resources.go holds the GraphQL descriptors (Kind/Plural) for Project, Secret, SandboxRunner, and code-provider Connection/Repository - isProjectAPIInitializingError recognises the gateway "schema not ready" shapes (no schema for cluster / Project type missing) for the 503 path Module builds, vets, and the full test suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 19d133c commit ba4d009

11 files changed

Lines changed: 109 additions & 47 deletions

File tree

providers/app-studio/api/code_repository.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (s *Server) prepareProjectRepository(ctx context.Context, c *asclient.Clien
101101

102102
func selectCodeConnection(ctx context.Context, c *asclient.Client, requested string) (string, error) {
103103
if requested != "" {
104-
conn, err := c.Dynamic().Resource(codeConnectionsGVR).Get(ctx, requested, metav1.GetOptions{})
104+
conn, err := c.Resource(codeConnectionResource, "").Get(ctx, requested, metav1.GetOptions{})
105105
if err != nil {
106106
return "", codeProviderRequestError("get Code connection", err)
107107
}
@@ -111,7 +111,7 @@ func selectCodeConnection(ctx context.Context, c *asclient.Client, requested str
111111
return requested, nil
112112
}
113113

114-
list, err := c.Dynamic().Resource(codeConnectionsGVR).List(ctx, metav1.ListOptions{})
114+
list, err := c.Resource(codeConnectionResource, "").List(ctx, metav1.ListOptions{})
115115
if err != nil {
116116
return "", codeProviderRequestError("list Code connections", err)
117117
}
@@ -142,7 +142,7 @@ func repositoryName(ctx context.Context, c *asclient.Client, requested, displayN
142142
if i > 0 {
143143
name = dns1123LabelWithSuffix(base, uuid.NewString()[:6])
144144
}
145-
if _, err := c.Dynamic().Resource(codeRepositoriesGVR).Get(ctx, name, metav1.GetOptions{}); apierrors.IsNotFound(err) {
145+
if _, err := c.Resource(codeRepositoryResource, "").Get(ctx, name, metav1.GetOptions{}); apierrors.IsNotFound(err) {
146146
return name, nil
147147
} else if err != nil {
148148
return "", codeProviderRequestError("get Code repository", err)
@@ -169,7 +169,7 @@ func (s *Server) createProjectRepository(ctx context.Context, c *asclient.Client
169169
"description": plan.Description,
170170
"autoInit": true,
171171
}
172-
if _, err := c.Dynamic().Resource(codeRepositoriesGVR).Create(ctx, repo, metav1.CreateOptions{}); err != nil {
172+
if _, err := c.Resource(codeRepositoryResource, "").Create(ctx, repo, metav1.CreateOptions{}); err != nil {
173173
if apierrors.IsAlreadyExists(err) {
174174
return newValidationError(fmt.Sprintf("Code repository %q already exists", plan.Ref))
175175
}
@@ -183,10 +183,10 @@ func projectRepositoryView(ctx context.Context, c *asclient.Client, p *aiv1alpha
183183
var list codeResourceLister
184184
if c != nil {
185185
get = func(ctx context.Context, gvr schema.GroupVersionResource, name string) (*unstructured.Unstructured, error) {
186-
return c.Dynamic().Resource(gvr).Get(ctx, name, metav1.GetOptions{})
186+
return c.Resource(codeResourceFor(gvr), "").Get(ctx, name, metav1.GetOptions{})
187187
}
188188
list = func(ctx context.Context, gvr schema.GroupVersionResource, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
189-
return c.Dynamic().Resource(gvr).List(ctx, opts)
189+
return c.Resource(codeResourceFor(gvr), "").List(ctx, opts)
190190
}
191191
}
192192
return projectRepositoryViewFromResources(ctx, p, get, list)

providers/app-studio/api/development_runtime.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (s *Server) runtimeTargetForProject(ctx context.Context, c *asclient.Client
6868
if strings.TrimSpace(name) == "" {
6969
return runtimeTarget{}, nil, fmt.Errorf("sandbox runner name is empty")
7070
}
71-
obj, err := c.Dynamic().Resource(sandboxRunnerGVR).Get(ctx, name, metav1.GetOptions{})
71+
obj, err := c.Resource(sandboxRunnerResource, "").Get(ctx, name, metav1.GetOptions{})
7272
if err != nil {
7373
return runtimeTarget{}, nil, err
7474
}
@@ -332,14 +332,14 @@ func patchLastSync(ctx context.Context, c *asclient.Client, name string, t metav
332332
if c == nil {
333333
return nil
334334
}
335-
obj, err := c.Dynamic().Resource(sandboxRunnerGVR).Get(ctx, name, metav1.GetOptions{})
335+
obj, err := c.Resource(sandboxRunnerResource, "").Get(ctx, name, metav1.GetOptions{})
336336
if err != nil {
337337
return err
338338
}
339339
if err := unstructured.SetNestedField(obj.Object, t.Format("2006-01-02T15:04:05Z07:00"), "status", "lastSyncTime"); err != nil {
340340
return err
341341
}
342-
_, err = c.Dynamic().Resource(sandboxRunnerGVR).UpdateStatus(ctx, obj, metav1.UpdateOptions{})
342+
_, err = c.Resource(sandboxRunnerResource, "").UpdateStatus(ctx, obj, metav1.UpdateOptions{})
343343
if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) {
344344
return nil
345345
}

providers/app-studio/api/development_sync.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,8 +457,8 @@ func (s *Server) scheduleDevelopmentSyncAfterMutation(id identity, p *aiv1alpha1
457457
}
458458

459459
func (s *Server) syncDevelopmentAfterMutation(id identity, p *aiv1alpha1.Project, name string) {
460-
if s.clients == nil {
461-
klog.V(2).Infof("development sandbox sync after %s skipped for project %s: tenant client factory is not configured", projectToolBaseName(name), p.Name)
460+
if s.gql == nil {
461+
klog.V(2).Infof("development sandbox sync after %s skipped for project %s: tenant GraphQL client is not configured", projectToolBaseName(name), p.Name)
462462
return
463463
}
464464
c, err := s.clientFor(id)

providers/app-studio/api/http.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
// headers (defense in depth — a spoofed header cannot mis-scope storage).
3232
type identity struct {
3333
tenantPath string // X-Kedge-Tenant, e.g. root:kedge:orgs:<org>:<ws>
34+
clusterID string // X-Kedge-Cluster, the workspace's kcp logical-cluster ID
3435
orgUUID string // parsed from tenantPath
3536
workspaceUUID string // parsed from tenantPath ("" when the path is org-only)
3637
user string // X-Kedge-User
@@ -50,6 +51,7 @@ func identityFromRequest(w http.ResponseWriter, r *http.Request) (identity, bool
5051
org, ws := parseTenantPath(tenantPath)
5152
return identity{
5253
tenantPath: tenantPath,
54+
clusterID: strings.TrimSpace(r.Header.Get("X-Kedge-Cluster")),
5355
orgUUID: org,
5456
workspaceUUID: ws,
5557
user: strings.TrimSpace(r.Header.Get("X-Kedge-User")),

providers/app-studio/api/llm.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,7 @@ func firstSSELine(body []byte) (json.RawMessage, bool) {
12791279

12801280
func readProjectLLMSettings(ctx context.Context, c *asclient.Client) (projectLLMSettings, error) {
12811281
settings := defaultProjectLLMSettings()
1282-
secret, err := c.Dynamic().Resource(secretGVR).Namespace(projectLLMSecretNamespace).Get(ctx, projectLLMSecretName, metav1.GetOptions{})
1282+
secret, err := c.Resource(secretResource, projectLLMSecretNamespace).Get(ctx, projectLLMSecretName, metav1.GetOptions{})
12831283
if apierrors.IsNotFound(err) {
12841284
return settings, nil
12851285
}
@@ -1301,16 +1301,16 @@ func readProjectLLMSettings(ctx context.Context, c *asclient.Client) (projectLLM
13011301

13021302
func writeProjectLLMSettings(ctx context.Context, c *asclient.Client, settings projectLLMSettings) error {
13031303
secret := projectLLMSettingsSecret(settings)
1304-
existing, err := c.Dynamic().Resource(secretGVR).Namespace(projectLLMSecretNamespace).Get(ctx, projectLLMSecretName, metav1.GetOptions{})
1304+
existing, err := c.Resource(secretResource, projectLLMSecretNamespace).Get(ctx, projectLLMSecretName, metav1.GetOptions{})
13051305
if apierrors.IsNotFound(err) {
1306-
_, err = c.Dynamic().Resource(secretGVR).Namespace(projectLLMSecretNamespace).Create(ctx, secret, metav1.CreateOptions{})
1306+
_, err = c.Resource(secretResource, projectLLMSecretNamespace).Create(ctx, secret, metav1.CreateOptions{})
13071307
return err
13081308
}
13091309
if err != nil {
13101310
return err
13111311
}
13121312
secret.SetResourceVersion(existing.GetResourceVersion())
1313-
_, err = c.Dynamic().Resource(secretGVR).Namespace(projectLLMSecretNamespace).Update(ctx, secret, metav1.UpdateOptions{})
1313+
_, err = c.Resource(secretResource, projectLLMSecretNamespace).Update(ctx, secret, metav1.UpdateOptions{})
13141314
return err
13151315
}
13161316

providers/app-studio/api/projects.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,20 @@ func writeProjectError(w http.ResponseWriter, err error) {
167167
}
168168

169169
func isProjectAPIInitializingError(err error) bool {
170-
if !apierrors.IsNotFound(err) {
170+
if err == nil {
171171
return false
172172
}
173-
return strings.Contains(strings.ToLower(err.Error()), "server could not find the requested resource")
173+
low := strings.ToLower(err.Error())
174+
// kcp dynamic path: the Project API isn't served in the workspace yet.
175+
if apierrors.IsNotFound(err) && strings.Contains(low, "server could not find the requested resource") {
176+
return true
177+
}
178+
// GraphQL path: either the gateway has no schema for the workspace cluster
179+
// yet, or the schema lacks the Project type (APIBinding not established) —
180+
// both surface while the workspace is still being provisioned.
181+
return strings.Contains(low, "workspace initializing") ||
182+
strings.Contains(low, "cannot query field") ||
183+
strings.Contains(low, "unknown field")
174184
}
175185

176186
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
@@ -297,7 +307,7 @@ func (s *Server) cleanupCreatedProjectSetup(ctx context.Context, c *asclient.Cli
297307
_ = s.deleteProjectProviderResources(ctx, c, p, id)
298308
if p.Spec.Repository != nil {
299309
if ref := strings.TrimSpace(p.Spec.Repository.RepositoryRef); ref != "" {
300-
_ = c.Dynamic().Resource(codeRepositoriesGVR).Delete(ctx, ref, metav1.DeleteOptions{})
310+
_ = c.Resource(codeRepositoryResource, "").Delete(ctx, ref, metav1.DeleteOptions{})
301311
}
302312
}
303313
if name := strings.TrimSpace(p.Name); name != "" {

providers/app-studio/api/provider_resources.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func ensureProjectProviderResource(ctx context.Context, c *asclient.Client, p *a
8989
if owner := projectProviderResourceOwnerRef(p); owner != nil {
9090
want.SetOwnerReferences([]metav1.OwnerReference{*owner})
9191
}
92-
res := c.Dynamic().Resource(gvr)
92+
res := c.Resource(providerBindingResource(gvr, binding.ResourceRef.Kind), "")
9393
existing, err := res.Get(ctx, name, metav1.GetOptions{})
9494
if apierrors.IsNotFound(err) {
9595
return res.Create(ctx, want, metav1.CreateOptions{})
@@ -138,7 +138,7 @@ func (s *Server) deleteProjectProviderResources(ctx context.Context, c *asclient
138138
}
139139
runtimeNamespace := ""
140140
if isSandboxRunnerBinding(binding) && s != nil && s.runtimeClient != nil {
141-
obj, err := c.Dynamic().Resource(gvr).Get(ctx, name, metav1.GetOptions{})
141+
obj, err := c.Resource(providerBindingResource(gvr, binding.ResourceRef.Kind), "").Get(ctx, name, metav1.GetOptions{})
142142
if err != nil && !apierrors.IsNotFound(err) {
143143
return err
144144
}
@@ -149,7 +149,7 @@ func (s *Server) deleteProjectProviderResources(ctx context.Context, c *asclient
149149
}
150150
}
151151
}
152-
err = c.Dynamic().Resource(gvr).Delete(ctx, name, metav1.DeleteOptions{})
152+
err = c.Resource(providerBindingResource(gvr, binding.ResourceRef.Kind), "").Delete(ctx, name, metav1.DeleteOptions{})
153153
if err != nil && !apierrors.IsNotFound(err) {
154154
return err
155155
}
@@ -291,7 +291,7 @@ func projectProviderBindingStatus(ctx context.Context, c *asclient.Client, p *ai
291291
status.Phase = "Invalid"
292292
return status
293293
}
294-
obj, err := c.Dynamic().Resource(gvr).Get(ctx, name, metav1.GetOptions{})
294+
obj, err := c.Resource(providerBindingResource(gvr, binding.ResourceRef.Kind), "").Get(ctx, name, metav1.GetOptions{})
295295
if err != nil {
296296
status.Phase = "Pending"
297297
return status

providers/app-studio/api/server.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import (
4646
// for dev MCP calls; workspaces stores project files owned by App Studio; and
4747
// assistantEngine runs project assistant turns.
4848
type Server struct {
49-
clients *tenant.ClientFactory
49+
gql *tenant.GraphQLClient
5050
store store.Store
5151
workspaces *workspace.FileStore
5252
hubBase string
@@ -65,14 +65,14 @@ type Server struct {
6565
}
6666

6767
// New constructs a Server.
68-
func New(clients *tenant.ClientFactory, msgStore store.Store, hubBase string, mcpInsecureSkipTLSVerify bool) *Server {
69-
return NewWithWorkspace(clients, msgStore, nil, hubBase, mcpInsecureSkipTLSVerify)
68+
func New(gql *tenant.GraphQLClient, msgStore store.Store, hubBase string, mcpInsecureSkipTLSVerify bool) *Server {
69+
return NewWithWorkspace(gql, msgStore, nil, hubBase, mcpInsecureSkipTLSVerify)
7070
}
7171

7272
// NewWithWorkspace constructs a Server with an explicit project workspace store.
73-
func NewWithWorkspace(clients *tenant.ClientFactory, msgStore store.Store, workspaces *workspace.FileStore, hubBase string, mcpInsecureSkipTLSVerify bool) *Server {
73+
func NewWithWorkspace(gql *tenant.GraphQLClient, msgStore store.Store, workspaces *workspace.FileStore, hubBase string, mcpInsecureSkipTLSVerify bool) *Server {
7474
s := &Server{
75-
clients: clients,
75+
gql: gql,
7676
store: msgStore,
7777
workspaces: workspaces,
7878
hubBase: hubBase,
@@ -190,13 +190,14 @@ func (s *Server) Register(r *mux.Router) {
190190
r.HandleFunc("/api/projects/{project}/memory", s.patchProjectMemory).Methods(http.MethodPatch)
191191
}
192192

193-
// clientFor builds a workspace-scoped client acting as the caller.
193+
// clientFor builds a workspace-scoped client acting as the caller, talking to
194+
// the hub's GraphQL gateway for the caller's current workspace cluster.
194195
func (s *Server) clientFor(id identity) (*asclient.Client, error) {
195-
dyn, err := s.clients.For(id.tenantPath, id.token)
196+
scope, err := s.gql.For(id.clusterID, id.token)
196197
if err != nil {
197198
return nil, err
198199
}
199-
return asclient.NewFromDynamic(dyn), nil
200+
return asclient.NewFromGraphQL(scope), nil
200201
}
201202

202203
// requireProjectClient resolves the caller identity and a workspace-scoped
@@ -210,8 +211,12 @@ func (s *Server) requireProjectClient(w http.ResponseWriter, r *http.Request) (*
210211
writeStatus(w, http.StatusBadRequest, "BadRequest", "a workspace is required for this endpoint — select an organization and workspace first")
211212
return nil, identity{}, false
212213
}
213-
if s.clients == nil {
214-
writeStatus(w, http.StatusNotImplemented, "NotImplemented", "tenant client factory not configured — provider has no kubeconfig")
214+
if s.gql == nil {
215+
writeStatus(w, http.StatusNotImplemented, "NotImplemented", "tenant GraphQL client not configured — provider has no hub URL")
216+
return nil, identity{}, false
217+
}
218+
if id.clusterID == "" {
219+
writeStatus(w, http.StatusBadRequest, "BadRequest", "no workspace cluster on request (X-Kedge-Cluster missing) — the hub did not resolve a cluster for this workspace")
215220
return nil, identity{}, false
216221
}
217222
c, err := s.clientFor(id)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
*/
10+
11+
package api
12+
13+
import (
14+
"k8s.io/apimachinery/pkg/runtime/schema"
15+
16+
"github.com/faroshq/provider-app-studio/tenant"
17+
)
18+
19+
// tenant.Resource descriptors for the workspace resources App Studio accesses
20+
// through asclient.Client.Resource. Kind/Plural drive the GraphQL field names
21+
// (<Kind>Yaml / <Plural>Yaml / delete<Kind>); they must match the CRD's kind
22+
// and its pluralization.
23+
var (
24+
secretResource = tenant.Resource{GVR: secretGVR, Kind: "Secret", Plural: "Secrets", Namespaced: true}
25+
sandboxRunnerResource = tenant.Resource{GVR: sandboxRunnerGVR, Kind: "SandboxRunner", Plural: "SandboxRunners"}
26+
codeConnectionResource = tenant.Resource{GVR: codeConnectionsGVR, Kind: "Connection", Plural: "Connections"}
27+
codeRepositoryResource = tenant.Resource{GVR: codeRepositoriesGVR, Kind: "Repository", Plural: "Repositories"}
28+
)
29+
30+
// codeResourceFor maps a code-provider GVR to its descriptor, for the
31+
// repository-view getter/lister closures that are keyed by GVR.
32+
func codeResourceFor(gvr schema.GroupVersionResource) tenant.Resource {
33+
switch gvr {
34+
case codeRepositoriesGVR:
35+
return codeRepositoryResource
36+
case codeConnectionsGVR:
37+
return codeConnectionResource
38+
default:
39+
return tenant.Resource{GVR: gvr, Kind: "", Plural: ""}
40+
}
41+
}
42+
43+
// providerBindingResource builds a descriptor for a project provider-binding's
44+
// target CR. The kind comes from the binding's ResourceRef; these CRs are
45+
// cluster-scoped in the workspace.
46+
func providerBindingResource(gvr schema.GroupVersionResource, kind string) tenant.Resource {
47+
return tenant.Resource{GVR: gvr, Kind: kind, Plural: kind + "s"}
48+
}

providers/app-studio/main.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,14 @@ func runServe() {
165165
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
166166
defer stop()
167167

168-
// Provider config → tenant client factory. Without a kubeconfig the project
169-
// API returns 501 (useful for UI-only dev), with a loud warning.
170-
var clientFactory *tenant.ClientFactory
171-
if cfg, err := loadProviderConfig(); err != nil {
172-
log.Printf("WARNING project API disabled (no provider kubeconfig): %v", err)
168+
// Tenant access goes through the hub's GraphQL gateway (the hub injects
169+
// X-Kedge-Cluster per request). Without a hub URL the project API returns
170+
// 501 (useful for UI-only dev), with a loud warning.
171+
var gqlClient *tenant.GraphQLClient
172+
if hubURL := os.Getenv("KEDGE_HUB_URL"); hubURL == "" {
173+
log.Printf("WARNING project API disabled (no KEDGE_HUB_URL)")
173174
} else {
174-
clientFactory = tenant.NewClientFactory(cfg)
175+
gqlClient = tenant.NewGraphQLClient(hubURL, os.Getenv("KEDGE_HUB_INSECURE") == "true")
175176
}
176177

177178
msgStore, closeStore, err := openMessageStore(ctx)
@@ -181,7 +182,7 @@ func runServe() {
181182
defer closeStore()
182183

183184
apiServer := api.NewWithWorkspace(
184-
clientFactory,
185+
gqlClient,
185186
msgStore,
186187
openWorkspaceStore(),
187188
os.Getenv("KEDGE_HUB_URL"),

0 commit comments

Comments
 (0)