diff --git a/cli/config/config_test.go b/cli/config/config_test.go index 922641a726ad..07b86b5e6fb9 100644 --- a/cli/config/config_test.go +++ b/cli/config/config_test.go @@ -378,7 +378,9 @@ func TestLoadDefaultConfigFile(t *testing.T) { expected.CredentialsStore = credStore expected.PsFormat = "format" - assert.Check(t, is.DeepEqual(expected, configFile)) + assert.Check(t, is.Equal(expected.Filename, configFile.Filename)) + assert.Check(t, is.Equal(expected.CredentialsStore, configFile.CredentialsStore)) + assert.Check(t, is.Equal(expected.PsFormat, configFile.PsFormat)) assert.Check(t, is.Equal(buffer.String(), "")) }) diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index e32b1e767cbb..be96aa5d61b6 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -12,6 +12,7 @@ import ( "maps" "os" "path/filepath" + "reflect" "strings" "github.com/docker/cli/cli/config/credentials" @@ -75,8 +76,30 @@ type ConfigFile struct { Plugins map[string]map[string]string `json:"plugins,omitempty"` Aliases map[string]string `json:"aliases,omitempty"` Features map[string]string `json:"features,omitempty"` + + // extras keeps JSON keys this version of the CLI does not know about, so a + // later Save does not drop them (login, logout, context switch, …). + extras map[string]json.RawMessage `json:"-"` } +// configFileKnownJSONKeys is the set of json object keys owned by [ConfigFile]. +var configFileKnownJSONKeys = func() map[string]struct{} { + t := reflect.TypeFor[ConfigFile]() + keys := make(map[string]struct{}, t.NumField()) + for i := range t.NumField() { + tag := t.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name, _, _ := strings.Cut(tag, ",") + if name == "" || name == "-" { + continue + } + keys[name] = struct{}{} + } + return keys +}() + type configEnvAuth struct { Auth string `json:"auth"` } @@ -143,6 +166,53 @@ func (c *ConfigFile) LoadFromReader(configData io.Reader) error { return nil } +// UnmarshalJSON records object keys that are not fields of [ConfigFile]. +func (c *ConfigFile) UnmarshalJSON(data []byte) error { + type plain ConfigFile + if err := json.Unmarshal(data, (*plain)(c)); err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + extras := make(map[string]json.RawMessage) + for k, v := range raw { + if _, known := configFileKnownJSONKeys[k]; !known { + extras[k] = v + } + } + if len(extras) > 0 { + c.extras = extras + } + return nil +} + +// MarshalJSON writes known fields, then any extra keys captured on load. +func (c *ConfigFile) MarshalJSON() ([]byte, error) { + if c == nil { + return []byte("null"), nil + } + type plain ConfigFile + data, err := json.Marshal((*plain)(c)) + if err != nil { + return nil, err + } + if len(c.extras) == 0 { + return data, nil + } + var encoded map[string]json.RawMessage + if err := json.Unmarshal(data, &encoded); err != nil { + return nil, err + } + for k, v := range c.extras { + if _, exists := encoded[k]; !exists { + encoded[k] = v + } + } + return json.Marshal(encoded) +} + // ContainsAuth returns whether there is authentication configured // in this file or not. func (c *ConfigFile) ContainsAuth() bool { diff --git a/cli/config/configfile/file_test.go b/cli/config/configfile/file_test.go index 92df02c74352..77f3da7e07c5 100644 --- a/cli/config/configfile/file_test.go +++ b/cli/config/configfile/file_test.go @@ -757,3 +757,34 @@ func TestPluginConfig(t *testing.T) { assert.NilError(t, err) golden.Assert(t, string(cfg), "plugin-config-2.golden") } + +func TestSavePreservesUnknownFields(t *testing.T) { + dir := fs.NewDir(t, t.Name()) + defer dir.Remove() + path := dir.Join("config.json") + + const orig = `{ + "experimental": "enabled", + "features": { + "hooks": "true", + "something-else": "false" + }, + "futureThing": {"a": 1} +}` + assert.NilError(t, os.WriteFile(path, []byte(orig), 0o600)) + + cfg := New(path) + assert.NilError(t, cfg.LoadFromReader(bytes.NewReader([]byte(orig)))) + cfg.CurrentContext = "somecontext" + assert.NilError(t, cfg.Save()) + + got, err := os.ReadFile(path) + assert.NilError(t, err) + + var parsed map[string]json.RawMessage + assert.NilError(t, json.Unmarshal(got, &parsed)) + assert.Equal(t, string(parsed["experimental"]), `"enabled"`) + assert.Equal(t, string(parsed["currentContext"]), `"somecontext"`) + assert.Check(t, parsed["features"] != nil, "features should be kept") + assert.Check(t, parsed["futureThing"] != nil, "unknown fields should be kept") +}