Skip to content

Commit f40a98c

Browse files
author
Alessio.Diamanti
committed
feat: support custom type recursive reference
1 parent a260048 commit f40a98c

File tree

6 files changed

+300
-9
lines changed

6 files changed

+300
-9
lines changed
76 MB
Binary file not shown.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# kro recursive custom type reference example
2+
3+
This example creates a ResourceGraphDefinition called `CompanyConfig` with three custom types referencing mong each other
4+
5+
### Create ResourceGraphDefinition called CompanyConfig
6+
7+
Apply the RGD to your cluster:
8+
9+
```
10+
kubectl apply -f rg.yaml
11+
```
12+
13+
Validate the RGD status is Active:
14+
15+
```
16+
kubectl get rgd company.kro.run
17+
```
18+
19+
Expected result:
20+
21+
```
22+
NAME APIVERSION KIND STATE AGE
23+
company.kro.run v1alpha1 CompanyConfig Active XXX
24+
```
25+
26+
### Create an Instance of kind App
27+
28+
Apply the provided instance.yaml
29+
30+
```
31+
kubectl apply -f instance.yaml
32+
```
33+
34+
Validate instance status:
35+
36+
```
37+
kubectl get companyconfigs my-company-config
38+
```
39+
40+
Expected result:
41+
42+
```
43+
NAME STATE SYNCED AGE
44+
my-company-config ACTIVE True XXX
45+
```
46+
47+
### Clean up
48+
49+
Remove the instance:
50+
51+
```
52+
kubectl delete companyconfigs my-company-config
53+
```
54+
55+
Remove the resourcegraphdefinition:
56+
57+
```
58+
kubectl delete rgd company.kro.run
59+
```
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
apiVersion: kro.run/v1alpha1
3+
kind: CompanyConfig
4+
metadata:
5+
name: my-company-config
6+
spec:
7+
name: my-company-cm
8+
namespace: default
9+
roles:
10+
- name: CEO
11+
seniority: Senior
12+
- name: CFO
13+
seniority: Senior
14+
- name: Engineer
15+
seniority: Senior
16+
- name: Intern
17+
seniority: Junior
18+
- name: Marketer
19+
seniority: Mid
20+
- name: Social Media Manager
21+
seniority: Mid
22+
management:
23+
- name: Mario
24+
role:
25+
name: CEO
26+
seniority: Senior
27+
- name: Luigi
28+
role:
29+
name: CFO
30+
seniority: Senior
31+
divisions:
32+
- name: Engineering
33+
employees:
34+
- name: Peach
35+
role:
36+
name: Engineer
37+
seniority: Senior
38+
- name: Toad
39+
role:
40+
name: Intern
41+
seniority: Junior
42+
- name: Marketing
43+
employees:
44+
- name: Yoshi
45+
role:
46+
name: Marketer
47+
seniority: Mid
48+
- name: Donkey Kong
49+
role:
50+
name: Social Media Manager
51+
seniority: Mid
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
apiVersion: kro.run/v1alpha1
3+
kind: ResourceGraphDefinition
4+
metadata:
5+
name: company.kro.run
6+
spec:
7+
schema:
8+
types:
9+
Person:
10+
name: string
11+
role: "Role"
12+
Division:
13+
name: string
14+
employees: "[]Person"
15+
Role:
16+
name: string
17+
seniority: string
18+
spec:
19+
name: string
20+
namespace: string | default=default
21+
management: "[]Person"
22+
divisions: "[]Division"
23+
roles: "[]Role"
24+
apiVersion: v1alpha1
25+
kind: CompanyConfig
26+
resources:
27+
- id: configmap
28+
template:
29+
apiVersion: v1
30+
kind: ConfigMap
31+
metadata:
32+
name: ${schema.spec.name}
33+
namespace: ${schema.spec.namespace}
34+
labels:
35+
app.kubernetes.io/name: ${schema.spec.name}
36+
data:
37+
management: |
38+
${schema.spec.management.map(e, e.name + " (" + e.role.name + ")").join(", ")}
39+
divisions: |
40+
${schema.spec.divisions.map(d, d.name + ": " + d.employees.map(e, e.name).join(", ")).join("\n")}
41+
roles: |
42+
${schema.spec.roles.map(r, r.name + " (" + r.seniority + ")").join(", ")}

