From 804488ac57c3a8952dd6e349dc293939f9dbc430 Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Thu, 4 Jun 2026 02:50:07 +0300 Subject: [PATCH] Fix copy exclude pattern matching Signed-off-by: Stefan Prodan --- api/v1beta1/artifactgenerator_types.go | 5 +- ...tensions.fluxcd.io_artifactgenerators.yaml | 5 +- docs/spec/v1beta1/artifactgenerators.md | 4 + internal/builder/builder.go | 90 ++++-- internal/builder/builder_test.go | 302 ++++++++++++++++++ internal/builder/merge_test.go | 64 ++++ 6 files changed, 450 insertions(+), 20 deletions(-) diff --git a/api/v1beta1/artifactgenerator_types.go b/api/v1beta1/artifactgenerator_types.go index 93a5d77..0a9f65a 100644 --- a/api/v1beta1/artifactgenerator_types.go +++ b/api/v1beta1/artifactgenerator_types.go @@ -159,7 +159,10 @@ type CopyOperation struct { To string `json:"to"` // Exclude specifies a list of glob patterns to exclude - // files and dirs matched by the 'From' field. + // files and dirs matched by the 'From' field. Patterns are matched + // against paths relative to the source alias root or to the non-glob + // prefix of 'From'. Patterns without a separator (e.g. "*.md") match + // the file name at any depth. // +kubebuilder:validation:MaxItems=100 // +optional Exclude []string `json:"exclude,omitempty"` diff --git a/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml b/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml index 54b7f28..726fbe9 100644 --- a/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml +++ b/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml @@ -68,7 +68,10 @@ spec: exclude: description: |- Exclude specifies a list of glob patterns to exclude - files and dirs matched by the 'From' field. + files and dirs matched by the 'From' field. Patterns are matched + against paths relative to the source alias root or to the non-glob + prefix of 'From'. Patterns without a separator (e.g. "*.md") match + the file name at any depth. items: type: string maxItems: 100 diff --git a/docs/spec/v1beta1/artifactgenerators.md b/docs/spec/v1beta1/artifactgenerators.md index 1c83475..3ed1474 100644 --- a/docs/spec/v1beta1/artifactgenerators.md +++ b/docs/spec/v1beta1/artifactgenerators.md @@ -268,6 +268,9 @@ Each copy operation specifies how to copy files from sources into the generated the root of the generated artifact and `path` is the relative path to a file or directory. - `exclude` (optional): A list of glob patterns to filter out from the source selection. Any file matched by `from` that also matches an exclude pattern will be ignored. + Patterns are matched against paths relative to the source alias root or to the + non-glob prefix of `from`. Patterns without a separator (e.g. `*.md`) match the + file name at any depth. - `strategy` (optional): Defines how to handle files during copy operations: `Overwrite` (default), `Merge` (for YAML files), or `Extract` (for tarball archives). @@ -302,6 +305,7 @@ Examples of copy operations: exclude: - "*.md" # Excludes all .md files - "**/testdata/**" # Excludes all files under any testdata/ dir + - "subdir/**" # Excludes configs/subdir/ relative to the from prefix ``` #### Copy Strategies diff --git a/internal/builder/builder.go b/internal/builder/builder.go index e28131e..8e636fe 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -200,6 +200,8 @@ func applyCopyOperation(ctx context.Context, return fmt.Errorf("no files match pattern '%s' in source '%s'", srcPattern, srcAlias) } + excludeBasePath := sourceSelectionRoot(srcPattern) + // Filter out excluded files and special directory entries filteredMatches := make([]string, 0, len(matches)) for _, match := range matches { @@ -209,7 +211,7 @@ func applyCopyOperation(ctx context.Context, if match == "." || match == ".." { continue } - if shouldExclude(match, op.Exclude) { + if shouldExcludePath(match, excludeBasePath, op.Exclude) { continue } filteredMatches = append(filteredMatches, match) @@ -238,7 +240,7 @@ func applyCopyOperation(ctx context.Context, // Calculate destination path based on glob pattern type destFile := calculateGlobDestination(srcPattern, match, destRelPath) - if err := copyFileWithRoots(ctx, op, srcRoot, match, stagingRoot, destFile); err != nil { + if err := copyFileWithRoots(ctx, op, srcRoot, match, stagingRoot, destFile, excludeBasePath); err != nil { return fmt.Errorf("failed to copy file '%s' to '%s': %w", match, destFile, err) } } @@ -292,7 +294,7 @@ func applySingleFileCopy(ctx context.Context, destPath string, destEndsWithSlash bool) error { // Check if the file should be excluded - if shouldExclude(srcPath, op.Exclude) { + if shouldExcludePath(srcPath, ".", op.Exclude) { return nil // Skip excluded file } @@ -321,7 +323,7 @@ func applySingleFileCopy(ctx context.Context, } } - return copyFileWithRoots(ctx, op, srcRoot, srcPath, stagingRoot, finalDestPath) + return copyFileWithRoots(ctx, op, srcRoot, srcPath, stagingRoot, finalDestPath, ".") } // applySingleDirectoryCopy handles copying a single directory using cp-like semantics. @@ -336,7 +338,7 @@ func applySingleDirectoryCopy(ctx context.Context, srcDirName := filepath.Base(srcPath) finalDestPath := filepath.Join(destPath, srcDirName) - return copyFileWithRoots(ctx, op, srcRoot, srcPath, stagingRoot, finalDestPath) + return copyFileWithRoots(ctx, op, srcRoot, srcPath, stagingRoot, finalDestPath, srcPath) } // containsGlobChars returns true if the path contains glob metacharacters @@ -350,15 +352,12 @@ func containsGlobChars(path string) bool { // - other patterns preserve the full match path func calculateGlobDestination(pattern, match, destPath string) string { - // Check if pattern ends with /** (recursive contents pattern) - if strings.HasSuffix(pattern, "/**") { - // Extract the directory prefix from pattern (everything before /**) - dirPrefix := strings.TrimSuffix(pattern, "/**") - + // Check if pattern ends with /** (recursive contents pattern) and extract + // the directory prefix from pattern (everything before /**). + if dirPrefix, ok := strings.CutSuffix(pattern, "/**"); ok { // If match starts with this prefix, strip it (cp-like behavior) - if strings.HasPrefix(match, dirPrefix+"/") { - // Strip the directory prefix but keep the rest of the path - relativeMatch := strings.TrimPrefix(match, dirPrefix+"/") + // but keep the rest of the path. + if relativeMatch, ok := strings.CutPrefix(match, dirPrefix+"/"); ok { return filepath.Join(destPath, relativeMatch) } } @@ -367,6 +366,26 @@ func calculateGlobDestination(pattern, match, destPath string) string { return filepath.Join(destPath, match) } +// sourceSelectionRoot returns the literal source prefix before the first glob segment. +func sourceSelectionRoot(pattern string) string { + pattern = filepath.Clean(pattern) + + parts := strings.Split(pattern, string(filepath.Separator)) + rootParts := make([]string, 0, len(parts)) + for _, part := range parts { + if containsGlobChars(part) { + break + } + rootParts = append(rootParts, part) + } + + if len(rootParts) == 0 { + return "." + } + + return filepath.Join(rootParts...) +} + // parseCopySource parses the source string and returns the alias and pattern. func parseCopySource(from string) (alias, pattern string, err error) { if !strings.HasPrefix(from, "@") { @@ -398,14 +417,15 @@ func copyFileWithRoots(ctx context.Context, srcRoot *os.Root, srcPath string, stagingRoot *os.Root, - destPath string) error { + destPath string, + excludeBasePath string) error { srcInfo, err := srcRoot.Stat(srcPath) if err != nil { return err } if srcInfo.IsDir() { - return copyDirWithRoots(ctx, srcRoot, srcPath, stagingRoot, destPath, op.Exclude) + return copyDirWithRoots(ctx, srcRoot, srcPath, stagingRoot, destPath, op.Exclude, excludeBasePath) } if shouldMergeFile(op, stagingRoot, destPath) { @@ -514,7 +534,8 @@ func copyDirWithRoots(ctx context.Context, srcPath string, stagingRoot *os.Root, destPath string, - excludePatterns []string) error { + excludePatterns []string, + excludeBasePath string) error { return fs.WalkDir(srcRoot.FS(), srcPath, func(path string, d fs.DirEntry, err error) error { if err := ctx.Err(); err != nil { return err @@ -536,8 +557,7 @@ func copyDirWithRoots(ctx context.Context, return createDirRecursive(stagingRoot, destPath) } - // Check if this path should be excluded - if shouldExclude(relPath, excludePatterns) { + if shouldExcludePath(path, excludeBasePath, excludePatterns) { if d.IsDir() { // Skip entire directory return fs.SkipDir @@ -586,6 +606,40 @@ func createDirRecursive(root *os.Root, path string) error { return err } +// shouldExcludePath matches excludes against both the source-root-relative path +// and the path relative to the operation's selected source root. +func shouldExcludePath(filePath, basePath string, excludePatterns []string) bool { + if shouldExclude(filePath, excludePatterns) { + return true + } + + relPath, ok := relativeToBase(basePath, filePath) + if !ok || relPath == "." || relPath == filePath { + return false + } + + return shouldExclude(relPath, excludePatterns) +} + +func relativeToBase(basePath, filePath string) (string, bool) { + basePath = filepath.Clean(basePath) + filePath = filepath.Clean(filePath) + + if basePath == "." || basePath == "" { + return filePath, true + } + + relPath, err := filepath.Rel(basePath, filePath) + if err != nil { + return "", false + } + if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return "", false + } + + return relPath, true +} + // shouldExclude checks if a path matches any of the exclude patterns. func shouldExclude(filePath string, excludePatterns []string) bool { if len(excludePatterns) == 0 { diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index bb0d2a0..5caf5ea 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -946,6 +946,308 @@ func TestBuildWithExcludes(t *testing.T) { g.Expect(entries).To(HaveLen(0)) }, }, + { + // Reproduces https://github.com/fluxcd/source-watcher/issues/306 + // Excluding a nested directory using a full path relative to the + // source root (e.g. "infrastructure/controllers/descheduler/**") + // must filter out the matching files, just like "**/dir/**" does. + name: "exclude nested directory by full path from source root", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + // Mirror a monorepo layout like flux2-kustomize-helm-example. + createDir(t, srcDir, "infrastructure/controllers/descheduler") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/descheduler"), "release.yaml", "descheduler release") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/descheduler"), "kustomization.yaml", "descheduler kustomization") + + createDir(t, srcDir, "infrastructure/controllers/sealed-secrets") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/sealed-secrets"), "release.yaml", "sealed-secrets release") + + createDir(t, srcDir, "infrastructure/configs") + createFile(t, filepath.Join(srcDir, "infrastructure/configs"), "cluster-issuer.yaml", "cluster issuer") + + spec := &swapi.OutputArtifact{ + Name: "test-artifact", + Revision: "v1.0.0", + Copy: []swapi.CopyOperation{ + { + From: "@source/infrastructure/**", + To: "@artifact/", + Exclude: []string{"infrastructure/controllers/descheduler/**"}, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, stagingDir string) { + g := NewWithT(t) + artifactDir := filepath.Join(stagingDir, "test-artifact") + + // Should keep everything outside the excluded directory. + g.Expect(filepath.Join(artifactDir, "controllers", "sealed-secrets", "release.yaml")).To(BeAnExistingFile()) + g.Expect(filepath.Join(artifactDir, "configs", "cluster-issuer.yaml")).To(BeAnExistingFile()) + + // Should NOT have the excluded descheduler directory or its contents. + g.Expect(filepath.Join(artifactDir, "controllers", "descheduler")).ToNot(BeADirectory()) + g.Expect(filepath.Join(artifactDir, "controllers", "descheduler", "release.yaml")).ToNot(BeAnExistingFile()) + g.Expect(filepath.Join(artifactDir, "controllers", "descheduler", "kustomization.yaml")).ToNot(BeAnExistingFile()) + }, + }, + { + // Exclude patterns must be anchored at the source-alias root even + // when the copy source is a plain directory reference (non-glob), + // so the same pattern works regardless of whether "from" is a glob. + name: "exclude nested directory from non-glob directory copy by source root path", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + createDir(t, srcDir, "infrastructure/controllers/descheduler") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/descheduler"), "release.yaml", "descheduler release") + + createDir(t, srcDir, "infrastructure/controllers/sealed-secrets") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/sealed-secrets"), "release.yaml", "sealed-secrets release") + + createDir(t, srcDir, "infrastructure/configs") + createFile(t, filepath.Join(srcDir, "infrastructure/configs"), "cluster-issuer.yaml", "cluster issuer") + + spec := &swapi.OutputArtifact{ + Name: "test-artifact", + Revision: "v1.0.0", + Copy: []swapi.CopyOperation{ + { + From: "@source/infrastructure", + To: "@artifact/", + Exclude: []string{"infrastructure/controllers/descheduler/**"}, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, stagingDir string) { + g := NewWithT(t) + artifactDir := filepath.Join(stagingDir, "test-artifact") + + // A non-glob directory copy nests under the source directory name. + g.Expect(filepath.Join(artifactDir, "infrastructure", "controllers", "sealed-secrets", "release.yaml")).To(BeAnExistingFile()) + g.Expect(filepath.Join(artifactDir, "infrastructure", "configs", "cluster-issuer.yaml")).To(BeAnExistingFile()) + + // Should NOT have the excluded descheduler directory or its contents. + g.Expect(filepath.Join(artifactDir, "infrastructure", "controllers", "descheduler")).ToNot(BeADirectory()) + g.Expect(filepath.Join(artifactDir, "infrastructure", "controllers", "descheduler", "release.yaml")).ToNot(BeAnExistingFile()) + }, + }, + { + // Exclude patterns can also be anchored at the selected source root, + // so the same subtree pattern works for glob and non-glob directory copies. + name: "exclude nested directory from glob directory copy by selected root path", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + createDir(t, srcDir, "infrastructure/controllers/descheduler") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/descheduler"), "release.yaml", "descheduler release") + + createDir(t, srcDir, "infrastructure/controllers/sealed-secrets") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/sealed-secrets"), "release.yaml", "sealed-secrets release") + + createDir(t, srcDir, "infrastructure/configs") + createFile(t, filepath.Join(srcDir, "infrastructure/configs"), "cluster-issuer.yaml", "cluster issuer") + + spec := &swapi.OutputArtifact{ + Name: "test-artifact", + Revision: "v1.0.0", + Copy: []swapi.CopyOperation{ + { + From: "@source/infrastructure/**", + To: "@artifact/", + Exclude: []string{"controllers/descheduler/**"}, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, stagingDir string) { + g := NewWithT(t) + artifactDir := filepath.Join(stagingDir, "test-artifact") + + g.Expect(filepath.Join(artifactDir, "controllers", "sealed-secrets", "release.yaml")).To(BeAnExistingFile()) + g.Expect(filepath.Join(artifactDir, "configs", "cluster-issuer.yaml")).To(BeAnExistingFile()) + + g.Expect(filepath.Join(artifactDir, "controllers", "descheduler")).ToNot(BeADirectory()) + g.Expect(filepath.Join(artifactDir, "controllers", "descheduler", "release.yaml")).ToNot(BeAnExistingFile()) + }, + }, + { + // Selected-root matching should not use the directory currently being + // walked as the anchor, otherwise a basename pattern can over-exclude + // nested directories with the same name. + name: "exclude does not use recursive walk root as anchor", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + createDir(t, srcDir, "infrastructure/descheduler") + createFile(t, filepath.Join(srcDir, "infrastructure/descheduler"), "release.yaml", "top-level descheduler") + + createDir(t, srcDir, "infrastructure/controllers/descheduler") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/descheduler"), "release.yaml", "nested descheduler") + + createDir(t, srcDir, "infrastructure/controllers/sealed-secrets") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/sealed-secrets"), "release.yaml", "sealed-secrets release") + + spec := &swapi.OutputArtifact{ + Name: "test-artifact", + Revision: "v1.0.0", + Copy: []swapi.CopyOperation{ + { + From: "@source/infrastructure/**", + To: "@artifact/", + Exclude: []string{"descheduler/**"}, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, stagingDir string) { + g := NewWithT(t) + artifactDir := filepath.Join(stagingDir, "test-artifact") + + g.Expect(filepath.Join(artifactDir, "descheduler")).ToNot(BeADirectory()) + g.Expect(filepath.Join(artifactDir, "descheduler", "release.yaml")).ToNot(BeAnExistingFile()) + + g.Expect(filepath.Join(artifactDir, "controllers", "descheduler", "release.yaml")).To(BeAnExistingFile()) + g.Expect(filepath.Join(artifactDir, "controllers", "sealed-secrets", "release.yaml")).To(BeAnExistingFile()) + }, + }, + { + // Existing non-glob directory copy behavior matched exclude patterns + // against paths relative to the selected source root. + name: "exclude nested directory from non-glob directory copy by subtree path", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + createDir(t, srcDir, "infrastructure/controllers/descheduler") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/descheduler"), "release.yaml", "descheduler release") + + createDir(t, srcDir, "infrastructure/controllers/sealed-secrets") + createFile(t, filepath.Join(srcDir, "infrastructure/controllers/sealed-secrets"), "release.yaml", "sealed-secrets release") + + createDir(t, srcDir, "infrastructure/configs") + createFile(t, filepath.Join(srcDir, "infrastructure/configs"), "cluster-issuer.yaml", "cluster issuer") + + spec := &swapi.OutputArtifact{ + Name: "test-artifact", + Revision: "v1.0.0", + Copy: []swapi.CopyOperation{ + { + From: "@source/infrastructure", + To: "@artifact/", + Exclude: []string{"controllers/descheduler/**"}, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, stagingDir string) { + g := NewWithT(t) + artifactDir := filepath.Join(stagingDir, "test-artifact") + + // A non-glob directory copy nests under the source directory name. + g.Expect(filepath.Join(artifactDir, "infrastructure", "controllers", "sealed-secrets", "release.yaml")).To(BeAnExistingFile()) + g.Expect(filepath.Join(artifactDir, "infrastructure", "configs", "cluster-issuer.yaml")).To(BeAnExistingFile()) + + // Should NOT have the excluded descheduler directory or its contents. + g.Expect(filepath.Join(artifactDir, "infrastructure", "controllers", "descheduler")).ToNot(BeADirectory()) + g.Expect(filepath.Join(artifactDir, "infrastructure", "controllers", "descheduler", "release.yaml")).ToNot(BeAnExistingFile()) + }, + }, + { + // Excluding a single nested file by its full path from the source + // root must filter out just that file, leaving its siblings intact. + name: "exclude single nested file by full path from source root", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + createDir(t, srcDir, "infrastructure/configs") + createFile(t, filepath.Join(srcDir, "infrastructure/configs"), "app.yaml", "app config") + createFile(t, filepath.Join(srcDir, "infrastructure/configs"), "secret.yaml", "secret config") + + spec := &swapi.OutputArtifact{ + Name: "test-artifact", + Revision: "v1.0.0", + Copy: []swapi.CopyOperation{ + { + From: "@source/infrastructure/**", + To: "@artifact/", + Exclude: []string{"infrastructure/configs/secret.yaml"}, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, stagingDir string) { + g := NewWithT(t) + artifactDir := filepath.Join(stagingDir, "test-artifact") + + // Should keep the sibling file. + g.Expect(filepath.Join(artifactDir, "configs", "app.yaml")).To(BeAnExistingFile()) + + // Should NOT have the single excluded file. + g.Expect(filepath.Join(artifactDir, "configs", "secret.yaml")).ToNot(BeAnExistingFile()) + }, + }, { name: "all files excluded - error", setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { diff --git a/internal/builder/merge_test.go b/internal/builder/merge_test.go index f6f1115..8b94e7b 100644 --- a/internal/builder/merge_test.go +++ b/internal/builder/merge_test.go @@ -243,6 +243,70 @@ version: 1.0.0 `)) }, }, + { + name: "merge YAML in dirs with exclude", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + source1Dir := filepath.Join(tmpDir, "source1") + source2Dir := filepath.Join(tmpDir, "source2") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, source1Dir, source2Dir, workspaceDir) + + // Base config and a secret to be protected from the overlay. + createFile(t, source1Dir, "config.yaml", ` +env: dev +region: us-west-1 +`) + createFile(t, source1Dir, "secret.yaml", "token: base") + + // Overlay merges config but tries to override the secret. + createFile(t, source2Dir, "config.yaml", "env: prod") + createFile(t, source2Dir, "secret.yaml", "token: leak") + + spec := &swapi.OutputArtifact{ + Name: "yaml-merge-exclude", + Copy: []swapi.CopyOperation{ + { + From: "@source1/**", + To: "@artifact/", + Strategy: swapi.OverwriteStrategy, + }, + { + From: "@source2/**", + To: "@artifact/", + Strategy: swapi.MergeStrategy, + Exclude: []string{"secret.yaml"}, + }, + }, + } + + sources := map[string]string{ + "source1": source1Dir, + "source2": source2Dir, + } + return spec, sources, workspaceDir + }, + validateFunc: func(t *testing.T, artifact *gotkmeta.Artifact, workspaceDir string) { + g := NewWithT(t) + g.Expect(artifact).ToNot(BeNil()) + + stagingDir := filepath.Join(workspaceDir, "yaml-merge-exclude") + + // config.yaml is merged from both sources. + configContent, err := os.ReadFile(filepath.Join(stagingDir, "config.yaml")) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(configContent).To(MatchYAML(` +env: prod +region: us-west-1 +`)) + + // secret.yaml keeps the base content as the overlay was excluded. + secretContent, err := os.ReadFile(filepath.Join(stagingDir, "secret.yaml")) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secretContent).To(MatchYAML("token: base")) + }, + }, { name: "merge with non existing destination file", setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) {