From e2e538eaacf7b9a8c4c4f3fa11675f3ed066cd7f Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Tue, 1 Jul 2025 18:53:20 -0400 Subject: [PATCH 1/2] feat(bundler): Add kustomize bundler --- pkg/bundler/kustomize_bundler.go | 74 ++++++ pkg/bundler/kustomize_bundler_test.go | 353 ++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 pkg/bundler/kustomize_bundler.go create mode 100644 pkg/bundler/kustomize_bundler_test.go diff --git a/pkg/bundler/kustomize_bundler.go b/pkg/bundler/kustomize_bundler.go new file mode 100644 index 000000000..d88f30199 --- /dev/null +++ b/pkg/bundler/kustomize_bundler.go @@ -0,0 +1,74 @@ +package bundler + +import ( + "fmt" + "os" + "path/filepath" +) + +// The KustomizeBundler handles bundling of kustomize manifests and related files. +// It copies all files from the kustomize directory to the artifact build directory. +// The KustomizeBundler ensures that all kustomize resources are properly bundled +// for distribution with the artifact for use with Flux OCIRegistry. + +// ============================================================================= +// Types +// ============================================================================= + +// KustomizeBundler handles bundling of kustomize files +type KustomizeBundler struct { + BaseBundler +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewKustomizeBundler creates a new KustomizeBundler instance +func NewKustomizeBundler() *KustomizeBundler { + return &KustomizeBundler{ + BaseBundler: *NewBaseBundler(), + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Bundle adds all files from kustomize directory to the artifact by recursively walking the directory tree. +// It validates that the kustomize directory exists, then walks through all files preserving the directory structure. +// Each file is read and added to the artifact maintaining the original kustomize path structure. +// Directories are skipped and only regular files are processed for bundling. +func (k *KustomizeBundler) Bundle(artifact Artifact) error { + kustomizeSource := "kustomize" + + if _, err := k.shims.Stat(kustomizeSource); os.IsNotExist(err) { + return fmt.Errorf("kustomize directory not found: %s", kustomizeSource) + } + + return k.shims.Walk(kustomizeSource, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + relPath, err := k.shims.FilepathRel(kustomizeSource, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + data, err := k.shims.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read kustomize file %s: %w", path, err) + } + + artifactPath := filepath.Join("kustomize", relPath) + return artifact.AddFile(artifactPath, data) + }) +} + +// Ensure KustomizeBundler implements Bundler interface +var _ Bundler = (*KustomizeBundler)(nil) diff --git a/pkg/bundler/kustomize_bundler_test.go b/pkg/bundler/kustomize_bundler_test.go new file mode 100644 index 000000000..329cd9236 --- /dev/null +++ b/pkg/bundler/kustomize_bundler_test.go @@ -0,0 +1,353 @@ +package bundler + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// ============================================================================= +// Test KustomizeBundler +// ============================================================================= + +func TestKustomizeBundler_NewKustomizeBundler(t *testing.T) { + setup := func(t *testing.T) *KustomizeBundler { + t.Helper() + return NewKustomizeBundler() + } + + t.Run("CreatesInstanceWithBaseBundler", func(t *testing.T) { + // Given no preconditions + // When creating a new kustomize bundler + bundler := setup(t) + + // Then it should not be nil + if bundler == nil { + t.Fatal("Expected non-nil bundler") + } + // And it should have inherited BaseBundler properties + if bundler.shims == nil { + t.Error("Expected shims to be inherited from BaseBundler") + } + // And other fields should be nil until Initialize + if bundler.shell != nil { + t.Error("Expected shell to be nil before Initialize") + } + if bundler.injector != nil { + t.Error("Expected injector to be nil before Initialize") + } + }) +} + +func TestKustomizeBundler_Bundle(t *testing.T) { + setup := func(t *testing.T) (*KustomizeBundler, *BundlerMocks) { + t.Helper() + mocks := setupBundlerMocks(t) + bundler := NewKustomizeBundler() + bundler.shims = mocks.Shims + bundler.Initialize(mocks.Injector) + return bundler, mocks + } + + t.Run("SuccessWithValidKustomizeFiles", func(t *testing.T) { + // Given a kustomize bundler with valid kustomize files + bundler, mocks := setup(t) + + // Set up mocks to simulate finding kustomize files + filesAdded := make(map[string][]byte) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded[path] = content + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Simulate finding multiple files in kustomize directory + fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) + fn("kustomize/deployment.yaml", &mockFileInfo{name: "deployment.yaml", isDir: false}, nil) + fn("kustomize/base", &mockFileInfo{name: "base", isDir: true}, nil) + fn("kustomize/base/service.yaml", &mockFileInfo{name: "service.yaml", isDir: false}, nil) + fn("kustomize/overlays", &mockFileInfo{name: "overlays", isDir: true}, nil) + fn("kustomize/overlays/prod", &mockFileInfo{name: "prod", isDir: true}, nil) + fn("kustomize/overlays/prod/patch.yaml", &mockFileInfo{name: "patch.yaml", isDir: false}, nil) + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + switch targpath { + case "kustomize/kustomization.yaml": + return "kustomization.yaml", nil + case "kustomize/deployment.yaml": + return "deployment.yaml", nil + case "kustomize/base/service.yaml": + return "base/service.yaml", nil + case "kustomize/overlays/prod/patch.yaml": + return "overlays/prod/patch.yaml", nil + default: + return "", fmt.Errorf("unexpected path: %s", targpath) + } + } + + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + switch filename { + case "kustomize/kustomization.yaml": + return []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization"), nil + case "kustomize/deployment.yaml": + return []byte("apiVersion: apps/v1\nkind: Deployment"), nil + case "kustomize/base/service.yaml": + return []byte("apiVersion: v1\nkind: Service"), nil + case "kustomize/overlays/prod/patch.yaml": + return []byte("- op: replace\n path: /spec/replicas\n value: 3"), nil + default: + return nil, fmt.Errorf("unexpected file: %s", filename) + } + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And files should be added with correct paths + expectedFiles := map[string]string{ + "kustomize/kustomization.yaml": "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization", + "kustomize/deployment.yaml": "apiVersion: apps/v1\nkind: Deployment", + "kustomize/base/service.yaml": "apiVersion: v1\nkind: Service", + "kustomize/overlays/prod/patch.yaml": "- op: replace\n path: /spec/replicas\n value: 3", + } + + for expectedPath, expectedContent := range expectedFiles { + if content, exists := filesAdded[expectedPath]; !exists { + t.Errorf("Expected file %s to be added", expectedPath) + } else if string(content) != expectedContent { + t.Errorf("Expected content %q for %s, got %q", expectedContent, expectedPath, string(content)) + } + } + + // And directories should be skipped (only 4 files should be added) + if len(filesAdded) != 4 { + t.Errorf("Expected 4 files to be added, got %d", len(filesAdded)) + } + }) + + t.Run("ErrorWhenKustomizeDirectoryNotFound", func(t *testing.T) { + // Given a kustomize bundler with missing kustomize directory + bundler, mocks := setup(t) + bundler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "kustomize" { + return nil, os.ErrNotExist + } + return &mockFileInfo{name: name, isDir: true}, nil + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then an error should be returned + if err == nil { + t.Error("Expected error when kustomize directory not found") + } + expectedMsg := "kustomize directory not found: kustomize" + if err.Error() != expectedMsg { + t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) + } + }) + + t.Run("ErrorWhenWalkFails", func(t *testing.T) { + // Given a kustomize bundler with failing filesystem walk + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fmt.Errorf("permission denied") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the walk error should be returned + if err == nil { + t.Error("Expected error when walk fails") + } + if err.Error() != "permission denied" { + t.Errorf("Expected walk error, got: %v", err) + } + }) + + t.Run("ErrorWhenWalkCallbackFails", func(t *testing.T) { + // Given a kustomize bundler with walk callback returning error + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Simulate walk callback being called with an error + return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, fmt.Errorf("callback error")) + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the callback error should be returned + if err == nil { + t.Error("Expected error when walk callback fails") + } + if err.Error() != "callback error" { + t.Errorf("Expected callback error, got: %v", err) + } + }) + + t.Run("ErrorWhenFilepathRelFails", func(t *testing.T) { + // Given a kustomize bundler with failing relative path calculation + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, nil) + } + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "", fmt.Errorf("relative path error") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the relative path error should be returned + if err == nil { + t.Error("Expected error when filepath rel fails") + } + expectedMsg := "failed to get relative path: relative path error" + if err.Error() != expectedMsg { + t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) + } + }) + + t.Run("ErrorWhenReadFileFails", func(t *testing.T) { + // Given a kustomize bundler with failing file read + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, nil) + } + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "test.yaml", nil + } + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + return nil, fmt.Errorf("read permission denied") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the read error should be returned + if err == nil { + t.Error("Expected error when read file fails") + } + expectedMsg := "failed to read kustomize file kustomize/test.yaml: read permission denied" + if err.Error() != expectedMsg { + t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) + } + }) + + t.Run("ErrorWhenArtifactAddFileFails", func(t *testing.T) { + // Given a kustomize bundler with failing artifact add file + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, nil) + } + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "test.yaml", nil + } + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("content"), nil + } + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + return fmt.Errorf("artifact storage full") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the add file error should be returned + if err == nil { + t.Error("Expected error when artifact add file fails") + } + if err.Error() != "artifact storage full" { + t.Errorf("Expected add file error, got: %v", err) + } + }) + + t.Run("SkipsDirectoriesInWalk", func(t *testing.T) { + // Given a kustomize bundler with mix of files and directories + bundler, mocks := setup(t) + + filesAdded := make([]string, 0) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded = append(filesAdded, path) + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Mix of directories and files + fn("kustomize/base", &mockFileInfo{name: "base", isDir: true}, nil) + fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) + fn("kustomize/overlays", &mockFileInfo{name: "overlays", isDir: true}, nil) + fn("kustomize/deployment.yaml", &mockFileInfo{name: "deployment.yaml", isDir: false}, nil) + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + if targpath == "kustomize/kustomization.yaml" { + return "kustomization.yaml", nil + } + if targpath == "kustomize/deployment.yaml" { + return "deployment.yaml", nil + } + return "", nil + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And only files should be added (not directories) + expectedFiles := []string{"kustomize/kustomization.yaml", "kustomize/deployment.yaml"} + if len(filesAdded) != len(expectedFiles) { + t.Errorf("Expected %d files added, got %d", len(expectedFiles), len(filesAdded)) + } + + for i, expected := range expectedFiles { + if i < len(filesAdded) && filesAdded[i] != expected { + t.Errorf("Expected file %s at index %d, got %s", expected, i, filesAdded[i]) + } + } + }) + + t.Run("HandlesEmptyKustomizeDirectory", func(t *testing.T) { + // Given a kustomize bundler with empty kustomize directory + bundler, mocks := setup(t) + + filesAdded := make([]string, 0) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded = append(filesAdded, path) + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // No files found in directory + return nil + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And no files should be added + if len(filesAdded) != 0 { + t.Errorf("Expected 0 files added, got %d", len(filesAdded)) + } + }) +} From f9d6971af08585c26b1816c83ce9f121d594f53f Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Tue, 1 Jul 2025 19:00:34 -0400 Subject: [PATCH 2/2] Windows fix --- pkg/bundler/kustomize_bundler.go | 2 +- pkg/bundler/kustomize_bundler_test.go | 57 ++++++++++++++------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/pkg/bundler/kustomize_bundler.go b/pkg/bundler/kustomize_bundler.go index d88f30199..cd3bd0fa0 100644 --- a/pkg/bundler/kustomize_bundler.go +++ b/pkg/bundler/kustomize_bundler.go @@ -65,7 +65,7 @@ func (k *KustomizeBundler) Bundle(artifact Artifact) error { return fmt.Errorf("failed to read kustomize file %s: %w", path, err) } - artifactPath := filepath.Join("kustomize", relPath) + artifactPath := "kustomize/" + filepath.ToSlash(relPath) return artifact.AddFile(artifactPath, data) }) } diff --git a/pkg/bundler/kustomize_bundler_test.go b/pkg/bundler/kustomize_bundler_test.go index 329cd9236..e52beb388 100644 --- a/pkg/bundler/kustomize_bundler_test.go +++ b/pkg/bundler/kustomize_bundler_test.go @@ -63,26 +63,27 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { // Simulate finding multiple files in kustomize directory - fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) - fn("kustomize/deployment.yaml", &mockFileInfo{name: "deployment.yaml", isDir: false}, nil) - fn("kustomize/base", &mockFileInfo{name: "base", isDir: true}, nil) - fn("kustomize/base/service.yaml", &mockFileInfo{name: "service.yaml", isDir: false}, nil) - fn("kustomize/overlays", &mockFileInfo{name: "overlays", isDir: true}, nil) - fn("kustomize/overlays/prod", &mockFileInfo{name: "prod", isDir: true}, nil) - fn("kustomize/overlays/prod/patch.yaml", &mockFileInfo{name: "patch.yaml", isDir: false}, nil) + // Use filepath.Join to ensure cross-platform compatibility + fn(filepath.Join("kustomize", "kustomization.yaml"), &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) + fn(filepath.Join("kustomize", "deployment.yaml"), &mockFileInfo{name: "deployment.yaml", isDir: false}, nil) + fn(filepath.Join("kustomize", "base"), &mockFileInfo{name: "base", isDir: true}, nil) + fn(filepath.Join("kustomize", "base", "service.yaml"), &mockFileInfo{name: "service.yaml", isDir: false}, nil) + fn(filepath.Join("kustomize", "overlays"), &mockFileInfo{name: "overlays", isDir: true}, nil) + fn(filepath.Join("kustomize", "overlays", "prod"), &mockFileInfo{name: "prod", isDir: true}, nil) + fn(filepath.Join("kustomize", "overlays", "prod", "patch.yaml"), &mockFileInfo{name: "patch.yaml", isDir: false}, nil) return nil } bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { switch targpath { - case "kustomize/kustomization.yaml": + case filepath.Join("kustomize", "kustomization.yaml"): return "kustomization.yaml", nil - case "kustomize/deployment.yaml": + case filepath.Join("kustomize", "deployment.yaml"): return "deployment.yaml", nil - case "kustomize/base/service.yaml": - return "base/service.yaml", nil - case "kustomize/overlays/prod/patch.yaml": - return "overlays/prod/patch.yaml", nil + case filepath.Join("kustomize", "base", "service.yaml"): + return filepath.Join("base", "service.yaml"), nil + case filepath.Join("kustomize", "overlays", "prod", "patch.yaml"): + return filepath.Join("overlays", "prod", "patch.yaml"), nil default: return "", fmt.Errorf("unexpected path: %s", targpath) } @@ -90,13 +91,13 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler.shims.ReadFile = func(filename string) ([]byte, error) { switch filename { - case "kustomize/kustomization.yaml": + case filepath.Join("kustomize", "kustomization.yaml"): return []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization"), nil - case "kustomize/deployment.yaml": + case filepath.Join("kustomize", "deployment.yaml"): return []byte("apiVersion: apps/v1\nkind: Deployment"), nil - case "kustomize/base/service.yaml": + case filepath.Join("kustomize", "base", "service.yaml"): return []byte("apiVersion: v1\nkind: Service"), nil - case "kustomize/overlays/prod/patch.yaml": + case filepath.Join("kustomize", "overlays", "prod", "patch.yaml"): return []byte("- op: replace\n path: /spec/replicas\n value: 3"), nil default: return nil, fmt.Errorf("unexpected file: %s", filename) @@ -180,7 +181,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler, mocks := setup(t) bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { // Simulate walk callback being called with an error - return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, fmt.Errorf("callback error")) + return fn(filepath.Join("kustomize", "test.yaml"), &mockFileInfo{name: "test.yaml", isDir: false}, fmt.Errorf("callback error")) } // When calling Bundle @@ -199,7 +200,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { // Given a kustomize bundler with failing relative path calculation bundler, mocks := setup(t) bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { - return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, nil) + return fn(filepath.Join("kustomize", "test.yaml"), &mockFileInfo{name: "test.yaml", isDir: false}, nil) } bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { return "", fmt.Errorf("relative path error") @@ -222,7 +223,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { // Given a kustomize bundler with failing file read bundler, mocks := setup(t) bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { - return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, nil) + return fn(filepath.Join("kustomize", "test.yaml"), &mockFileInfo{name: "test.yaml", isDir: false}, nil) } bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { return "test.yaml", nil @@ -238,7 +239,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { if err == nil { t.Error("Expected error when read file fails") } - expectedMsg := "failed to read kustomize file kustomize/test.yaml: read permission denied" + expectedMsg := "failed to read kustomize file " + filepath.Join("kustomize", "test.yaml") + ": read permission denied" if err.Error() != expectedMsg { t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) } @@ -248,7 +249,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { // Given a kustomize bundler with failing artifact add file bundler, mocks := setup(t) bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { - return fn("kustomize/test.yaml", &mockFileInfo{name: "test.yaml", isDir: false}, nil) + return fn(filepath.Join("kustomize", "test.yaml"), &mockFileInfo{name: "test.yaml", isDir: false}, nil) } bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { return "test.yaml", nil @@ -284,18 +285,18 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { // Mix of directories and files - fn("kustomize/base", &mockFileInfo{name: "base", isDir: true}, nil) - fn("kustomize/kustomization.yaml", &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) - fn("kustomize/overlays", &mockFileInfo{name: "overlays", isDir: true}, nil) - fn("kustomize/deployment.yaml", &mockFileInfo{name: "deployment.yaml", isDir: false}, nil) + fn(filepath.Join("kustomize", "base"), &mockFileInfo{name: "base", isDir: true}, nil) + fn(filepath.Join("kustomize", "kustomization.yaml"), &mockFileInfo{name: "kustomization.yaml", isDir: false}, nil) + fn(filepath.Join("kustomize", "overlays"), &mockFileInfo{name: "overlays", isDir: true}, nil) + fn(filepath.Join("kustomize", "deployment.yaml"), &mockFileInfo{name: "deployment.yaml", isDir: false}, nil) return nil } bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { - if targpath == "kustomize/kustomization.yaml" { + if targpath == filepath.Join("kustomize", "kustomization.yaml") { return "kustomization.yaml", nil } - if targpath == "kustomize/deployment.yaml" { + if targpath == filepath.Join("kustomize", "deployment.yaml") { return "deployment.yaml", nil } return "", nil