Skip to content

Commit 883dd73

Browse files
myasnikovdaniillexfrei
authored andcommitted
fix(api): preserve nulls without applicable chart defaults
A schema default cannot fill arbitrary map entries or replacement array items. Keep those nulls, nullable fields and required fields without a non-null default unchanged so Helm retains their validation semantics. Validate the normalized copy on both write paths so admission sees the values that reach storage without mutating the caller object. Assisted-By: LLM Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent da146b3 commit 883dd73

5 files changed

Lines changed: 452 additions & 137 deletions

File tree

‎pkg/registry/apps/application/rest.go‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
184184
return nil, fmt.Errorf("expected *appsv1alpha1.Application object, got %T", obj)
185185
}
186186

187+
app = app.DeepCopy()
188+
187189
// Validate Application name format (DNS-1035 plus any kind-specific rules)
188190
if errs := r.validateNameFormat(app.Name); len(errs) > 0 {
189191
return nil, apierrors.NewInvalid(r.gvk.GroupKind(), app.Name, errs)
@@ -215,12 +217,8 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
215217
return nil, apierrors.NewBadRequest(err.Error())
216218
}
217219

218-
// An empty key in the spec (`registryMirrors:` with nothing under it)
219-
// arrives here as a JSON null, and Helm's coalescing deletes it together
220-
// with the chart default underneath it - which fails the render outright
221-
// for a field the schema requires. Repair those before admission, storage
222-
// and the emitted HelmRelease see the object; a null on a field the schema
223-
// allows to be absent keeps the meaning it has today.
220+
// Omission permits a chart default; explicit null suppresses it in Helm.
221+
// Normalize only repairable fields before admission and persistence.
224222
emptied, err := r.normalizeSpecNulls(app)
225223
if err != nil {
226224
return nil, apierrors.NewBadRequest(fmt.Sprintf("failed to normalize spec: %v", err))
@@ -242,7 +240,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
242240
// this hook explicitly — unlike genericregistry.Store, which
243241
// wires it automatically.
244242
if createValidation != nil {
245-
if err := createValidation(ctx, obj); err != nil {
243+
if err := createValidation(ctx, app); err != nil {
246244
return nil, err
247245
}
248246
}
@@ -547,21 +545,15 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
547545
return nil, false, err
548546
}
549547

550-
// Validate the update if a validation function is provided
551-
if updateValidation != nil {
552-
if err := updateValidation(ctx, newObj, oldObj); err != nil {
553-
klog.Errorf("Update validation failed for Application %s: %v", name, err)
554-
return nil, false, err
555-
}
556-
}
557-
558548
// Assert the new object is of type Application
559549
app, ok := newObj.(*appsv1alpha1.Application)
560550
if !ok {
561551
klog.Errorf("expected *appsv1alpha1.Application object, got %T", newObj)
562552
return nil, false, fmt.Errorf("expected *appsv1alpha1.Application object, got %T", newObj)
563553
}
564554

555+
app = app.DeepCopy()
556+
565557
// Note: name validation (DNS-1035 format + length) is intentionally skipped on
566558
// Update because Kubernetes names are immutable. Validating here would block
567559
// updates to pre-existing resources whose names don't conform to the new rules.
@@ -571,8 +563,7 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
571563
return nil, false, apierrors.NewBadRequest(err.Error())
572564
}
573565

574-
// Same normalization as on create: an empty key on a required field means
575-
// the field was left unset, not that the chart default should be deleted.
566+
// Admission must validate the same normalized values that will be stored.
576567
emptied, err := r.normalizeSpecNulls(app)
577568
if err != nil {
578569
return nil, false, apierrors.NewBadRequest(fmt.Sprintf("failed to normalize spec: %v", err))
@@ -581,6 +572,14 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
581572
warning.AddWarning(ctx, "", emptyFieldsWarning(emptied))
582573
}
583574

