diff --git a/kustomize/filesys/fs_memory.go b/kustomize/filesys/fs_memory.go new file mode 100644 index 000000000..5558bd56c --- /dev/null +++ b/kustomize/filesys/fs_memory.go @@ -0,0 +1,123 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package filesys + +import ( + "os" + "path/filepath" + + "sigs.k8s.io/kustomize/kyaml/filesys" +) + +// MakeFsInMemory returns a filesystem that reads from disk and writes to +// memory. Read operations check the memory layer first, then fall back to the +// disk layer. Write operations only go to the memory layer, so the on-disk +// files are never modified. +// +// The disk layer can be any filesys.FileSystem (e.g., an fsSecure instance to +// constrain reads to a root directory). +func MakeFsInMemory(disk filesys.FileSystem) filesys.FileSystem { + return fsMemory{disk: disk, memory: filesys.MakeFsInMemory()} +} + +// fsMemory layers an in-memory filesystem on top of a read-only disk +// filesystem. Writes go to memory; reads check memory first, then disk. +type fsMemory struct { + disk filesys.FileSystem + memory filesys.FileSystem +} + +// Write operations: memory only. + +func (fs fsMemory) Create(path string) (filesys.File, error) { + return fs.memory.Create(path) +} +func (fs fsMemory) Mkdir(path string) error { return fs.memory.Mkdir(path) } +func (fs fsMemory) MkdirAll(path string) error { return fs.memory.MkdirAll(path) } +func (fs fsMemory) RemoveAll(path string) error { return fs.memory.RemoveAll(path) } +func (fs fsMemory) WriteFile(path string, data []byte) error { return fs.memory.WriteFile(path, data) } + +// Read operations: memory first, then disk. + +func (fs fsMemory) Exists(path string) bool { return fs.memory.Exists(path) || fs.disk.Exists(path) } +func (fs fsMemory) IsDir(path string) bool { return fs.memory.IsDir(path) || fs.disk.IsDir(path) } + +func (fs fsMemory) Open(path string) (filesys.File, error) { + if fs.memory.Exists(path) { + return fs.memory.Open(path) + } + return fs.disk.Open(path) +} + +func (fs fsMemory) ReadFile(path string) ([]byte, error) { + if fs.memory.Exists(path) { + return fs.memory.ReadFile(path) + } + return fs.disk.ReadFile(path) +} + +func (fs fsMemory) CleanedAbs(path string) (filesys.ConfirmedDir, string, error) { + return fs.disk.CleanedAbs(path) +} + +func (fs fsMemory) ReadDir(path string) ([]string, error) { + return mergeResults(fs.memory.ReadDir(path))(fs.disk.ReadDir(path)) +} + +func (fs fsMemory) Glob(pattern string) ([]string, error) { + return mergeResults(fs.memory.Glob(pattern))(fs.disk.Glob(pattern)) +} + +func (fs fsMemory) Walk(path string, walkFn filepath.WalkFunc) error { + visited := make(map[string]struct{}) + if fs.memory.Exists(path) { + if err := fs.memory.Walk(path, func(p string, info os.FileInfo, err error) error { + visited[p] = struct{}{} + return walkFn(p, info, err) + }); err != nil { + return err + } + } + return fs.disk.Walk(path, func(p string, info os.FileInfo, err error) error { + if _, ok := visited[p]; ok { + return nil + } + return walkFn(p, info, err) + }) +} + +// mergeResults deduplicates two ([]string, error) results, preferring the +// first set. Returns a closure so both calls can be inlined at the call site. +func mergeResults(primary []string, pErr error) func([]string, error) ([]string, error) { + return func(secondary []string, sErr error) ([]string, error) { + if pErr != nil && sErr != nil { + return nil, sErr + } + seen := make(map[string]struct{}, len(primary)) + merged := make([]string, 0, len(primary)+len(secondary)) + for _, e := range primary { + seen[e] = struct{}{} + merged = append(merged, e) + } + for _, e := range secondary { + if _, ok := seen[e]; !ok { + merged = append(merged, e) + } + } + return merged, nil + } +} diff --git a/kustomize/filesys/fs_memory_test.go b/kustomize/filesys/fs_memory_test.go new file mode 100644 index 000000000..a890e290b --- /dev/null +++ b/kustomize/filesys/fs_memory_test.go @@ -0,0 +1,245 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package filesys + +import ( + "os" + "path/filepath" + "testing" + + "sigs.k8s.io/kustomize/kyaml/filesys" +) + +func testTemp(t *testing.T) string { + t.Helper() + tmp, err := testTempDir(t) + if err != nil { + t.Fatal(err) + } + return tmp +} + +func Test_fsMemory_ReadFile_fromDisk(t *testing.T) { + tmp := testTemp(t) + if err := os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("disk"), 0o644); err != nil { + t.Fatal(err) + } + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + data, err := fs.ReadFile(filepath.Join(tmp, "a.txt")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "disk" { + t.Errorf("got %q, want %q", data, "disk") + } +} + +func Test_fsMemory_WriteFile_toMemory(t *testing.T) { + tmp := testTemp(t) + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + path := filepath.Join(tmp, "new.txt") + if err := fs.WriteFile(path, []byte("memory")); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Readable from memory fs. + data, err := fs.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "memory" { + t.Errorf("got %q, want %q", data, "memory") + } + + // Not written to disk. + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file should not exist on disk, got err: %v", err) + } +} + +func Test_fsMemory_memoryOverridesDisk(t *testing.T) { + tmp := testTemp(t) + path := filepath.Join(tmp, "f.txt") + if err := os.WriteFile(path, []byte("disk"), 0o644); err != nil { + t.Fatal(err) + } + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + if err := fs.WriteFile(path, []byte("memory")); err != nil { + t.Fatal(err) + } + + data, err := fs.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "memory" { + t.Errorf("got %q, want %q", data, "memory") + } +} + +func Test_fsMemory_Exists(t *testing.T) { + tmp := testTemp(t) + if err := os.WriteFile(filepath.Join(tmp, "disk.txt"), []byte("d"), 0o644); err != nil { + t.Fatal(err) + } + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + if err := fs.WriteFile(filepath.Join(tmp, "mem.txt"), []byte("m")); err != nil { + t.Fatal(err) + } + + if !fs.Exists(filepath.Join(tmp, "disk.txt")) { + t.Error("disk file should exist") + } + if !fs.Exists(filepath.Join(tmp, "mem.txt")) { + t.Error("memory file should exist") + } + if fs.Exists(filepath.Join(tmp, "nope.txt")) { + t.Error("non-existent file should not exist") + } +} + +func Test_fsMemory_IsDir(t *testing.T) { + tmp := testTemp(t) + if err := os.MkdirAll(filepath.Join(tmp, "diskdir"), 0o755); err != nil { + t.Fatal(err) + } + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + if err := fs.MkdirAll(filepath.Join(tmp, "memdir")); err != nil { + t.Fatal(err) + } + + if !fs.IsDir(filepath.Join(tmp, "diskdir")) { + t.Error("disk dir should be a dir") + } + if !fs.IsDir(filepath.Join(tmp, "memdir")) { + t.Error("memory dir should be a dir") + } +} + +func Test_fsMemory_ReadDir_merged(t *testing.T) { + tmp := testTemp(t) + if err := os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + if err := fs.WriteFile(filepath.Join(tmp, "b.txt"), []byte("b")); err != nil { + t.Fatal(err) + } + + entries, err := fs.ReadDir(tmp) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + has := func(name string) bool { + for _, e := range entries { + if e == name { + return true + } + } + return false + } + if !has("a.txt") { + t.Error("should contain disk file a.txt") + } + if !has("b.txt") { + t.Error("should contain memory file b.txt") + } +} + +func Test_fsMemory_diskSecurityConstraint(t *testing.T) { + tmp := testTemp(t) + + diskFS, err := MakeFsOnDiskSecure(tmp) + if err != nil { + t.Fatal(err) + } + fs := MakeFsInMemory(diskFS) + + // Reading outside the secure root should fail. + _, err = fs.ReadFile("/etc/passwd") + if err == nil { + t.Error("expected error reading outside secure root") + } +} + +func Test_fsMemory_Walk(t *testing.T) { + tmp := testTemp(t) + if err := os.WriteFile(filepath.Join(tmp, "disk.txt"), []byte("d"), 0o644); err != nil { + t.Fatal(err) + } + + fs := MakeFsInMemory(filesys.MakeFsOnDisk()) + if err := fs.WriteFile(filepath.Join(tmp, "mem.txt"), []byte("m")); err != nil { + t.Fatal(err) + } + + visited := map[string]bool{} + err := fs.Walk(tmp, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(tmp, p) + visited[rel] = true + return nil + }) + if err != nil { + t.Fatalf("Walk: %v", err) + } + if !visited["disk.txt"] { + t.Error("should visit disk.txt") + } + if !visited["mem.txt"] { + t.Error("should visit mem.txt") + } +} diff --git a/kustomize/kustomize_generator.go b/kustomize/kustomize_generator.go index f6215ab41..0867e49d7 100644 --- a/kustomize/kustomize_generator.go +++ b/kustomize/kustomize_generator.go @@ -76,6 +76,7 @@ type Generator struct { root string ignore string filter bool + fs filesys.FileSystem kustomization unstructured.Unstructured } @@ -94,7 +95,7 @@ func NewGenerator(root string, kustomization unstructured.Unstructured) *Generat // NewGeneratorWithIgnore creates a new kustomize generator // It takes a root directory, a kustomization object and a string of files to ignore -// The generator will combine the ignore files with the default ignore files i.e. .sourceignoreƒ +// The generator will combine the ignore files with the default ignore files i.e. .sourceignore func NewGeneratorWithIgnore(root, ignore string, kustomization unstructured.Unstructured) *Generator { return &Generator{ root: root, @@ -130,19 +131,52 @@ func WithSaveOriginalKustomization() SavingOptions { // log.Fatal(err) // } func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, error) { + fs, err := g.getFS() + if err != nil { + return UnchangedAction, err + } + + manifest, kfile, action, err := g.GenerateManifest(dirPath) + if err != nil { + return action, err + } + + for _, opt := range opts { + if err := opt(dirPath, kfile, action); err != nil { + return action, fmt.Errorf("failed to save original kustomization.yaml: %w", err) + } + } + + err = fs.WriteFile(kfile, manifest) + if err != nil { + errf := CleanDirectory(dirPath, action) + return action, fmt.Errorf("%v %v", err, errf) + } + + return action, nil +} + +// GenerateManifest returns the kustomization.yaml content, the full path to the +// kustomization file, and the action taken, without writing to disk. +func (g *Generator) GenerateManifest(dirPath string) ([]byte, string, Action, error) { + fs, err := g.getFS() + if err != nil { + return nil, "", UnchangedAction, err + } + var ignorePatterns []gitignore.Pattern var ignoreDomain []string if g.filter || g.ignore != "" { absPath, err := filepath.Abs(dirPath) if err != nil { - return UnchangedAction, fmt.Errorf("failed to get absolute path: %w", err) + return nil, "", UnchangedAction, fmt.Errorf("failed to get absolute path: %w", err) } ignoreDomain = strings.Split(absPath, string(filepath.Separator)) ignorePatterns, err = sourceignore.LoadIgnorePatterns(absPath, ignoreDomain) if err != nil { - return UnchangedAction, fmt.Errorf("failed to load ignore patterns: %w", err) + return nil, "", UnchangedAction, fmt.Errorf("failed to load ignore patterns: %w", err) } // Add additional patterns from command line @@ -152,17 +186,9 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er } } - action, kfile, err := g.generateKustomization(dirPath, ignorePatterns, ignoreDomain) - - if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) - } - - data, err := os.ReadFile(kfile) + data, kfile, action, err := g.findOrGenerateKustomization(fs, dirPath, ignorePatterns, ignoreDomain) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%w %s", err, errf) + return nil, "", action, err } kus := kustypes.Kustomization{ @@ -173,8 +199,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er } if err := yaml.Unmarshal(data, &kus); err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + return nil, "", action, err } if action == UnchangedAction && len(kus.Resources) == 0 { @@ -185,17 +210,14 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er // apply filters if any if g.filter { - err = filterKsWithIgnoreFiles(&kus, dirPath, ignorePatterns, ignoreDomain) - if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + if err := filterKsWithIgnoreFiles(&kus, dirPath, ignorePatterns, ignoreDomain); err != nil { + return nil, "", action, err } } tg, ok, err := g.getNestedString(specField, targetNSField) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + return nil, "", action, err } if ok { kus.Namespace = tg @@ -203,8 +225,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er nprefix, ok, err := g.getNestedString(specField, namePrefixField) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + return nil, "", action, err } if ok { kus.NamePrefix = nprefix @@ -212,8 +233,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er nsuffix, ok, err := g.getNestedString(specField, nameSuffixField) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + return nil, "", action, err } if ok { kus.NameSuffix = nsuffix @@ -221,8 +241,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er patches, err := g.getPatches() if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("unable to get patches: %w", fmt.Errorf("%v %v", err, errf)) + return nil, "", action, fmt.Errorf("unable to get patches: %w", err) } for _, p := range patches { @@ -234,16 +253,15 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er components, _, err := g.getNestedStringSlice(specField, componentsField) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("unable to get components: %w", fmt.Errorf("%v %v", err, errf)) + return nil, "", action, fmt.Errorf("unable to get components: %w", err) } ignoreMissing := g.getNestedBool(specField, ignoreComponentsField) for _, component := range components { if !IsLocalRelativePath(component) { - return "", fmt.Errorf("component path '%s' must be local and relative", component) + return nil, "", action, fmt.Errorf("component path '%s' must be local and relative", component) } - if _, err := os.Stat(filepath.Join(dirPath, component)); errors.Is(err, os.ErrNotExist) && ignoreMissing { + if !fs.Exists(filepath.Join(dirPath, component)) && ignoreMissing { continue } kus.Components = append(kus.Components, component) @@ -251,8 +269,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er patchesSM, err := g.getPatchesStrategicMerge() if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("unable to get patchesStrategicMerge: %w", fmt.Errorf("%v %v", err, errf)) + return nil, "", action, fmt.Errorf("unable to get patchesStrategicMerge: %w", err) } for _, p := range patchesSM { @@ -261,15 +278,13 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er patchesJSON, err := g.getPatchesJson6902() if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("unable to get patchesJson6902: %w", fmt.Errorf("%v %v", err, errf)) + return nil, "", action, fmt.Errorf("unable to get patchesJson6902: %w", err) } for _, p := range patchesJSON { patch, err := json.Marshal(p.Patch) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + return nil, "", action, err } kus.PatchesJson6902 = append(kus.PatchesJson6902, kustypes.Patch{ Patch: string(patch), @@ -279,8 +294,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er images, err := g.getImages() if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("unable to get images: %w", fmt.Errorf("%v %v", err, errf)) + return nil, "", action, fmt.Errorf("unable to get images: %w", err) } for _, image := range images { @@ -299,8 +313,7 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er buildMetadata, _, err := g.getNestedStringSlice(specField, buildMetadataField) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("unable to get buildMetadata: %w", fmt.Errorf("%v %v", err, errf)) + return nil, "", action, fmt.Errorf("unable to get buildMetadata: %w", err) } if len(buildMetadata) > 0 { kus.BuildMetadata = buildMetadata @@ -308,24 +321,10 @@ func (g *Generator) WriteFile(dirPath string, opts ...SavingOptions) (Action, er manifest, err := yaml.Marshal(kus) if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) - } - - // copy the original kustomization.yaml to the directory if we did not create it - for _, opt := range opts { - if err := opt(dirPath, kfile, action); err != nil { - return action, fmt.Errorf("failed to save original kustomization.yaml: %w", err) - } - } - - err = os.WriteFile(kfile, manifest, os.ModePerm) - if err != nil { - errf := CleanDirectory(dirPath, action) - return action, fmt.Errorf("%v %v", err, errf) + return nil, "", action, err } - return action, nil + return manifest, kfile, action, nil } func (g *Generator) getPatches() ([]kustomize.Patch, error) { @@ -486,46 +485,45 @@ func (g *Generator) getNestedSlice(fields ...string) ([]interface{}, bool, error return val, ok, nil } -func (g *Generator) generateKustomization(dirPath string, ignorePatterns []gitignore.Pattern, ignoreDomain []string) (Action, string, error) { - var ( - err error - fs filesys.FileSystem - ) - // use securefs only if the path is specified - // otherwise, use the default filesystem. +// getFS returns the generator's filesystem. If a custom FS was provided via +// WithFS, it is returned directly. Otherwise a secure filesystem rooted at +// g.root is created (or a plain on-disk filesystem when no root is +// configured) and cached for subsequent calls. +func (g *Generator) getFS() (filesys.FileSystem, error) { + if g.fs != nil { + return g.fs, nil + } + var err error if g.root != "" { - fs, err = securefs.MakeFsOnDiskSecure(g.root) + g.fs, err = securefs.MakeFsOnDiskSecure(g.root) } else { - fs = filesys.MakeFsOnDisk() - } - if err != nil { - return UnchangedAction, "", err + g.fs = filesys.MakeFsOnDisk() } + return g.fs, err +} + +// findOrGenerateKustomization returns existing kustomization content or generates new content. +func (g *Generator) findOrGenerateKustomization(fs filesys.FileSystem, dirPath string, ignorePatterns []gitignore.Pattern, ignoreDomain []string) ([]byte, string, Action, error) { // Determine if there already is a Kustomization file at the root, // as this means we do not have to generate one. for _, kfilename := range konfig.RecognizedKustomizationFileNames() { - if kpath := filepath.Join(dirPath, kfilename); fs.Exists(kpath) && !fs.IsDir(kpath) { - return UnchangedAction, kpath, nil + kpath := filepath.Join(dirPath, kfilename) + if fs.Exists(kpath) && !fs.IsDir(kpath) { + data, err := fs.ReadFile(kpath) + return data, kpath, UnchangedAction, err } } abs, err := filepath.Abs(dirPath) if err != nil { - return UnchangedAction, "", err + return nil, "", UnchangedAction, err } files, err := scanManifests(fs, abs, ignorePatterns, ignoreDomain) if err != nil { - return UnchangedAction, "", err - } - - kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName()) - f, err := fs.Create(kfile) - if err != nil { - return UnchangedAction, "", err + return nil, "", UnchangedAction, err } - f.Close() kus := kustypes.Kustomization{ TypeMeta: kustypes.TypeMeta{ @@ -547,14 +545,9 @@ func (g *Generator) generateKustomization(dirPath string, ignorePatterns []gitig kus.Resources = resources } - kd, err := yaml.Marshal(kus) - if err != nil { - // delete the kustomization file - errf := CleanDirectory(dirPath, CreatedAction) - return UnchangedAction, "", fmt.Errorf("%v %v", err, errf) - } - - return CreatedAction, kfile, os.WriteFile(kfile, kd, os.ModePerm) + kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName()) + data, err := yaml.Marshal(kus) + return data, kfile, CreatedAction, err } // scanManifests walks through the given base path parsing all the files and diff --git a/kustomize/kustomize_generator_test.go b/kustomize/kustomize_generator_test.go index 179788860..a84ab5667 100644 --- a/kustomize/kustomize_generator_test.go +++ b/kustomize/kustomize_generator_test.go @@ -448,7 +448,7 @@ resources: [] g.Expect(err).ToNot(HaveOccurred()) // Generate kustomization in the overlay directory - _, err = kustomize.NewGenerator(overlayDir, ks).WriteFile(overlayDir) + _, err = kustomize.NewGenerator(baseDir, ks).WriteFile(overlayDir) g.Expect(err).ToNot(HaveOccurred()) // Read generated kustomization.yaml @@ -477,6 +477,104 @@ resources: [] } } +func TestGenerateManifest(t *testing.T) { + tests := []struct { + name string + sourceDir string + ksFile string + expectedAction kustomize.Action + checkManifest func(g Gomega, manifest []byte) + }{ + { + name: "existing kustomization returns unchanged action and file content", + sourceDir: "./testdata/resources", + ksFile: "./testdata/kustomization.yaml", + expectedAction: kustomize.UnchangedAction, + checkManifest: func(g Gomega, manifest []byte) { + var kus kustypes.Kustomization + g.Expect(yaml.Unmarshal(manifest, &kus)).To(Succeed()) + g.Expect(kus.Resources).To(ContainElement("./deployment.yaml")) + g.Expect(kus.Resources).To(ContainElement("./config.yaml")) + g.Expect(kus.Namespace).To(Equal("apps")) + }, + }, + { + name: "empty dir returns created action with placeholder", + sourceDir: "", + ksFile: "./testdata/empty/ks.yaml", + expectedAction: kustomize.CreatedAction, + checkManifest: func(g Gomega, manifest []byte) { + g.Expect(string(manifest)).To(ContainSubstring("_placeholder")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + dataKS, err := os.ReadFile(tt.ksFile) + g.Expect(err).NotTo(HaveOccurred()) + ks, err := readYamlObjects(strings.NewReader(string(dataKS))) + g.Expect(err).NotTo(HaveOccurred()) + + var dirPath string + if tt.sourceDir != "" { + tmpDir, err := testTempDir(t) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(copy.Copy(tt.sourceDir, tmpDir)).To(Succeed()) + dirPath = tmpDir + } else { + tmpDir, err := testTempDir(t) + g.Expect(err).NotTo(HaveOccurred()) + dirPath = tmpDir + } + + beforeEntries := snapshotDir(g, dirPath) + + gen := kustomize.NewGenerator(dirPath, ks[0]) + manifest, kfile, action, err := gen.GenerateManifest(dirPath) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(action).To(Equal(tt.expectedAction)) + g.Expect(manifest).NotTo(BeEmpty()) + + // full path, resolvable relative to dirPath + g.Expect(kfile).To(HavePrefix(dirPath)) + g.Expect(filepath.Base(kfile)).To(HavePrefix("kustomization")) + + tt.checkManifest(g, manifest) + + // no disk writes + afterEntries := snapshotDir(g, dirPath) + g.Expect(afterEntries).To(Equal(beforeEntries)) + }) + } +} + +func snapshotDir(g Gomega, dir string) map[string]string { + entries := map[string]string{} + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + entries[rel] = string(data) + return nil + }) + g.Expect(err).NotTo(HaveOccurred()) + return entries +} + func testTempDir(t *testing.T) (string, error) { tmpDir := t.TempDir()