Skip to content

Commit aed69bb

Browse files
mjudeikisclaude
andcommitted
feat(app-studio): add GraphQL tenant client (gateway-backed)
App Studio's per-workspace data path: talk to the hub's embedded GraphQL gateway at <hubBase>/graphql/<clusterID> as the caller, instead of direct kcp access (which the hub user-proxy gates to the caller's DefaultCluster, so it fails in non-default workspaces). Every op exchanges whole serialized objects via the gateway's <Kind>Yaml / <Plural>Yaml queries and applyYaml / applyStatusYaml mutations, so the client needs no typed field-selection set over unstructured resources. GraphQL errors are mapped onto k8s apierrors so callers' IsNotFound/IsConflict checks keep working. Not yet wired into the call sites — follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe23cf4 commit aed69bb

1 file changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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+
// GraphQL-backed tenant access. App Studio reaches a tenant's kcp workspace
12+
// through the hub's embedded GraphQL gateway at <hubBase>/graphql/<clusterID>,
13+
// authenticating as the caller. Unlike the hub's kcp user-proxy (which gates
14+
// every request to the caller's DefaultCluster), the gateway serves any
15+
// workspace the caller has RBAC in, so App Studio works in non-default
16+
// workspaces.
17+
//
18+
// Every operation exchanges whole serialized objects (the gateway's <Kind>Yaml
19+
// / <Plural>Yaml queries and applyYaml / applyStatusYaml mutations), so this
20+
// client never needs a typed GraphQL field-selection set over App Studio's
21+
// unstructured resources. GraphQL errors are mapped back onto k8s apierrors so
22+
// callers' IsNotFound / IsConflict / IsAlreadyExists checks keep working.
23+
package tenant
24+
25+
import (
26+
"bytes"
27+
"context"
28+
"crypto/tls"
29+
"encoding/json"
30+
"fmt"
31+
"io"
32+
"net/http"
33+
"regexp"
34+
"strings"
35+
36+
apierrors "k8s.io/apimachinery/pkg/api/errors"
37+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
38+
"k8s.io/apimachinery/pkg/runtime/schema"
39+
"sigs.k8s.io/yaml"
40+
)
41+
42+
// GraphQLClient is a factory for per-(cluster, caller) GraphQL access to the
43+
// hub's embedded gateway.
44+
type GraphQLClient struct {
45+
hubBase string
46+
http *http.Client
47+
}
48+
49+
// NewGraphQLClient targets the hub's GraphQL gateway under hubBase (the hub's
50+
// base URL, e.g. https://kedge-hub.kedge.svc:9443). insecureSkipVerify relaxes
51+
// TLS for in-cluster hub certs that aren't in the provider's trust store.
52+
func NewGraphQLClient(hubBase string, insecureSkipVerify bool) *GraphQLClient {
53+
tr := http.DefaultTransport.(*http.Transport).Clone()
54+
if insecureSkipVerify {
55+
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // opt-in for in-cluster hub cert
56+
}
57+
return &GraphQLClient{
58+
hubBase: strings.TrimRight(hubBase, "/"),
59+
http: &http.Client{Transport: tr},
60+
}
61+
}
62+
63+
// Resource identifies a kcp resource for GraphQL field-name derivation. The
64+
// gateway nests fields as <sanitizedGroup>.<version>.<field>, with field names
65+
// derived from the Kind (singular) and its pluralization.
66+
type Resource struct {
67+
GVR schema.GroupVersionResource
68+
Kind string // e.g. "Project" — GraphQL singular field + create/update/delete suffix
69+
Plural string // e.g. "Projects" — GraphQL list field
70+
Namespaced bool
71+
}
72+
73+
var (
74+
invalidGroupChar = regexp.MustCompile(`[^a-zA-Z0-9_]`)
75+
validGroupStart = regexp.MustCompile(`^[a-zA-Z_]`)
76+
)
77+
78+
// groupField mirrors the gateway's SanitizeGroupName: non-[a-zA-Z0-9_] become
79+
// "_", and a leading invalid char gets an "_" prefix.
80+
func groupField(group string) string {
81+
s := invalidGroupChar.ReplaceAllString(group, "_")
82+
if s != "" && !validGroupStart.MatchString(s) {
83+
s = "_" + s
84+
}
85+
return s
86+
}
87+
88+
// Scope is a GraphQL client bound to one workspace cluster and caller token.
89+
type Scope struct {
90+
c *GraphQLClient
91+
clusterID string
92+
token string
93+
}
94+
95+
// For returns a client scoped to clusterID (the X-Kedge-Cluster the hub
96+
// injected) authenticating as the caller via token.
97+
func (c *GraphQLClient) For(clusterID, token string) (*Scope, error) {
98+
if clusterID == "" {
99+
return nil, fmt.Errorf("no cluster id (X-Kedge-Cluster missing) — cannot target the tenant workspace")
100+
}
101+
if token == "" {
102+
return nil, fmt.Errorf("no bearer token on request — cannot act on the tenant's behalf")
103+
}
104+
return &Scope{c: c, clusterID: clusterID, token: token}, nil
105+
}
106+
107+
type gqlError struct {
108+
Message string `json:"message"`
109+
}
110+
111+
type gqlResponse struct {
112+
Data json.RawMessage `json:"data"`
113+
Errors []gqlError `json:"errors"`
114+
}
115+
116+
// exec POSTs a GraphQL request and returns the raw data object. res/name give
117+
// error mapping the resource + object identity for apierrors construction.
118+
func (s *Scope) exec(ctx context.Context, query string, vars map[string]any, res *Resource, name string) (json.RawMessage, error) {
119+
reqBody, err := json.Marshal(map[string]any{"query": query, "variables": vars})
120+
if err != nil {
121+
return nil, err
122+
}
123+
url := s.c.hubBase + "/graphql/" + s.clusterID
124+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
125+
if err != nil {
126+
return nil, err
127+
}
128+
httpReq.Header.Set("Content-Type", "application/json")
129+
httpReq.Header.Set("Authorization", "Bearer "+s.token)
130+
131+
resp, err := s.c.http.Do(httpReq)
132+
if err != nil {
133+
return nil, err
134+
}
135+
defer func() { _ = resp.Body.Close() }()
136+
body, err := io.ReadAll(resp.Body)
137+
if err != nil {
138+
return nil, err
139+
}
140+
if resp.StatusCode == http.StatusNotFound {
141+
// No schema for this cluster: the gateway hasn't (yet) generated a
142+
// schema for the workspace. Surface a NotFound the caller can treat as
143+
// "initializing" rather than a hard failure.
144+
gr := schema.GroupResource{}
145+
if res != nil {
146+
gr = res.GVR.GroupResource()
147+
}
148+
return nil, apierrors.NewNotFound(gr, name)
149+
}
150+
if resp.StatusCode/100 != 2 {
151+
return nil, fmt.Errorf("graphql gateway HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
152+
}
153+
var out gqlResponse
154+
if err := json.Unmarshal(body, &out); err != nil {
155+
return nil, fmt.Errorf("decode graphql response: %w", err)
156+
}
157+
if len(out.Errors) > 0 {
158+
return nil, mapGraphQLError(out.Errors, res, name)
159+
}
160+
return out.Data, nil
161+
}
162+
163+
// mapGraphQLError converts a gateway error into a k8s apierror where the
164+
// message identifies a well-known condition, so callers' apierrors.Is* checks
165+
// keep working across the migration.
166+
func mapGraphQLError(errs []gqlError, res *Resource, name string) error {
167+
first := errs[0].Message
168+
low := strings.ToLower(first)
169+
gr := schema.GroupResource{}
170+
if res != nil {
171+
gr = res.GVR.GroupResource()
172+
}
173+
switch {
174+
case strings.Contains(low, "not found"), strings.Contains(low, "could not find"):
175+
return apierrors.NewNotFound(gr, name)
176+
case strings.Contains(low, "already exists"):
177+
return apierrors.NewAlreadyExists(gr, name)
178+
case strings.Contains(low, "conflict"):
179+
return apierrors.NewConflict(gr, name, fmt.Errorf("%s", first))
180+
}
181+
msgs := make([]string, 0, len(errs))
182+
for _, e := range errs {
183+
msgs = append(msgs, e.Message)
184+
}
185+
return fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
186+
}
187+
188+
// Get fetches one object via the <Kind>Yaml query.
189+
func (s *Scope) Get(ctx context.Context, res Resource, namespace, name string) (*unstructured.Unstructured, error) {
190+
field := res.Kind + "Yaml"
191+
varDefs, args, vars := itemArgs(res, namespace, name)
192+
q := fmt.Sprintf("query(%s) { %s { %s { %s(%s) } } }",
193+
varDefs, groupField(res.GVR.Group), res.GVR.Version, field, args)
194+
195+
data, err := s.exec(ctx, q, vars, &res, name)
196+
if err != nil {
197+
return nil, err
198+
}
199+
str, err := nestedString(data, groupField(res.GVR.Group), res.GVR.Version, field)
200+
if err != nil {
201+
return nil, err
202+
}
203+
return decodeObject(str)
204+
}
205+
206+
// List fetches all objects via the <Plural>Yaml query. namespace is optional
207+
// (empty lists across all namespaces for namespaced resources).
208+
func (s *Scope) List(ctx context.Context, res Resource, namespace string) ([]unstructured.Unstructured, error) {
209+
field := res.Plural + "Yaml"
210+
var (
211+
varDefs string
212+
args string
213+
vars = map[string]any{}
214+
)
215+
if res.Namespaced && namespace != "" {
216+
varDefs = "$namespace: String"
217+
args = "namespace: $namespace"
218+
vars["namespace"] = namespace
219+
}
220+
q := ""
221+
if varDefs != "" {
222+
q = fmt.Sprintf("query(%s) { %s { %s { %s(%s) } } }",
223+
varDefs, groupField(res.GVR.Group), res.GVR.Version, field, args)
224+
} else {
225+
q = fmt.Sprintf("query { %s { %s { %s } } }",
226+
groupField(res.GVR.Group), res.GVR.Version, field)
227+
}
228+
229+
data, err := s.exec(ctx, q, vars, &res, "")
230+
if err != nil {
231+
return nil, err
232+
}
233+
str, err := nestedString(data, groupField(res.GVR.Group), res.GVR.Version, field)
234+
if err != nil {
235+
return nil, err
236+
}
237+
return decodeList(str)
238+
}
239+
240+
// Apply create-or-updates obj via the generic applyYaml mutation.
241+
func (s *Scope) Apply(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
242+
y, err := yaml.Marshal(obj.Object)
243+
if err != nil {
244+
return nil, err
245+
}
246+
q := "mutation($yaml: String!) { applyYaml(yaml: $yaml) }"
247+
res := resourceFromObject(obj)
248+
data, err := s.exec(ctx, q, map[string]any{"yaml": string(y)}, res, obj.GetName())
249+
if err != nil {
250+
return nil, err
251+
}
252+
str, err := nestedString(data, "applyYaml")
253+
if err != nil {
254+
return nil, err
255+
}
256+
return decodeObject(str)
257+
}
258+
259+
// ApplyStatus merge-patches obj's status subresource via applyStatusYaml.
260+
func (s *Scope) ApplyStatus(ctx context.Context, obj *unstructured.Unstructured) error {
261+
y, err := yaml.Marshal(obj.Object)
262+
if err != nil {
263+
return err
264+
}
265+
q := "mutation($yaml: String!) { applyStatusYaml(yaml: $yaml) }"
266+
res := resourceFromObject(obj)
267+
_, err = s.exec(ctx, q, map[string]any{"yaml": string(y)}, res, obj.GetName())
268+
return err
269+
}
270+
271+
// Delete removes one object via the delete<Kind> mutation.
272+
func (s *Scope) Delete(ctx context.Context, res Resource, namespace, name string) error {
273+
field := "delete" + res.Kind
274+
varDefs, args, vars := itemArgs(res, namespace, name)
275+
q := fmt.Sprintf("mutation(%s) { %s { %s { %s(%s) } } }",
276+
varDefs, groupField(res.GVR.Group), res.GVR.Version, field, args)
277+
_, err := s.exec(ctx, q, vars, &res, name)
278+
return err
279+
}
280+
281+
// itemArgs builds the (varDefs, args, vars) for a single-item operation.
282+
func itemArgs(res Resource, namespace, name string) (string, string, map[string]any) {
283+
varDefs := "$name: String!"
284+
args := "name: $name"
285+
vars := map[string]any{"name": name}
286+
if res.Namespaced {
287+
varDefs += ", $namespace: String!"
288+
args += ", namespace: $namespace"
289+
vars["namespace"] = namespace
290+
}
291+
return varDefs, args, vars
292+
}
293+
294+
// resourceFromObject derives a Resource for error mapping from an object's GVK.
295+
func resourceFromObject(obj *unstructured.Unstructured) *Resource {
296+
gvk := obj.GroupVersionKind()
297+
return &Resource{
298+
GVR: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"},
299+
Kind: gvk.Kind,
300+
}
301+
}
302+
303+
// nestedString walks the GraphQL data object along path and returns the leaf
304+
// string (the serialized object/list the *Yaml fields return).
305+
func nestedString(data json.RawMessage, path ...string) (string, error) {
306+
var cur any
307+
if err := json.Unmarshal(data, &cur); err != nil {
308+
return "", fmt.Errorf("decode graphql data: %w", err)
309+
}
310+
for _, key := range path {
311+
m, ok := cur.(map[string]any)
312+
if !ok || m[key] == nil {
313+
return "", fmt.Errorf("graphql response missing field %q", key)
314+
}
315+
cur = m[key]
316+
}
317+
str, ok := cur.(string)
318+
if !ok {
319+
return "", fmt.Errorf("graphql field %q is not a string", path[len(path)-1])
320+
}
321+
return str, nil
322+
}
323+
324+
// decodeObject parses a serialized (YAML or JSON) object into unstructured.
325+
func decodeObject(s string) (*unstructured.Unstructured, error) {
326+
if strings.TrimSpace(s) == "" {
327+
return nil, fmt.Errorf("empty object payload")
328+
}
329+
m := map[string]any{}
330+
if err := yaml.Unmarshal([]byte(s), &m); err != nil {
331+
return nil, fmt.Errorf("decode object: %w", err)
332+
}
333+
return &unstructured.Unstructured{Object: m}, nil
334+
}
335+
336+
// decodeList parses a serialized YAML sequence of objects into unstructured.
337+
func decodeList(s string) ([]unstructured.Unstructured, error) {
338+
if strings.TrimSpace(s) == "" {
339+
return nil, nil
340+
}
341+
var raw []map[string]any
342+
if err := yaml.Unmarshal([]byte(s), &raw); err != nil {
343+
return nil, fmt.Errorf("decode list: %w", err)
344+
}
345+
out := make([]unstructured.Unstructured, 0, len(raw))
346+
for _, m := range raw {
347+
out = append(out, unstructured.Unstructured{Object: m})
348+
}
349+
return out, nil
350+
}

0 commit comments

Comments
 (0)