From 91b947e0c4bfd4cb8f3a34a514e62440bfdb0c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Szynkiewicz?= Date: Mon, 19 Jan 2026 16:25:27 +0100 Subject: [PATCH 1/3] zarf values in UDS CLI --- go.mod | 2 + go.sum | 4 +- src/cmd/common.go | 4 + src/pkg/bundle/deploy.go | 10 + src/pkg/bundle/overrides.go | 183 ++++++++++++ src/pkg/bundle/values_test.go | 549 ++++++++++++++++++++++++++++++++++ src/types/bundle.go | 16 + src/types/options.go | 12 +- 8 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 src/pkg/bundle/values_test.go diff --git a/go.mod b/go.mod index 7bda5164a..7e468874b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.5 // TODO: awaiting release of derailed/k9s to include grype version bump: from https://github.com/zarf-dev/zarf/blob/6243c86df7c2de9abd28631108b6e84b5fecf29f/go.mod#L12 replace github.com/anchore/grype => github.com/anchore/grype v0.104.1 +replace github.com/zarf-dev/zarf => github.com/michalszynkiewicz/zarf v0.0.0-20260117212807-4ea3e68a3870 + require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/alecthomas/jsonschema v0.0.0-20220216202328-9eeeec9d044b diff --git a/go.sum b/go.sum index 43db55cbe..cdd22285d 100644 --- a/go.sum +++ b/go.sum @@ -1262,6 +1262,8 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= +github.com/michalszynkiewicz/zarf v0.0.0-20260117212807-4ea3e68a3870 h1:NU97ZLLvVeITNFY56CPXaL4WpZ8jpLuXRtT9qZGNh5c= +github.com/michalszynkiewicz/zarf v0.0.0-20260117212807-4ea3e68a3870/go.mod h1:YoEIXlqAbWkz2vlayiwAgeNQ7vHjo0s6mTHKuoyc3Dc= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -1750,8 +1752,6 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -github.com/zarf-dev/zarf v0.68.1 h1:GfboOML5faPaPIA5K2a3cPE0VwdcbpRn1MDj6n+eFUU= -github.com/zarf-dev/zarf v0.68.1/go.mod h1:95WeDzbeS1g7pWiO9Fonfk3K6UeKXI0ldE+WR/DWr+4= github.com/zclconf/go-cty v1.14.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= diff --git a/src/cmd/common.go b/src/cmd/common.go index 66ce4de95..ef39fe642 100644 --- a/src/cmd/common.go +++ b/src/cmd/common.go @@ -21,6 +21,7 @@ import ( "github.com/pterm/pterm" "github.com/spf13/cobra" zarfConfig "github.com/zarf-dev/zarf/src/config" + "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/pkg/logger" zarfTypes "github.com/zarf-dev/zarf/src/types" ) @@ -90,6 +91,9 @@ func configureZarf() { zarfConfig.CommonOptions.PlainHTTP = true zarfConfig.CommonOptions.InsecureSkipTLSVerify = true } + if err := feature.Set([]feature.Feature{{Name: feature.Values, Enabled: true}}); err != nil { + message.Warnf("Failed to enable the values feature automatically: %s. If you set ZARF_FEATURES env variable, please make sure to enable `values` feature", err.Error()) + } } func setBundleFile(args []string) error { diff --git a/src/pkg/bundle/deploy.go b/src/pkg/bundle/deploy.go index dd91ef7c4..118572967 100644 --- a/src/pkg/bundle/deploy.go +++ b/src/pkg/bundle/deploy.go @@ -28,6 +28,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/state" zarfState "github.com/zarf-dev/zarf/src/pkg/state" zarfUtils "github.com/zarf-dev/zarf/src/pkg/utils" + "github.com/zarf-dev/zarf/src/pkg/value" "golang.org/x/exp/slices" ) @@ -109,6 +110,14 @@ func deployPackages(ctx context.Context, packagesToDeploy []types.Package, b *Bu if err != nil { return err } + valuesVariables := getValuesVariables(variableData) + zarfValues, err := b.loadPackageValues(ctx, pkg, valuesVariables) + if err != nil { + return err + } + if len(zarfValues) > 0 { + message.Debugf("Loaded Zarf values for package %s: %v", pkg.Name, zarfValues) + } remoteOpts := packager.RemoteOptions{ PlainHTTP: config.CommonOptions.Insecure, @@ -134,6 +143,7 @@ func deployPackages(ctx context.Context, packagesToDeploy []types.Package, b *Bu deployOpts := packager.DeployOptions{ Timeout: config.HelmTimeout, SetVariables: pkgVars, + Values: value.Values(zarfValues), ValuesOverridesMap: valuesOverrides, Retries: b.cfg.DeployOpts.Retries, RemoteOptions: remoteOpts, diff --git a/src/pkg/bundle/overrides.go b/src/pkg/bundle/overrides.go index b54b3696a..4cc0ea9f6 100644 --- a/src/pkg/bundle/overrides.go +++ b/src/pkg/bundle/overrides.go @@ -5,6 +5,7 @@ package bundle import ( + "context" "encoding/json" "fmt" "os" @@ -17,6 +18,7 @@ import ( "github.com/defenseunicorns/uds-cli/src/types" "github.com/defenseunicorns/uds-cli/src/types/chartvariable" "github.com/defenseunicorns/uds-cli/src/types/valuesources" + goyaml "github.com/goccy/go-yaml" "github.com/zarf-dev/zarf/src/pkg/packager" "helm.sh/helm/v3/pkg/cli/values" "helm.sh/helm/v3/pkg/getter" @@ -31,6 +33,7 @@ type overrideData struct { } type bOverridesData map[string]overrideData type zarfVarData map[string]string +type ZarfValues map[string]any // loadVariables loads and sets precedence for config-level and imported variables func (b *Bundle) loadVariables(pkg types.Package, bundleExportedVars map[string]map[string]string) (zarfVarData, bOverridesData) { @@ -125,6 +128,186 @@ func (b *Bundle) loadChartOverrides(pkg types.Package, overrideData bOverridesDa return processed, nsOverrides, nil } +func resolveValuesFilePath(file string, baseDir string) (string, error) { + if filepath.IsAbs(file) { + if helpers.InvalidPath(file) { + return "", fmt.Errorf("values file not found: %s", file) + } + return file, nil + } + + resolved := filepath.Join(baseDir, file) + if helpers.InvalidPath(resolved) { + return "", fmt.Errorf("values file not found: %s (resolved to %s)", file, resolved) + } + return resolved, nil +} + +// parseValuesFile reads and parses a YAML values file +func parseValuesFile(path string) (ZarfValues, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var vals ZarfValues + if err := goyaml.Unmarshal(data, &vals); err != nil { + return nil, err + } + + return vals, nil +} + +// deepMergeValues recursively merges source values into destination +func deepMergeValues(dst, src ZarfValues) { + for key, srcVal := range src { + if dstVal, exists := dst[key]; exists { + srcMap, srcIsMap := srcVal.(map[string]any) + dstMap, dstIsMap := dstVal.(map[string]any) + if srcIsMap && dstIsMap { + deepMergeValues(dstMap, srcMap) + } else { + dst[key] = srcVal + } + } else { + dst[key] = srcVal + } + } +} + +// setValueAtPath sets a value at a dot-notation path (e.g., ".app.replicas") +func setValueAtPath(vals ZarfValues, path string, value interface{}) error { + if path == "" || !strings.HasPrefix(path, ".") { + return fmt.Errorf("invalid path format: %s (must start with '.')", path) + } + + // Handle root path "." + if path == "." { + valueMap, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("cannot merge non-map value at root path") + } + for k, v := range valueMap { + vals[k] = v + } + return nil + } + + // Split path into parts (remove leading dot) + parts := strings.Split(path[1:], ".") + + // Navigate to the nested location and set the value + current := vals + for i, part := range parts { + if i == len(parts)-1 { + current[part] = value + } else { + if _, exists := current[part]; !exists { + current[part] = make(map[string]any) + } + + nextMap, ok := current[part].(map[string]any) + if !ok { + return fmt.Errorf("conflict at %q, expected map but got %T", strings.Join(parts[:i+1], "."), current[part]) + } + current = nextMap + } + } + + return nil +} + +// getValuesVariables extracts variables with their original types preserved for use with Zarf values. +// This is needed because loadVariables converts values to strings for legacy Zarf variable compatibility, +// but Zarf values can accept complex objects (maps, arrays). +func getValuesVariables(overrideData bOverridesData) map[string]interface{} { + result := make(map[string]interface{}) + for name, data := range overrideData { + result[name] = data.value + } + return result +} + +func (b *Bundle) loadPackageValues(ctx context.Context, pkg types.Package, variables map[string]interface{}) (ZarfValues, error) { + result := make(ZarfValues) + + // Skip if no values configured + if pkg.Values == nil && b.cfg.DeployOpts.PackageValues == nil { + return result, nil + } + + bundleDir := filepath.Dir(b.cfg.DeployOpts.Source) + configDir := filepath.Dir(b.cfg.DeployOpts.Config) + + // 1. Load bundle-level values files (lowest precedence for files) + if pkg.Values != nil { + for _, file := range pkg.Values.Files { + resolvedPath, err := resolveValuesFilePath(file, bundleDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve bundle values file %s: %w", file, err) + } + fileVals, err := parseValuesFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to parse bundle values file %s: %w", resolvedPath, err) + } + deepMergeValues(result, fileVals) + } + + // 2. Apply bundle-level set values + for path, val := range pkg.Values.Set { + if err := setValueAtPath(result, path, val); err != nil { + return nil, fmt.Errorf("failed to set bundle value at path %s: %w", path, err) + } + } + + // 3. Apply bundle values.variables (resolve from UDS variables) + // Values can be complex objects (maps, arrays) or simple values (strings, numbers) + for _, v := range pkg.Values.Variables { + varName := strings.ToUpper(v.Name) + var varValue interface{} + + if val, ok := variables[varName]; ok { + varValue = val + } else if v.Default != nil { + varValue = v.Default + } else { + continue // Skip if no value and no default + } + + if err := setValueAtPath(result, v.Path, varValue); err != nil { + return nil, fmt.Errorf("failed to set value variable %s at path %s: %w", v.Name, v.Path, err) + } + } + } + + // 4. Load config-level values (higher precedence) + if b.cfg.DeployOpts.PackageValues != nil { + if pkgConfig, ok := b.cfg.DeployOpts.PackageValues[pkg.Name]; ok { + // Load config values files + for _, file := range pkgConfig.Files { + resolvedPath, err := resolveValuesFilePath(file, configDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve config values file %s: %w", file, err) + } + fileVals, err := parseValuesFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to parse config values file %s: %w", resolvedPath, err) + } + deepMergeValues(result, fileVals) + } + + // Apply config set values (highest precedence) + for path, val := range pkgConfig.Set { + if err := setValueAtPath(result, path, val); err != nil { + return nil, fmt.Errorf("failed to set config value at path %s: %w", path, err) + } + } + } + } + + return result, nil +} + // convertOverridesMap converts a map of overrides to a PkgOverrideMap func convertOverridesMap(overrideMap map[string]map[string]*values.Options) (packager.ValuesOverrides, error) { processed := make(packager.ValuesOverrides) diff --git a/src/pkg/bundle/values_test.go b/src/pkg/bundle/values_test.go new file mode 100644 index 000000000..142cef73d --- /dev/null +++ b/src/pkg/bundle/values_test.go @@ -0,0 +1,549 @@ +// Copyright 2024 Defense Unicorns +// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +package bundle + +import ( + "os" + "path/filepath" + "testing" + + "github.com/defenseunicorns/uds-cli/src/types" + "github.com/stretchr/testify/require" +) + +func TestSetValueAtPath(t *testing.T) { + tests := []struct { + name string + initial ZarfValues + path string + value interface{} + expected ZarfValues + wantErr bool + }{ + { + name: "simple path", + initial: make(ZarfValues), + path: ".app", + value: "myapp", + expected: ZarfValues{"app": "myapp"}, + wantErr: false, + }, + { + name: "nested path", + initial: make(ZarfValues), + path: ".app.name", + value: "myapp", + expected: ZarfValues{"app": map[string]any{"name": "myapp"}}, + wantErr: false, + }, + { + name: "deeply nested path", + initial: make(ZarfValues), + path: ".app.config.database.host", + value: "localhost", + expected: ZarfValues{"app": map[string]any{"config": map[string]any{"database": map[string]any{"host": "localhost"}}}}, + wantErr: false, + }, + { + name: "invalid path - no dot prefix", + initial: make(ZarfValues), + path: "app.name", + value: "myapp", + wantErr: true, + }, + { + name: "invalid path - empty", + initial: make(ZarfValues), + path: "", + value: "myapp", + wantErr: true, + }, + { + name: "override existing value", + initial: ZarfValues{"app": "old"}, + path: ".app", + value: "new", + expected: ZarfValues{"app": "new"}, + wantErr: false, + }, + { + name: "numeric value", + initial: make(ZarfValues), + path: ".replicas", + value: 3, + expected: ZarfValues{"replicas": 3}, + wantErr: false, + }, + { + name: "map value", + initial: make(ZarfValues), + path: ".resources", + value: map[string]any{"cpu": "100m", "memory": "128Mi"}, + expected: ZarfValues{"resources": map[string]any{"cpu": "100m", "memory": "128Mi"}}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := setValueAtPath(tt.initial, tt.path, tt.value) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, tt.initial) + } + }) + } +} + +func TestDeepMergeValues(t *testing.T) { + tests := []struct { + name string + dst ZarfValues + src ZarfValues + expected ZarfValues + }{ + { + name: "merge into empty", + dst: make(ZarfValues), + src: ZarfValues{"app": "myapp"}, + expected: ZarfValues{"app": "myapp"}, + }, + { + name: "override simple value", + dst: ZarfValues{"app": "old"}, + src: ZarfValues{"app": "new"}, + expected: ZarfValues{"app": "new"}, + }, + { + name: "merge nested maps", + dst: ZarfValues{ + "app": map[string]any{ + "name": "myapp", + "version": "1.0", + }, + }, + src: ZarfValues{ + "app": map[string]any{ + "version": "2.0", + "env": "prod", + }, + }, + expected: ZarfValues{ + "app": map[string]any{ + "name": "myapp", + "version": "2.0", + "env": "prod", + }, + }, + }, + { + name: "add new keys", + dst: ZarfValues{ + "app": "myapp", + }, + src: ZarfValues{ + "replicas": 3, + }, + expected: ZarfValues{ + "app": "myapp", + "replicas": 3, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deepMergeValues(tt.dst, tt.src) + require.Equal(t, tt.expected, tt.dst) + }) + } +} + +func TestParseValuesFile(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "values-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + t.Run("parse valid yaml file", func(t *testing.T) { + content := ` +app: + name: myapp + replicas: 3 + config: + debug: true +` + filePath := filepath.Join(tmpDir, "values.yaml") + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + + vals, err := parseValuesFile(filePath) + require.NoError(t, err) + require.NotNil(t, vals) + + app, ok := vals["app"].(map[string]any) + require.True(t, ok) + require.Equal(t, "myapp", app["name"]) + // YAML parser returns numbers as uint64 + require.Equal(t, uint64(3), app["replicas"]) + }) + + t.Run("file not found", func(t *testing.T) { + _, err := parseValuesFile(filepath.Join(tmpDir, "nonexistent.yaml")) + require.Error(t, err) + }) + + t.Run("invalid yaml", func(t *testing.T) { + content := `invalid: yaml: content: [broken` + filePath := filepath.Join(tmpDir, "invalid.yaml") + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + + _, err = parseValuesFile(filePath) + require.Error(t, err) + }) +} + +func TestResolveValuesFilePath(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "values-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a test file + testFile := filepath.Join(tmpDir, "values.yaml") + err = os.WriteFile(testFile, []byte("test: true"), 0644) + require.NoError(t, err) + + t.Run("resolve relative path", func(t *testing.T) { + resolved, err := resolveValuesFilePath("values.yaml", tmpDir) + require.NoError(t, err) + require.Equal(t, testFile, resolved) + }) + + t.Run("resolve absolute path", func(t *testing.T) { + resolved, err := resolveValuesFilePath(testFile, "/some/other/dir") + require.NoError(t, err) + require.Equal(t, testFile, resolved) + }) + + t.Run("file not found - relative", func(t *testing.T) { + _, err := resolveValuesFilePath("nonexistent.yaml", tmpDir) + require.Error(t, err) + }) + + t.Run("file not found - absolute", func(t *testing.T) { + _, err := resolveValuesFilePath("/nonexistent/path/values.yaml", tmpDir) + require.Error(t, err) + }) +} + +func TestLoadPackageValuesPrecedence(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "values-precedence-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create bundle values file + bundleValuesFile := filepath.Join(tmpDir, "bundle-values.yaml") + err = os.WriteFile(bundleValuesFile, []byte(` +app: + name: "from-bundle-file" + environment: "from-bundle-file" + replicas: 1 + fromBundleFile: true +`), 0644) + require.NoError(t, err) + + // Create config values file + configValuesFile := filepath.Join(tmpDir, "config-values.yaml") + err = os.WriteFile(configValuesFile, []byte(` +app: + environment: "from-config-file" + fromConfigFile: true +`), 0644) + require.NoError(t, err) + + t.Run("bundle values.set overrides values.files", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Files: []string{"bundle-values.yaml"}, + Set: map[string]interface{}{ + ".app.replicas": 5, + }, + }, + } + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + require.NoError(t, err) + + app := vals["app"].(map[string]any) + require.Equal(t, "from-bundle-file", app["name"]) // from file + require.Equal(t, 5, app["replicas"]) // overridden by set + require.Equal(t, true, app["fromBundleFile"]) // preserved from file + }) + + t.Run("bundle values.variables overrides values.set", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Files: []string{"bundle-values.yaml"}, + Set: map[string]interface{}{ + ".app.replicas": 5, + }, + Variables: []types.BundleValuesVariable{ + {Name: "REPLICAS", Path: ".app.replicas", Default: 10}, + }, + }, + } + + // Without UDS variable set, uses default from variables + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + require.NoError(t, err) + app := vals["app"].(map[string]any) + require.Equal(t, 10, app["replicas"]) // from variables default, overrides set + + // With UDS variable set (integer preserved, not stringified) + vals, err = b.loadPackageValues(t.Context(), pkg, map[string]interface{}{"REPLICAS": 20}) + require.NoError(t, err) + app = vals["app"].(map[string]any) + require.Equal(t, 20, app["replicas"]) // from UDS variable, type preserved + }) + + t.Run("config values override bundle values", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + Config: filepath.Join(tmpDir, "uds-config.yaml"), + PackageValues: map[string]types.PackageValuesConfig{ + "test-pkg": { + Files: []string{"config-values.yaml"}, + Set: map[string]interface{}{ + ".app.name": "from-config-set", + }, + }, + }, + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Files: []string{"bundle-values.yaml"}, + Set: map[string]interface{}{ + ".app.replicas": 5, + }, + }, + } + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + require.NoError(t, err) + + app := vals["app"].(map[string]any) + require.Equal(t, "from-config-set", app["name"]) // highest: config set + require.Equal(t, "from-config-file", app["environment"]) // config file overrides bundle + require.Equal(t, 5, app["replicas"]) // from bundle set (not in config) + require.Equal(t, true, app["fromBundleFile"]) // preserved from bundle file + require.Equal(t, true, app["fromConfigFile"]) // added by config file + }) + + t.Run("deep merge preserves nested values", func(t *testing.T) { + // Create a file with nested structure + nestedFile := filepath.Join(tmpDir, "nested-values.yaml") + err = os.WriteFile(nestedFile, []byte(` +app: + config: + database: + host: "localhost" + port: 5432 + cache: + enabled: true +`), 0644) + require.NoError(t, err) + + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Files: []string{"nested-values.yaml"}, + Set: map[string]interface{}{ + ".app.config.database.host": "production-db", + }, + }, + } + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + require.NoError(t, err) + + app := vals["app"].(map[string]any) + config := app["config"].(map[string]any) + database := config["database"].(map[string]any) + cache := config["cache"].(map[string]any) + + require.Equal(t, "production-db", database["host"]) // overridden + require.Equal(t, uint64(5432), database["port"]) // preserved + require.Equal(t, true, cache["enabled"]) // preserved (different branch) + }) +} + +func TestLoadPackageValuesNoConfig(t *testing.T) { + t.Run("returns empty map when no values configured", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: "/some/path/bundle.tar.zst", + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: nil, // no values configured + } + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + require.NoError(t, err) + require.Empty(t, vals) + }) +} + +func TestLoadPackageValuesComplexObjects(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "values-complex-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + t.Run("complex object from variable is preserved", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Variables: []types.BundleValuesVariable{ + {Name: "RESOURCES", Path: ".app.resources"}, + }, + }, + } + + complexValue := map[string]interface{}{ + "replicas": 3, + "limits": map[string]interface{}{ + "cpu": "500m", + "memory": "1Gi", + }, + } + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{ + "RESOURCES": complexValue, + }) + require.NoError(t, err) + + app, ok := vals["app"].(map[string]any) + require.True(t, ok, "expected app to be a map") + + resources, ok := app["resources"].(map[string]interface{}) + require.True(t, ok, "expected resources to be a map, not a string") + + require.Equal(t, 3, resources["replicas"]) + limits, ok := resources["limits"].(map[string]interface{}) + require.True(t, ok, "expected limits to be a map") + require.Equal(t, "500m", limits["cpu"]) + require.Equal(t, "1Gi", limits["memory"]) + }) + + t.Run("array from variable is preserved", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Variables: []types.BundleValuesVariable{ + {Name: "ADMIN_GROUPS", Path: ".sso.adminGroups"}, + }, + }, + } + + arrayValue := []interface{}{"/GitLab Admin", "/UDS Core/Admin"} + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{ + "ADMIN_GROUPS": arrayValue, + }) + require.NoError(t, err) + + sso, ok := vals["sso"].(map[string]any) + require.True(t, ok, "expected sso to be a map") + + adminGroups, ok := sso["adminGroups"].([]interface{}) + require.True(t, ok, "expected adminGroups to be an array, not a string") + require.Len(t, adminGroups, 2) + require.Equal(t, "/GitLab Admin", adminGroups[0]) + }) + + t.Run("string variable still works", func(t *testing.T) { + b := &Bundle{ + cfg: &types.BundleConfig{ + DeployOpts: types.BundleDeployOptions{ + Source: filepath.Join(tmpDir, "bundle.tar.zst"), + }, + }, + } + + pkg := types.Package{ + Name: "test-pkg", + Values: &types.PackageValues{ + Variables: []types.BundleValuesVariable{ + {Name: "APP_NAME", Path: ".app.name"}, + }, + }, + } + + vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{ + "APP_NAME": "my-app", + }) + require.NoError(t, err) + + app, ok := vals["app"].(map[string]any) + require.True(t, ok, "expected app to be a map") + require.Equal(t, "my-app", app["name"]) + }) +} diff --git a/src/types/bundle.go b/src/types/bundle.go index b1280f729..5dda36308 100644 --- a/src/types/bundle.go +++ b/src/types/bundle.go @@ -30,6 +30,22 @@ type Package struct { Imports []BundleVariableImport `json:"imports,omitempty" jsonschema:"description=List of Zarf variables to import from another Zarf package"` Exports []BundleVariableExport `json:"exports,omitempty" jsonschema:"description=List of Zarf variables to export from the Zarf package"` Overrides map[string]map[string]BundleChartOverrides `json:"overrides,omitempty" jsonschema:"description=Map of Helm chart overrides to set. The format is :, :"` + Values *PackageValues `json:"values,omitempty" jsonschema:"description=Values configuration for Zarf packages using the values feature (alpha)"` +} + +// PackageValues represents values configuration for Zarf packages using the values feature (alpha) +type PackageValues struct { + Files []string `json:"files,omitempty" jsonschema:"description=Paths to values YAML files to pass to the package (relative to bundle root)"` + Set map[string]interface{} `json:"set,omitempty" jsonschema:"description=Values to set using dot notation paths (e.g. .app.replicas)"` + Variables []BundleValuesVariable `json:"variables,omitempty" jsonschema:"description=Map UDS variables to Zarf values paths"` +} + +// BundleValuesVariable maps a UDS variable to a Zarf values path +type BundleValuesVariable struct { + Name string `json:"name" jsonschema:"description=UDS variable name,required"` + Path string `json:"path" jsonschema:"description=Dot notation path in Zarf values (e.g. .app.replicas),required"` + Description string `json:"description,omitempty" jsonschema:"description=Description of the variable"` + Default interface{} `json:"default,omitempty" jsonschema:"description=Default value if not provided"` } // BundleChartOverrides represents a Helm chart override to set via UDS variables diff --git a/src/types/options.go b/src/types/options.go index deccc6ef9..63b8e528e 100644 --- a/src/types/options.go +++ b/src/types/options.go @@ -37,8 +37,16 @@ type BundleDeployOptions struct { // Variables and SharedVariables are read in from uds-config.yaml Variables map[string]map[string]interface{} `yaml:"variables,omitempty"` SharedVariables map[string]interface{} `yaml:"shared,omitempty"` - Retries int `yaml:"retries"` - Options map[string]interface{} `yaml:"options,omitempty"` + // PackageValues contains per-package Zarf values configuration from uds-config.yaml + PackageValues map[string]PackageValuesConfig `yaml:"values,omitempty"` + Retries int `yaml:"retries"` + Options map[string]interface{} `yaml:"options,omitempty"` +} + +// PackageValuesConfig represents per-package Zarf values configuration from uds-config.yaml +type PackageValuesConfig struct { + Files []string `yaml:"files,omitempty" json:"files,omitempty" jsonschema:"description=Paths to values YAML files"` + Set map[string]interface{} `yaml:"set,omitempty" json:"set,omitempty" jsonschema:"description=Values to set using dot notation paths"` } // BundleInspectOptions is the options for the bundler.Inspect() function From dc19eae08134c0cd1712387f0f898fb13875ba21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Szynkiewicz?= Date: Mon, 19 Jan 2026 19:26:50 +0100 Subject: [PATCH 2/3] move values file to be processed on create and not deploy --- src/pkg/bundle/create.go | 88 +++++++++++++ src/pkg/bundle/overrides.go | 20 +-- src/pkg/bundle/values_test.go | 237 ++++++++++++++++++++++++++++------ src/types/bundle.go | 2 +- 4 files changed, 291 insertions(+), 56 deletions(-) diff --git a/src/pkg/bundle/create.go b/src/pkg/bundle/create.go index 946bb803c..04bae9d88 100644 --- a/src/pkg/bundle/create.go +++ b/src/pkg/bundle/create.go @@ -142,6 +142,14 @@ func (b *Bundle) confirmBundleCreation() (confirm bool) { func (b *Bundle) processValuesFiles() error { // Populate values from valuesFiles if provided for i, pkg := range b.bundle.Packages { + // Process package-level values files (for Zarf values feature) + if pkg.Values != nil && len(pkg.Values.Files) > 0 { + if err := b.processPackageValuesFiles(i); err != nil { + return err + } + } + + // Process chart override values files (existing behavior) for componentName, overrides := range pkg.Overrides { for chartName, bundleChartOverrides := range overrides { valuesFilesToMerge := make([][]types.BundleChartValue, 0) @@ -177,6 +185,86 @@ func (b *Bundle) processValuesFiles() error { return nil } +// processPackageValuesFiles reads package-level values files and merges them into Set +// This is processed at create time so the values are embedded in the bundle +func (b *Bundle) processPackageValuesFiles(pkgIdx int) error { + pkg := &b.bundle.Packages[pkgIdx] + + // Initialize Set map if nil + if pkg.Values.Set == nil { + pkg.Values.Set = make(map[string]interface{}) + } + + // Build merged values from all files (later files override earlier) + mergedValues := make(map[string]interface{}) + + for _, valuesFile := range pkg.Values.Files { + // Resolve file path relative to bundle source directory + fileName := filepath.Join(b.cfg.CreateOpts.SourceDirectory, valuesFile) + if filepath.IsAbs(valuesFile) { + fileName = valuesFile + } + + // Read and parse the values file + values, err := chartutil.ReadValuesFile(fileName) + if err != nil { + return err + } + + // Deep merge into accumulated values + for key, value := range values { + mergedValues[key] = deepMergeForCreate(mergedValues[key], value) + } + } + + // Merge file values into Set, with existing Set values taking precedence + // Files are processed first (lower precedence), then existing Set values override + finalSet := make(map[string]interface{}) + + // First, add all values from files using dot-notation paths + flattenToSet(mergedValues, ".", finalSet) + + // Then, apply existing Set values (higher precedence) + for path, val := range pkg.Values.Set { + finalSet[path] = val + } + + pkg.Values.Set = finalSet + + // Clear Files since they've been processed and embedded in Set + pkg.Values.Files = nil + + return nil +} + +// deepMergeForCreate recursively merges src into dst, returning the merged result +func deepMergeForCreate(dst, src interface{}) interface{} { + if dst == nil { + return src + } + + dstMap, dstIsMap := dst.(map[string]interface{}) + srcMap, srcIsMap := src.(map[string]interface{}) + + if dstIsMap && srcIsMap { + for key, srcVal := range srcMap { + dstMap[key] = deepMergeForCreate(dstMap[key], srcVal) + } + return dstMap + } + + // Non-map values: src overwrites dst + return src +} + +// flattenToSet converts a nested map to dot-notation paths in the Set map +func flattenToSet(values map[string]interface{}, prefix string, set map[string]interface{}) { + for key, value := range values { + path := prefix + key + set[path] = value + } +} + // mergeBundleChartValues merges lists of BundleChartValue using the values from the last list if there are any duplicates // such that values from the last list will take precedence over the values from previous lists func mergeBundleChartValues(bundleChartValueLists ...[]types.BundleChartValue) []types.BundleChartValue { diff --git a/src/pkg/bundle/overrides.go b/src/pkg/bundle/overrides.go index 4cc0ea9f6..7f13f0d1c 100644 --- a/src/pkg/bundle/overrides.go +++ b/src/pkg/bundle/overrides.go @@ -236,31 +236,17 @@ func (b *Bundle) loadPackageValues(ctx context.Context, pkg types.Package, varia return result, nil } - bundleDir := filepath.Dir(b.cfg.DeployOpts.Source) configDir := filepath.Dir(b.cfg.DeployOpts.Config) - // 1. Load bundle-level values files (lowest precedence for files) + // 1. Apply bundle-level set values (files are processed at create time and merged into Set) if pkg.Values != nil { - for _, file := range pkg.Values.Files { - resolvedPath, err := resolveValuesFilePath(file, bundleDir) - if err != nil { - return nil, fmt.Errorf("failed to resolve bundle values file %s: %w", file, err) - } - fileVals, err := parseValuesFile(resolvedPath) - if err != nil { - return nil, fmt.Errorf("failed to parse bundle values file %s: %w", resolvedPath, err) - } - deepMergeValues(result, fileVals) - } - - // 2. Apply bundle-level set values for path, val := range pkg.Values.Set { if err := setValueAtPath(result, path, val); err != nil { return nil, fmt.Errorf("failed to set bundle value at path %s: %w", path, err) } } - // 3. Apply bundle values.variables (resolve from UDS variables) + // 2. Apply bundle values.variables (resolve from UDS variables) // Values can be complex objects (maps, arrays) or simple values (strings, numbers) for _, v := range pkg.Values.Variables { varName := strings.ToUpper(v.Name) @@ -280,7 +266,7 @@ func (b *Bundle) loadPackageValues(ctx context.Context, pkg types.Package, varia } } - // 4. Load config-level values (higher precedence) + // 3. Load config-level values (higher precedence, processed at deploy time) if b.cfg.DeployOpts.PackageValues != nil { if pkgConfig, ok := b.cfg.DeployOpts.PackageValues[pkg.Name]; ok { // Load config values files diff --git a/src/pkg/bundle/values_test.go b/src/pkg/bundle/values_test.go index 142cef73d..39ccf0cee 100644 --- a/src/pkg/bundle/values_test.go +++ b/src/pkg/bundle/values_test.go @@ -247,18 +247,7 @@ func TestLoadPackageValuesPrecedence(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(tmpDir) - // Create bundle values file - bundleValuesFile := filepath.Join(tmpDir, "bundle-values.yaml") - err = os.WriteFile(bundleValuesFile, []byte(` -app: - name: "from-bundle-file" - environment: "from-bundle-file" - replicas: 1 - fromBundleFile: true -`), 0644) - require.NoError(t, err) - - // Create config values file + // Create config values file (config files are still loaded at deploy time) configValuesFile := filepath.Join(tmpDir, "config-values.yaml") err = os.WriteFile(configValuesFile, []byte(` app: @@ -267,7 +256,9 @@ app: `), 0644) require.NoError(t, err) - t.Run("bundle values.set overrides values.files", func(t *testing.T) { + t.Run("bundle values.set works correctly", func(t *testing.T) { + // Note: Bundle-level Files are processed at CREATE time and merged into Set. + // At deploy time, only Set is used (Files would be nil after create processing). b := &Bundle{ cfg: &types.BundleConfig{ DeployOpts: types.BundleDeployOptions{ @@ -276,12 +267,19 @@ app: }, } + // Simulate post-create state where files have been processed into Set pkg := types.Package{ Name: "test-pkg", Values: &types.PackageValues{ - Files: []string{"bundle-values.yaml"}, + // Files would be nil at deploy time (processed at create time) Set: map[string]interface{}{ - ".app.replicas": 5, + ".app": map[string]interface{}{ + "name": "from-bundle-file", + "environment": "from-bundle-file", + "replicas": 1, + "fromBundleFile": true, + }, + ".app.replicas": 5, // This overrides the value from file }, }, } @@ -290,9 +288,9 @@ app: require.NoError(t, err) app := vals["app"].(map[string]any) - require.Equal(t, "from-bundle-file", app["name"]) // from file - require.Equal(t, 5, app["replicas"]) // overridden by set - require.Equal(t, true, app["fromBundleFile"]) // preserved from file + require.Equal(t, "from-bundle-file", app["name"]) + require.Equal(t, 5, app["replicas"]) // overridden by more specific path + require.Equal(t, true, app["fromBundleFile"]) // preserved }) t.Run("bundle values.variables overrides values.set", func(t *testing.T) { @@ -307,7 +305,6 @@ app: pkg := types.Package{ Name: "test-pkg", Values: &types.PackageValues{ - Files: []string{"bundle-values.yaml"}, Set: map[string]interface{}{ ".app.replicas": 5, }, @@ -331,6 +328,7 @@ app: }) t.Run("config values override bundle values", func(t *testing.T) { + // Config files are still loaded at deploy time (they come from uds-config.yaml) b := &Bundle{ cfg: &types.BundleConfig{ DeployOpts: types.BundleDeployOptions{ @@ -348,12 +346,17 @@ app: }, } + // Simulate post-create state where bundle files have been processed into Set pkg := types.Package{ Name: "test-pkg", Values: &types.PackageValues{ - Files: []string{"bundle-values.yaml"}, Set: map[string]interface{}{ - ".app.replicas": 5, + ".app": map[string]interface{}{ + "name": "from-bundle-file", + "environment": "from-bundle-file", + "replicas": 5, + "fromBundleFile": true, + }, }, }, } @@ -365,24 +368,11 @@ app: require.Equal(t, "from-config-set", app["name"]) // highest: config set require.Equal(t, "from-config-file", app["environment"]) // config file overrides bundle require.Equal(t, 5, app["replicas"]) // from bundle set (not in config) - require.Equal(t, true, app["fromBundleFile"]) // preserved from bundle file + require.Equal(t, true, app["fromBundleFile"]) // preserved from bundle require.Equal(t, true, app["fromConfigFile"]) // added by config file }) t.Run("deep merge preserves nested values", func(t *testing.T) { - // Create a file with nested structure - nestedFile := filepath.Join(tmpDir, "nested-values.yaml") - err = os.WriteFile(nestedFile, []byte(` -app: - config: - database: - host: "localhost" - port: 5432 - cache: - enabled: true -`), 0644) - require.NoError(t, err) - b := &Bundle{ cfg: &types.BundleConfig{ DeployOpts: types.BundleDeployOptions{ @@ -391,12 +381,23 @@ app: }, } + // Simulate post-create state with nested values in Set pkg := types.Package{ Name: "test-pkg", Values: &types.PackageValues{ - Files: []string{"nested-values.yaml"}, Set: map[string]interface{}{ - ".app.config.database.host": "production-db", + ".app": map[string]interface{}{ + "config": map[string]interface{}{ + "database": map[string]interface{}{ + "host": "localhost", + "port": 5432, + }, + "cache": map[string]interface{}{ + "enabled": true, + }, + }, + }, + ".app.config.database.host": "production-db", // Override specific path }, }, } @@ -410,7 +411,7 @@ app: cache := config["cache"].(map[string]any) require.Equal(t, "production-db", database["host"]) // overridden - require.Equal(t, uint64(5432), database["port"]) // preserved + require.Equal(t, 5432, database["port"]) // preserved require.Equal(t, true, cache["enabled"]) // preserved (different branch) }) } @@ -547,3 +548,163 @@ func TestLoadPackageValuesComplexObjects(t *testing.T) { require.Equal(t, "my-app", app["name"]) }) } + +func TestProcessPackageFiles(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "process-values-files-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + t.Run("valuesFiles are processed and merged into Set", func(t *testing.T) { + // Create test values file + valuesFile := filepath.Join(tmpDir, "gitlab-values.yaml") + err = os.WriteFile(valuesFile, []byte(` +database: + endpoint: "pg-cluster.postgres.svc.cluster.local" + username: "gitlab" +redis: + endpoint: "valkey-primary.valkey.svc.cluster.local" +ssh: + enabled: true + port: 2230 +`), 0644) + require.NoError(t, err) + + b := &Bundle{ + cfg: &types.BundleConfig{ + CreateOpts: types.BundleCreateOptions{ + SourceDirectory: tmpDir, + }, + }, + bundle: types.UDSBundle{ + Packages: []types.Package{ + { + Name: "gitlab", + Values: &types.PackageValues{ + Files: []string{"gitlab-values.yaml"}, + }, + }, + }, + }, + } + + err = b.processValuesFiles() + require.NoError(t, err) + + // Files should be cleared after processing + require.Nil(t, b.bundle.Packages[0].Values.Files) + + // Set should contain the values from the file + set := b.bundle.Packages[0].Values.Set + require.NotNil(t, set) + + // Check that top-level keys are in Set with dot prefix + database, ok := set[".database"].(map[string]interface{}) + require.True(t, ok, "expected .database in Set") + require.Equal(t, "pg-cluster.postgres.svc.cluster.local", database["endpoint"]) + require.Equal(t, "gitlab", database["username"]) + + redis, ok := set[".redis"].(map[string]interface{}) + require.True(t, ok, "expected .redis in Set") + require.Equal(t, "valkey-primary.valkey.svc.cluster.local", redis["endpoint"]) + + ssh, ok := set[".ssh"].(map[string]interface{}) + require.True(t, ok, "expected .ssh in Set") + require.Equal(t, true, ssh["enabled"]) + require.Equal(t, float64(2230), ssh["port"]) + }) + + t.Run("existing Set values take precedence over valuesFiles", func(t *testing.T) { + // Create test values file + valuesFile := filepath.Join(tmpDir, "base-values.yaml") + err = os.WriteFile(valuesFile, []byte(` +app: + name: "from-file" + replicas: 3 +`), 0644) + require.NoError(t, err) + + b := &Bundle{ + cfg: &types.BundleConfig{ + CreateOpts: types.BundleCreateOptions{ + SourceDirectory: tmpDir, + }, + }, + bundle: types.UDSBundle{ + Packages: []types.Package{ + { + Name: "test-pkg", + Values: &types.PackageValues{ + Files: []string{"base-values.yaml"}, + Set: map[string]interface{}{ + ".app.replicas": 5, // This should override the file value + }, + }, + }, + }, + }, + } + + err = b.processValuesFiles() + require.NoError(t, err) + + set := b.bundle.Packages[0].Values.Set + + // The inline Set value should take precedence + require.Equal(t, 5, set[".app.replicas"]) + + // The file value should still be present for other keys + app, ok := set[".app"].(map[string]interface{}) + require.True(t, ok, "expected .app in Set") + require.Equal(t, "from-file", app["name"]) + }) + + t.Run("multiple valuesFiles are merged in order", func(t *testing.T) { + // Create two values files + file1 := filepath.Join(tmpDir, "values1.yaml") + err = os.WriteFile(file1, []byte(` +config: + name: "from-file1" + value1: "only-in-file1" +`), 0644) + require.NoError(t, err) + + file2 := filepath.Join(tmpDir, "values2.yaml") + err = os.WriteFile(file2, []byte(` +config: + name: "from-file2" + value2: "only-in-file2" +`), 0644) + require.NoError(t, err) + + b := &Bundle{ + cfg: &types.BundleConfig{ + CreateOpts: types.BundleCreateOptions{ + SourceDirectory: tmpDir, + }, + }, + bundle: types.UDSBundle{ + Packages: []types.Package{ + { + Name: "test-pkg", + Values: &types.PackageValues{ + Files: []string{"values1.yaml", "values2.yaml"}, + }, + }, + }, + }, + } + + err = b.processValuesFiles() + require.NoError(t, err) + + set := b.bundle.Packages[0].Values.Set + config, ok := set[".config"].(map[string]interface{}) + require.True(t, ok, "expected .config in Set") + + // Later file should override earlier file for same key + require.Equal(t, "from-file2", config["name"]) + // Values from both files should be present + require.Equal(t, "only-in-file1", config["value1"]) + require.Equal(t, "only-in-file2", config["value2"]) + }) +} diff --git a/src/types/bundle.go b/src/types/bundle.go index 5dda36308..d8f63fa11 100644 --- a/src/types/bundle.go +++ b/src/types/bundle.go @@ -35,7 +35,7 @@ type Package struct { // PackageValues represents values configuration for Zarf packages using the values feature (alpha) type PackageValues struct { - Files []string `json:"files,omitempty" jsonschema:"description=Paths to values YAML files to pass to the package (relative to bundle root)"` + Files []string `json:"files,omitempty" jsonschema:"description=Paths to values YAML files (relative to bundle directory, processed at create time and embedded in bundle)"` Set map[string]interface{} `json:"set,omitempty" jsonschema:"description=Values to set using dot notation paths (e.g. .app.replicas)"` Variables []BundleValuesVariable `json:"variables,omitempty" jsonschema:"description=Map UDS variables to Zarf values paths"` } From d67cd152e8cd155f2b2ff4d203244b9d35abde52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Szynkiewicz?= Date: Thu, 22 Jan 2026 12:27:55 +0100 Subject: [PATCH 3/3] added printing values when deploying a bundle --- src/pkg/bundle/deploy.go | 15 ++++++++++-- src/pkg/bundle/deploy_test.go | 43 +++++++++++++++++++++++++++++++++++ src/pkg/bundle/overrides.go | 3 +-- src/pkg/bundle/values_test.go | 18 +++++++-------- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/pkg/bundle/deploy.go b/src/pkg/bundle/deploy.go index 118572967..c120b88a5 100644 --- a/src/pkg/bundle/deploy.go +++ b/src/pkg/bundle/deploy.go @@ -111,7 +111,7 @@ func deployPackages(ctx context.Context, packagesToDeploy []types.Package, b *Bu return err } valuesVariables := getValuesVariables(variableData) - zarfValues, err := b.loadPackageValues(ctx, pkg, valuesVariables) + zarfValues, err := b.loadPackageValues(pkg, valuesVariables) if err != nil { return err } @@ -381,6 +381,11 @@ func (b *Bundle) ConfirmBundleDeploy() (confirm bool) { if err := zarfUtils.ColorPrintYAML(pkg.meta, nil, false); err != nil { message.WarnErr(err, "unable to print package metadata yaml") } + if len(pkg.values) > 0 { + if err := zarfUtils.ColorPrintYAML(map[string]interface{}{"values": pkg.values}, nil, false); err != nil { + message.WarnErr(err, "unable to print package values yaml") + } + } if err := zarfUtils.ColorPrintYAML(pkg.overrides, nil, false); err != nil { message.WarnErr(err, "unable to print package overrides yaml") } @@ -405,6 +410,7 @@ func (b *Bundle) ConfirmBundleDeploy() (confirm bool) { type PkgView struct { meta map[string]string + values map[string]interface{} overrides map[string]interface{} } @@ -438,7 +444,12 @@ func formPkgViews(b *Bundle) []PkgView { } variables = addZarfVars(variableData, variables) - pkgViews = append(pkgViews, PkgView{meta: formPkgMeta(pkg), overrides: map[string]interface{}{"overrides": variables}}) + valuesVariables := getValuesVariables(variableData) + zarfValues, err := b.loadPackageValues(pkg, valuesVariables) + if err != nil { + message.Warnf("Failed to load package %s values: %v", pkg.Name, err) + } + pkgViews = append(pkgViews, PkgView{meta: formPkgMeta(pkg), values: zarfValues, overrides: map[string]interface{}{"overrides": variables}}) } return pkgViews } diff --git a/src/pkg/bundle/deploy_test.go b/src/pkg/bundle/deploy_test.go index 721e909c8..3deaba0bc 100644 --- a/src/pkg/bundle/deploy_test.go +++ b/src/pkg/bundle/deploy_test.go @@ -685,6 +685,49 @@ func TestFormPkgViews(t *testing.T) { } } +func TestFormPkgViewsValues(t *testing.T) { + t.Run("values.set appears in view as nested object", func(t *testing.T) { + b := newTestBundle(nil, nil, nil, "", "") + b.bundle = types.UDSBundle{ + Packages: []types.Package{{ + Name: "test-pkg", + Values: &types.PackageValues{ + Set: map[string]interface{}{ + ".app.replicas": 3, + ".database.host": "localhost", + }, + }, + }}, + } + + pkgViews := formPkgViews(&b) + + require.Len(t, pkgViews, 1) + require.NotEmpty(t, pkgViews[0].values) + + app := pkgViews[0].values["app"].(map[string]interface{}) + require.Equal(t, 3, app["replicas"]) + + db := pkgViews[0].values["database"].(map[string]interface{}) + require.Equal(t, "localhost", db["host"]) + }) + + t.Run("no values configured returns empty", func(t *testing.T) { + b := newTestBundle(nil, nil, nil, "", "") + b.bundle = types.UDSBundle{ + Packages: []types.Package{{ + Name: "test-pkg", + // Values: nil + }}, + } + + pkgViews := formPkgViews(&b) + + require.Len(t, pkgViews, 1) + require.Empty(t, pkgViews[0].values) + }) +} + func TestFilterOverrides(t *testing.T) { chartVars := []types.BundleChartVariable{{Name: "over1"}, {Name: "over2"}} pkgVars := map[string]overrideData{"OVER1": {"val", valuesources.Config}, "ZARFVAR": {"val", valuesources.Env}} diff --git a/src/pkg/bundle/overrides.go b/src/pkg/bundle/overrides.go index 7f13f0d1c..788d71783 100644 --- a/src/pkg/bundle/overrides.go +++ b/src/pkg/bundle/overrides.go @@ -5,7 +5,6 @@ package bundle import ( - "context" "encoding/json" "fmt" "os" @@ -228,7 +227,7 @@ func getValuesVariables(overrideData bOverridesData) map[string]interface{} { return result } -func (b *Bundle) loadPackageValues(ctx context.Context, pkg types.Package, variables map[string]interface{}) (ZarfValues, error) { +func (b *Bundle) loadPackageValues(pkg types.Package, variables map[string]interface{}) (ZarfValues, error) { result := make(ZarfValues) // Skip if no values configured diff --git a/src/pkg/bundle/values_test.go b/src/pkg/bundle/values_test.go index 39ccf0cee..b64fcedbb 100644 --- a/src/pkg/bundle/values_test.go +++ b/src/pkg/bundle/values_test.go @@ -284,7 +284,7 @@ app: }, } - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + vals, err := b.loadPackageValues(pkg, map[string]interface{}{}) require.NoError(t, err) app := vals["app"].(map[string]any) @@ -315,13 +315,13 @@ app: } // Without UDS variable set, uses default from variables - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + vals, err := b.loadPackageValues(pkg, map[string]interface{}{}) require.NoError(t, err) app := vals["app"].(map[string]any) require.Equal(t, 10, app["replicas"]) // from variables default, overrides set // With UDS variable set (integer preserved, not stringified) - vals, err = b.loadPackageValues(t.Context(), pkg, map[string]interface{}{"REPLICAS": 20}) + vals, err = b.loadPackageValues(pkg, map[string]interface{}{"REPLICAS": 20}) require.NoError(t, err) app = vals["app"].(map[string]any) require.Equal(t, 20, app["replicas"]) // from UDS variable, type preserved @@ -361,7 +361,7 @@ app: }, } - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + vals, err := b.loadPackageValues(pkg, map[string]interface{}{}) require.NoError(t, err) app := vals["app"].(map[string]any) @@ -402,7 +402,7 @@ app: }, } - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + vals, err := b.loadPackageValues(pkg, map[string]interface{}{}) require.NoError(t, err) app := vals["app"].(map[string]any) @@ -431,7 +431,7 @@ func TestLoadPackageValuesNoConfig(t *testing.T) { Values: nil, // no values configured } - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{}) + vals, err := b.loadPackageValues(pkg, map[string]interface{}{}) require.NoError(t, err) require.Empty(t, vals) }) @@ -468,7 +468,7 @@ func TestLoadPackageValuesComplexObjects(t *testing.T) { }, } - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{ + vals, err := b.loadPackageValues(pkg, map[string]interface{}{ "RESOURCES": complexValue, }) require.NoError(t, err) @@ -506,7 +506,7 @@ func TestLoadPackageValuesComplexObjects(t *testing.T) { arrayValue := []interface{}{"/GitLab Admin", "/UDS Core/Admin"} - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{ + vals, err := b.loadPackageValues(pkg, map[string]interface{}{ "ADMIN_GROUPS": arrayValue, }) require.NoError(t, err) @@ -538,7 +538,7 @@ func TestLoadPackageValuesComplexObjects(t *testing.T) { }, } - vals, err := b.loadPackageValues(t.Context(), pkg, map[string]interface{}{ + vals, err := b.loadPackageValues(pkg, map[string]interface{}{ "APP_NAME": "my-app", }) require.NoError(t, err)