Skip to content

Commit 45d4d24

Browse files
committed
fix(api): warn when a release still carries a field nothing reads
The apps API accepts and stores a values key the chart has stopped reading, so a kubectl apply or a terraform apply carrying one succeeds and changes nothing. The Phase 2 warning for the Kubernetes worker-pool keys now covers the image overrides and the FoundationDB cluster.version as well, one table per kind. The version helpers name the value and the versions on offer when the release values carry an explicit null, instead of failing on a template type error that names neither. Assisted-by: LLM Signed-off-by: Andrei Kvapil <[email protected]>
1 parent 7328256 commit 45d4d24

7 files changed

Lines changed: 141 additions & 36 deletions

File tree

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
{{/* toString keeps an explicit `version: null` in the release values on the
2+
unsupported-version message below. Helm drops such a key when it merges
3+
the chart defaults, so indexing the map with it would fail on a template
4+
type error naming neither the value nor the versions on offer. */}}
15
{{- define "foundationdb.versionMap" }}
26
{{- $versionMap := .Files.Get "files/versions.yaml" | fromYaml }}
3-
{{- if not (hasKey $versionMap .Values.version) }}
4-
{{- printf `FoundationDB version %s is not supported, allowed versions are %s` $.Values.version (keys $versionMap | sortAlpha) | fail }}
7+
{{- $version := .Values.version | toString }}
8+
{{- if not (hasKey $versionMap $version) }}
9+
{{- printf `FoundationDB version %s is not supported, allowed versions are %s` $version (keys $versionMap | sortAlpha) | fail }}
510
{{- end }}
6-
{{- index $versionMap .Values.version }}
11+
{{- index $versionMap $version }}
712
{{- end }}

‎packages/apps/foundationdb/tests/versions_test.yaml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ tests:
3434
path: spec.version
3535
value: "7.4.1"
3636

37+
- it: names the value and the versions on offer when version is null
38+
set:
39+
version: null
40+
asserts:
41+
- failedTemplate:
42+
errorMessage: "FoundationDB version <nil> is not supported, allowed versions are [v7.1 v7.3 v7.4]"
43+
3744
- it: ignores a leftover cluster.version
3845
set:
3946
cluster:
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
{{/* toString keeps an explicit `version: null` in the release values on the
2+
unsupported-version message below. Helm drops such a key when it merges
3+
the chart defaults, so indexing the map with it would fail on a template
4+
type error naming neither the value nor the versions on offer. */}}
15
{{- define "opensearch.versionMap" }}
26
{{- $versionMap := .Files.Get "files/versions.yaml" | fromYaml }}
3-
{{- if not (hasKey $versionMap .Values.version) }}
4-
{{- printf `OpenSearch version %s is not supported, allowed versions are %s` $.Values.version (keys $versionMap | sortAlpha) | fail }}
7+
{{- $version := .Values.version | toString }}
8+
{{- if not (hasKey $versionMap $version) }}
9+
{{- printf `OpenSearch version %s is not supported, allowed versions are %s` $version (keys $versionMap | sortAlpha) | fail }}
510
{{- end }}
6-
{{- index $versionMap .Values.version }}
11+
{{- index $versionMap $version }}
712
{{- end }}

‎packages/apps/opensearch/tests/opensearch_test.yaml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@ tests:
8383
value: "1.3.20"
8484
documentIndex: 0
8585

86+
- it: names the value and the versions on offer when version is null
87+
release:
88+
name: test-os
89+
namespace: tenant-test
90+
set:
91+
_cluster:
92+
cluster-domain: cozy.local
93+
version: null
94+
asserts:
95+
- failedTemplate:
96+
errorMessage: "OpenSearch version <nil> is not supported, allowed versions are [v1 v2 v3]"
97+
8698
- it: leaves the image to the operator even when values carry an image override
8799
release:
88100
name: test-os

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

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
203203
}
204204

205205
r.warnLegacyPresets(app)
206-
r.warnRemovedKubernetesFields(ctx, app)
206+
r.warnRemovedFields(ctx, app)
207207

208208
// Run the genericapiserver-supplied validating admission chain
209209
// (validating webhooks + ValidatingAdmissionPolicies) before
@@ -551,7 +551,7 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
551551
}
552552

553553
r.warnLegacyPresets(app)
554-
r.warnRemovedKubernetesFields(ctx, app)
554+
r.warnRemovedFields(ctx, app)
555555

556556
// Convert Application to HelmRelease
557557
helmRelease, err := r.ConvertApplicationToHelmRelease(app)
@@ -1946,32 +1946,78 @@ func (r *REST) warnLegacyPresets(app *appsv1alpha1.Application) {
19461946
}
19471947
}
19481948

