Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ TODO.*
node_modules/

tools/lsp/client/out/
# VSCode Debug cache files
**/__debug*
59 changes: 59 additions & 0 deletions examples/kubernetes/recursive-custom-types/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# kro recursive custom type reference example

This example creates a ResourceGraphDefinition called `CompanyConfig` with three custom types referencing mong each other

### Create ResourceGraphDefinition called CompanyConfig

Apply the RGD to your cluster:

```
kubectl apply -f rg.yaml
```

Validate the RGD status is Active:

```
kubectl get rgd company.kro.run
```

Expected result:

```
NAME APIVERSION KIND STATE AGE
company.kro.run v1alpha1 CompanyConfig Active XXX
```

### Create an Instance of kind App

Apply the provided instance.yaml

```
kubectl apply -f instance.yaml
```

Validate instance status:

```
kubectl get companyconfigs my-company-config
```

Expected result:

```
NAME STATE SYNCED AGE
my-company-config ACTIVE True XXX
```

### Clean up

Remove the instance:

```
kubectl delete companyconfigs my-company-config
```

Remove the resourcegraphdefinition:

```
kubectl delete rgd company.kro.run
```
79 changes: 79 additions & 0 deletions examples/kubernetes/recursive-custom-types/instance.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
apiVersion: kro.run/v1alpha1
kind: CompanyConfig
metadata:
name: my-company-config
spec:
name: my-company-cm
namespace: default
roles:
- name: CEO
seniority: Senior
aliases:
- SupremeChief
- name: CFO
seniority: Senior
aliases:
- MoneyBoss
- name: Engineer
seniority: Senior
aliases:
- Developer
- name: Intern
seniority: Junior
aliases:
- Trainee
- name: Marketer
seniority: Mid
aliases:
- Marketing Specialist
- name: Social Media Manager
seniority: Mid
aliases:
- Social Media Specialist
management:
- name: Mario
role:
name: CEO
seniority: Senior
colleagues:
luigi:
name: Secretary
seniority: Mid
- name: Luigi
role:
name: CFO
seniority: Senior
colleagues:
viktor:
name: Assistant
seniority: Mid
divisions:
- name: Engineering
employees:
- name: Peach
role:
name: Engineer
seniority: Senior
colleagues:
daisy:
name: Engineer
seniority: Mid
aliases: [DevOps]
- name: Toad
role:
name: Intern
seniority: Junior
colleagues:
tony:
name: Intern
seniority: Junior
- name: Marketing
employees:
- name: Yoshi
role:
name: Marketer
seniority: Mid
- name: Donkey Kong
role:
name: Social Media Manager
seniority: Mid
48 changes: 48 additions & 0 deletions examples/kubernetes/recursive-custom-types/rg.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
name: company.kro.run
spec:
schema:
types:
Person:
name: string
role: "Role"
colleagues: "map[string]Role"
address:
street: string
city: string
state: string
zip: string
Division:
name: string
employees: "[]Person"
Role:
name: string
seniority: string
aliases: "[]string"
spec:
name: string
namespace: string | default=default
management: "[]Person"
divisions: "[]Division"
roles: "[]Role"
apiVersion: v1alpha1
kind: CompanyConfig
resources:
- id: configmap
template:
apiVersion: v1
kind: ConfigMap
metadata:
name: ${schema.spec.name}
namespace: ${schema.spec.namespace}
labels:
app.kubernetes.io/name: ${schema.spec.name}
data:
management: |
${schema.spec.management.map(e, e.name + " (" + e.role.name + ")").join(", ")}
divisions: |
${schema.spec.divisions.map(d, d.name + ": " + d.employees.map(e, e.name).join(", ")).join("\n")}
# roles: |
# ${schema.spec.roles.map(r, r.name + " (" + r.seniority.join(", ") + ")").join(", ")}
4 changes: 4 additions & 0 deletions pkg/simpleschema/atomic.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ func isSliceType(s string) bool {
return strings.HasPrefix(s, "[]")
}

func isObjectType(s string) bool {
return s == "object"
}

// parseMapType parses a map type string and returns the key and value types.
func parseMapType(s string) (string, string, error) {
if !strings.HasPrefix(s, "map[") {
Expand Down
149 changes: 140 additions & 9 deletions pkg/simpleschema/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
package simpleschema

import (
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"

"github.com/kubernetes-sigs/kro/pkg/graph/dag"
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/utils/ptr"
)

Expand Down Expand Up @@ -57,23 +60,151 @@

// loadPreDefinedTypes loads pre-defined types into the transformer.
// The pre-defined types are used to resolve references in the schema.
//
// Types are loaded one by one so that each type can reference one of the
// other custom types.
// Cyclic dependencies are detected and reported as errors.
// As of today, kro doesn't support custom types in the schema - do
// not use this function.
func (t *transformer) loadPreDefinedTypes(obj map[string]interface{}) error {

//Constructs a dag of the dependencies between the types
//If there is a cycle in the graph, then there is a cyclic dependency between the types
//and we cannot load the types
Comment on lines +70 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
//Constructs a dag of the dependencies between the types
//If there is a cycle in the graph, then there is a cyclic dependency between the types
//and we cannot load the types
// Constructs a dag of the dependencies between the types
// If there is a cycle in the graph, then there is a cyclic dependency between the types
// and we cannot load the types

dagInstance := dag.NewDirectedAcyclicGraph[string]()
t.preDefinedTypes = make(map[string]predefinedType)

jsonSchemaProps, err := t.buildOpenAPISchema(obj)
for k := range obj {
if err := dagInstance.AddVertex(k, 0); err != nil {
return err
}
}

// Build dependencies and construct the schema
for k, v := range obj {
dependencies, err := extractDependenciesFromMap(v)
if err != nil {
return fmt.Errorf("failed to extract dependencies for type %s: %w", k, err)
}

// Add dependencies to the DAG and check for cycles
err = dagInstance.AddDependencies(k, dependencies)
if err != nil {
var cycleErr *dag.CycleError[string]
if errors.As(err, &cycleErr) {
return fmt.Errorf("failed to load type %s due to cyclic dependency. Please remove the cyclic dependency: %w", k, err)
}
return err
}
}

// Perform a topological sort of the DAG to get the order of the types
// to be processed
orderedVertexes, err := dagInstance.TopologicalSort()
if err != nil {
return fmt.Errorf("failed to build pre-defined types schema: %w", err)
return fmt.Errorf("failed to get topological order for the types: %w", err)
}

for k, properties := range jsonSchemaProps.Properties {
required := false
if slices.Contains(jsonSchemaProps.Required, k) {
required = true
// Build the pre-defined types from the sorted DAG
for _, vertex := range orderedVertexes {
objValueAtKey, _ := obj[vertex]

Check failure on line 109 in pkg/simpleschema/transform.go

View workflow job for this annotation

GitHub Actions / lint

S1005: unnecessary assignment to the blank identifier (gosimple)
objMap := map[string]interface{}{
vertex: objValueAtKey,
}
schemaProps, err := t.buildOpenAPISchema(objMap)
if err != nil {
return fmt.Errorf("failed to build pre-defined types schema : %w", err)
}
for propKey, properties := range schemaProps.Properties {
required := false
if slices.Contains(schemaProps.Required, propKey) {
required = true
}
t.preDefinedTypes[propKey] = predefinedType{Schema: properties, Required: required}
}
}

return nil
}

func extractDependenciesFromMap(obj interface{}) (dependencies []string, err error) {
dependenciesSet := sets.Set[string]{}

// Extract dependencies using a helper function
if rootMap, ok := obj.(map[string]interface{}); ok {
err := parseMap(rootMap, dependenciesSet)
if err != nil {
return nil, err
}
}
return dependenciesSet.UnsortedList(), nil
}

func handleStringType(v string, dependencies sets.Set[string]) error {
// Check if the value is an atomic type
if isAtomicType(v) {
return nil
}
// Check if the value is a collection type
if isCollectionType(v) {
if isSliceType(v) {
// It is a slice, we add the type as dependency
elementType, err := parseSliceType(v)
if err != nil {
return fmt.Errorf("Failed to parse slice type %s: %w", v, err)
}
if !isAtomicType(elementType) {
dependencies.Insert(elementType)
}
return nil
} else if isMapType(v) {
keyType, valueType, err := parseMapType(v)
if err != nil {
return fmt.Errorf("Failed to parse map type %s: %w", v, err)
}
// Only strings are supported as map keys
if keyType != keyTypeString {
return fmt.Errorf("unsupported key type for maps, only strings are supported key types: %s", keyType)
}
// If the value is not an atomic type, add to dependencies
if !isAtomicType(valueType) {
dependencies.Insert(strings.TrimPrefix(valueType, "[]"))
}
return nil
}
}

// If the type is object, we do not add any dependency
// As unstructured objects are not validated https://kro.run/docs/concepts/simple-schema#unstructured-objects
if isObjectType(v) {
return nil
}
// At this point, we have a new custom type, we add it as dependency
dependencies.Insert(v)
return nil
}

func parseMap(m map[string]interface{}, dependencies sets.Set[string]) (err error) {

for _, value := range m {
switch v := value.(type) {
case map[string]interface{}:
// Recursively parse nested maps
if err := parseMap(v, dependencies); err != nil {
return err
}
case []interface{}:
// Handle slices of types (e.g., []string or [][nested type])
for key, elem := range v {
print(key)
if nestedMap, ok := elem.(map[string]interface{}); ok {
parseMap(nestedMap, dependencies)

Check failure on line 200 in pkg/simpleschema/transform.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
} else {
return fmt.Errorf("unexpected type in slice: %T", elem)
}
}
case string:
handleStringType(v, dependencies)

Check failure on line 206 in pkg/simpleschema/transform.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
}
t.preDefinedTypes[k] = predefinedType{Schema: properties, Required: required}
}
return nil
}
Expand Down Expand Up @@ -170,7 +301,7 @@
return nil, fmt.Errorf("failed to parse map type for %s: %w", key, err)
}
if keyType != keyTypeString {
return nil, fmt.Errorf("unsupported key type for maps: %s", keyType)
return nil, fmt.Errorf("unsupported key type for maps, only strings are supported key types: %s", keyType)
}

fieldJSONSchemaProps := &extv1.JSONSchemaProps{
Expand Down
Loading
Loading