pkg/simpleschema/transform.go

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"strconv"
2222
"strings"
2323

24+
"github.com/kubernetes-sigs/kro/pkg/graph/dag"
2425
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2526
"k8s.io/utils/ptr"
2627
)
@@ -57,27 +58,130 @@ func newTransformer() *transformer {
5758

5859
// loadPreDefinedTypes loads pre-defined types into the transformer.
5960
// The pre-defined types are used to resolve references in the schema.
60-
//
61+
// Types are loaded one by one so that each type can reference one of the
62+
// other custom types.
63+
// Cyclic dependencies are detected and reported as errors.
6164
// As of today, kro doesn't support custom types in the schema - do
6265
// not use this function.
6366
func (t *transformer) loadPreDefinedTypes(obj map[string]interface{}) error {
67+
68+
//Constructs a dag of the dependencies between the types
69+
//If there is a cycle in the graph, then there is a cyclic dependency between the types
70+
//and we cannot load the types
71+
dagInstance := dag.NewDirectedAcyclicGraph[string]()
6472
t.preDefinedTypes = make(map[string]predefinedType)
6573

66-
jsonSchemaProps, err := t.buildOpenAPISchema(obj)
74+
for k := range obj {
75+
if err := dagInstance.AddVertex(k, 0); err != nil {
76+
return err
77+
}
78+
}
79+
80+
// Build dependencies and construct the schema
81+
for k, v := range obj {
82+
dependencies := extractDependenciesFromMap(v)
83+
84+
// Add dependencies to the DAG and check for cycles
85+
err := dagInstance.AddDependencies(k, dependencies)
86+
if err != nil {
87+
if cycleErr, isCycle := err.(*dag.CycleError[string]); isCycle {
88+
return fmt.Errorf("failed to load type %s due to cyclic dependency. Please remove the cyclic dependency: %w", k, cycleErr)
89+
}
90+
return err
91+
}
92+
}
93+
94+
// Perform a topological sort of the DAG to get the order of the types
95+
// to be processed
96+
orderedVertexes, err := dagInstance.TopologicalSort()
6797
if err != nil {
68-
return fmt.Errorf("failed to build pre-defined types schema: %w", err)
98+
return fmt.Errorf("failed to sort DAG: %w", err)
6999
}
70100

71-
for k, properties := range jsonSchemaProps.Properties {
72-
required := false
73-
if slices.Contains(jsonSchemaProps.Required, k) {
74-
required = true
101+
// Build the pre-defined types from the sorted DAG
102+
for _, vertex := range orderedVertexes {
103+
objValueAtKey, ok := obj[vertex]
104+
if !ok {
105+
return fmt.Errorf("failed to assert type for vertex %s", vertex)
106+
}
107+
objMap := map[string]interface{}{
108+
vertex: objValueAtKey,
109+
}
110+
if schemaProps, err := t.buildOpenAPISchema(objMap); err == nil {
111+
for propKey, properties := range schemaProps.Properties {
112+
required := false
113+
if slices.Contains(schemaProps.Required, propKey) {
114+
required = true
115+
}
116+
t.preDefinedTypes[propKey] = predefinedType{Schema: properties, Required: required}
117+
}
118+
} else {
119+
return fmt.Errorf("failed to build schema props for %s: %w", vertex, err)
75120
}
76-
t.preDefinedTypes[k] = predefinedType{Schema: properties, Required: required}
77121
}
122+
78123
return nil
79124
}
80125

126+
// Define the set of basic types as
127+
// defined in https://kro.run/docs/concepts/simple-schema#basic-types
128+
var excludedTypes = map[string]struct{}{
129+
"string": {},
130+
"integer": {},
131+
"bool": {},
132+
"float": {},
133+
"object": {},
134+
}
135+
136+
func extractDependenciesFromMap(obj interface{}) []string {
137+
var dependencies []string
138+
139+
// Use a recursive helper function to traverse the map and extract dependencies
140+
var parseMap func(map[string]interface{})
141+
parseMap = func(m map[string]interface{}) {
142+
for _, value := range m {
143+
switch v := value.(type) {
144+
case string:
145+
// Strip array prefixes like "[]"
146+
trimmed := strings.TrimPrefix(v, "[]")
147+
148+
// Filter out primitive types
149+
if _, isExcluded := excludedTypes[trimmed]; !isExcluded {
150+
dependencies = append(dependencies, trimmed)
151+
}
152+
case map[string]interface{}:
153+
// Recursively parse nested maps
154+
parseMap(v)
155+
case []interface{}:
156+
// Handle slices of types (e.g., []string or [][nested type])
157+
for _, elem := range v {
158+
if nestedMap, ok := elem.(map[string]interface{}); ok {
159+
parseMap(nestedMap)
160+
}
161+
}
162+
}
163+
}
164+
}
165+
166+
if rootMap, ok := obj.(map[string]interface{}); ok {
167+
parseMap(rootMap)
168+
}
169+
170+
// Remove duplicates using a set
171+
dependencySet := make(map[string]struct{})
172+
for _, dep := range dependencies {
173+
dependencySet[dep] = struct{}{}
174+
}
175+
176+
// Convert the set back to a slice
177+
result := make([]string, 0, len(dependencySet))
178+
for dep := range dependencySet {
179+
result = append(result, dep)
180+
}
181+
182+
return result
183+
}
184+
81185
// buildOpenAPISchema builds an OpenAPI schema from the given object
82186
// of a SimpleSchema.
83187
func (tf *transformer) buildOpenAPISchema(obj map[string]interface{}) (*extv1.JSONSchemaProps, error) {

pkg/simpleschema/transform_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ func TestLoadPreDefinedTypes(t *testing.T) {
991991
wantErr bool
992992
}{
993993
{
994-
name: "Valid types",
994+
name: "Valid types with cross-references",
995995
obj: map[string]interface{}{
996996
"Person": map[string]interface{}{
997997
"name": "string",
@@ -1000,6 +1000,7 @@ func TestLoadPreDefinedTypes(t *testing.T) {
10001000
"street": "string",
10011001
"city": "string",
10021002
},
1003+
"company": "Company",
10031004
},
10041005
"Company": map[string]interface{}{
10051006
"name": "string",
@@ -1020,6 +1021,20 @@ func TestLoadPreDefinedTypes(t *testing.T) {
10201021
"city": {Type: "string"},
10211022
},
10221023
},
1024+
"company": {
1025+
Type: "object",
1026+
Properties: map[string]extv1.JSONSchemaProps{
1027+
"name": {Type: "string"},
1028+
"employees": {
1029+
Type: "array",
1030+
Items: &extv1.JSONSchemaPropsOrArray{
1031+
Schema: &extv1.JSONSchemaProps{
1032+
Type: "string",
1033+
},
1034+
},
1035+
},
1036+
},
1037+
},
10231038
},
10241039
},
10251040
Required: false,
@@ -1044,6 +1059,26 @@ func TestLoadPreDefinedTypes(t *testing.T) {
10441059
},
10451060
wantErr: false,
10461061
},
1062+
{
1063+
name: "Invalid types with cyclic references",
1064+
obj: map[string]interface{}{
1065+
"Person": map[string]interface{}{
1066+
"name": "string",
1067+
"age": "integer",
1068+
"address": map[string]interface{}{
1069+
"street": "string",
1070+
"city": "string",
1071+
},
1072+
"company": "Company",
1073+
},
1074+
"Company": map[string]interface{}{
1075+
"name": "string",
1076+
"employees": "[]Person",
1077+
},
1078+
},
1079+
want: map[string]predefinedType{},
1080+
wantErr: true,
1081+
},
10471082
{
10481083
name: "Simple type alias",
10491084
obj: map[string]interface{}{

0 commit comments

Comments
 (0)