1949-
// removedKubernetesFields are Kubernetes CR value keys that Phase 2 moved to the
1950-
// separate KubernetesNodes resource. They are still accepted and stored -- an
1951-
// upgraded cluster keeps them in its values until re-edited, and the adoption
1952-
// migration deliberately leaves them in place rather than rewriting parent
1953-
// values mid-upgrade -- but they no longer render anything on the Kubernetes CR.
1954-
var removedKubernetesFields = []string{"nodeGroups", "nodeHealthCheck", "maxNodeProvisionTime"}
1955-
1956-
// warnRemovedKubernetesFields emits a client-facing admission warning for each
1957-
// Phase 2-removed field still present in a Kubernetes CR's values, so an
1958-
// operator editing one (e.g. bumping spec.nodeGroups.<x>.minReplicas) is told it
1959-
// has no effect instead of silently getting nothing. Non-blocking: the field is
1960-
// preserved as-is; worker pools are managed as KubernetesNodes resources.
1961-
func (r *REST) warnRemovedKubernetesFields(ctx context.Context, app *appsv1alpha1.Application) {
1962-
if r.kindName != kubernetesKind || app == nil || app.Spec == nil || len(app.Spec.Raw) == 0 {
1949+
// removedField is a values key whose chart has stopped reading it. Such a key
1950+
// is still accepted and stored -- an upgraded release keeps it until re-edited,
1951+
// and the migrations deliberately leave it in place rather than rewriting
1952+
// values mid-upgrade -- so the write path says so rather than letting the field
1953+
// look effective.
1954+
type removedField struct {
1955+
// path is the values key, with "." between the segments of a nested one.
1956+
path string
1957+
// replacement says what happens instead, in the second half of the warning.
1958+
replacement string
1959+
}
1960+
1961+
var removedFieldsByKind = map[string][]removedField{
1962+
kubernetesKind: {
1963+
{path: "nodeGroups", replacement: "worker pools are managed as separate KubernetesNodes resources (see the kubernetes-nodes chart)"},
1964+
{path: "nodeHealthCheck", replacement: "worker pools are managed as separate KubernetesNodes resources (see the kubernetes-nodes chart)"},
1965+
{path: "maxNodeProvisionTime", replacement: "worker pools are managed as separate KubernetesNodes resources (see the kubernetes-nodes chart)"},
1966+
{path: "images", replacement: "the images come from the chart, and an air-gapped install moves them with the platform-wide registry"},
1967+
},
1968+
"KubernetesNodes": {
1969+
{path: "images", replacement: "the images come from the chart, and an air-gapped install moves them with the platform-wide registry"},
1970+
},
1971+
"OpenSearch": {
1972+
{path: "images", replacement: "the image follows spec.version"},
1973+
},
1974+
"FoundationDB": {
1975+
{path: "cluster.version", replacement: "the version is selected with spec.version"},
1976+
},
1977+
}
1978+
1979+
// warnRemovedFields emits a client-facing admission warning for each removed
1980+
// field still present in the values, so an operator editing one (e.g. bumping
1981+
// spec.nodeGroups.<x>.minReplicas) is told it has no effect instead of silently
1982+
// getting nothing. Non-blocking: the field is preserved as-is.
1983+
func (r *REST) warnRemovedFields(ctx context.Context, app *appsv1alpha1.Application) {
1984+
fields, ok := removedFieldsByKind[r.kindName]
1985+
if !ok || app == nil || app.Spec == nil || len(app.Spec.Raw) == 0 {
19631986
return
19641987
}
19651988
var values map[string]any
19661989
if err := json.Unmarshal(app.Spec.Raw, &values); err != nil {
19671990
return
19681991
}
1969-
for _, key := range removedKubernetesFields {
1970-
if _, present := values[key]; present {
1971-
warning.AddWarning(ctx, "", fmt.Sprintf(
1972-
"spec.%s is ignored on the Kubernetes resource since Phase 2: worker pools are managed as separate KubernetesNodes resources (see the kubernetes-nodes chart). The field is stored but has no effect.", key))
1992+
for _, f := range fields {
1993+
if !hasValuesPath(values, strings.Split(f.path, ".")) {
1994+
continue
1995+
}
1996+
warning.AddWarning(ctx, "", fmt.Sprintf(
1997+
"spec.%s is ignored on the %s resource: %s. The field is stored but has no effect.",
1998+
f.path, r.kindName, f.replacement))
1999+
}
2000+
}
2001+
2002+
// hasValuesPath reports whether the values carry every segment of the path,
2003+
// walking only objects: a segment under a scalar is not present.
2004+
func hasValuesPath(values map[string]any, path []string) bool {
2005+
current := values
2006+
for i, segment := range path {
2007+
value, present := current[segment]
2008+
if !present {
2009+
return false
2010+
}
2011+
if i == len(path)-1 {
2012+
return true
2013+
}
2014+
next, ok := value.(map[string]any)
2015+
if !ok {
2016+
return false
19732017
}
2018+
current = next
19742019
}
2020+
return false
19752021
}
19762022

19772023
// errNotAcceptable indicates that the resource does not support conversion to Table

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -427,11 +427,11 @@ type fakeWarningRecorder struct{ warnings []string }
427427

428428
func (f *fakeWarningRecorder) AddWarning(_, text string) { f.warnings = append(f.warnings, text) }
429429

430-
// TestWarnRemovedKubernetesFields covers the Phase 2 inert-field warning: a
431-
// Kubernetes CR that still carries nodeGroups / nodeHealthCheck /
432-
// maxNodeProvisionTime is accepted (the migration leaves them in place) but the
433-
// operator is warned that they no longer take effect. Other kinds never warn.
434-
func TestWarnRemovedKubernetesFields(t *testing.T) {
430+
// TestWarnRemovedFields covers the inert-field warning: a release that still
431+
// carries a key its chart has stopped reading is accepted (the migrations leave
432+
// those keys in place) but the operator is warned that it no longer takes
433+
// effect. A kind with no removed fields never warns.
434+
func TestWarnRemovedFields(t *testing.T) {
435435
tests := []struct {
436436
name string
437437
kindName string
@@ -462,6 +462,36 @@ func TestWarnRemovedKubernetesFields(t *testing.T) {
462462
specJSON: `{"nodeGroups":{"md0":{}}}`,
463463
wantKeys: nil,
464464
},
465+
{
466+
name: "kubernetes with the removed image overrides warns",
467+
kindName: "Kubernetes",
468+
specJSON: `{"images":{"kubectl":"example.test/kubectl:1"}}`,
469+
wantKeys: []string{"images"},
470+
},
471+
{
472+
name: "opensearch with the removed image override warns",
473+
kindName: "OpenSearch",
474+
specJSON: `{"version":"v2","images":{"opensearch":"example.test/opensearch:1"}}`,
475+
wantKeys: []string{"images"},
476+
},
477+
{
478+
name: "foundationdb with the removed cluster.version warns",
479+
kindName: "FoundationDB",
480+
specJSON: `{"version":"v7.3","cluster":{"version":"7.1.67","redundancyMode":"double"}}`,
481+
wantKeys: []string{"cluster.version"},
482+
},
483+
{
484+
name: "foundationdb without cluster.version does not warn",
485+
kindName: "FoundationDB",
486+
specJSON: `{"version":"v7.3","cluster":{"redundancyMode":"double"}}`,
487+
wantKeys: nil,
488+
},
489+
{
490+
name: "a scalar where the nested path expects an object does not warn",
491+
kindName: "FoundationDB",
492+
specJSON: `{"cluster":"broken"}`,
493+
wantKeys: nil,
494+
},
465495
}
466496

467497
for _, tt := range tests {
@@ -471,7 +501,7 @@ func TestWarnRemovedKubernetesFields(t *testing.T) {
471501
r := &REST{kindName: tt.kindName}
472502
app := &appsv1alpha1.Application{Spec: &apiextv1.JSON{Raw: []byte(tt.specJSON)}}
473503

474-
r.warnRemovedKubernetesFields(ctx, app)
504+
r.warnRemovedFields(ctx, app)
475505

476506
if len(rec.warnings) != len(tt.wantKeys) {
477507
t.Fatalf("expected %d warnings, got %d: %v", len(tt.wantKeys), len(rec.warnings), rec.warnings)

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ import (
3636
"github.com/cozystack/cozystack/pkg/config"
3737
)
3838

39-
// The Phase 2 inert-field warning is only useful if it is actually wired into
40-
// the write path. TestWarnRemovedKubernetesFields (rest_validation_test.go)
39+
// The inert-field warning is only useful if it is actually wired into
40+
// the write path. TestWarnRemovedFields (rest_validation_test.go)
4141
// pins the helper in isolation, but deleting either call site
4242
// (rest.go Create / Update) leaves that test green while an operator editing a
4343
// removed field silently gets no warning. These two tests drive Create and
@@ -99,7 +99,7 @@ func TestCreate_WarnsOnRemovedKubernetesFields(t *testing.T) {
9999
ctx := warning.WithWarningRecorder(request.WithNamespace(context.Background(), "tenant-foo"), rec)
100100

101101
// Short-circuit right after the warning (createValidation runs immediately
102-
// after warnRemovedKubernetesFields), so the test does not depend on
102+
// after warnRemovedFields), so the test does not depend on
103103
// conversion or the fake client's write path.
104104
sentinel := errors.New("stop after warning")
105105
createValidation := func(_ context.Context, _ runtime.Object) error { return sentinel }
@@ -128,7 +128,7 @@ func TestUpdate_WarnsOnRemovedKubernetesFields(t *testing.T) {
128128
rec := &fakeWarningRecorder{}
129129
ctx := warning.WithWarningRecorder(request.WithNamespace(context.Background(), "tenant-foo"), rec)
130130

131-
// updateValidation runs BEFORE warnRemovedKubernetesFields, so it must pass
131+
// updateValidation runs BEFORE warnRemovedFields, so it must pass
132132
// (return nil) for the warning to be reached. The final Update result is
133133
// irrelevant — the warning is recorded before any conversion/write — so any
134134
// downstream error is ignored; only the recorded warning is asserted.

0 commit comments

Comments
 (0)