Skip to content

Commit b18a2ce

Browse files
committed
feat: relations
On-behalf-of: @SAP [email protected] Signed-off-by: Artem Shcherbatiuk <[email protected]>
1 parent 9efd880 commit b18a2ce

File tree

4 files changed

+499
-10
lines changed

4 files changed

+499
-10
lines changed

gateway/resolver/relationships.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package resolver
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/graphql-go/graphql"
8+
"go.opentelemetry.io/otel"
9+
"go.opentelemetry.io/otel/attribute"
10+
"go.opentelemetry.io/otel/trace"
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
14+
"github.com/openmfp/golang-commons/logger"
15+
)
16+
17+
// RelationshipResolver handles resolution of relationships between Kubernetes resources
18+
type RelationshipResolver struct {
19+
log *logger.Logger
20+
groupNames map[string]string
21+
}
22+
23+
// EnhancedRef represents our custom relationship reference structure
24+
type EnhancedRef struct {
25+
Kind string `json:"kind,omitempty"`
26+
GroupVersion string `json:"groupVersion,omitempty"`
27+
Name string `json:"name"`
28+
Namespace string `json:"namespace,omitempty"`
29+
}
30+
31+
// NewRelationshipResolver creates a new relationship resolver
32+
func NewRelationshipResolver(log *logger.Logger, groupNames map[string]string) *RelationshipResolver {
33+
return &RelationshipResolver{
34+
log: log,
35+
groupNames: groupNames,
36+
}
37+
}
38+
39+
// IsRelationshipField checks if a field name follows the relationship convention (<kind>Ref)
40+
func IsRelationshipField(fieldName string) bool {
41+
return strings.HasSuffix(fieldName, "Ref")
42+
}
43+
44+
// ExtractTargetKind extracts the target kind from a relationship field name
45+
// e.g., "roleRef" -> "Role"
46+
func ExtractTargetKind(fieldName string) string {
47+
if !IsRelationshipField(fieldName) {
48+
return ""
49+
}
50+
51+
// Remove "Ref" suffix and capitalize first letter
52+
kindName := strings.TrimSuffix(fieldName, "Ref")
53+
if len(kindName) == 0 {
54+
return ""
55+
}
56+
57+
return strings.ToUpper(kindName[:1]) + kindName[1:]
58+
}
59+
60+
// CreateSingleRelationResolver creates a GraphQL field resolver for a single relation field
61+
func (r *RelationshipResolver) CreateSingleRelationResolver(sourceGVK schema.GroupVersionKind, fieldName string) graphql.FieldResolveFn {
62+
return func(p graphql.ResolveParams) (interface{}, error) {
63+
_, span := otel.Tracer("").Start(p.Context, "ResolveSingleRelation",
64+
trace.WithAttributes(
65+
attribute.String("sourceKind", sourceGVK.Kind),
66+
attribute.String("fieldName", fieldName),
67+
))
68+
defer span.End()
69+
70+
// Get the source object from the parent resolver
71+
sourceObj, ok := p.Source.(map[string]interface{})
72+
if !ok {
73+
return nil, fmt.Errorf("expected source to be map[string]interface{}, got %T", p.Source)
74+
}
75+
76+
// Extract the relationship reference from the source object
77+
refValue, found, err := unstructured.NestedFieldNoCopy(sourceObj, fieldName)
78+
if err != nil {
79+
r.log.Debug().Err(err).Str("field", fieldName).Msg("Error accessing relationship field")
80+
return nil, nil // Return nil for optional field
81+
}
82+
if !found {
83+
return nil, nil // Field not present, return nil
84+
}
85+
86+
refMap, ok := refValue.(map[string]interface{})
87+
if !ok {
88+
r.log.Debug().Str("field", fieldName).Msg("Relationship field is not a map")
89+
return nil, nil
90+
}
91+
92+
// Create enhanced reference
93+
enhancedRef, err := r.createEnhancedRef(refMap, sourceObj, sourceGVK, fieldName)
94+
if err != nil {
95+
r.log.Debug().Err(err).Str("field", fieldName).Msg("Error creating enhanced reference")
96+
return nil, nil
97+
}
98+
99+
return enhancedRef, nil
100+
}
101+
}
102+
103+
// createEnhancedRef transforms a native Kubernetes reference to our enhanced structure
104+
func (r *RelationshipResolver) createEnhancedRef(nativeRefMap map[string]interface{}, sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string) (map[string]interface{}, error) {
105+
enhancedRef := make(map[string]interface{})
106+
107+
// Extract name (required)
108+
name, ok := nativeRefMap["name"].(string)
109+
if !ok {
110+
return nil, fmt.Errorf("name is required in relationship reference")
111+
}
112+
enhancedRef["name"] = name
113+
114+
// Extract or infer kind
115+
if kind, ok := nativeRefMap["kind"].(string); ok {
116+
enhancedRef["kind"] = kind
117+
} else {
118+
// Infer kind from field name
119+
targetKind := ExtractTargetKind(fieldName)
120+
if targetKind != "" {
121+
enhancedRef["kind"] = targetKind
122+
}
123+
}
124+
125+
// Extract or construct groupVersion
126+
if groupVersion, ok := nativeRefMap["groupVersion"].(string); ok {
127+
enhancedRef["groupVersion"] = groupVersion
128+
} else if apiGroup, ok := nativeRefMap["apiGroup"].(string); ok {
129+
// Construct groupVersion from apiGroup
130+
if apiGroup == "rbac.authorization.k8s.io" {
131+
enhancedRef["groupVersion"] = "rbac.authorization.k8s.io/v1"
132+
} else if apiGroup == "" {
133+
enhancedRef["groupVersion"] = "v1"
134+
} else {
135+
enhancedRef["groupVersion"] = apiGroup + "/v1"
136+
}
137+
}
138+
139+
// Extract or infer namespace
140+
if namespace, ok := nativeRefMap["namespace"].(string); ok {
141+
enhancedRef["namespace"] = namespace
142+
} else {
143+
// Infer namespace from source object and relationship context
144+
targetKind, _ := enhancedRef["kind"].(string)
145+
inferredNamespace := r.inferNamespaceForReference(sourceObj, sourceGVK, fieldName, targetKind)
146+
if inferredNamespace != "" {
147+
enhancedRef["namespace"] = inferredNamespace
148+
}
149+
}
150+
151+
r.log.Debug().
152+
Str("fieldName", fieldName).
153+
Str("name", name).
154+
Interface("enhancedRef", enhancedRef).
155+
Msg("Created enhanced reference")
156+
157+
return enhancedRef, nil
158+
}
159+
160+
// inferNamespaceForReference infers the namespace for a reference based on Kubernetes conventions
161+
func (r *RelationshipResolver) inferNamespaceForReference(sourceObj map[string]interface{}, sourceGVK schema.GroupVersionKind, fieldName string, targetKind string) string {
162+
// For roleRef in RoleBinding, the referenced Role is in the same namespace as the RoleBinding
163+
if fieldName == "roleRef" && sourceGVK.Kind == "RoleBinding" && targetKind == "Role" {
164+
if metadata, found := sourceObj["metadata"]; found {
165+
if metadataMap, ok := metadata.(map[string]interface{}); ok {
166+
if namespace, ok := metadataMap["namespace"].(string); ok {
167+
return namespace
168+
}
169+
}
170+
}
171+
}
172+
173+
// For clusterRoleRef or when targeting ClusterRole, no namespace needed (cluster-scoped)
174+
if fieldName == "roleRef" && targetKind == "ClusterRole" {
175+
return ""
176+
}
177+
178+
// Default: try to use source object's namespace for namespaced targets
179+
if metadata, found := sourceObj["metadata"]; found {
180+
if metadataMap, ok := metadata.(map[string]interface{}); ok {
181+
if namespace, ok := metadataMap["namespace"].(string); ok {
182+
return namespace
183+
}
184+
}
185+
}
186+
187+
return ""
188+
}
189+
190+
// getOriginalGroupName converts sanitized group name back to original

