Skip to content

Commit 2807dae

Browse files
authored
fix(parameters): reject immutable parameter updates (#10204)
1 parent d150c77 commit 2807dae

5 files changed

Lines changed: 384 additions & 14 deletions

File tree

pkg/controller/configuration/patch_merger.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ func mergeUpdatedParams(base map[string]string,
6060
// merge updated files into configmap
6161
if len(updatedFiles) != 0 {
6262
updatedConfig = core.MergeUpdatedConfig(base, updatedFiles)
63+
// Reject content-level updates that would alter any immutable parameter.
64+
// MergeUpdatedConfig substitutes whole file contents key-by-key, so
65+
// without this guard a user submitting raw file content could bypass
66+
// the parameter-level immutable check performed below by
67+
// MergeAndValidateConfigs/filterImmutableParameters.
68+
if err := intctrlutil.ValidateImmutableContentChanges(base, updatedConfig, updatedFiles, paramsDefs, configDescs); err != nil {
69+
return nil, err
70+
}
6371
}
6472
if len(configDescs) == 0 {
6573
return updatedConfig, nil
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
Copyright (C) 2022-2025 ApeCloud Co., Ltd
3+
4+
This file is part of KubeBlocks project
5+
6+
This program is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU Affero General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
This program is distributed in the hope that it will be useful
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU Affero General Public License for more details.
15+
16+
You should have received a copy of the GNU Affero General Public License
17+
along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package configuration
21+
22+
import (
23+
"strings"
24+
"testing"
25+
26+
parametersv1alpha1 "github.com/apecloud/kubeblocks/apis/parameters/v1alpha1"
27+
)
28+
29+
// The following tests cover the content-path immutable guard added in
30+
// intctrlutil.ValidateImmutableContentChanges (release-1.0 counterpart of
31+
// the main-branch PR; see pkg/parameters/patch_merger_test.go on main for
32+
// the same coverage matrix).
33+
//
34+
// Coverage matrix:
35+
// - content modifies an immutable parameter -> reject
36+
// - content adds an immutable parameter -> reject
37+
// - content removes an immutable parameter -> reject
38+
// - content only reorders / re-formats -> accept
39+
// - content has mutable change plus format
40+
// noise but immutable unchanged -> accept
41+
// - file has no immutable candidate, missing
42+
// FileFormatConfig -> accept
43+
// - file has immutable candidate but missing
44+
// FileFormatConfig -> reject (fail-safe)
45+
46+
func strPtr(v string) *string {
47+
return &v
48+
}
49+
50+
func iniConfigDescsForMyCnf() []parametersv1alpha1.ComponentConfigDescription {
51+
return []parametersv1alpha1.ComponentConfigDescription{{
52+
Name: "my.cnf",
53+
TemplateName: "mysql-config",
54+
FileFormatConfig: &parametersv1alpha1.FileFormatConfig{
55+
Format: parametersv1alpha1.Ini,
56+
FormatterAction: parametersv1alpha1.FormatterAction{
57+
IniConfig: &parametersv1alpha1.IniConfig{SectionName: "mysqld"},
58+
},
59+
},
60+
}}
61+
}
62+
63+
func paramsDefsWithImmutable(immutable []string) []*parametersv1alpha1.ParametersDefinition {
64+
return []*parametersv1alpha1.ParametersDefinition{{
65+
Spec: parametersv1alpha1.ParametersDefinitionSpec{
66+
FileName: "my.cnf",
67+
ImmutableParameters: immutable,
68+
},
69+
}}
70+
}
71+
72+
func TestDoMergeRejectsContentModifyingImmutableParameter(t *testing.T) {
73+
base := map[string]string{
74+
"my.cnf": "[mysqld]\ngtid_mode=OFF\nmax_connections=1000\n",
75+
}
76+
patch := map[string]parametersv1alpha1.ParametersInFile{
77+
"my.cnf": {
78+
Content: strPtr("[mysqld]\ngtid_mode=ON\nmax_connections=1000\n"),
79+
},
80+
}
81+
configDescs := iniConfigDescsForMyCnf()
82+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
83+
84+
_, err := DoMerge(base, patch, paramsDefs, configDescs)
85+
if err == nil {
86+
t.Fatalf("expected immutable parameter modification via content to be rejected, got nil error")
87+
}
88+
if !strings.Contains(err.Error(), "gtid_mode") {
89+
t.Fatalf("expected error to mention immutable parameter gtid_mode, got %q", err.Error())
90+
}
91+
}
92+
93+
func TestDoMergeRejectsContentAddingImmutableParameter(t *testing.T) {
94+
base := map[string]string{
95+
"my.cnf": "[mysqld]\nmax_connections=1000\n",
96+
}
97+
patch := map[string]parametersv1alpha1.ParametersInFile{
98+
"my.cnf": {
99+
Content: strPtr("[mysqld]\nmax_connections=1000\ngtid_mode=ON\n"),
100+
},
101+
}
102+
configDescs := iniConfigDescsForMyCnf()
103+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
104+
105+
_, err := DoMerge(base, patch, paramsDefs, configDescs)
106+
if err == nil {
107+
t.Fatalf("expected immutable parameter addition via content to be rejected, got nil error")
108+
}
109+
if !strings.Contains(err.Error(), "gtid_mode") {
110+
t.Fatalf("expected error to mention immutable parameter gtid_mode, got %q", err.Error())
111+
}
112+
}
113+
114+
func TestDoMergeRejectsContentRemovingImmutableParameter(t *testing.T) {
115+
base := map[string]string{
116+
"my.cnf": "[mysqld]\ngtid_mode=OFF\nmax_connections=1000\n",
117+
}
118+
patch := map[string]parametersv1alpha1.ParametersInFile{
119+
"my.cnf": {
120+
Content: strPtr("[mysqld]\nmax_connections=1000\n"),
121+
},
122+
}
123+
configDescs := iniConfigDescsForMyCnf()
124+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
125+
126+
_, err := DoMerge(base, patch, paramsDefs, configDescs)
127+
if err == nil {
128+
t.Fatalf("expected immutable parameter removal via content to be rejected, got nil error")
129+
}
130+
if !strings.Contains(err.Error(), "gtid_mode") {
131+
t.Fatalf("expected error to mention immutable parameter gtid_mode, got %q", err.Error())
132+
}
133+
}
134+
135+
func TestDoMergeAcceptsContentWithOnlyFormatChange(t *testing.T) {
136+
base := map[string]string{
137+
"my.cnf": "[mysqld]\ngtid_mode=OFF\nmax_connections=1000\n",
138+
}
139+
// Same semantic content, just reordered and with an added blank line.
140+
// Must not be flagged as an immutable change because the parsed parameter
141+
// map for gtid_mode is unchanged.
142+
patch := map[string]parametersv1alpha1.ParametersInFile{
143+
"my.cnf": {
144+
Content: strPtr("[mysqld]\n\nmax_connections=1000\ngtid_mode=OFF\n"),
145+
},
146+
}
147+
configDescs := iniConfigDescsForMyCnf()
148+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
149+
150+
if _, err := DoMerge(base, patch, paramsDefs, configDescs); err != nil {
151+
t.Fatalf("expected format-only content change to be accepted, got error %v", err)
152+
}
153+
}
154+
155+
func TestDoMergeAcceptsContentWithMutableChangeAndFormatNoiseWhileImmutableUnchanged(t *testing.T) {
156+
// Realistic MySQL-style fixture covering the joint case the guard must
157+
// accept:
158+
// 1) format noise — comment rewrites, extra blank lines, `key=value`
159+
// vs `key = value` spacing — must NOT trip the immutable check;
160+
// 2) a mutable parameter (max_connections) changes value — that is
161+
// explicitly allowed because it is not in ImmutableParameters;
162+
// 3) the immutable parameter (gtid_mode) keeps the same parsed value
163+
// across base and patch, so the guard must let the merge through.
164+
//
165+
// Together these confirm the guard only triggers on immutable-parameter
166+
// semantic delta and ignores everything else.
167+
base := map[string]string{
168+
"my.cnf": "" +
169+
"# header comment\n" +
170+
"[mysqld]\n" +
171+
"gtid_mode=OFF\n" +
172+
"max_connections=1000\n" +
173+
"# inline comment\n" +
174+
"slow_query_log=ON\n",
175+
}
176+
patch := map[string]parametersv1alpha1.ParametersInFile{
177+
"my.cnf": {
178+
Content: strPtr("" +
179+
"# rewritten header\n" +
180+
"\n" +
181+
"[mysqld]\n" +
182+
"\n" +
183+
"# reordered block\n" +
184+
"slow_query_log = ON\n" +
185+
"max_connections = 2000\n" +
186+
"gtid_mode = OFF\n"),
187+
},
188+
}
189+
configDescs := iniConfigDescsForMyCnf()
190+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
191+
192+
if _, err := DoMerge(base, patch, paramsDefs, configDescs); err != nil {
193+
t.Fatalf("expected realistic format/whitespace/comment noise without immutable-parameter change to be accepted, got error %v", err)
194+
}
195+
}
196+
197+
func TestDoMergeAcceptsContentOnFileWithoutImmutableCandidate(t *testing.T) {
198+
// File has no ParametersDefinition entry, and the content payload uses an
199+
// arbitrary format with no FileFormatConfig registered. The guard must
200+
// stay out of the way: there is no immutable contract to protect on this
201+
// file, so a missing parser must not block a benign content update.
202+
base := map[string]string{
203+
"custom.conf": "key1=value1\n",
204+
}
205+
patch := map[string]parametersv1alpha1.ParametersInFile{
206+
"custom.conf": {
207+
Content: strPtr("key1=value1\nkey2=value2\n"),
208+
},
209+
}
210+
211+
if _, err := DoMerge(base, patch, nil, nil); err != nil {
212+
t.Fatalf("expected content update on file without immutable candidate to be accepted even without parser, got error %v", err)
213+
}
214+
}
215+
216+
func TestDoMergeRejectsContentRemovingEntireSectionContainingImmutableParameter(t *testing.T) {
217+
// Regression for the nil-deref panic introduced by core.FromConfigObject
218+
// returning nil when an INI sub-section is absent. The content payload
219+
// drops the entire [mysqld] section that contains the immutable
220+
// parameter gtid_mode; the guard must reject the change (immutable
221+
// removed) instead of panicking on GetAllParameters of a nil
222+
// ConfigObject.
223+
base := map[string]string{
224+
"my.cnf": "[mysqld]\ngtid_mode=OFF\nmax_connections=1000\n",
225+
}
226+
patch := map[string]parametersv1alpha1.ParametersInFile{
227+
"my.cnf": {
228+
Content: strPtr("[other]\nfoo=bar\n"),
229+
},
230+
}
231+
configDescs := iniConfigDescsForMyCnf()
232+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
233+
234+
_, err := DoMerge(base, patch, paramsDefs, configDescs)
235+
if err == nil {
236+
t.Fatalf("expected immutable parameter removal via whole-section deletion to be rejected, got nil error")
237+
}
238+
if !strings.Contains(err.Error(), "gtid_mode") {
239+
t.Fatalf("expected error to mention immutable parameter gtid_mode, got %q", err.Error())
240+
}
241+
}
242+
243+
func TestDoMergeRejectsContentWhenImmutableDeclaredButFormatMissing(t *testing.T) {
244+
// File has an immutable parameter declared but no FileFormatConfig is
245+
// available. Fail-safe: reject rather than silently allow because the
246+
// guard cannot verify whether the content change touched gtid_mode.
247+
base := map[string]string{
248+
"my.cnf": "[mysqld]\ngtid_mode=OFF\n",
249+
}
250+
patch := map[string]parametersv1alpha1.ParametersInFile{
251+
"my.cnf": {
252+
Content: strPtr("[mysqld]\ngtid_mode=OFF\nmax_connections=1000\n"),
253+
},
254+
}
255+
paramsDefs := paramsDefsWithImmutable([]string{"gtid_mode"})
256+
257+
_, err := DoMerge(base, patch, paramsDefs, nil)
258+
if err == nil {
259+
t.Fatalf("expected fail-safe rejection when immutable parameter declared but file format config is missing, got nil error")
260+
}
261+
if !strings.Contains(err.Error(), "missing file format config") {
262+
t.Fatalf("expected error to mention missing file format config, got %q", err.Error())
263+
}
264+
}

pkg/controllerutil/config_util.go

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ func MergeAndValidateConfigs(baseConfigs map[string]string,
7878

7979
// merge param to config file
8080
for _, params := range updatedParams {
81-
validUpdatedParameters := filterImmutableParameters(params.UpdatedParams, params.Key, paramsDefs)
81+
validUpdatedParameters, err := filterImmutableParameters(params.UpdatedParams, params.Key, paramsDefs)
82+
if err != nil {
83+
return nil, err
84+
}
8285
if len(validUpdatedParameters) == 0 {
8386
continue
8487
}
@@ -245,20 +248,98 @@ func resolveParametersDef(paramsDefs []*parametersv1alpha1.ParametersDefinition,
245248
return nil
246249
}
247250

248-
func filterImmutableParameters(parameters map[string]any, fileName string, paramsDefs []*parametersv1alpha1.ParametersDefinition) map[string]any {
251+
func filterImmutableParameters(parameters map[string]any, fileName string, paramsDefs []*parametersv1alpha1.ParametersDefinition) (map[string]any, error) {
249252
paramsDef := resolveParametersDef(paramsDefs, fileName)
250253
if paramsDef == nil || len(paramsDef.Spec.ImmutableParameters) == 0 {
251-
return parameters
254+
return parameters, nil
252255
}
253256

254257
immutableParams := paramsDef.Spec.ImmutableParameters
255258
validParameters := make(map[string]any, len(parameters))
256259
for key, val := range parameters {
257-
if !slices.Contains(immutableParams, key) {
258-
validParameters[key] = val
260+
if slices.Contains(immutableParams, key) {
261+
return nil, fmt.Errorf("immutable parameter %s cannot be modified", key)
262+
}
263+
validParameters[key] = val
264+
}
265+
return validParameters, nil
266+
}
267+
268+
// ValidateImmutableContentChanges rejects raw-content updates that would
269+
// modify (add, remove, or change) any immutable parameter declared on the
270+
// file's ParametersDefinition.
271+
//
272+
// Scope: only files present in updatedFiles are inspected. Files outside
273+
// that set are untouched by this call.
274+
//
275+
// Skip rule: if a file has no ParametersDefinition match, or its
276+
// ParametersDefinition declares no immutable parameters, the file is
277+
// skipped entirely. This preserves backward-compatible behavior for content
278+
// updates on files that have no immutable-parameter contract.
279+
//
280+
// Diff is computed on parsed parameter maps via the file's FileFormatConfig,
281+
// not on raw text. Pure format changes (whitespace, comment order, blank
282+
// lines) therefore do not trip the check; only a semantic parameter delta
283+
// will.
284+
//
285+
// Fail-safe: when a file *does* have immutable-parameter candidates but the
286+
// parser cannot be applied — missing FileFormatConfig in configDescs, or
287+
// parse error on base/merged content — the operation is rejected rather
288+
// than silently allowed. Otherwise an unknown-format file would let
289+
// immutable parameter changes leak through this guard.
290+
func ValidateImmutableContentChanges(
291+
base map[string]string,
292+
merged map[string]string,
293+
updatedFiles map[string]string,
294+
paramsDefs []*parametersv1alpha1.ParametersDefinition,
295+
configDescs []parametersv1alpha1.ComponentConfigDescription,
296+
) error {
297+
for fileName := range updatedFiles {
298+
paramsDef := resolveParametersDef(paramsDefs, fileName)
299+
if paramsDef == nil || len(paramsDef.Spec.ImmutableParameters) == 0 {
300+
continue
301+
}
302+
formatConfig := core.ResolveConfigFormat(configDescs, fileName)
303+
if formatConfig == nil {
304+
return fmt.Errorf("cannot validate immutable parameters for file %q: missing file format config", fileName)
305+
}
306+
baseParams, err := parseFileParameters(fileName, base[fileName], formatConfig)
307+
if err != nil {
308+
return fmt.Errorf("failed to parse base content of file %q for immutable validation: %w", fileName, err)
259309
}
310+
mergedParams, err := parseFileParameters(fileName, merged[fileName], formatConfig)
311+
if err != nil {
312+
return fmt.Errorf("failed to parse merged content of file %q for immutable validation: %w", fileName, err)
313+
}
314+
for _, immutableKey := range paramsDef.Spec.ImmutableParameters {
315+
baseVal, baseOK := baseParams[immutableKey]
316+
mergedVal, mergedOK := mergedParams[immutableKey]
317+
if baseOK != mergedOK {
318+
return fmt.Errorf("immutable parameter %s cannot be modified (file %q)", immutableKey, fileName)
319+
}
320+
if baseOK && !reflect.DeepEqual(baseVal, mergedVal) {
321+
return fmt.Errorf("immutable parameter %s cannot be modified (file %q)", immutableKey, fileName)
322+
}
323+
}
324+
}
325+
return nil
326+
}
327+
328+
func parseFileParameters(name, content string, formatConfig *parametersv1alpha1.FileFormatConfig) (map[string]interface{}, error) {
329+
configObject, err := core.FromConfigObject(name, content, formatConfig)
330+
if err != nil {
331+
return nil, err
332+
}
333+
if configObject == nil {
334+
// core.FromConfigObject returns nil when the requested sub-section is
335+
// absent (e.g., the INI [section] header is missing or has been
336+
// removed in the content payload). Treat as an empty parameter map
337+
// so the caller's diff sees this as "all keys in that section
338+
// removed" and rejects immutable removals, instead of nil-deref
339+
// panicking on GetAllParameters below.
340+
return map[string]interface{}{}, nil
260341
}
261-
return validParameters
342+
return configObject.GetAllParameters(), nil
262343
}
263344

264345
func ResolveCmpdParametersDefs(ctx context.Context, reader client.Reader, cmpd *appsv1.ComponentDefinition) (*parametersv1alpha1.ParamConfigRenderer, []*parametersv1alpha1.ParametersDefinition, error) {

0 commit comments

Comments
 (0)