575+
// Validate the update if a validation function is provided
576+
if updateValidation != nil {
577+
if err := updateValidation(ctx, app, oldObj); err != nil {
578+
klog.Errorf("Update validation failed for Application %s: %v", name, err)
579+
return nil, false, err
580+
}
581+
}
582+
584583
// Enforce hierarchical quota allocation on quota changes too: raising a
585584
// child tenant's declared quota above its parent's remaining budget is
586585
// rejected the same way as on create.

‎pkg/registry/apps/application/rest_nullstrip.go‎

Lines changed: 18 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ limitations under the License.
1717
package application
1818

1919
import (
20+
"bytes"
2021
"encoding/json"
2122
"fmt"
23+
"io"
2224
"sort"
2325
"strings"
2426

@@ -32,48 +34,25 @@ import (
3234
// turn into a multi-kilobyte response header.
3335
const maxWarnedNullPaths = 8
3436

35-
// normalizeSpecNulls repairs a JSON null on a field the schema marks required,
36-
// by removing the key so the chart's own default applies to it.
37-
//
38-
// The spec reaches flux verbatim as HelmRelease .spec.values (see
39-
// convertApplicationToHelmRelease), and Helm's value coalescing deletes a
40-
// null-valued key together with the chart default underneath it, before the
41-
// result is validated against values.schema.json. A required field written with
42-
// nothing under it is therefore rejected for being absent - "at '/talos':
43-
// missing property 'registryMirrors'", naming a property the spec does contain -
44-
// and the whole release fails to render. Nothing reaches the user that would
45-
// point at the empty key they actually typed.
46-
//
47-
// Only required properties are repaired, and that boundary is the point. A
48-
// required property's null cannot be load-bearing for anyone, because today it
49-
// fails the render outright; resolving it to the chart default is pure repair.
50-
// Where the schema permits the field to be absent, a null keeps the meaning it
51-
// has today - the chart default is suppressed and the key does not reach the
52-
// template - and that meaning is worth keeping: 167 optional fields across the
53-
// catalogue carry a non-trivial default, placeholders such as "<password>" and
54-
// example S3 buckets among them, so resolving a user's null to one of those
55-
// would be worse than leaving the key out.
56-
//
57-
// Filling in the schema default instead of removing the key would also pin
58-
// today's value into the stored HelmRelease and freeze the application on it,
59-
// so a later bump of that default in the chart would never reach an application
60-
// that had once been created with an empty key.
61-
//
62-
// Free-form subtrees are left alone, as is a null map entry whose key the user
63-
// chose (a node group, a registry host): there the null may be the payload, and
64-
// dropping the entry would drop the user's intent along with it. Array elements
65-
// keep their positions for the same reason.
66-
//
67-
// Returns the dotted paths that were removed, sorted, so the caller can tell
68-
// the user what happened to them.
37+
// normalizeSpecNulls removes required null properties whose schema supplies a
38+
// non-null default. Helm deletes explicit nulls before validating values, whereas
39+
// omission lets the chart default apply without persisting its current value.
40+
// Optional/nullable properties retain their explicit-null meaning. User-keyed
41+
// maps and array elements do not imply a matching Helm values-default path,
42+
// so defaults in their item schemas alone cannot justify a repair.
6943
func (r *REST) normalizeSpecNulls(app *appsv1alpha1.Application) ([]string, error) {
7044
if r.specSchema == nil || app == nil || app.Spec == nil || len(app.Spec.Raw) == 0 {
7145
return nil, nil
7246
}
7347
var spec map[string]any
74-
if err := json.Unmarshal(app.Spec.Raw, &spec); err != nil {
48+
decoder := json.NewDecoder(bytes.NewReader(app.Spec.Raw))
49+
decoder.UseNumber()
50+
if err := decoder.Decode(&spec); err != nil {
7551
return nil, err
7652
}
53+
if decoder.Decode(new(any)) != io.EOF {
54+
return nil, fmt.Errorf("spec must contain a single JSON object")
55+
}
7756
if spec == nil {
7857
return nil, nil
7958
}
@@ -93,10 +72,9 @@ func (r *REST) normalizeSpecNulls(app *appsv1alpha1.Application) ([]string, erro
9372
return stripped, nil
9473
}
9574

96-
// stripDeclaredNulls walks a value alongside its structural schema, deleting
97-
// every null whose key the schema declares as a property *and* lists as
98-
// required. Dispatch is on the shape of the value rather than on schema.Type,
99-
// which is empty for a schema that carries only properties or only items.
75+
// Only declared property paths identify defaults independently of user keys.
76+
// In particular, a schema default inside an array item is not a
77+
// default for an element of a user-supplied replacement array.
10078
func stripDeclaredNulls(v any, s *structuralschema.Structural, path string, stripped *[]string) {
10179
if s == nil || v == nil {
10280
return
@@ -111,7 +89,7 @@ func stripDeclaredNulls(v any, s *structuralschema.Structural, path string, stri
11189
child := value[name]
11290
if prop, declared := s.Properties[name]; declared {
11391
if child == nil {
114-
if _, mustBeSet := required[name]; mustBeSet {
92+
if _, mustBeSet := required[name]; mustBeSet && prop.Default.Object != nil && !prop.Nullable {
11593
delete(value, name)
11694
*stripped = append(*stripped, joinFieldPath(path, name))
11795
}
@@ -120,16 +98,6 @@ func stripDeclaredNulls(v any, s *structuralschema.Structural, path string, stri
12098
stripDeclaredNulls(child, &prop, joinFieldPath(path, name), stripped)
12199
continue
122100
}
123-
if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil {
124-
stripDeclaredNulls(child, s.AdditionalProperties.Structural, joinFieldPath(path, name), stripped)
125-
}
126-
}
127-
case []any:
128-
if s.Items == nil {
129-
return
130-
}
131-
for i := range value {
132-
stripDeclaredNulls(value[i], s.Items, fmt.Sprintf("%s[%d]", path, i), stripped)
133101
}
134102
}
135103
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/*
2+
Copyright 2026 The Cozystack Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package application
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"reflect"
24+
"strings"
25+
"testing"
26+
27+
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
28+
helmv2 "github.com/fluxcd/helm-controller/api/v2"
29+
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
30+
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
32+
"k8s.io/apimachinery/pkg/runtime"
33+
"k8s.io/apiserver/pkg/endpoints/request"
34+
"k8s.io/apiserver/pkg/warning"
35+
"sigs.k8s.io/controller-runtime/pkg/client"
36+
)
37+
38+
type nullChartCase struct {
39+
name, chart, rd, input, want string
40+
schema, values string
41+
paths []string
42+
}
43+
44+
var nullChartCases = []nullChartCase{
45+
{name: "nullable schema default", schema: `{"type":"object","required":["name"],"properties":{"name":{"type":"string","nullable":true,"default":"example"}}}`, values: `{"name":"example"}`, input: `{"name":null}`},
46+
{name: "item schema defaults", schema: `{"type":"object","properties":{"entries":{"type":"object","additionalProperties":{"type":"object","required":["name"],"properties":{"name":{"type":"string","default":"example"}}}},"items":{"type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string","default":"example"}}}}}}`, values: `{"entries":{},"items":[{"name":"chart-default"}]}`, input: `{"entries":{"mine":{"name":null}},"items":[{"name":null}]}`},
47+
48+
{name: "nested defaults", chart: "apps/kubernetes", rd: "kubernetes", input: `{"talos":{"registryMirrors":null,"version":null},"controlPlane":{"replicas":null,"apiServer":{"resourcesPreset":null}},"nodeGroups":{"legacy":{"instanceType":null}}}`, want: `{"talos":{},"controlPlane":{"apiServer":{}},"nodeGroups":{"legacy":{"instanceType":null}}}`, paths: []string{"controlPlane.apiServer.resourcesPreset", "controlPlane.replicas", "talos.registryMirrors", "talos.version"}},
49+
{name: "mariadb users", chart: "apps/mariadb", rd: "mariadb", input: `{"users":{"alice":{"password":null,"maxUserConnections":null},"blank":null}}`},
50+
{name: "rabbitmq vhosts", chart: "apps/rabbitmq", rd: "rabbitmq", input: `{"vhosts":{"vh1":{"roles":null},"blank":null}}`},
51+
{name: "seaweedfs pools", chart: "extra/seaweedfs", rd: "seaweedfs", input: `{"volume":{"pools":{"ssd":{"diskType":null},"blank":null}}}`},
52+
{name: "vm source http", chart: "apps/vm-disk", rd: "vm-disk", input: `{"source":{"http":{"url":null}}}`},
53+
{name: "vm source disk", chart: "apps/vm-disk", rd: "vm-disk", input: `{"source":{"disk":{"name":null}}}`},
54+
{name: "vm source image", chart: "apps/vm-disk", rd: "vm-disk", input: `{"source":{"image":{"name":null}}}`},
55+
{name: "optional section", chart: "apps/kubernetes", rd: "kubernetes", input: `{"talos":null,"storageClass":null}`},
56+
}
57+
58+
func chartSpecSchema(t *testing.T, rd string) *structuralschema.Structural {
59+
t.Helper()
60+
raw := readEmbeddedOpenAPISchema(t, "../../../../packages/system/"+rd+"-rd/cozyrds/"+rd+".yaml")
61+
s, err := buildSpecSchema(raw)
62+
if err != nil || s == nil {
63+
t.Fatalf("build %s schema: %v", rd, err)
64+
}
65+
return s
66+
}
67+
68+
func nullCaseSchema(t *testing.T, tt nullChartCase) *structuralschema.Structural {
69+
t.Helper()
70+
if tt.schema == "" {
71+
return chartSpecSchema(t, tt.rd)
72+
}
73+
schema, err := buildSpecSchema(tt.schema)
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
return schema
78+
}
79+
80+
func jsonValue(t *testing.T, raw []byte) any {
81+
t.Helper()
82+
var v any
83+
decoder := json.NewDecoder(strings.NewReader(string(raw)))
84+
decoder.UseNumber()
85+
if err := decoder.Decode(&v); err != nil {
86+
t.Fatal(err)
87+
}
88+
return v
89+
}
90+
91+
func TestNullNormalizationAdmissionAndStorage(t *testing.T) {
92+
for _, tt := range nullChartCases {
93+
for _, verb := range []string{"create", "update", "upsert"} {
94+
for _, reject := range []bool{false, true} {
95+
t.Run(tt.name+"/"+verb+"/reject="+map[bool]string{true: "true", false: "false"}[reject], func(t *testing.T) {
96+
existing := &helmv2.HelmRelease{ObjectMeta: metav1.ObjectMeta{Name: "kubernetes-good-name", Namespace: "tenant-foo", Labels: map[string]string{
97+
ApplicationKindLabel: kubernetesKind, ApplicationGroupLabel: appsv1alpha1.GroupName, ApplicationNameLabel: "good-name",
98+
}}, Spec: helmv2.HelmReleaseSpec{Values: &apiextv1.JSON{Raw: []byte(`{"unchanged":"old"}`)}}}
99+
r := newKubernetesWarnREST(t)
100+
if verb == "update" {
101+
r = newKubernetesWarnREST(t, existing)
102+
}
103+
r.specSchema = nullCaseSchema(t, tt)
104+
app := &appsv1alpha1.Application{TypeMeta: metav1.TypeMeta{Kind: kubernetesKind, APIVersion: "apps.cozystack.io/v1alpha1"}, ObjectMeta: metav1.ObjectMeta{Name: "good-name", Namespace: "tenant-foo"}, Spec: &apiextv1.JSON{Raw: []byte(tt.input)}}
105+
original := app.DeepCopy()
106+
want := tt.want
107+
if want == "" {
108+
want = tt.input
109+
}
110+
expected := jsonValue(t, []byte(want))
111+
rec := &fakeWarningRecorder{}
112+
ctx := warning.WithWarningRecorder(request.WithNamespace(context.Background(), app.Namespace), rec)
113+
called := 0
114+
sentinel := errors.New("admission rejected the normalized object")
115+
validate := func(_ context.Context, obj runtime.Object) error {
116+
called++
117+
got, ok := obj.(*appsv1alpha1.Application)
118+
if !ok {
119+
t.Fatalf("admission got %T", obj)
120+
}
121+
if !reflect.DeepEqual(jsonValue(t, got.Spec.Raw), expected) {
122+
t.Errorf("admission saw %s; want %s", got.Spec.Raw, want)
123+
}
124+
if reject {
125+
return sentinel
126+
}
127+
return nil
128+
}
129+
updateValidate := func(ctx context.Context, obj, old runtime.Object) error {
130+
oldApp, ok := old.(*appsv1alpha1.Application)
131+
if !ok {
132+
t.Fatalf("old object %T", old)
133+
}
134+
if !strings.Contains(string(oldApp.Spec.Raw), `"unchanged":"old"`) {
135+
t.Errorf("old object changed: %s", oldApp.Spec.Raw)
136+
}
137+
return validate(ctx, obj)
138+
}
139+
var err error
140+
if verb == "create" {
141+
_, err = r.Create(ctx, app, validate, &metav1.CreateOptions{})
142+
} else {
143+
_, created, e := r.Update(ctx, app.Name, newDefaultUpdatedObjectInfo(app), validate, updateValidate, verb == "upsert", &metav1.UpdateOptions{})
144+
err = e
145+
if err == nil && created != (verb == "upsert") {
146+
t.Errorf("created=%t for %s", created, verb)
147+
}
148+
}
149+
if called != 1 {
150+
t.Errorf("admission calls=%d; want 1", called)
151+
}
152+
if reject {
153+
if !errors.Is(err, sentinel) {
154+
t.Errorf("rejection lost: %v", err)
155+
}
156+
} else if err != nil {
157+
t.Fatal(err)
158+
}
159+
if !reflect.DeepEqual(app, original) {
160+
t.Error("caller input was mutated")
161+
}
162+
got := &helmv2.HelmRelease{}
163+
getErr := r.c.Get(ctx, client.ObjectKeyFromObject(existing), got)
164+
if reject && verb != "update" {
165+
all := &helmv2.HelmReleaseList{}
166+
if err := r.c.List(ctx, all); err != nil {
167+
t.Fatal(err)
168+
}
169+
if len(all.Items) != 0 {
170+
t.Error("rejected admission wrote a HelmRelease")
171+
}
172+
} else {
173+
if getErr != nil {
174+
t.Fatal(getErr)
175+
}
176+
storedWant := expected
177+
if reject {
178+
storedWant = jsonValue(t, existing.Spec.Values.Raw)
179+
}
180+
if !reflect.DeepEqual(jsonValue(t, got.Spec.Values.Raw), storedWant) {
181+
t.Errorf("stored values=%s; want %v", got.Spec.Values.Raw, storedWant)
182+
}
183+
}
184+
var nullWarnings []string
185+
for _, w := range rec.warnings {
186+
if strings.HasPrefix(w, "spec:") {
187+
nullWarnings = append(nullWarnings, w)
188+
}
189+
}
190+
if len(tt.paths) == 0 {
191+
if len(nullWarnings) != 0 {
192+
t.Errorf("false repair warning: %v", nullWarnings)
193+
}
194+
} else if !reflect.DeepEqual(nullWarnings, []string{emptyFieldsWarning(tt.paths)}) {
195+
t.Errorf("warnings=%v for removed paths=%v", nullWarnings, tt.paths)
196+
}
197+
})
198+
}
199+
}
200+
}
201+
}

0 commit comments

Comments
 (0)