From 8b7adab83cd6d56c1ca7165be6c84c360bcc6b37 Mon Sep 17 00:00:00 2001 From: rycli Date: Sun, 29 Mar 2026 16:10:56 +0200 Subject: [PATCH 1/5] feat: add WithInMemoryBuild to use filesys.MakeFsInMemory for kustomize Signed-off-by: rycli --- cmd/flux/build_kustomization.go | 5 ++ cmd/flux/diff_kustomization.go | 5 ++ internal/build/build.go | 115 +++++++++++++++++++++++---- internal/build/build_test.go | 133 ++++++++++++++++++++++++++++++++ internal/build/diff.go | 1 + 5 files changed, 244 insertions(+), 15 deletions(-) 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 From 07915259694a518492a682068782063c095b8d6e Mon Sep 17 00:00:00 2001 From: rycli Date: Sun, 29 Mar 2026 18:41:17 +0200 Subject: [PATCH 2/5] test: resolve relative resource references using in-memory build Signed-off-by: rycli --- internal/build/build_test.go | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 3e007cec..bbd0aa6d 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -744,3 +744,68 @@ data: t.Errorf("source directory was modified: (-got +want)%s", diff) } } + +func Test_inMemoryBackend_Generate_parentRef(t *testing.T) { + // tmpDir/ + // configmap.yaml (referenced as ../../configmap.yaml) + // overlay/sub/kustomization.yaml + tmpDir := t.TempDir() + + cmYAML := `apiVersion: v1 +kind: ConfigMap +metadata: + name: parent-cm +data: + key: value +` + if err := os.WriteFile(filepath.Join(tmpDir, "configmap.yaml"), []byte(cmYAML), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + overlayDir := filepath.Join(tmpDir, "overlay", "sub") + if err := os.MkdirAll(overlayDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + kusYAML := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../../configmap.yaml +` + if err := os.WriteFile(filepath.Join(overlayDir, "kustomization.yaml"), []byte(kusYAML), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + 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": "parent-ns", + }, + }} + gen := kustomize.NewGenerator(overlayDir, ks) + + backend := inMemoryBackend{} + fs, dir, _, err := backend.Generate(gen, overlayDir) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + // ../../configmap.yaml must resolve through the overlay + m, err := kustomize.Build(fs, dir) + if err != nil { + t.Fatalf("kustomize.Build failed (parent ref not resolved): %v", err) + } + + resources := m.Resources() + if len(resources) != 1 { + t.Fatalf("expected 1 resource, got %d", len(resources)) + } + if resources[0].GetName() != "parent-cm" { + t.Errorf("expected resource name parent-cm, got %s", resources[0].GetName()) + } + if resources[0].GetNamespace() != "parent-ns" { + t.Errorf("expected namespace parent-ns, got %s", resources[0].GetNamespace()) + } +} From cd3bb2d61229cb83168269eb43146d6e1cdd095b Mon Sep 17 00:00:00 2001 From: rycli Date: Sun, 29 Mar 2026 20:01:51 +0200 Subject: [PATCH 3/5] test: use memfs fork for pkg/kustomize Signed-off-by: rycli --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 7554accd..ee440ed0 100644 --- a/go.mod +++ b/go.mod @@ -267,3 +267,5 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) + +replace github.com/fluxcd/pkg/kustomize => github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329153243-0671cee33351 diff --git a/go.sum b/go.sum index 3aed1cda..4d52455b 100644 --- a/go.sum +++ b/go.sum @@ -204,8 +204,6 @@ github.com/fluxcd/pkg/git v0.46.0 h1:QMh0+ZzQ2jO6rIGj4ffR5trZ8g/cxvt8cVajReJ8Iyw github.com/fluxcd/pkg/git v0.46.0/go.mod h1:iHcIjx9c8zye3PQiajTJYxgOMRiy7WCs+hfLKDswpfI= github.com/fluxcd/pkg/gittestserver v0.26.0 h1:+RZrCzFRsE+d5WaqAoqaPCEgcgv/jZp6+f7DS0+Ynb8= github.com/fluxcd/pkg/gittestserver v0.26.0/go.mod h1:7fybYb0yej1fFNiF1ohs0Jr0XzyaZQ/cRh3AFEoCtuc= -github.com/fluxcd/pkg/kustomize v1.28.0 h1:0RuFVczJRabbt8frHZ/ql8aqte6BOOKk274O09l6/hE= -github.com/fluxcd/pkg/kustomize v1.28.0/go.mod h1:cW08mnngSP8MJYb6mDmMvxH8YjNATdiML0udb37dk+M= github.com/fluxcd/pkg/oci v0.63.0 h1:ZPKTT2C+gWYjhP63xC76iTPdYE9w3ABcsDq77uhAgwo= github.com/fluxcd/pkg/oci v0.63.0/go.mod h1:qMPz4njvm6hJzdyGSb8ydSqrapXxTQwJonxHIsdeXSQ= github.com/fluxcd/pkg/runtime v0.103.0 h1:J5y5GPhWdkyqIUBlaI1FP2N02TtZmsjbWhhZubuTSFk= @@ -504,6 +502,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329153243-0671cee33351 h1:HqutfZNQ2KtDfrgryyjBOmVTRW8c4T2LXNxpDYOL2q8= +github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329153243-0671cee33351/go.mod h1:cW08mnngSP8MJYb6mDmMvxH8YjNATdiML0udb37dk+M= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= From 3e0eb31dcc974b1d08fa3cff57533d666acfc669 Mon Sep 17 00:00:00 2001 From: rycli Date: Sun, 29 Mar 2026 21:24:39 +0200 Subject: [PATCH 4/5] refactor: use new filesys/fs_memory for in-memory layer Signed-off-by: rycli --- go.mod | 2 +- go.sum | 4 +- internal/build/build.go | 94 ++++++++++++-------------- internal/build/build_test.go | 123 ++++++++++++++++++++++------------- internal/build/diff.go | 2 +- 5 files changed, 124 insertions(+), 101 deletions(-) diff --git a/go.mod b/go.mod index ee440ed0..fbd8d80b 100644 --- a/go.mod +++ b/go.mod @@ -268,4 +268,4 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) -replace github.com/fluxcd/pkg/kustomize => github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329153243-0671cee33351 +replace github.com/fluxcd/pkg/kustomize => github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329192052-94b031e1aca6 diff --git a/go.sum b/go.sum index 4d52455b..5a7d37bd 100644 --- a/go.sum +++ b/go.sum @@ -502,8 +502,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329153243-0671cee33351 h1:HqutfZNQ2KtDfrgryyjBOmVTRW8c4T2LXNxpDYOL2q8= -github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329153243-0671cee33351/go.mod h1:cW08mnngSP8MJYb6mDmMvxH8YjNATdiML0udb37dk+M= +github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329192052-94b031e1aca6 h1:a231NZKoN+nuPkqivk8u0kKA6OaWdB30CCurQigx/Tg= +github.com/rycli/fluxcd-pkg/kustomize v0.0.0-20260329192052-94b031e1aca6/go.mod h1:cW08mnngSP8MJYb6mDmMvxH8YjNATdiML0udb37dk+M= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= diff --git a/internal/build/build.go b/internal/build/build.go index db9a8f8e..6f1b44c5 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -45,6 +45,7 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" "github.com/fluxcd/pkg/kustomize" + buildfs "github.com/fluxcd/pkg/kustomize/filesys" runclient "github.com/fluxcd/pkg/runtime/client" ssautil "github.com/fluxcd/pkg/ssa/utils" "sigs.k8s.io/kustomize/kyaml/filesys" @@ -65,17 +66,17 @@ const ( var defaultTimeout = 80 * time.Second -// buildBackend controls how the kustomization manifest is generated +// fsBackend controls how the kustomization manifest is generated // and which filesystem is used for the kustomize build. -type buildBackend interface { +type fsBackend 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{} +// onDiskFsBackend writes to the source directory. +type onDiskFsBackend struct{} -func (onDiskBackend) Generate(gen *kustomize.Generator, dirPath string) (filesys.FileSystem, string, kustomize.Action, error) { +func (onDiskFsBackend) 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 @@ -83,33 +84,46 @@ func (onDiskBackend) Generate(gen *kustomize.Generator, dirPath string) (filesys return filesys.MakeFsOnDisk(), dirPath, action, nil } -func (onDiskBackend) Cleanup(dirPath string, action kustomize.Action) error { +func (onDiskFsBackend) Cleanup(dirPath string, action kustomize.Action) error { return kustomize.CleanDirectory(dirPath, action) } -const memFSRoot = "/work" +// inMemoryFsBackend builds in an in-memory filesystem without modifying the source directory. +type inMemoryFsBackend struct{} -// 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) { +func (inMemoryFsBackend) 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) + absDirPath, err := filepath.Abs(dirPath) + if err != nil { + return nil, "", action, fmt.Errorf("failed to resolve dirPath: %w", err) + } + absDirPath, err = filepath.EvalSymlinks(absDirPath) + if err != nil { + return nil, "", action, fmt.Errorf("failed to eval symlinks: %w", err) } - if err := memFS.WriteFile(filepath.Join(memFSRoot, filepath.Base(kfilePath)), manifest); err != nil { + cwd, err := os.Getwd() + if err != nil { + return nil, "", action, fmt.Errorf("failed to get working directory: %w", err) + } + + diskFS, err := buildfs.MakeFsOnDiskSecure(cwd) + if err != nil { + return nil, "", action, fmt.Errorf("failed to create secure filesystem: %w", err) + } + fs := buildfs.MakeFsInMemory(diskFS) + + if err := fs.WriteFile(filepath.Join(absDirPath, filepath.Base(kfilePath)), manifest); err != nil { return nil, "", action, err } - return memFS, memFSRoot, action, nil + return fs, absDirPath, action, nil } -func (inMemoryBackend) Cleanup(string, kustomize.Action) error { return nil } +func (inMemoryFsBackend) Cleanup(string, kustomize.Action) error { return nil } // Builder builds yaml manifests // It retrieves the kustomization object from the k8s cluster @@ -134,7 +148,7 @@ type Builder struct { localSources map[string]string // diff needs to handle kustomizations one by one singleKustomization bool - backend buildBackend + fsBackend fsBackend } // BuilderOptionFunc is a function that configures a Builder @@ -249,7 +263,7 @@ func WithLocalSources(localSources map[string]string) BuilderOptionFunc { func WithInMemoryBuild(inMemoryBuild bool) BuilderOptionFunc { return func(b *Builder) error { if inMemoryBuild { - b.backend = inMemoryBackend{} + b.fsBackend = inMemoryFsBackend{} } return nil } @@ -280,10 +294,10 @@ func withSpinnerFrom(in *Builder) BuilderOptionFunc { } } -// withBackend sets the build backend -func withBackend(s buildBackend) BuilderOptionFunc { +// withFsBackend sets the build backend +func withFsBackend(s fsBackend) BuilderOptionFunc { return func(b *Builder) error { - b.backend = s + b.fsBackend = s return nil } } @@ -323,8 +337,8 @@ func NewBuilder(name, resources string, opts ...BuilderOptionFunc) (*Builder, er b.timeout = defaultTimeout } - if b.backend == nil { - b.backend = onDiskBackend{} + if b.fsBackend == nil { + b.fsBackend = onDiskFsBackend{} } if b.dryRun && b.kustomizationFile == "" && b.kustomization == nil { @@ -449,7 +463,7 @@ func (b *Builder) build() (m resmap.ResMap, err error) { // generate kustomization.yaml if needed buildFS, buildDir, action, er := b.generate(*k, b.resourcesPath) if er != nil { - errf := b.backend.Cleanup(b.resourcesPath, action) + errf := b.fsBackend.Cleanup(b.resourcesPath, action) err = fmt.Errorf("failed to generate kustomization.yaml: %w", fmt.Errorf("%v %v", er, errf)) return } @@ -457,7 +471,7 @@ func (b *Builder) build() (m resmap.ResMap, err error) { b.action = action defer func() { - errf := b.backend.Cleanup(b.resourcesPath, b.action) + errf := b.fsBackend.Cleanup(b.resourcesPath, b.action) if err == nil { err = errf } @@ -505,7 +519,7 @@ func (b *Builder) kustomizationBuild(k *kustomizev1.Kustomization) ([]*unstructu WithRecursive(b.recursive), WithLocalSources(b.localSources), WithDryRun(b.dryRun), - withBackend(b.backend), + withFsBackend(b.fsBackend), ) if err != nil { return nil, err @@ -575,29 +589,7 @@ func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath stri b.mu.Lock() defer b.mu.Unlock() - return b.backend.Generate(gen, dirPath) -} - -// 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) - }) + return b.fsBackend.Generate(gen, dirPath) } func (b *Builder) do(ctx context.Context, kustomization kustomizev1.Kustomization, fs filesys.FileSystem, dirPath string) (resmap.ResMap, error) { @@ -824,7 +816,7 @@ func (b *Builder) Cancel() error { b.mu.Lock() defer b.mu.Unlock() - return b.backend.Cleanup(b.resourcesPath, b.action) + return b.fsBackend.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 bbd0aa6d..fa7ffff3 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -31,7 +31,6 @@ import ( 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" ) @@ -616,46 +615,22 @@ 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) +// chdirTemp changes to the given directory and restores the original on cleanup. +func chdirTemp(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(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) - } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) } + t.Cleanup(func() { os.Chdir(orig) }) } -func Test_inMemoryBackend_Generate(t *testing.T) { +func Test_inMemoryFsBackend_Generate(t *testing.T) { srcDir := t.TempDir() + chdirTemp(t, srcDir) kusYAML := `apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -698,21 +673,18 @@ data: }} gen := kustomize.NewGenerator(srcDir, ks) - backend := inMemoryBackend{} + backend := inMemoryFsBackend{} 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")) + data, err := fs.ReadFile(filepath.Join(dir, "kustomization.yaml")) if err != nil { t.Fatalf("ReadFile kustomization.yaml: %v", err) } @@ -720,8 +692,8 @@ data: 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")) + // resource file should be readable from disk through the memory fs + data, err = fs.ReadFile(filepath.Join(dir, "configmap.yaml")) if err != nil { t.Fatalf("ReadFile configmap.yaml: %v", err) } @@ -745,11 +717,12 @@ data: } } -func Test_inMemoryBackend_Generate_parentRef(t *testing.T) { +func Test_inMemoryFsBackend_Generate_parentRef(t *testing.T) { // tmpDir/ // configmap.yaml (referenced as ../../configmap.yaml) // overlay/sub/kustomization.yaml tmpDir := t.TempDir() + chdirTemp(t, tmpDir) cmYAML := `apiVersion: v1 kind: ConfigMap @@ -786,13 +759,13 @@ resources: }} gen := kustomize.NewGenerator(overlayDir, ks) - backend := inMemoryBackend{} + backend := inMemoryFsBackend{} fs, dir, _, err := backend.Generate(gen, overlayDir) if err != nil { t.Fatalf("Generate: %v", err) } - // ../../configmap.yaml must resolve through the overlay + // ../../configmap.yaml must resolve through the disk layer m, err := kustomize.Build(fs, dir) if err != nil { t.Fatalf("kustomize.Build failed (parent ref not resolved): %v", err) @@ -809,3 +782,61 @@ resources: t.Errorf("expected namespace parent-ns, got %s", resources[0].GetNamespace()) } } + +func Test_inMemoryFsBackend_Generate_outsideCwd(t *testing.T) { + // Two sibling temp dirs: one for the source tree, one as cwd. + // The kustomization references a file that exists on disk but is + // outside cwd, so the secure filesystem must reject it. + // + // parentDir/ + // outside/configmap.yaml (exists but outside cwd) + // cwd/overlay/kustomization.yaml (references ../../outside/configmap.yaml) + parentDir := t.TempDir() + + outsideDir := filepath.Join(parentDir, "outside") + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outsideDir, "configmap.yaml"), []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: outside-cm +`), 0o644); err != nil { + t.Fatal(err) + } + + cwdDir := filepath.Join(parentDir, "cwd") + overlayDir := filepath.Join(cwdDir, "overlay") + if err := os.MkdirAll(overlayDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(overlayDir, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../../outside/configmap.yaml +`), 0o644); err != nil { + t.Fatal(err) + } + + // Set cwd to cwdDir so the secure root excludes outsideDir. + chdirTemp(t, cwdDir) + + ks := unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "metadata": map[string]interface{}{"name": "test", "namespace": "default"}, + }} + gen := kustomize.NewGenerator(overlayDir, ks) + + backend := inMemoryFsBackend{} + fs, dir, _, err := backend.Generate(gen, overlayDir) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + // Build must fail because the resource is outside the secure root. + _, err = kustomize.Build(fs, dir) + if err == nil { + t.Fatal("expected error when referencing resource outside cwd, got nil") + } +} diff --git a/internal/build/diff.go b/internal/build/diff.go index 4f32bddf..8884e57f 100644 --- a/internal/build/diff.go +++ b/internal/build/diff.go @@ -230,7 +230,7 @@ func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (s WithRecursive(b.recursive), WithLocalSources(b.localSources), WithSingleKustomization(), - withBackend(b.backend), + withFsBackend(b.fsBackend), ) if err != nil { return "", false, err From 5142a93367f578afbf1a6f7b4fb29b64baa4cc4b Mon Sep 17 00:00:00 2001 From: rycli Date: Mon, 30 Mar 2026 19:49:55 +0200 Subject: [PATCH 5/5] test: add new flag combination tests Signed-off-by: rycli --- cmd/flux/build_kustomization_test.go | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cmd/flux/build_kustomization_test.go b/cmd/flux/build_kustomization_test.go index 7b49506e..b9fde3c0 100644 --- a/cmd/flux/build_kustomization_test.go +++ b/cmd/flux/build_kustomization_test.go @@ -52,6 +52,12 @@ func TestBuildKustomization(t *testing.T) { resultFile: "./testdata/build-kustomization/podinfo-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build podinfo with in-memory-build", + args: "build kustomization podinfo --path ./testdata/build-kustomization/podinfo --in-memory-build", + resultFile: "./testdata/build-kustomization/podinfo-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, { name: "build podinfo without service", args: "build kustomization podinfo --path ./testdata/build-kustomization/delete-service", @@ -70,12 +76,24 @@ func TestBuildKustomization(t *testing.T) { resultFile: "./testdata/build-kustomization/podinfo-with-ignore-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build ignore with in-memory-build", + args: "build kustomization podinfo --path ./testdata/build-kustomization/ignore --ignore-paths \"!configmap.yaml,!secret.yaml\" --in-memory-build", + resultFile: "./testdata/build-kustomization/podinfo-with-ignore-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, { name: "build with recursive", args: "build kustomization podinfo --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization", resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build with recursive and in-memory-build", + args: "build kustomization podinfo --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization --in-memory-build", + resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, } tmpl := map[string]string{ @@ -145,6 +163,12 @@ spec: resultFile: "./testdata/build-kustomization/podinfo-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build podinfo with in-memory-build", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/podinfo --in-memory-build", + resultFile: "./testdata/build-kustomization/podinfo-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, { name: "build podinfo without service", args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/delete-service", @@ -175,6 +199,18 @@ spec: resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build with recursive and in-memory-build", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization --in-memory-build", + resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, + { + name: "build with recursive and in-memory-build in dry-run mode", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization --in-memory-build --dry-run", + resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, } tmpl := map[string]string{ @@ -241,6 +277,12 @@ func TestBuildKustomizationPathNormalization(t *testing.T) { resultFile: "./testdata/build-kustomization/podinfo-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build with absolute path and in-memory-build", + args: "build kustomization podinfo --path " + absTestDataPath + " --in-memory-build", + resultFile: "./testdata/build-kustomization/podinfo-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, { name: "build with complex relative path (parent dir)", args: "build kustomization podinfo --path ./testdata/build-kustomization/../build-kustomization/podinfo",