@@ -34,6 +34,7 @@ import (
3434 "k8s.io/client-go/dynamic"
3535
3636 aiv1alpha1 "github.com/faroshq/provider-app-studio/apis/ai/v1alpha1"
37+ "github.com/faroshq/provider-app-studio/tenant"
3738)
3839
3940// ProjectGVR points at the workspace-scoped Project CRD.
@@ -43,17 +44,63 @@ var ProjectGVR = schema.GroupVersionResource{
4344 Resource : "projects" ,
4445}
4546
46- // Client provides typed access to App Studio resources via the dynamic client.
47+ // projectResource describes the Project CRD for the GraphQL tenant client. The
48+ // Project is cluster-scoped in the workspace.
49+ var projectResource = tenant.Resource {
50+ GVR : ProjectGVR ,
51+ Kind : "Project" ,
52+ Plural : "Projects" ,
53+ Namespaced : false ,
54+ }
55+
56+ // ResourceClient is the per-resource surface App Studio needs. Its signatures
57+ // match dynamic.ResourceInterface's subset exactly, so both a real
58+ // dynamic.ResourceInterface (tests) and the GraphQL-backed gqlResource
59+ // (production) satisfy it.
60+ type ResourceClient interface {
61+ Get (ctx context.Context , name string , opts metav1.GetOptions , subresources ... string ) (* unstructured.Unstructured , error )
62+ List (ctx context.Context , opts metav1.ListOptions ) (* unstructured.UnstructuredList , error )
63+ Create (ctx context.Context , obj * unstructured.Unstructured , opts metav1.CreateOptions , subresources ... string ) (* unstructured.Unstructured , error )
64+ Update (ctx context.Context , obj * unstructured.Unstructured , opts metav1.UpdateOptions , subresources ... string ) (* unstructured.Unstructured , error )
65+ UpdateStatus (ctx context.Context , obj * unstructured.Unstructured , opts metav1.UpdateOptions ) (* unstructured.Unstructured , error )
66+ Delete (ctx context.Context , name string , opts metav1.DeleteOptions , subresources ... string ) error
67+ Patch (ctx context.Context , name string , pt types.PatchType , data []byte , opts metav1.PatchOptions , subresources ... string ) (* unstructured.Unstructured , error )
68+ }
69+
70+ // Client provides typed access to App Studio resources. It is backed by either
71+ // the GraphQL tenant client (production: the hub's gateway, which serves any
72+ // workspace the caller has access to) or a dynamic client (tests).
4773type Client struct {
74+ scope * tenant.Scope
4875 dynamic dynamic.Interface
4976}
5077
51- // NewFromDynamic creates a Client from an existing dynamic.Interface.
78+ // NewFromGraphQL creates a Client backed by the hub's GraphQL gateway.
79+ func NewFromGraphQL (scope * tenant.Scope ) * Client {
80+ return & Client {scope : scope }
81+ }
82+
83+ // NewFromDynamic creates a Client from an existing dynamic.Interface (tests).
5284func NewFromDynamic (d dynamic.Interface ) * Client {
5385 return & Client {dynamic : d }
5486}
5587
56- // Dynamic returns the underlying dynamic client.
88+ // Resource returns a ResourceClient for an arbitrary resource (e.g. provider
89+ // CRs, secrets). namespace is "" for cluster-scoped access.
90+ func (c * Client ) Resource (res tenant.Resource , namespace string ) ResourceClient {
91+ if c .scope != nil {
92+ return & gqlResource {scope : c .scope , res : res , namespace : namespace }
93+ }
94+ nri := c .dynamic .Resource (res .GVR )
95+ if namespace != "" {
96+ return nri .Namespace (namespace )
97+ }
98+ return nri
99+ }
100+
101+ // Dynamic returns the underlying dynamic client. Only valid for clients built
102+ // with NewFromDynamic (tests); nil in GraphQL mode. Production code paths must
103+ // use Resource() so they work against either backend.
57104func (c * Client ) Dynamic () dynamic.Interface {
58105 return c .dynamic
59106}
@@ -62,16 +109,16 @@ func (c *Client) Dynamic() dynamic.Interface {
62109// workspace.
63110func (c * Client ) Projects () * TypedResource [aiv1alpha1.Project , aiv1alpha1.ProjectList ] {
64111 return & TypedResource [aiv1alpha1.Project , aiv1alpha1.ProjectList ]{
65- client : c .dynamic . Resource (ProjectGVR ),
112+ client : c .Resource (projectResource , "" ),
66113 gvk : ProjectGVR .GroupVersion ().WithKind ("Project" ),
67114 }
68115}
69116
70117// TypedResource provides typed CRUD operations for a specific resource type.
71118// gvk is used to populate apiVersion/kind on objects before sending them to
72- // the dynamic client.
119+ // the backing client.
73120type TypedResource [T any , L any ] struct {
74- client dynamic. ResourceInterface
121+ client ResourceClient
75122 gvk schema.GroupVersionKind
76123}
77124
@@ -180,6 +227,74 @@ func fromUnstructured[T any](u *unstructured.Unstructured) (*T, error) {
180227 return & obj , nil
181228}
182229
230+ // gqlResource adapts a GraphQL tenant Scope to the ResourceClient surface.
231+ // Create/Update map to the gateway's generic applyYaml (create-or-update);
232+ // status writes map to applyStatusYaml.
233+ type gqlResource struct {
234+ scope * tenant.Scope
235+ res tenant.Resource
236+ namespace string
237+ }
238+
239+ func (g * gqlResource ) Get (ctx context.Context , name string , _ metav1.GetOptions , _ ... string ) (* unstructured.Unstructured , error ) {
240+ return g .scope .Get (ctx , g .res , g .namespace , name )
241+ }
242+
243+ func (g * gqlResource ) List (ctx context.Context , _ metav1.ListOptions ) (* unstructured.UnstructuredList , error ) {
244+ items , err := g .scope .List (ctx , g .res , g .namespace )
245+ if err != nil {
246+ return nil , err
247+ }
248+ return & unstructured.UnstructuredList {Items : items }, nil
249+ }
250+
251+ func (g * gqlResource ) Create (ctx context.Context , obj * unstructured.Unstructured , _ metav1.CreateOptions , subresources ... string ) (* unstructured.Unstructured , error ) {
252+ if len (subresources ) == 1 && subresources [0 ] == "status" {
253+ return g .UpdateStatus (ctx , obj , metav1.UpdateOptions {})
254+ }
255+ return g .scope .Apply (ctx , obj )
256+ }
257+
258+ func (g * gqlResource ) Update (ctx context.Context , obj * unstructured.Unstructured , _ metav1.UpdateOptions , subresources ... string ) (* unstructured.Unstructured , error ) {
259+ if len (subresources ) == 1 && subresources [0 ] == "status" {
260+ return g .UpdateStatus (ctx , obj , metav1.UpdateOptions {})
261+ }
262+ return g .scope .Apply (ctx , obj )
263+ }
264+
265+ func (g * gqlResource ) UpdateStatus (ctx context.Context , obj * unstructured.Unstructured , _ metav1.UpdateOptions ) (* unstructured.Unstructured , error ) {
266+ if err := g .scope .ApplyStatus (ctx , obj ); err != nil {
267+ return nil , err
268+ }
269+ return obj , nil
270+ }
271+
272+ func (g * gqlResource ) Delete (ctx context.Context , name string , _ metav1.DeleteOptions , _ ... string ) error {
273+ return g .scope .Delete (ctx , g .res , g .namespace , name )
274+ }
275+
276+ // Patch supports only the status subresource (the sole patch App Studio uses).
277+ // The merge-patch body is applied to the object's status via applyStatusYaml.
278+ func (g * gqlResource ) Patch (ctx context.Context , name string , _ types.PatchType , data []byte , _ metav1.PatchOptions , subresources ... string ) (* unstructured.Unstructured , error ) {
279+ if len (subresources ) != 1 || subresources [0 ] != "status" {
280+ return nil , fmt .Errorf ("graphql client: Patch supports only the status subresource, got %v" , subresources )
281+ }
282+ patch := map [string ]any {}
283+ if err := json .Unmarshal (data , & patch ); err != nil {
284+ return nil , fmt .Errorf ("decode status patch: %w" , err )
285+ }
286+ obj := & unstructured.Unstructured {Object : patch }
287+ obj .SetGroupVersionKind (g .res .GVR .GroupVersion ().WithKind (g .res .Kind ))
288+ obj .SetName (name )
289+ if g .namespace != "" {
290+ obj .SetNamespace (g .namespace )
291+ }
292+ if err := g .scope .ApplyStatus (ctx , obj ); err != nil {
293+ return nil , err
294+ }
295+ return obj , nil
296+ }
297+
183298func fromUnstructuredList [L any ](u * unstructured.UnstructuredList ) (* L , error ) {
184299 content := u .UnstructuredContent ()
185300 items := make ([]interface {}, 0 , len (u .Items ))
0 commit comments