|
| 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