diff --git a/cmd/flux/build_kustomization.go b/cmd/flux/build_kustomization.go index 2000faac..7827f7c9 100644 --- a/cmd/flux/build_kustomization.go +++ b/cmd/flux/build_kustomization.go @@ -72,6 +72,7 @@ type buildKsFlags struct { strictSubst bool recursive bool localSources map[string]string + inMemoryBuild bool } var buildKsArgs buildKsFlags @@ -85,6 +86,8 @@ func init() { "When enabled, the post build substitutions will fail if a var without a default value is declared in files but is missing from the input vars.") buildKsCmd.Flags().BoolVarP(&buildKsArgs.recursive, "recursive", "r", false, "Recursively build Kustomizations") buildKsCmd.Flags().StringToStringVar(&buildKsArgs.localSources, "local-sources", nil, "Comma-separated list of repositories in format: Kind/namespace/name=path") + buildKsCmd.Flags().BoolVar(&buildKsArgs.inMemoryBuild, "in-memory-build", false, + "Use in-memory filesystem during build.") buildCmd.AddCommand(buildKsCmd) } @@ -130,6 +133,7 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) { build.WithStrictSubstitute(buildKsArgs.strictSubst), build.WithRecursive(buildKsArgs.recursive), build.WithLocalSources(buildKsArgs.localSources), + build.WithInMemoryBuild(buildKsArgs.inMemoryBuild), ) } else { builder, err = build.NewBuilder(name, buildKsArgs.path, @@ -140,6 +144,7 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) { build.WithStrictSubstitute(buildKsArgs.strictSubst), build.WithRecursive(buildKsArgs.recursive), build.WithLocalSources(buildKsArgs.localSources), + build.WithInMemoryBuild(buildKsArgs.inMemoryBuild), ) } diff --git a/cmd/flux/diff_kustomization.go b/cmd/flux/diff_kustomization.go index 0480e293..6b9aa08a 100644 --- a/cmd/flux/diff_kustomization.go +++ b/cmd/flux/diff_kustomization.go @@ -62,6 +62,7 @@ type diffKsFlags struct { strictSubst bool recursive bool localSources map[string]string + inMemoryBuild bool } var diffKsArgs diffKsFlags @@ -75,6 +76,8 @@ func init() { "When enabled, the post build substitutions will fail if a var without a default value is declared in files but is missing from the input vars.") diffKsCmd.Flags().BoolVarP(&diffKsArgs.recursive, "recursive", "r", false, "Recursively diff Kustomizations") diffKsCmd.Flags().StringToStringVar(&diffKsArgs.localSources, "local-sources", nil, "Comma-separated list of repositories in format: Kind/namespace/name=path") + diffKsCmd.Flags().BoolVar(&diffKsArgs.inMemoryBuild, "in-memory-build", false, + "Use in-memory filesystem during build.") diffCmd.AddCommand(diffKsCmd) } @@ -113,6 +116,7 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { build.WithRecursive(diffKsArgs.recursive), build.WithLocalSources(diffKsArgs.localSources), build.WithSingleKustomization(), + build.WithInMemoryBuild(diffKsArgs.inMemoryBuild), ) } else { builder, err = build.NewBuilder(name, diffKsArgs.path, @@ -124,6 +128,7 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { build.WithRecursive(diffKsArgs.recursive), build.WithLocalSources(diffKsArgs.localSources), build.WithSingleKustomization(), + build.WithInMemoryBuild(diffKsArgs.inMemoryBuild), ) } diff --git a/internal/build/build.go b/internal/build/build.go index 7deca47c..db9a8f8e 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -65,6 +65,52 @@ const ( var defaultTimeout = 80 * time.Second +// buildBackend controls how the kustomization manifest is generated +// and which filesystem is used for the kustomize build. +type buildBackend interface { + Generate(gen *kustomize.Generator, dirPath string) (filesys.FileSystem, string, kustomize.Action, error) + Cleanup(dirPath string, action kustomize.Action) error +} + +// onDiskBackend writes to the source directory, matching upstream behaviour. +type onDiskBackend struct{} + +func (onDiskBackend) Generate(gen *kustomize.Generator, dirPath string) (filesys.FileSystem, string, kustomize.Action, error) { + action, err := gen.WriteFile(dirPath, kustomize.WithSaveOriginalKustomization()) + if err != nil { + return nil, "", action, err + } + return filesys.MakeFsOnDisk(), dirPath, action, nil +} + +func (onDiskBackend) Cleanup(dirPath string, action kustomize.Action) error { + return kustomize.CleanDirectory(dirPath, action) +} + +const memFSRoot = "/work" + +// inMemoryBackend builds in an in-memory filesystem without modifying the source directory. +type inMemoryBackend struct{} + +func (inMemoryBackend) Generate(gen *kustomize.Generator, dirPath string) (filesys.FileSystem, string, kustomize.Action, error) { + manifest, kfilePath, action, err := gen.GenerateManifest(dirPath) + if err != nil { + return nil, "", action, err + } + + memFS := filesys.MakeFsInMemory() + if err := loadDirToMemFS(dirPath, memFSRoot, memFS); err != nil { + return nil, "", action, fmt.Errorf("failed to load source dir: %w", err) + } + + if err := memFS.WriteFile(filepath.Join(memFSRoot, filepath.Base(kfilePath)), manifest); err != nil { + return nil, "", action, err + } + return memFS, memFSRoot, action, nil +} + +func (inMemoryBackend) Cleanup(string, kustomize.Action) error { return nil } + // Builder builds yaml manifests // It retrieves the kustomization object from the k8s cluster // and overlays the manifests with the resources specified in the resourcesPath @@ -88,6 +134,7 @@ type Builder struct { localSources map[string]string // diff needs to handle kustomizations one by one singleKustomization bool + backend buildBackend } // BuilderOptionFunc is a function that configures a Builder @@ -198,6 +245,16 @@ func WithLocalSources(localSources map[string]string) BuilderOptionFunc { } } +// WithInMemoryBuild sets the in-memory build backend +func WithInMemoryBuild(inMemoryBuild bool) BuilderOptionFunc { + return func(b *Builder) error { + if inMemoryBuild { + b.backend = inMemoryBackend{} + } + return nil + } +} + // WithSingleKustomization sets the single kustomization field to true func WithSingleKustomization() BuilderOptionFunc { return func(b *Builder) error { @@ -223,6 +280,14 @@ func withSpinnerFrom(in *Builder) BuilderOptionFunc { } } +// withBackend sets the build backend +func withBackend(s buildBackend) BuilderOptionFunc { + return func(b *Builder) error { + b.backend = s + return nil + } +} + // withKustomization sets the kustomization field func withKustomization(k *kustomizev1.Kustomization) BuilderOptionFunc { return func(b *Builder) error { @@ -258,6 +323,10 @@ func NewBuilder(name, resources string, opts ...BuilderOptionFunc) (*Builder, er b.timeout = defaultTimeout } + if b.backend == nil { + b.backend = onDiskBackend{} + } + if b.dryRun && b.kustomizationFile == "" && b.kustomization == nil { return nil, fmt.Errorf("kustomization file is required for dry-run") } @@ -378,9 +447,9 @@ func (b *Builder) build() (m resmap.ResMap, err error) { b.kustomization = k // generate kustomization.yaml if needed - action, er := b.generate(*k, b.resourcesPath) + buildFS, buildDir, action, er := b.generate(*k, b.resourcesPath) if er != nil { - errf := kustomize.CleanDirectory(b.resourcesPath, action) + errf := b.backend.Cleanup(b.resourcesPath, action) err = fmt.Errorf("failed to generate kustomization.yaml: %w", fmt.Errorf("%v %v", er, errf)) return } @@ -388,14 +457,14 @@ func (b *Builder) build() (m resmap.ResMap, err error) { b.action = action defer func() { - errf := b.Cancel() + errf := b.backend.Cleanup(b.resourcesPath, b.action) if err == nil { err = errf } }() // build the kustomization - m, err = b.do(ctx, *k, b.resourcesPath) + m, err = b.do(ctx, *k, buildFS, buildDir) if err != nil { return } @@ -436,6 +505,7 @@ func (b *Builder) kustomizationBuild(k *kustomizev1.Kustomization) ([]*unstructu WithRecursive(b.recursive), WithLocalSources(b.localSources), WithDryRun(b.dryRun), + withBackend(b.backend), ) if err != nil { return nil, err @@ -490,10 +560,10 @@ func (b *Builder) unMarshallKustomization() (*kustomizev1.Kustomization, error) return k, nil } -func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath string) (kustomize.Action, error) { +func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath string) (filesys.FileSystem, string, kustomize.Action, error) { data, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&kustomization) if err != nil { - return "", err + return nil, "", kustomize.UnchangedAction, err } // a scanner will be used down the line to parse the list @@ -505,12 +575,32 @@ func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath stri b.mu.Lock() defer b.mu.Unlock() - return gen.WriteFile(dirPath, kustomize.WithSaveOriginalKustomization()) + return b.backend.Generate(gen, dirPath) } -func (b *Builder) do(ctx context.Context, kustomization kustomizev1.Kustomization, dirPath string) (resmap.ResMap, error) { - fs := filesys.MakeFsOnDisk() +// loadDirToMemFS copies srcDir into dstDir on the given filesystem. +func loadDirToMemFS(srcDir, dstDir string, fs filesys.FileSystem) error { + return filepath.Walk(srcDir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcDir, p) + if err != nil { + return err + } + target := filepath.Join(dstDir, rel) + if info.IsDir() { + return fs.MkdirAll(target) + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + return fs.WriteFile(target, data) + }) +} +func (b *Builder) do(ctx context.Context, kustomization kustomizev1.Kustomization, fs filesys.FileSystem, dirPath string) (resmap.ResMap, error) { // acquire the lock b.mu.Lock() defer b.mu.Unlock() @@ -734,12 +824,7 @@ func (b *Builder) Cancel() error { b.mu.Lock() defer b.mu.Unlock() - err := kustomize.CleanDirectory(b.resourcesPath, b.action) - if err != nil { - return err - } - - return nil + return b.backend.Cleanup(b.resourcesPath, b.action) } func (b *Builder) StartSpinner() error { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index bf82513f..3e007cec 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -18,16 +18,20 @@ package build import ( "fmt" + "os" + "path/filepath" "strings" "testing" "time" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/kustomize" "github.com/google/go-cmp/cmp" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/kustomize/api/resource" + "sigs.k8s.io/kustomize/kyaml/filesys" "sigs.k8s.io/kustomize/kyaml/yaml" ) @@ -611,3 +615,132 @@ func Test_kustomizationPath(t *testing.T) { }) } } + +func Test_loadDirToMemFS(t *testing.T) { + srcDir := t.TempDir() + + nested := filepath.Join(srcDir, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "file.yaml"), []byte("nested-content"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(filepath.Join(srcDir, "root.yaml"), []byte("root-content"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + memFS := filesys.MakeFsInMemory() + dst := "/target" + if err := loadDirToMemFS(srcDir, dst, memFS); err != nil { + t.Fatalf("loadDirToMemFS: %v", err) + } + + tests := []struct { + path string + content string + }{ + {filepath.Join(dst, "root.yaml"), "root-content"}, + {filepath.Join(dst, "nested", "file.yaml"), "nested-content"}, + } + for _, tt := range tests { + data, err := memFS.ReadFile(tt.path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", tt.path, err) + } + if diff := cmp.Diff(string(data), tt.content); diff != "" { + t.Errorf("content mismatch for %s: (-got +want)%s", tt.path, diff) + } + } +} + +func Test_inMemoryBackend_Generate(t *testing.T) { + srcDir := t.TempDir() + + kusYAML := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- configmap.yaml +` + cmYAML := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value +` + if err := os.WriteFile(filepath.Join(srcDir, "kustomization.yaml"), []byte(kusYAML), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(filepath.Join(srcDir, "configmap.yaml"), []byte(cmYAML), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // snapshot source dir + beforeFiles := map[string]string{} + filepath.Walk(srcDir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + data, _ := os.ReadFile(p) + rel, _ := filepath.Rel(srcDir, p) + beforeFiles[rel] = string(data) + return nil + }) + + ks := unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "metadata": map[string]interface{}{"name": "test", "namespace": "default"}, + "spec": map[string]interface{}{ + "targetNamespace": "my-ns", + }, + }} + gen := kustomize.NewGenerator(srcDir, ks) + + backend := inMemoryBackend{} + fs, dir, action, err := backend.Generate(gen, srcDir) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if dir != memFSRoot { + t.Errorf("expected dir %q, got %q", memFSRoot, dir) + } + if action != kustomize.UnchangedAction { + t.Errorf("expected UnchangedAction, got %q", action) + } + + // kustomization.yaml should contain the merged targetNamespace + data, err := fs.ReadFile(filepath.Join(memFSRoot, "kustomization.yaml")) + if err != nil { + t.Fatalf("ReadFile kustomization.yaml: %v", err) + } + if !strings.Contains(string(data), "my-ns") { + t.Errorf("expected kustomization to contain targetNamespace, got:\n%s", data) + } + + // resource file should be copied verbatim + data, err = fs.ReadFile(filepath.Join(memFSRoot, "configmap.yaml")) + if err != nil { + t.Fatalf("ReadFile configmap.yaml: %v", err) + } + if diff := cmp.Diff(string(data), cmYAML); diff != "" { + t.Errorf("configmap mismatch: (-got +want)%s", diff) + } + + // source directory must be unmodified + afterFiles := map[string]string{} + filepath.Walk(srcDir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + data, _ := os.ReadFile(p) + rel, _ := filepath.Rel(srcDir, p) + afterFiles[rel] = string(data) + return nil + }) + if diff := cmp.Diff(afterFiles, beforeFiles); diff != "" { + t.Errorf("source directory was modified: (-got +want)%s", diff) + } +} diff --git a/internal/build/diff.go b/internal/build/diff.go index 4485dd0f..4f32bddf 100644 --- a/internal/build/diff.go +++ b/internal/build/diff.go @@ -230,6 +230,7 @@ func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (s WithRecursive(b.recursive), WithLocalSources(b.localSources), WithSingleKustomization(), + withBackend(b.backend), ) if err != nil { return "", false, err