gateway/resolver/resolver.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Provider interface {
3030
CustomQueriesProvider
3131
CommonResolver() graphql.FieldResolveFn
3232
SanitizeGroupName(string) string
33+
GetRelationshipResolver() *RelationshipResolver
3334
}
3435

3536
type CrudProvider interface {
@@ -50,16 +51,22 @@ type CustomQueriesProvider interface {
5051
type Service struct {
5152
log *logger.Logger
5253
// groupNames stores relation between sanitized group names and original group names that are used in the Kubernetes API
53-
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
54-
runtimeClient client.WithWatch
54+
groupNames map[string]string // map[sanitizedGroupName]originalGroupName
55+
runtimeClient client.WithWatch
56+
relationshipResolver *RelationshipResolver
5557
}
5658

5759
func New(log *logger.Logger, runtimeClient client.WithWatch) *Service {
58-
return &Service{
60+
service := &Service{
5961
log: log,
6062
groupNames: make(map[string]string),
6163
runtimeClient: runtimeClient,
6264
}
65+
66+
// Initialize relationship resolver
67+
service.relationshipResolver = NewRelationshipResolver(log, service.groupNames)
68+
69+
return service
6370
}
6471

6572
// ListItems returns a GraphQL CommonResolver function that lists Kubernetes resources of the given GroupVersionKind.
@@ -397,6 +404,10 @@ func (r *Service) getOriginalGroupName(groupName string) string {
397404
return groupName
398405
}
399406

407+
func (r *Service) GetRelationshipResolver() *RelationshipResolver {
408+
return r.relationshipResolver
409+
}
410+
400411
func compareUnstructured(a, b unstructured.Unstructured, fieldPath string) int {
401412
segments := strings.Split(fieldPath, ".")
402413

gateway/resolver/subscription.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ func (r *Service) runWatch(
7979

8080
fieldsToWatch := extractRequestedFields(p.Info)
8181

82+
// Extract relationship watches (for future implementation)
83+
relationshipWatches := r.extractRelationshipWatches(fieldsToWatch, gvk)
84+
if len(relationshipWatches) > 0 {
85+
r.log.Debug().
86+
Int("relationshipCount", len(relationshipWatches)).
87+
Str("sourceGVK", gvk.String()).
88+
Msg("Detected relationships in subscription - multiple watches not yet implemented")
89+
// TODO: Implement multiple watches for relationships
90+
}
91+
8292
list := &unstructured.UnstructuredList{}
8393
list.SetGroupVersionKind(schema.GroupVersionKind{
8494
Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List",
@@ -356,3 +366,75 @@ func CreateSubscriptionResolver(isSingle bool) graphql.FieldResolveFn {
356366
return source, nil
357367
}
358368
}
369+
370+
// RelationshipWatch represents a relationship that needs to be watched
371+
type RelationshipWatch struct {
372+
FieldPath string
373+
TargetGVK schema.GroupVersionKind
374+
TargetScope v1.ResourceScope
375+
}
376+
377+
// extractRelationshipWatches analyzes requested fields to identify relationships that need additional watches
378+
func (r *Service) extractRelationshipWatches(fieldsToWatch []string, sourceGVK schema.GroupVersionKind) []RelationshipWatch {
379+
var relationshipWatches []RelationshipWatch
380+
381+
for _, fieldPath := range fieldsToWatch {
382+
// Check if this field path contains a relationship field
383+
pathParts := strings.Split(fieldPath, ".")
384+
for i, part := range pathParts {
385+
if IsRelationshipField(part) {
386+
targetKind := ExtractTargetKind(part)
387+
if targetKind != "" {
388+
// Try to resolve the target GVK
389+
// For now, we'll make some assumptions about common relationships
390+
targetGVK := r.resolveTargetGVK(targetKind, sourceGVK)
391+
if targetGVK != (schema.GroupVersionKind{}) {
392+
// TODO: Determine target scope - for now assume same as source
393+
relationshipWatches = append(relationshipWatches, RelationshipWatch{
394+
FieldPath: strings.Join(pathParts[:i+1], "."),
395+
TargetGVK: targetGVK,
396+
TargetScope: v1.NamespaceScoped, // Default assumption
397+
})
398+
}
399+
}
400+
}
401+
}
402+
}
403+
404+
return relationshipWatches
405+
}
406+
407+
// resolveTargetGVK attempts to resolve the target GVK for a relationship
408+
func (r *Service) resolveTargetGVK(targetKind string, sourceGVK schema.GroupVersionKind) schema.GroupVersionKind {
409+
// Handle common RBAC relationships
410+
switch targetKind {
411+
case "Role":
412+
return schema.GroupVersionKind{
413+
Group: "rbac.authorization.k8s.io",
414+
Version: "v1",
415+
Kind: "Role",
416+
}
417+
case "ClusterRole":
418+
return schema.GroupVersionKind{
419+
Group: "rbac.authorization.k8s.io",
420+
Version: "v1",
421+
Kind: "ClusterRole",
422+
}
423+
case "ServiceAccount":
424+
return schema.GroupVersionKind{
425+
Group: "",
426+
Version: "v1",
427+
Kind: "ServiceAccount",
428+
}
429+
case "User", "Group":
430+
// These are not Kubernetes resources, so we can't watch them
431+
return schema.GroupVersionKind{}
432+
default:
433+
// For other kinds, assume same group/version as source
434+
return schema.GroupVersionKind{
435+
Group: sourceGVK.Group,
436+
Version: sourceGVK.Version,
437+
Kind: targetKind,
438+
}
439+
}
440+
}

0 commit comments

Comments
 (0)