[cozystack-api] Implement Kubernetes-like defaulting (#1432)

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>

<!-- Thank you for making a contribution! Here are some tips for you:
- Start the PR title with the [label] of Cozystack component:
- For system components: [platform], [system], [linstor], [cilium],
[kube-ovn], [dashboard], [cluster-api], etc.
- For managed apps: [apps], [tenant], [kubernetes], [postgres],
[virtual-machine] etc.
- For development and maintenance: [tests], [ci], [docs], [maintenance].
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does


### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same [label] as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
[]
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Application specs now get recursive, Kubernetes-like defaulting:
missing fields in nested objects and arrays are auto-populated safely
without mutating shared defaults.
- No changes to public APIs; existing manifests remain compatible while
gaining broader defaulting.

- **Tests**
- Added unit tests validating defaulting behavior, per-item defaults,
and non-creation of absent keys.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Andrei Kvapil
2025-09-18 03:01:39 +02:00
committed by GitHub
3 changed files with 328 additions and 31 deletions

View File

@@ -45,7 +45,6 @@ import (
internalapiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
schemadefault "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting"
// Importing API errors package to construct appropriate error responses
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -89,12 +88,24 @@ type REST struct {
// NewREST creates a new REST storage for Application with specific configuration
func NewREST(dynamicClient dynamic.Interface, config *config.Resource) *REST {
var specSchema *structuralschema.Structural
if raw := strings.TrimSpace(config.Application.OpenAPISchema); raw != "" {
var js internalapiext.JSONSchemaProps
if err := json.Unmarshal([]byte(raw), &js); err != nil {
klog.Errorf("Failed to unmarshal OpenAPI schema: %v", err)
} else if specSchema, err = structuralschema.NewStructural(&js); err != nil {
klog.Errorf("Failed to create structural schema: %v", err)
var v1js apiextv1.JSONSchemaProps
if err := json.Unmarshal([]byte(raw), &v1js); err != nil {
klog.Errorf("Failed to unmarshal v1 OpenAPI schema: %v", err)
} else {
scheme := runtime.NewScheme()
_ = internalapiext.AddToScheme(scheme)
_ = apiextv1.AddToScheme(scheme)
var ijs internalapiext.JSONSchemaProps
if err := scheme.Convert(&v1js, &ijs, nil); err != nil {
klog.Errorf("Failed to convert v1->internal JSONSchemaProps: %v", err)
} else if s, err := structuralschema.NewStructural(&ijs); err != nil {
klog.Errorf("Failed to create structural schema: %v", err)
} else {
specSchema = s
}
}
}
@@ -1205,28 +1216,3 @@ func (e errNotAcceptable) Status() metav1.Status {
Message: e.Error(),
}
}
// applySpecDefaults applies default values to the Application spec based on the schema
func (r *REST) applySpecDefaults(app *appsv1alpha1.Application) error {
if r.specSchema == nil {
return nil
}
var m map[string]any
if app.Spec != nil && len(app.Spec.Raw) > 0 {
if err := json.Unmarshal(app.Spec.Raw, &m); err != nil {
return err
}
}
if m == nil {
m = map[string]any{}
}
schemadefault.Default(m, r.specSchema)
raw, err := json.Marshal(m)
if err != nil {
return err
}
app.Spec = &apiextv1.JSON{Raw: raw}
return nil
}

View File

@@ -0,0 +1,187 @@
/*
Copyright 2024 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"encoding/json"
"fmt"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
)
// applySpecDefaults applies default values to the Application spec based on the schema
func (r *REST) applySpecDefaults(app *appsv1alpha1.Application) error {
if r.specSchema == nil {
return nil
}
var m map[string]any
if app.Spec != nil && len(app.Spec.Raw) > 0 {
if err := json.Unmarshal(app.Spec.Raw, &m); err != nil {
return err
}
}
if m == nil {
m = map[string]any{}
}
if err := defaultLikeKubernetes(&m, r.specSchema); err != nil {
return err
}
raw, err := json.Marshal(m)
if err != nil {
return err
}
app.Spec = &apiextv1.JSON{Raw: raw}
return nil
}
func defaultLikeKubernetes(root *map[string]any, s *structuralschema.Structural) error {
v := any(*root)
nv, err := applyDefaults(v, s, true)
if err != nil {
return err
}
obj, ok := nv.(map[string]any)
if !ok && nv != nil {
return fmt.Errorf("internal error: applyDefaults returned non-map type %T for object root", nv)
}
if obj == nil {
obj = map[string]any{}
}
*root = obj
return nil
}
func applyDefaults(v any, s *structuralschema.Structural, top bool) (any, error) {
if s == nil {
return v, nil
}
effType := s.Generic.Type
if effType == "" {
switch {
case len(s.Properties) > 0 || (s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil):
effType = "object"
case s.Items != nil:
effType = "array"
default:
// scalar
}
}
switch effType {
case "object":
mv, isMap := v.(map[string]any)
if !isMap || v == nil {
if s.Generic.Default.Object != nil && !top {
if dm, ok := s.Generic.Default.Object.(map[string]any); ok {
mv = cloneMap(dm)
}
}
if mv == nil {
mv = map[string]any{}
}
}
for name, ps := range s.Properties {
if _, ok := mv[name]; !ok {
if ps.Generic.Default.Object != nil {
mv[name] = clone(ps.Generic.Default.Object)
}
}
if cur, ok := mv[name]; ok {
cv, err := applyDefaults(cur, &ps, false)
if err != nil {
return nil, err
}
mv[name] = cv
}
}
if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil {
ap := s.AdditionalProperties.Structural
for k, cur := range mv {
if _, isKnown := s.Properties[k]; isKnown {
continue
}
cv, err := applyDefaults(cur, ap, false)
if err != nil {
return nil, err
}
mv[k] = cv
}
}
return mv, nil
case "array":
sl, isSlice := v.([]any)
if !isSlice || v == nil {
if s.Generic.Default.Object != nil {
if ds, ok := s.Generic.Default.Object.([]any); ok {
sl = cloneSlice(ds)
}
}
if sl == nil {
sl = []any{}
}
}
if s.Items != nil {
for i := range sl {
cv, err := applyDefaults(sl[i], s.Items, false)
if err != nil {
return nil, err
}
sl[i] = cv
}
}
return sl, nil
default:
if v == nil && s.Generic.Default.Object != nil {
return clone(s.Generic.Default.Object), nil
}
return v, nil
}
}
func clone(x any) any {
switch t := x.(type) {
case map[string]any:
return cloneMap(t)
case []any:
return cloneSlice(t)
default:
return t
}
}
func cloneMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = clone(v)
}
return out
}
func cloneSlice(s []any) []any {
out := make([]any, len(s))
for i := range s {
out[i] = clone(s[i])
}
return out
}

View File

@@ -0,0 +1,124 @@
package application
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
apischema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
)
func TestApplication(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Application defaulting Suite")
}
var _ = Describe("defaultLikeKubernetes", func() {
var rootSchema *apischema.Structural
BeforeEach(func() {
rootSchema = buildTestSchema()
})
It("applies value-schema defaults to existing map key without merging parent object default", func() {
spec := map[string]any{
"nodeGroups": map[string]any{
"md0": map[string]any{
"minReplicas": 3,
},
},
}
err := defaultLikeKubernetes(&spec, rootSchema)
Expect(err).NotTo(HaveOccurred())
ng := spec["nodeGroups"].(map[string]any)["md0"].(map[string]any)
Expect(ng).To(HaveKeyWithValue("minReplicas", BeNumerically("==", 3)))
Expect(ng).To(HaveKeyWithValue("instanceType", "u1.medium"))
Expect(ng["roles"]).To(ConsistOf("ingress-nginx"))
Expect(ng).NotTo(HaveKey("ephemeralStorage"))
Expect(ng).NotTo(HaveKey("maxReplicas"))
Expect(ng).NotTo(HaveKey("resources"))
})
It("does not create new map keys from parent object default", func() {
spec := map[string]any{
"nodeGroups": map[string]any{},
}
err := defaultLikeKubernetes(&spec, rootSchema)
Expect(err).NotTo(HaveOccurred())
ng := spec["nodeGroups"].(map[string]any)
Expect(ng).NotTo(HaveKey("md0"))
})
})
func buildTestSchema() *apischema.Structural {
instanceType := apischema.Structural{
Generic: apischema.Generic{
Type: "string",
Default: apischema.JSON{Object: "u1.medium"},
},
}
roles := apischema.Structural{
Generic: apischema.Generic{
Type: "array",
Default: apischema.JSON{Object: []any{"ingress-nginx"}},
},
Items: &apischema.Structural{
Generic: apischema.Generic{Type: "string"},
},
}
minReplicas := apischema.Structural{
Generic: apischema.Generic{Type: "integer"},
}
ephemeralStorage := apischema.Structural{
Generic: apischema.Generic{Type: "string"},
}
maxReplicas := apischema.Structural{
Generic: apischema.Generic{Type: "integer"},
}
resources := apischema.Structural{
Generic: apischema.Generic{Type: "object"},
Properties: map[string]apischema.Structural{},
}
nodeGroupsValue := &apischema.Structural{
Generic: apischema.Generic{Type: "object"},
Properties: map[string]apischema.Structural{
"instanceType": instanceType,
"roles": roles,
"minReplicas": minReplicas,
"ephemeralStorage": ephemeralStorage,
"maxReplicas": maxReplicas,
"resources": resources,
},
}
nodeGroups := apischema.Structural{
Generic: apischema.Generic{
Type: "object",
Default: apischema.JSON{Object: map[string]any{
"md0": map[string]any{
"ephemeralStorage": "20Gi",
"maxReplicas": 10,
"minReplicas": 0,
"resources": map[string]any{},
},
}},
},
AdditionalProperties: &apischema.StructuralOrBool{
Structural: nodeGroupsValue,
},
}
return &apischema.Structural{
Generic: apischema.Generic{Type: "object"},
Properties: map[string]apischema.Structural{
"nodeGroups": nodeGroups,
},
}
}