Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(), ""))
})

Expand Down
70 changes: 70 additions & 0 deletions cli/config/configfile/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"maps"
"os"
"path/filepath"
"reflect"
"strings"

"github.com/docker/cli/cli/config/credentials"
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions cli/config/configfile/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading