Repository: elastic/go-ucfg (go)

Issue:
Empty arrays are turned into nil 
When parsing empty arrays from YAML to config objects and unpacking into an untyped representation like `map[string]interface{}` arrays in the original source are turned into `nil`. Marshalling back to YAML this turns into `null` and is thereby not round-trippeable through ucfg:

Consider: 
```
node.roles: []
```

```golang
input, err := yaml.Unmarshal(`node.roles: []`)
...
c, err := NewConfig([]byte(input))
...
var output map[string]interface{}
err = c.Unpack(output)
...
yaml.Marshal(output)
```
leaves us with:

```
node.roles: null
```

We have been hit in the ECK operator a couple of times by this issue:
* Kibana Fleet config uses empty arrays to turn off monitoring: https://github.com/elastic/cloud-on-k8s/issues/4853
* Elasticsearch uses empty arrays to express the `coordinating` node role https://github.com/elastic/cloud-on-k8s/issues/3718

We are using untyped `map[string]interface{}` target data structures in the operator for application configuration because it is difficult for us to model each Elastic stack application's configuration completely and for all versions of the Elastic stack that we need to support.


Pull request description:
Preserve empty untyped arrays when reifying
Fixes #188 

This adds a special case for empty arrays when reifying configuration objects during Unpack. This is a breaking change compared to existing behaviour as visible in the test cases I adjusted. The original issue #160 that introduced said tests is however still addressed.

Patch:
diff --git a/.ci/Jenkinsfile b/.ci/Jenkinsfile
index e1e26cd..dec499f 100644
--- a/.ci/Jenkinsfile
+++ b/.ci/Jenkinsfile
@@ -37,7 +37,7 @@ pipeline {
         withGithubNotify(context: 'Check') {
           deleteDir()
           unstash 'source'
-          withGoEnv(version: '1.14', pkgs: ['github.com/elastic/go-licenser', 'go.elastic.co/go-licence-detector']){
+          withGoEnv(version: '1.17', pkgs: ['github.com/elastic/go-licenser', 'go.elastic.co/go-licence-detector']){
             dir("${BASE_DIR}"){
               sh(label: '.ci/check.sh', script: '.ci/check.sh')
             }
@@ -53,7 +53,7 @@ pipeline {
         axes {
           axis {
             name 'GO_VERSION'
-            values '1.13', '1.14', '1.15'
+            values '1.16', '1.17'
           }
         }
         stages {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c3af53..4c1fa8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 
 ### Fixed
 - Fixed panic on zero Value while processing a collection of interfaces. #159
+- Preserve empty arrays when reifying #188
 
 ## [0.8.3]
 
diff --git a/NOTICE.txt b/NOTICE.txt
index 274316e..f6c9af3 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -1,4 +1,4 @@
-Copyright 2016-2020 Elasticsearch BV
+Copyright 2016-2021 Elasticsearch BV
 
 This product includes software developed by The Apache Software
 Foundation (http://www.apache.org/).
diff --git a/types.go b/types.go
index 13d601c..b0b8e92 100644
--- a/types.go
+++ b/types.go
@@ -372,6 +372,9 @@ func (c cfgSub) reify(opts *options) (interface{}, error) {
 	arr := c.c.fields.array()
 
 	switch {
+	case len(fields) == 0 && len(arr) == 0 && arr != nil:
+		// preserve empty arrays
+		return []interface{}{}, nil
 	case len(fields) == 0 && len(arr) == 0:
 		return nil, nil
 	case len(fields) > 0 && len(arr) == 0:


Test patch:
diff --git a/yaml/yaml_test.go b/yaml/yaml_test.go
index d62fd86..114547f 100644
--- a/yaml/yaml_test.go
+++ b/yaml/yaml_test.go
@@ -115,6 +115,13 @@ func TestEmptyCollections(t *testing.T) {
 			to:    &[]interface{}{},
 			want:  &[]interface{}{},
 		},
+		"empty array into array of string": {
+			input: `a: []`,
+			to: &struct {
+				A []string
+			}{},
+			want: &struct{ A []string }{A: []string{}},
+		},
 		"struct with empty map into struct with interface": {
 			input: "a: {}",
 			to: &struct {
@@ -150,7 +157,9 @@ func TestEmptyCollections(t *testing.T) {
 		"struct with empty array into map of interfaces": {
 			input: `{"a": []}`,
 			to:    &map[string]interface{}{},
-			want:  &map[string]interface{}{},
+			want: &map[string]interface{}{
+				"a": []interface{}{},
+			},
 		},
 		"struct with empty array into map of interfaces with existing fields": {
 			input: `{"a": []}`,
@@ -159,7 +168,7 @@ func TestEmptyCollections(t *testing.T) {
 				"b": 3,
 			},
 			want: &map[string]interface{}{
-				"a": nil,
+				"a": []interface{}{},
 				"b": 3,
 			},
 		},
@@ -185,7 +194,7 @@ func TestEmptyCollections(t *testing.T) {
 			input: `[[]]`,
 			to:    &[]interface{}{},
 			want: &[]interface{}{
-				nil,
+				[]interface{}{},
 			},
 		},
 		"empty array in array into array of array of interfaces": {