-
Notifications
You must be signed in to change notification settings - Fork 198
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
POC: appendable string arrays in containers.conf
As discussed in containers/podman#20000, we need an opt-in mechanism to _append_ string arrays during loading sequence of containers.conf files. At the moment, existing fields/data will be overriden with each loaded config that sets the specified field/option. The TOML (toml.io) config format does not allow for attributing fields and structs are implicitly represented as "tables". I wanted to extend a string array with a simple boolean field, for instance: ```TOML env=["FOO=bar"] env.append=true ``` TOML doesn't suppor tthe upper idea as it's not a properly formatted table. So I looked for alternatives and found that TOML supports so-called "mixed-type arrays". As the same suggests, such arrays allow for including more than one type and that seemed like a reasonable candidate as it allows for _extending_ the existing syntax without introducing new fields or even yet-another way of loading conf files. The new format can be seen in the tests. Please note that this is just a _tested_ POC. Integrating the POC in containers.conf may turn into a bigger journey as Podman is directly (ab)using many of the fields. Since they have to be changed to the new type (see POC), Podman will not compile without changes. Signed-off-by: Valentin Rothberg <vrothberg@redhat.com>
- Loading branch information
Showing
3 changed files
with
207 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
internal/attributed-string-slice/attributed_string_slice.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
package main | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"reflect" | ||
|
||
"github.com/BurntSushi/toml" | ||
) | ||
|
||
type attributedStringSlice struct { // A "mixed-type array" in TOML. | ||
slice []string | ||
attributes struct { // Using a struct allows for adding more attributes in the feature. | ||
append *bool // Nil if not set by the user | ||
} | ||
} | ||
|
||
func (ts *attributedStringSlice) UnmarshalTOML(data interface{}) error { | ||
iFaceSlice, ok := data.([]interface{}) | ||
if !ok { | ||
return fmt.Errorf("unable to cast to interface array: %v", data) | ||
} | ||
|
||
var loadedStrings []string | ||
for _, x := range iFaceSlice { // Iterate over each item in the slice. | ||
kind := reflect.ValueOf(x).Kind() | ||
switch kind { | ||
case reflect.String: // Strings are directly appended to the slice. | ||
loadedStrings = append(loadedStrings, fmt.Sprintf("%v", x)) | ||
case reflect.Map: // The attribute struct is represented as a map. | ||
attrMap, ok := x.(map[string]interface{}) | ||
if !ok { | ||
return fmt.Errorf("unable to cast to map of interfaces: %v", data) | ||
} | ||
for k, v := range attrMap { // Iterate over all _supported_ keys. | ||
switch k { | ||
case "append": | ||
boolVal, ok := v.(bool) | ||
if !ok { | ||
return fmt.Errorf("unable to cast append to bool: %v", k) | ||
} | ||
ts.attributes.append = &boolVal | ||
default: // Unsupported map key. | ||
return fmt.Errorf("unsupported key %q in map: %v", k, attrMap) | ||
} | ||
} | ||
default: // Unsupported item. | ||
return fmt.Errorf("unsupported item in attributed string slice %v: %v", kind, x) | ||
} | ||
} | ||
|
||
if ts.attributes.append != nil && *ts.attributes.append { // If _explicitly_ configured, append the loaded slice. | ||
ts.slice = append(ts.slice, loadedStrings...) | ||
} else { // Default: override the existing slice. | ||
ts.slice = loadedStrings | ||
} | ||
return nil | ||
} | ||
|
||
func (ts *attributedStringSlice) MarshalTOML() ([]byte, error) { | ||
iFaceSlice := make([]interface{}, 0, len(ts.slice)) | ||
|
||
for _, x := range ts.slice { | ||
iFaceSlice = append(iFaceSlice, x) | ||
} | ||
|
||
if ts.attributes.append != nil { | ||
attributes := make(map[string]any) | ||
attributes["append"] = *ts.attributes.append | ||
iFaceSlice = append(iFaceSlice, attributes) | ||
} | ||
|
||
buf := new(bytes.Buffer) | ||
enc := toml.NewEncoder(buf) | ||
if err := enc.Encode(iFaceSlice); err != nil { | ||
return nil, err | ||
} | ||
return buf.Bytes(), nil | ||
} |
127 changes: 127 additions & 0 deletions
127
internal/attributed-string-slice/attributed_string_slice_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
package main | ||
|
||
import ( | ||
"bytes" | ||
"testing" | ||
|
||
"github.com/BurntSushi/toml" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
type testConfig struct { | ||
Array attributedStringSlice `toml:"array,omitempty"` | ||
} | ||
|
||
const ( | ||
confingDefault = `array=["1", "2", "3"]` | ||
configAppendFront = `array=[{append=true},"4", "5", "6"]` | ||
configAppendMid = `array=["7", {append=true}, "8"]` | ||
configAppendBack = `array=["9", {append=true}]` | ||
configAppendFalse = `array=["10", {append=false}]` | ||
) | ||
|
||
var ( | ||
bTrue = true | ||
bFalse = false | ||
) | ||
|
||
func loadConfigs(configs []string) (*testConfig, error) { | ||
var config testConfig | ||
for _, c := range configs { | ||
if _, err := toml.Decode(c, &config); err != nil { | ||
return nil, err | ||
} | ||
} | ||
return &config, nil | ||
} | ||
|
||
func TestAttributedStringSliceLoading(t *testing.T) { | ||
for _, test := range []struct { | ||
configs []string | ||
expectedSlice []string | ||
expectedAppend *bool | ||
expectedErrorSubstring string | ||
}{ | ||
// Load single configs | ||
{[]string{confingDefault}, []string{"1", "2", "3"}, nil, ""}, | ||
{[]string{configAppendFront}, []string{"4", "5", "6"}, &bTrue, ""}, | ||
{[]string{configAppendMid}, []string{"7", "8"}, &bTrue, ""}, | ||
{[]string{configAppendBack}, []string{"9"}, &bTrue, ""}, | ||
{[]string{configAppendFalse}, []string{"10"}, &bFalse, ""}, | ||
// Append=true | ||
{[]string{confingDefault, configAppendFront}, []string{"1", "2", "3", "4", "5", "6"}, &bTrue, ""}, | ||
{[]string{configAppendFront, confingDefault}, []string{"4", "5", "6", "1", "2", "3"}, &bTrue, ""}, // The attribute is sticky unless explicitly being turned off in a later config | ||
{[]string{configAppendFront, confingDefault, configAppendBack}, []string{"4", "5", "6", "1", "2", "3", "9"}, &bTrue, ""}, | ||
// Append=false | ||
{[]string{confingDefault, configAppendFalse}, []string{"10"}, &bFalse, ""}, | ||
{[]string{confingDefault, configAppendMid, configAppendFalse}, []string{"10"}, &bFalse, ""}, | ||
{[]string{confingDefault, configAppendFalse, configAppendMid}, []string{"10", "7", "8"}, &bTrue, ""}, // Append can be re-enabled by a later config | ||
|
||
// Error checks | ||
{[]string{`array=["1", false]`}, nil, nil, `unsupported item in attributed string slice bool: false`}, | ||
{[]string{`array=["1", 42]`}, nil, nil, `unsupported item in attributed string slice int`}, // Stop a `int` such that it passes on 32bit as well | ||
{[]string{`array=["1", {foo=true}]`}, nil, nil, `unsupported key "foo" in map: `}, | ||
{[]string{`array=["1", {append="false"}]`}, nil, nil, `unable to cast append to bool: `}, | ||
} { | ||
result, err := loadConfigs(test.configs) | ||
if test.expectedErrorSubstring != "" { | ||
require.Error(t, err, "test is expected to fail: %v", test) | ||
require.ErrorContains(t, err, test.expectedErrorSubstring, "error does not match: %v", test) | ||
continue | ||
} | ||
require.NoError(t, err, "test is expected to succeed: %v", test) | ||
require.NotNil(t, result, "loaded config must not be nil: %v", test) | ||
require.Equal(t, result.Array.slice, test.expectedSlice, "slices do not match: %v", test) | ||
require.Equal(t, result.Array.attributes.append, test.expectedAppend, "append field does not match: %v", test) | ||
} | ||
} | ||
|
||
func TestAttributedStringSliceEncoding(t *testing.T) { | ||
for _, test := range []struct { | ||
configs []string | ||
marshalledData string | ||
expectedSlice []string | ||
expectedAppend *bool | ||
}{ | ||
{ | ||
[]string{confingDefault}, | ||
"array = [\"1\", \"2\", \"3\"]\n", | ||
[]string{"1", "2", "3"}, | ||
nil, | ||
}, | ||
{ | ||
[]string{configAppendFront}, | ||
"array = [\"4\", \"5\", \"6\", {append = true}]\n", | ||
[]string{"4", "5", "6"}, | ||
&bTrue, | ||
}, | ||
{ | ||
[]string{configAppendFront, configAppendFalse}, | ||
"array = [\"10\", {append = false}]\n", | ||
[]string{"10"}, | ||
&bFalse, | ||
}, | ||
} { | ||
// 1) Load the configs | ||
result, err := loadConfigs(test.configs) | ||
require.NoError(t, err, "loading config must succeed") | ||
require.NotNil(t, result, "loaded config must not be nil") | ||
require.Equal(t, result.Array.slice, test.expectedSlice, "slices do not match: %v", test) | ||
require.Equal(t, result.Array.attributes.append, test.expectedAppend, "append field does not match: %v", test) | ||
|
||
// 2) Marshal the config to emulate writing it to disk | ||
buf := new(bytes.Buffer) | ||
enc := toml.NewEncoder(buf) | ||
encErr := enc.Encode(result) | ||
require.NoError(t, encErr, "encoding config must work") | ||
require.Equal(t, buf.String(), test.marshalledData) | ||
|
||
// 3) Reload the marshaled config to make sure that data is preserved | ||
var reloadedConfig testConfig | ||
_, decErr := toml.Decode(buf.String(), &reloadedConfig) | ||
require.NoError(t, decErr, "loading config must succeed") | ||
require.NotNil(t, reloadedConfig, "re-loaded config must not be nil") | ||
require.Equal(t, reloadedConfig.Array.slice, test.expectedSlice, "slices do not match: %v", test) | ||
require.Equal(t, reloadedConfig.Array.attributes.append, test.expectedAppend, "append field does not match: %v", test) | ||
} | ||
} |