Skip to content

Commit 62c39ee

Browse files
committed
Simplify API & add CRD validation
Signed-off-by: jose.vazquez <[email protected]>
1 parent b030a33 commit 62c39ee

File tree

7 files changed

+142
-61
lines changed

7 files changed

+142
-61
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1
2525
github.com/onsi/ginkgo/v2 v2.26.0
2626
github.com/onsi/gomega v1.38.2
27+
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
2728
github.com/sethvargo/go-password v0.3.1
2829
github.com/stretchr/testify v1.11.1
2930
github.com/yudai/gojsondiff v1.0.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG
261261
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
262262
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
263263
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
264+
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
265+
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
264266
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
265267
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
266268
github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU=

internal/autogen/translate/crd.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@ import (
2121
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2222
)
2323

24-
type CRDInfo struct {
25-
definition *apiextensionsv1.CustomResourceDefinition
26-
version string
27-
}
28-
2924
// selectVersion returns the version from the CRD spec that matches the given version string
3025
func selectVersion(spec *apiextensionsv1.CustomResourceDefinitionSpec, version string) *apiextensionsv1.CustomResourceDefinitionVersion {
3126
if len(spec.Versions) == 0 {

internal/autogen/translate/mapping.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ type mapContext struct {
3838
added []client.Object
3939
}
4040

41-
func newMapContext(main client.Object, objs ...client.Object) *mapContext {
41+
func newMapContext(main client.Object, deps []client.Object) *mapContext {
4242
m := map[client.ObjectKey]client.Object{}
43-
for _, obj := range objs {
43+
for _, obj := range deps {
4444
m[client.ObjectKeyFromObject(obj)] = obj
4545
}
4646
return &mapContext{main: main, m: m}
@@ -65,24 +65,24 @@ type mapper struct {
6565
expand bool
6666
}
6767

68-
func newExpanderMapper(main client.Object, objs ...client.Object) *mapper {
69-
return newMapper(true, main, objs...)
68+
func newExpanderMapper(main client.Object, deps []client.Object) *mapper {
69+
return newMapper(true, main, deps)
7070
}
7171

72-
func newCollarserMapper(main client.Object, objs ...client.Object) *mapper {
73-
return newMapper(false, main, objs...)
72+
func newCollarserMapper(main client.Object, deps []client.Object) *mapper {
73+
return newMapper(false, main, deps)
7474
}
7575

76-
func newMapper(expand bool, main client.Object, objs ...client.Object) *mapper {
76+
func newMapper(expand bool, main client.Object, deps []client.Object) *mapper {
7777
return &mapper{
78-
mapContext: newMapContext(main, objs...),
78+
mapContext: newMapContext(main, deps),
7979
expand: expand,
8080
}
8181
}
8282

83-
func ExpandMappings(t *Translator, obj map[string]any, main client.Object, objs ...client.Object) ([]client.Object, error) {
84-
em := newExpanderMapper(main, objs...)
85-
mappingsYML := t.crd.definition.Annotations[APIMAppingsAnnotation]
83+
func ExpandMappings(r *Request, obj map[string]any, main client.Object) ([]client.Object, error) {
84+
em := newExpanderMapper(main, r.Dependencies)
85+
mappingsYML := r.Translator.annotations[APIMAppingsAnnotation]
8686
if mappingsYML == "" {
8787
return []client.Object{}, nil
8888
}
@@ -91,21 +91,21 @@ func ExpandMappings(t *Translator, obj map[string]any, main client.Object, objs
9191
return nil, fmt.Errorf("failed to unmarshal mappings YAML: %w", err)
9292
}
9393

94-
if err := em.expandMappingsAt(obj, mappings, "spec", t.majorVersion); err != nil {
94+
if err := em.expandMappingsAt(obj, mappings, "spec", r.Translator.majorVersion); err != nil {
9595
return nil, fmt.Errorf("failed to map properties of spec from API to Kubernetes: %w", err)
9696
}
97-
if err := em.expandMappingsAt(obj, mappings, "spec", t.majorVersion, "entry"); err != nil {
97+
if err := em.expandMappingsAt(obj, mappings, "spec", r.Translator.majorVersion, "entry"); err != nil {
9898
return nil, fmt.Errorf("failed to map properties of spec from API to Kubernetes: %w", err)
9999
}
100-
if err := em.expandMappingsAt(obj, mappings, "status", t.majorVersion); err != nil {
100+
if err := em.expandMappingsAt(obj, mappings, "status", r.Translator.majorVersion); err != nil {
101101
return nil, fmt.Errorf("failed to map properties of status from API to Kubernetes: %w", err)
102102
}
103103
return em.added, nil
104104
}
105105

106-
func CollapseMappings(t *Translator, spec map[string]any, main client.Object, objs ...client.Object) error {
107-
cm := newCollarserMapper(main, objs...)
108-
mappingsYML := t.crd.definition.Annotations[APIMAppingsAnnotation]
106+
func CollapseMappings(r *Request, spec map[string]any, main client.Object) error {
107+
cm := newCollarserMapper(main, r.Dependencies)
108+
mappingsYML := r.Translator.annotations[APIMAppingsAnnotation]
109109
if mappingsYML == "" {
110110
return nil
111111
}
@@ -114,7 +114,7 @@ func CollapseMappings(t *Translator, spec map[string]any, main client.Object, ob
114114
return fmt.Errorf("failed to unmarshal mappings YAML: %w", err)
115115
}
116116
props, err := unstructured.AccessField[map[string]any](mappings,
117-
"properties", "spec", "properties", t.majorVersion, "properties")
117+
"properties", "spec", "properties", r.Translator.majorVersion, "properties")
118118
if errors.Is(err, unstructured.ErrNotFound) {
119119
return nil
120120
}

internal/autogen/translate/translator.go

Lines changed: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616
package translate
1717

1818
import (
19+
"bytes"
20+
"encoding/json"
1921
"fmt"
2022
"reflect"
2123

24+
"github.com/go-logr/logr"
25+
"github.com/santhosh-tekuri/jsonschema/v5"
2226
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2327
"sigs.k8s.io/controller-runtime/pkg/client"
2428

@@ -36,20 +40,28 @@ type PtrClientObj[T any] interface {
3640
// A translator is an immutable configuration object, it can be safely shared
3741
// across threads
3842
type Translator struct {
39-
crd CRDInfo
4043
majorVersion string
44+
jsonSchema *jsonschema.Schema
45+
annotations map[string]string
46+
}
47+
48+
// Request holds common parameters for all translation request
49+
type Request struct {
50+
Translator *Translator
51+
Log logr.Logger
52+
Dependencies []client.Object
4153
}
4254

4355
// APIImporter can translate itself into Kubernetes Objects.
4456
// Use to customize or accelerate translations ad-hoc
4557
type APIImporter[T any, P PtrClientObj[T]] interface {
46-
FromAPI(t *Translator, target P) ([]client.Object, error)
58+
FromAPI(tr *Request, target P) ([]client.Object, error)
4759
}
4860

4961
// APIExporter can translate itself to an API Object.
5062
// Use to customize or accelerate translations ad-hoc
5163
type APIExporter[T any] interface {
52-
ToAPI(t *Translator, target *T) error
64+
ToAPI(tr *Request, target *T) error
5365
}
5466

5567
// NewTranslator creates a translator for a particular CRD and major version pairs,
@@ -65,81 +77,123 @@ type APIExporter[T any] interface {
6577
// v20250312:
6678
//
6779
// In the above case crdVersion is "v1" and majorVersion is "v20250312".
68-
func NewTranslator(crd *apiextensionsv1.CustomResourceDefinition, crdVersion string, majorVersion string) *Translator {
80+
func NewTranslator(crd *apiextensionsv1.CustomResourceDefinition, crdVersion string, majorVersion string) (*Translator, error) {
81+
specVersion := selectVersion(&crd.Spec, crdVersion)
82+
if err := assertMajorVersion(specVersion, crd.Spec.Names.Kind, majorVersion); err != nil {
83+
return nil, fmt.Errorf("failed to assert major version %s in CRD: %w", majorVersion, err)
84+
}
85+
86+
schemaBytes, err := json.Marshal(specVersion.Schema.OpenAPIV3Schema)
87+
if err != nil {
88+
return nil, fmt.Errorf("failed to marshal CRD schema to JSON: %w", err)
89+
}
90+
91+
// Compile the schema. You could cache this compiler for performance.
92+
compiler := jsonschema.NewCompiler()
93+
if err := compiler.AddResource("schema.json", bytes.NewReader(schemaBytes)); err != nil {
94+
return nil, fmt.Errorf("failed to add schema resource: %w", err)
95+
}
96+
schema, err := compiler.Compile("schema.json")
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to compile schema: %w", err)
99+
}
100+
69101
return &Translator{
70-
crd: CRDInfo{definition: crd, version: crdVersion},
71102
majorVersion: majorVersion,
103+
jsonSchema: schema,
104+
annotations: crd.Annotations,
105+
}, nil
106+
}
107+
108+
func assertMajorVersion(specVersion *apiextensionsv1.CustomResourceDefinitionVersion, kind string, majorVersion string) error {
109+
props, err := getOpenAPIProperties(kind, specVersion)
110+
if err != nil {
111+
return fmt.Errorf("failed to enumerate CRD schema properties: %w", err)
112+
}
113+
specProps, err := getSpecPropertiesFor(kind, props, "spec")
114+
if err != nil {
115+
return fmt.Errorf("failed to enumerate CRD spec properties: %w", err)
116+
}
117+
_, ok := specProps[majorVersion]
118+
if !ok {
119+
return fmt.Errorf("failed to match the CRD spec version %q in schema", majorVersion)
120+
}
121+
return nil
122+
}
123+
124+
func (t *Translator) Validate(unstructuredObj map[string]any) error {
125+
if err := t.jsonSchema.Validate(unstructuredObj); err != nil {
126+
return fmt.Errorf("object validation failed against CRD schema: %w", err)
72127
}
128+
return nil
73129
}
74130

75131
// FromAPI translaters a source API structure into a Kubernetes object.
76-
func FromAPI[S any, T any, P PtrClientObj[T]](t *Translator, target P, source *S, objs ...client.Object) ([]client.Object, error) {
132+
func FromAPI[S any, T any, P PtrClientObj[T]](r *Request, target P, source *S) ([]client.Object, error) {
77133
importer, ok := any(source).(APIImporter[T, P])
78134
if ok {
79-
return importer.FromAPI(t, target)
135+
return importer.FromAPI(r, target)
80136
}
81137
sourceUnstructured, err := unstructured.ToUnstructured(source)
82138
if err != nil {
83139
return nil, fmt.Errorf("failed to convert API source value to unstructured: %w", err)
84140
}
85141

86-
targetUnstructured := map[string]any{}
142+
targetUnstructured, err := unstructured.ToUnstructured(target)
143+
if err != nil {
144+
return nil, fmt.Errorf("failed to convert target value to unstructured: %w", err)
145+
}
87146

88-
versionedSpec := map[string]any{}
89-
unstructured.CopyFields(versionedSpec, sourceUnstructured)
90-
if err := unstructured.CreateField(targetUnstructured, versionedSpec, "spec", t.majorVersion); err != nil {
91-
return nil, fmt.Errorf("failed to create versioned spec object in unstructured target: %w", err)
147+
versionedSpec, err := unstructured.AccessOrCreateField(
148+
targetUnstructured, map[string]any{}, "spec", r.Translator.majorVersion)
149+
if err != nil {
150+
return nil, fmt.Errorf("failed to ensure versioned spec object in unstructured target: %w", err)
92151
}
152+
unstructured.CopyFields(versionedSpec, sourceUnstructured)
153+
93154
versionedSpecEntry := map[string]any{}
94155
unstructured.CopyFields(versionedSpecEntry, sourceUnstructured)
95156
versionedSpec["entry"] = versionedSpecEntry
96157

97158
versionedStatus := map[string]any{}
98159
unstructured.CopyFields(versionedStatus, sourceUnstructured)
99-
if err := unstructured.CreateField(targetUnstructured, versionedStatus, "status", t.majorVersion); err != nil {
160+
if err := unstructured.CreateField(targetUnstructured, versionedStatus, "status", r.Translator.majorVersion); err != nil {
100161
return nil, fmt.Errorf("failed to create versioned status object in unsstructured target: %w", err)
101162
}
102163

103-
extraObjects, err := ExpandMappings(t, targetUnstructured, target, objs...)
164+
extraObjects, err := ExpandMappings(r, targetUnstructured, target)
104165
if err != nil {
105166
return nil, fmt.Errorf("failed to process API mappings: %w", err)
106167
}
168+
if err := r.Translator.Validate(targetUnstructured); err != nil {
169+
return nil, fmt.Errorf("failed to validate unstructured object output: %w", err)
170+
}
107171
if err := unstructured.FromUnstructured(target, targetUnstructured); err != nil {
108172
return nil, fmt.Errorf("failed set structured kubernetes object from unstructured: %w", err)
109173
}
110174
return append([]client.Object{target}, extraObjects...), nil
111175
}
112176

113177
// ToAPI translates a source Kubernetes spec into a target API structure
114-
func ToAPI[T any](t *Translator, target *T, source client.Object, objs ...client.Object) error {
178+
func ToAPI[T any](r *Request, target *T, source client.Object) error {
115179
exporter, ok := (source).(APIExporter[T])
116180
if ok {
117-
return exporter.ToAPI(t, target)
118-
}
119-
specVersion := selectVersion(&t.crd.definition.Spec, t.crd.version)
120-
kind := t.crd.definition.Spec.Names.Kind
121-
props, err := getOpenAPIProperties(kind, specVersion)
122-
if err != nil {
123-
return fmt.Errorf("failed to enumerate CRD schema properties: %w", err)
124-
}
125-
specProps, err := getSpecPropertiesFor(kind, props, "spec")
126-
if err != nil {
127-
return fmt.Errorf("failed to enumerate CRD spec properties: %w", err)
128-
}
129-
if _, ok := specProps[t.majorVersion]; !ok {
130-
return fmt.Errorf("failed to match the CRD spec version %q in schema", t.majorVersion)
181+
return exporter.ToAPI(r, target)
131182
}
132183
unstructuredSrc, err := unstructured.ToUnstructured(source)
133184
if err != nil {
134185
return fmt.Errorf("failed to convert k8s source value to unstructured: %w", err)
135186
}
187+
if err := r.Translator.Validate(unstructuredSrc); err != nil {
188+
return fmt.Errorf("failed to validate unstructured object input: %w", err)
189+
}
136190
targetUnstructured := map[string]any{}
137-
value, err := unstructured.AccessField[map[string]any](unstructuredSrc, "spec", t.majorVersion)
191+
value, err := unstructured.AccessField[map[string]any](unstructuredSrc, "spec", r.Translator.majorVersion)
138192
if err != nil {
139193
return fmt.Errorf("failed to access source spec value: %w", err)
140194
}
141195

142-
if err := CollapseMappings(t, value, source, objs...); err != nil {
196+
if err := CollapseMappings(r, value, source); err != nil {
143197
return fmt.Errorf("failed to process API mappings: %w", err)
144198
}
145199

internal/autogen/translate/translator_test.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ func TestFromAPI(t *testing.T) {
7575
},
7676
WithDefaultAlertsSettings: pointer.MakePtr(true),
7777
}
78-
target := v1.Group{}
78+
target := v1.Group{
79+
Spec: v1.GroupSpec{
80+
V20250312: &v1.GroupSpecV20250312{
81+
ProjectOwnerId: "",
82+
},
83+
},
84+
}
7985
want := []client.Object{
8086
&v1.Group{
8187
Spec: v1.GroupSpec{
@@ -409,8 +415,10 @@ func testFromAPI[S any, T any, P translate.PtrClientObj[T]](t *testing.T, kind s
409415
crdsYML := bytes.NewBuffer(crdsYAMLBytes)
410416
crd, err := extractCRD(kind, bufio.NewScanner(crdsYML))
411417
require.NoError(t, err)
412-
translator := translate.NewTranslator(crd, version, sdkVersion)
413-
results, err := translate.FromAPI(translator, target, input)
418+
tr, err := translate.NewTranslator(crd, version, sdkVersion)
419+
require.NoError(t, err)
420+
r := translate.Request{Translator: tr}
421+
results, err := translate.FromAPI(&r, target, input)
414422
require.NoError(t, err)
415423
assert.Equal(t, want, results)
416424
}
@@ -711,8 +719,10 @@ func TestToAPIAllRefs(t *testing.T) {
711719
crdsYML := bytes.NewBuffer(crdsYAMLBytes)
712720
crd, err := extractCRD(tc.crd, bufio.NewScanner(crdsYML))
713721
require.NoError(t, err)
714-
translator := translate.NewTranslator(crd, version, sdkVersion)
715-
require.NoError(t, translate.ToAPI(translator, &tc.target, tc.input, tc.deps...))
722+
tr, err := translate.NewTranslator(crd, version, sdkVersion)
723+
require.NoError(t, err)
724+
r := translate.Request{Translator: tr, Dependencies: tc.deps}
725+
require.NoError(t, translate.ToAPI(&r, &tc.target, tc.input))
716726
assert.Equal(t, tc.want, tc.target)
717727
})
718728
}
@@ -2152,8 +2162,10 @@ func testToAPI[T any](t *testing.T, kind string, input client.Object, objs []cli
21522162
crdsYML := bytes.NewBuffer(crdsYAMLBytes)
21532163
crd, err := extractCRD(kind, bufio.NewScanner(crdsYML))
21542164
require.NoError(t, err)
2155-
translator := translate.NewTranslator(crd, version, sdkVersion)
2156-
require.NoError(t, translate.ToAPI(translator, target, input, objs...))
2165+
tr, err := translate.NewTranslator(crd, version, sdkVersion)
2166+
require.NoError(t, err)
2167+
r := translate.Request{Translator: tr, Dependencies: objs}
2168+
require.NoError(t, translate.ToAPI(&r, target, input))
21572169
assert.Equal(t, want, target)
21582170
}
21592171

internal/autogen/translate/unstructured/unstructured.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,23 @@ func CreateField[T any](obj map[string]any, value T, fields ...string) error {
9898
return nil
9999
}
100100

101+
// AccessOrCreateField access a field at the given path, it creates the
102+
// field with the given defaultValue if it did not exist
103+
func AccessOrCreateField[T any](obj map[string]any, defaultValue T, fields ...string) (T, error) {
104+
value, err := AccessField[T](obj, fields...)
105+
if err == nil {
106+
return value, nil
107+
}
108+
var emptyValue T
109+
if errors.Is(err, ErrNotFound) {
110+
if err := CreateField(obj, defaultValue, fields...); err != nil {
111+
return emptyValue, fmt.Errorf("failed to create field at path %v: %w", fields, err)
112+
}
113+
return defaultValue, nil
114+
}
115+
return emptyValue, fmt.Errorf("failed to check for field at path %v: %w", fields, err)
116+
}
117+
101118
// AsPath translates the given simplified xpath expression into a sequence of
102119
// path entries
103120
func AsPath(xpath string) []string {

0 commit comments

Comments
 (0)