Repository: replicatedhq/kots
Pull request #130 (2019-11-07 19:50:31)

migrate config values when downloading upgrades


Patch:
diff --git a/cmd/kots/cli/pull.go b/cmd/kots/cli/pull.go
index 4a066c424..33f7a9021 100644
--- a/cmd/kots/cli/pull.go
+++ b/cmd/kots/cli/pull.go
@@ -32,12 +32,13 @@ func PullCmd() *cobra.Command {
 			// strip it if included or else the rewrite images will fail
 
 			pullOptions := pull.PullOptions{
-				HelmRepoURI:         v.GetString("repo"),
-				RootDir:             ExpandDir(v.GetString("rootdir")),
-				Namespace:           v.GetString("namespace"),
-				Downstreams:         v.GetStringSlice("downstream"),
-				LocalPath:           ExpandDir(v.GetString("local-path")),
-				LicenseFile:         ExpandDir(v.GetString("license-file")),
+				HelmRepoURI: v.GetString("repo"),
+				RootDir:     ExpandDir(v.GetString("rootdir")),
+				Namespace:   v.GetString("namespace"),
+				Downstreams: v.GetStringSlice("downstream"),
+				LocalPath:   ExpandDir(v.GetString("local-path")),
+				LicenseFile: ExpandDir(v.GetString("license-file")),
+				// ConfigFile:          TODO: specify existing config file here
 				ExcludeKotsKinds:    v.GetBool("exclude-kots-kinds"),
 				ExcludeAdminConsole: v.GetBool("exclude-admin-console"),
 				SharedPassword:      v.GetString("shared-password"),
diff --git a/ffi/config.go b/ffi/config.go
index 95d573ceb..b1fa46d7c 100644
--- a/ffi/config.go
+++ b/ffi/config.go
@@ -12,7 +12,7 @@ import (
 func TemplateConfig(configPath string, configData string, configValuesData string) *C.char {
 	rendered, err := config.TemplateConfig(configPath, configData, configValuesData)
 	if err != nil {
-		fmt.Printf("failed to decode config data: %s\n", err.Error())
+		fmt.Printf("failed to apply templates to config: %s\n", err.Error())
 		return C.CString("")
 	}
 	return C.CString(rendered)
diff --git a/ffi/main.go b/ffi/main.go
index f4c1d24ee..ef3b6c71f 100644
--- a/ffi/main.go
+++ b/ffi/main.go
@@ -6,7 +6,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"os"
-	"path"
+	"path/filepath"
 
 	"github.com/mholt/archiver"
 	"github.com/pkg/errors"
@@ -57,7 +57,7 @@ func UpdateCheck(socket, fromArchivePath string) {
 			return
 		}
 
-		expectedLicenseFile := path.Join(tmpRoot, "upstream", "userdata", "license.yaml")
+		expectedLicenseFile := filepath.Join(tmpRoot, "upstream", "userdata", "license.yaml")
 		_, err = os.Stat(expectedLicenseFile)
 		if err != nil {
 			fmt.Printf("failed to find license file in archive\n")
@@ -83,6 +83,7 @@ func UpdateCheck(socket, fromArchivePath string) {
 
 		pullOptions := pull.PullOptions{
 			LicenseFile:         expectedLicenseFile,
+			ConfigFile:          filepath.Join(tmpRoot, "upstream", "userdata", "config.yaml"),
 			RootDir:             tmpRoot,
 			ExcludeKotsKinds:    true,
 			ExcludeAdminConsole: true,
@@ -111,9 +112,9 @@ func UpdateCheck(socket, fromArchivePath string) {
 		}
 
 		paths := []string{
-			path.Join(tmpRoot, "upstream"),
-			path.Join(tmpRoot, "base"),
-			path.Join(tmpRoot, "overlays"),
+			filepath.Join(tmpRoot, "upstream"),
+			filepath.Join(tmpRoot, "base"),
+			filepath.Join(tmpRoot, "overlays"),
 		}
 
 		err = os.Remove(fromArchivePath)
@@ -134,7 +135,7 @@ func UpdateCheck(socket, fromArchivePath string) {
 }
 
 func readCursorFromPath(rootPath string) (string, error) {
-	installationFilePath := path.Join(rootPath, "upstream", "userdata", "installation.yaml")
+	installationFilePath := filepath.Join(rootPath, "upstream", "userdata", "installation.yaml")
 	_, err := os.Stat(installationFilePath)
 	if os.IsNotExist(err) {
 		return "", nil
diff --git a/kotskinds/apis/kots/v1beta1/configvalues_types.go b/kotskinds/apis/kots/v1beta1/configvalues_types.go
index 35008ad27..90a54f6d5 100644
--- a/kotskinds/apis/kots/v1beta1/configvalues_types.go
+++ b/kotskinds/apis/kots/v1beta1/configvalues_types.go
@@ -20,9 +20,14 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+type ConfigValue struct {
+	Value   string `json:"value,omitempty"`
+	Default string `json:"default,omitempty"`
+}
+
 // ConfigValuesSpec defines the desired state of ConfigValue
 type ConfigValuesSpec struct {
-	Values map[string]string `json:"values"`
+	Values map[string]ConfigValue `json:"values"`
 }
 
 // ConfigValuesStatus defines the observed state of ConfigValues
diff --git a/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go b/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
index 2589c6f5a..c5945e65a 100644
--- a/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
+++ b/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
@@ -106,6 +106,18 @@ func (in *ApplicationSpec) DeepCopyInto(out *ApplicationSpec) {
 		*out = make([]ApplicationPort, len(*in))
 		copy(*out, *in)
 	}
+	if in.StatusInformers != nil {
+		in, out := &in.StatusInformers, &out.StatusInformers
+		*out = make([]StatusInformer, len(*in))
+		copy(*out, *in)
+	}
+	if in.Graphs != nil {
+		in, out := &in.Graphs, &out.Graphs
+		*out = make([]MetricGraph, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationSpec.
@@ -291,6 +303,21 @@ func (in *ConfigStatus) DeepCopy() *ConfigStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ConfigValue) DeepCopyInto(out *ConfigValue) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigValue.
+func (in *ConfigValue) DeepCopy() *ConfigValue {
+	if in == nil {
+		return nil
+	}
+	out := new(ConfigValue)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ConfigValues) DeepCopyInto(out *ConfigValues) {
 	*out = *in
@@ -355,7 +382,7 @@ func (in *ConfigValuesSpec) DeepCopyInto(out *ConfigValuesSpec) {
 	*out = *in
 	if in.Values != nil {
 		in, out := &in.Values, &out.Values
-		*out = make(map[string]string, len(*in))
+		*out = make(map[string]ConfigValue, len(*in))
 		for key, val := range *in {
 			(*out)[key] = val
 		}
@@ -607,3 +634,53 @@ func (in *LicenseStatus) DeepCopy() *LicenseStatus {
 	in.DeepCopyInto(out)
 	return out
 }
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MetricGraph) DeepCopyInto(out *MetricGraph) {
+	*out = *in
+	if in.Queries != nil {
+		in, out := &in.Queries, &out.Queries
+		*out = make([]MetricQuery, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricGraph.
+func (in *MetricGraph) DeepCopy() *MetricGraph {
+	if in == nil {
+		return nil
+	}
+	out := new(MetricGraph)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MetricQuery) DeepCopyInto(out *MetricQuery) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricQuery.
+func (in *MetricQuery) DeepCopy() *MetricQuery {
+	if in == nil {
+		return nil
+	}
+	out := new(MetricQuery)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *StatusInformer) DeepCopyInto(out *StatusInformer) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusInformer.
+func (in *StatusInformer) DeepCopy() *StatusInformer {
+	if in == nil {
+		return nil
+	}
+	out := new(StatusInformer)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/pkg/base/replicated.go b/pkg/base/replicated.go
index ff3570f0c..8df24aacb 100644
--- a/pkg/base/replicated.go
+++ b/pkg/base/replicated.go
@@ -20,7 +20,7 @@ func renderReplicated(u *upstream.Upstream, renderOptions *RenderOptions) (*Base
 	}
 
 	// Find the values from the context
-	var templateContext map[string]interface{}
+	var templateContext map[string]template.ItemValue
 	for _, c := range u.Files {
 		if c.Path == "userdata/config.yaml" {
 			ctx, err := UnmarshalConfigValuesContent(c.Content)
@@ -66,7 +66,7 @@ func renderReplicated(u *upstream.Upstream, renderOptions *RenderOptions) (*Base
 	return &base, nil
 }
 
-func UnmarshalConfigValuesContent(content []byte) (map[string]interface{}, error) {
+func UnmarshalConfigValuesContent(content []byte) (map[string]template.ItemValue, error) {
 	kotsscheme.AddToScheme(scheme.Scheme)
 	decode := scheme.Codecs.UniversalDeserializer().Decode
 	obj, gvk, err := decode(content, nil, nil)
@@ -80,9 +80,12 @@ func UnmarshalConfigValuesContent(content []byte) (map[string]interface{}, error
 
 	values := obj.(*kotsv1beta1.ConfigValues)
 
-	ctx := map[string]interface{}{}
+	ctx := map[string]template.ItemValue{}
 	for k, v := range values.Spec.Values {
-		ctx[k] = v
+		ctx[k] = template.ItemValue{
+			Value:   v.Value,
+			Default: v.Default,
+		}
 	}
 
 	return ctx, nil
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 915a51193..66e0d6215 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -2,7 +2,6 @@ package config
 
 import (
 	"bytes"
-	"fmt"
 
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
@@ -70,17 +69,19 @@ func marshalConfig(config *kotsv1beta1.Config) (string, error) {
 	return string(marshalled.Bytes()), nil
 }
 
-func applyValuesToConfig(config *kotsv1beta1.Config, values map[string]interface{}) {
+func applyValuesToConfig(config *kotsv1beta1.Config, values map[string]template.ItemValue) {
 	for idxG, g := range config.Spec.Groups {
 		for idxI, i := range g.Items {
 			value, ok := values[i.Name]
 			if ok {
-				config.Spec.Groups[idxG].Items[idxI].Value = fmt.Sprintf("%s", value) // this assumes "value" implements String function
+				config.Spec.Groups[idxG].Items[idxI].Value = value.ValueStr()
+				config.Spec.Groups[idxG].Items[idxI].Default = value.DefaultStr()
 			}
 			for idxC, c := range i.Items {
 				value, ok := values[c.Name]
 				if ok {
-					config.Spec.Groups[idxG].Items[idxI].Items[idxC].Value = fmt.Sprintf("%s", value) // this assumes "value" implements String function
+					config.Spec.Groups[idxG].Items[idxI].Items[idxC].Value = value.ValueStr()
+					config.Spec.Groups[idxG].Items[idxI].Items[idxC].Default = value.DefaultStr()
 				}
 			}
 		}
diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go
index 74ca0df71..22b81e7c1 100644
--- a/pkg/pull/pull.go
+++ b/pkg/pull/pull.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"io/ioutil"
 	"net/url"
+	"os"
 	"path/filepath"
 
 	"github.com/pkg/errors"
@@ -32,6 +33,7 @@ type PullOptions struct {
 	Downstreams         []string
 	LocalPath           string
 	LicenseFile         string
+	ConfigFile          string
 	ExcludeKotsKinds    bool
 	ExcludeAdminConsole bool
 	SharedPassword      string
@@ -123,6 +125,13 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 
 		fetchOptions.License = license
 	}
+	if pullOptions.ConfigFile != "" {
+		config, err := parseConfigValuesFromFile(pullOptions.ConfigFile)
+		if err != nil {
+			return "", errors.Wrap(err, "failed to parse license from file")
+		}
+		fetchOptions.ConfigValues = config
+	}
 
 	log.ActionWithSpinner("Pulling upstream")
 	u, err := upstream.FetchUpstream(upstreamURI, &fetchOptions)
@@ -358,6 +367,31 @@ func parseLicenseFromFile(filename string) (*kotsv1beta1.License, error) {
 	return license, nil
 }
 
+func parseConfigValuesFromFile(filename string) (*kotsv1beta1.ConfigValues, error) {
+	contents, err := ioutil.ReadFile(filename)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, errors.Wrap(err, "failed to read config values file")
+	}
+
+	kotsscheme.AddToScheme(scheme.Scheme)
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	decoded, gvk, err := decode(contents, nil, nil)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to decode config values file")
+	}
+
+	if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "ConfigValues" {
+		return nil, errors.New("not config values")
+	}
+
+	config := decoded.(*kotsv1beta1.ConfigValues)
+
+	return config, nil
+}
+
 type registryInfo struct {
 	Registry string
 	Proxy    string
diff --git a/pkg/template/config_context.go b/pkg/template/config_context.go
index f3bd5d3b4..3cdcfb995 100644
--- a/pkg/template/config_context.go
+++ b/pkg/template/config_context.go
@@ -9,7 +9,7 @@ import (
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 )
 
-func (b *Builder) NewConfigContext(configGroups []kotsv1beta1.ConfigGroup, templateContext map[string]interface{}) (*ConfigCtx, error) {
+func (b *Builder) NewConfigContext(configGroups []kotsv1beta1.ConfigGroup, templateContext map[string]ItemValue) (*ConfigCtx, error) {
 	configCtx := &ConfigCtx{
 		ItemValues: templateContext,
 	}
@@ -20,19 +20,23 @@ func (b *Builder) NewConfigContext(configGroups []kotsv1beta1.ConfigGroup, templ
 			// We have to ignore errors here because we only have the static context loaded
 			// for rendering. some items have templates that need the config context,
 			// so we can ignore these.
-			builtDefault, _ := b.String(configItem.Default)
-			builtValue, _ := b.String(configItem.Value)
 
-			var built string
+			var itemValue ItemValue
 			if v, ok := templateContext[configItem.Name]; ok {
-				built = fmt.Sprintf("%s", v)
-			} else if builtValue != "" {
-				built = builtValue
+				itemValue = ItemValue{
+					Value:   v.Value,
+					Default: v.Default,
+				}
 			} else {
-				built = builtDefault
+				builtDefault, _ := b.String(configItem.Default)
+				builtValue, _ := b.String(configItem.Value)
+				itemValue = ItemValue{
+					Value:   builtValue,
+					Default: builtDefault,
+				}
 			}
 
-			configCtx.ItemValues[configItem.Name] = built
+			configCtx.ItemValues[configItem.Name] = itemValue
 		}
 	}
 
@@ -40,8 +44,35 @@ func (b *Builder) NewConfigContext(configGroups []kotsv1beta1.ConfigGroup, templ
 }
 
 // ConfigCtx is the context for builder functions before the application has started.
+type ItemValue struct {
+	Value   interface{}
+	Default interface{}
+}
+
+func (i ItemValue) HasValue() bool {
+	return i.Value != nil
+}
+
+func (i ItemValue) ValueStr() string {
+	if i.HasValue() {
+		return fmt.Sprintf("%s", i.Value)
+	}
+	return ""
+}
+
+func (i ItemValue) HasDefault() bool {
+	return i.Default != nil
+}
+
+func (i ItemValue) DefaultStr() string {
+	if i.HasDefault() {
+		return fmt.Sprintf("%s", i.Default)
+	}
+	return ""
+}
+
 type ConfigCtx struct {
-	ItemValues map[string]interface{}
+	ItemValues map[string]ItemValue
 }
 
 // FuncMap represents the available functions in the ConfigCtx.
@@ -100,9 +131,14 @@ func (ctx ConfigCtx) configOptionNotEquals(name string, value string) bool {
 }
 
 func (ctx ConfigCtx) getConfigOptionValue(itemName string) (string, error) {
-	if val, ok := ctx.ItemValues[itemName]; ok {
-		return fmt.Sprintf("%s", val), nil
+	val, ok := ctx.ItemValues[itemName]
+	if !ok {
+		return "", errors.New("unable to find config item")
+	}
+
+	if val.HasValue() {
+		return val.ValueStr(), nil
 	}
 
-	return "", errors.New("unable to find config item")
+	return val.DefaultStr(), nil
 }
diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go
index 235f3f94c..eae6dc7d9 100644
--- a/pkg/upstream/fetch.go
+++ b/pkg/upstream/fetch.go
@@ -14,6 +14,7 @@ type FetchOptions struct {
 	HelmOptions  []string
 	LocalPath    string
 	License      *kotsv1beta1.License
+	ConfigValues *kotsv1beta1.ConfigValues
 }
 
 func FetchUpstream(upstreamURI string, fetchOptions *FetchOptions) (*Upstream, error) {
@@ -38,7 +39,7 @@ func downloadUpstream(upstreamURI string, fetchOptions *FetchOptions) (*Upstream
 		return downloadHelm(u, fetchOptions.HelmRepoURI)
 	}
 	if u.Scheme == "replicated" {
-		return downloadReplicated(u, fetchOptions.LocalPath, fetchOptions.License)
+		return downloadReplicated(u, fetchOptions.LocalPath, fetchOptions.License, fetchOptions.ConfigValues)
 	}
 	if u.Scheme == "git" {
 		return downloadGit(upstreamURI)
diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go
index 51a0aa16b..70c8d18db 100644
--- a/pkg/upstream/replicated.go
+++ b/pkg/upstream/replicated.go
@@ -60,7 +60,7 @@ type Release struct {
 	Manifests    map[string][]byte
 }
 
-func downloadReplicated(u *url.URL, localPath string, license *kotsv1beta1.License) (*Upstream, error) {
+func downloadReplicated(u *url.URL, localPath string, license *kotsv1beta1.License, existingConfig *kotsv1beta1.ConfigValues) (*Upstream, error) {
 	var release *Release
 
 	if localPath != "" {
@@ -104,8 +104,10 @@ func downloadReplicated(u *url.URL, localPath string, license *kotsv1beta1.Licen
 	}
 
 	config := findConfigInRelease(release)
-	if config != nil {
-		configValues, err := createEmptyConfigValues(application.Name, config)
+	if config != nil || existingConfig != nil {
+		// If config existed and was removed from the app,
+		// values will be carried over to the new version anyway.
+		configValues, err := createConfigValues(application.Name, config, existingConfig)
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to create empty config values")
 		}
@@ -330,9 +332,29 @@ func mustMarshalConfigValues(configValues *kotsv1beta1.ConfigValues) []byte {
 	return b.Bytes()
 }
 
-func createEmptyConfigValues(applicationName string, config *kotsv1beta1.Config) (*kotsv1beta1.ConfigValues, error) {
-	emptyValues := kotsv1beta1.ConfigValuesSpec{
-		Values: map[string]string{},
+func createConfigValues(applicationName string, config *kotsv1beta1.Config, existingConfig *kotsv1beta1.ConfigValues) (*kotsv1beta1.ConfigValues, error) {
+	var newValues kotsv1beta1.ConfigValuesSpec
+	if existingConfig != nil {
+		newValues = kotsv1beta1.ConfigValuesSpec{
+			Values: existingConfig.Spec.Values,
+		}
+	} else {
+		newValues = kotsv1beta1.ConfigValuesSpec{
+			Values: map[string]kotsv1beta1.ConfigValue{},
+		}
+	}
+
+	if config == nil {
+		return &kotsv1beta1.ConfigValues{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: "kots.io/v1beta1",
+				Kind:       "ConfigValues",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: applicationName,
+			},
+			Spec: newValues,
+		}, nil
 	}
 
 	builder := template.Builder{}
@@ -340,13 +362,36 @@ func createEmptyConfigValues(applicationName string, config *kotsv1beta1.Config)
 
 	for _, group := range config.Spec.Groups {
 		for _, item := range group.Items {
-			if item.Value != "" {
-				rendered, err := builder.RenderTemplate(item.Name, item.Value)
-				if err != nil {
-					return nil, errors.Wrap(err, "failed to render config item value")
-				}
+			var foundValue string
+			prevValue, ok := newValues.Values[item.Name]
+			if ok && prevValue.Value != "" {
+				foundValue = prevValue.Value
+			}
 
-				emptyValues.Values[item.Name] = rendered
+			renderedValue, err := builder.RenderTemplate(item.Name, item.Value)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to render config item value")
+			}
+
+			renderedDefault, err := builder.RenderTemplate(item.Name, item.Default)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to render config item default")
+			}
+
+			if renderedValue == "" && renderedDefault == "" && foundValue == "" {
+				continue
+			}
+
+			if foundValue != "" {
+				newValues.Values[item.Name] = kotsv1beta1.ConfigValue{
+					Value:   foundValue,
+					Default: renderedDefault,
+				}
+			} else {
+				newValues.Values[item.Name] = kotsv1beta1.ConfigValue{
+					Value:   renderedValue,
+					Default: renderedDefault,
+				}
 			}
 		}
 	}
@@ -359,7 +404,7 @@ func createEmptyConfigValues(applicationName string, config *kotsv1beta1.Config)
 		ObjectMeta: metav1.ObjectMeta{
 			Name: applicationName,
 		},
-		Spec: emptyValues,
+		Spec: newValues,
 	}
 
 	return &configValues, nil


Test patch:
diff --git a/pkg/upstream/replicated_test.go b/pkg/upstream/replicated_test.go
index e1a806209..32c166e8e 100644
--- a/pkg/upstream/replicated_test.go
+++ b/pkg/upstream/replicated_test.go
@@ -4,8 +4,10 @@ import (
 	"net/url"
 	"testing"
 
+	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
 var (
@@ -147,3 +149,117 @@ func Test_releaseToFiles(t *testing.T) {
 		})
 	}
 }
+
+func Test_createConfigValues(t *testing.T) {
+	applicationName := "Test App"
+
+	config := &kotsv1beta1.Config{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "kots.io/v1beta1",
+			Kind:       "Config",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: applicationName,
+		},
+		Spec: kotsv1beta1.ConfigSpec{
+			Groups: []kotsv1beta1.ConfigGroup{
+				kotsv1beta1.ConfigGroup{
+					Name:  "group_name",
+					Title: "Group Title",
+					Items: []kotsv1beta1.ConfigItem{
+						// should replace default
+						kotsv1beta1.ConfigItem{
+							Name:    "1_with_default",
+							Type:    "string",
+							Default: "default_1_new",
+							Value:   "",
+						},
+						// should preserve value and add default
+						kotsv1beta1.ConfigItem{
+							Name:    "2_with_value",
+							Type:    "string",
+							Default: "default_2",
+							Value:   "value_2_new",
+						},
+						// should add a new item
+						kotsv1beta1.ConfigItem{
+							Name:    "4_with_default",
+							Type:    "string",
+							Default: "default_4",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	configValues := &kotsv1beta1.ConfigValues{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "kots.io/v1beta1",
+			Kind:       "ConfigValues",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: applicationName,
+		},
+		Spec: kotsv1beta1.ConfigValuesSpec{
+			Values: map[string]kotsv1beta1.ConfigValue{
+				"1_with_default": kotsv1beta1.ConfigValue{
+					Default: "default_1",
+				},
+				"2_with_value": kotsv1beta1.ConfigValue{
+					Value: "value_2",
+				},
+				"3_with_both": kotsv1beta1.ConfigValue{
+					Value:   "value_3",
+					Default: "default_3",
+				},
+			},
+		},
+	}
+
+	req := require.New(t)
+
+	// like new install, should match config
+	expected1 := map[string]kotsv1beta1.ConfigValue{
+		"1_with_default": kotsv1beta1.ConfigValue{
+			Default: "default_1_new",
+		},
+		"2_with_value": kotsv1beta1.ConfigValue{
+			Value:   "value_2_new",
+			Default: "default_2",
+		},
+		"4_with_default": kotsv1beta1.ConfigValue{
+			Default: "default_4",
+		},
+	}
+	values1, err := createConfigValues(applicationName, config, nil)
+	req.NoError(err)
+	assert.Equal(t, expected1, values1.Spec.Values)
+
+	// Like an app without a config, should have exact same values
+	expected2 := configValues.Spec.Values
+	values2, err := createConfigValues(applicationName, nil, configValues)
+	req.NoError(err)
+	assert.Equal(t, expected2, values2.Spec.Values)
+
+	// updating existing values with new config, should do a merge
+	expected3 := map[string]kotsv1beta1.ConfigValue{
+		"1_with_default": kotsv1beta1.ConfigValue{
+			Default: "default_1_new",
+		},
+		"2_with_value": kotsv1beta1.ConfigValue{
+			Value:   "value_2",
+			Default: "default_2",
+		},
+		"3_with_both": kotsv1beta1.ConfigValue{
+			Value:   "value_3",
+			Default: "default_3",
+		},
+		"4_with_default": kotsv1beta1.ConfigValue{
+			Default: "default_4",
+		},
+	}
+	values3, err := createConfigValues(applicationName, config, configValues)
+	req.NoError(err)
+	assert.Equal(t, expected3, values3.Spec.Values)
+}