From e5a53f33b3281f1d3efebaac7440e112141c6c8b Mon Sep 17 00:00:00 2001 From: Boris Kreitchman Date: Fri, 9 Aug 2024 18:11:28 +0300 Subject: [PATCH] Recursively build and diff Kustomizations Signed-off-by: Boris Kreitchman --- cmd/flux/build_kustomization.go | 15 +- cmd/flux/build_kustomization_test.go | 12 + cmd/flux/diff_kustomization.go | 23 +- cmd/flux/diff_kustomization_test.go | 6 + cmd/flux/main_test.go | 4 +- .../build-kustomization/my-app/configmap.yaml | 6 + .../podinfo-with-my-app-result.yaml | 29 +++ .../podinfo-with-my-app/kustomization.yaml | 4 + .../podinfo-with-my-app/my-app.yaml | 14 + .../diff-with-recursive.golden | 2 + .../testdata/diff-kustomization/my-app.yaml | 18 ++ internal/build/build.go | 172 +++++++++++-- internal/build/build_test.go | 240 ++++++++++++++++++ internal/build/diff.go | 82 +++++- 14 files changed, 599 insertions(+), 28 deletions(-) create mode 100644 cmd/flux/testdata/build-kustomization/my-app/configmap.yaml create mode 100644 cmd/flux/testdata/build-kustomization/podinfo-with-my-app-result.yaml create mode 100644 cmd/flux/testdata/build-kustomization/podinfo-with-my-app/kustomization.yaml create mode 100644 cmd/flux/testdata/build-kustomization/podinfo-with-my-app/my-app.yaml create mode 100644 cmd/flux/testdata/diff-kustomization/diff-with-recursive.golden create mode 100644 cmd/flux/testdata/diff-kustomization/my-app.yaml diff --git a/cmd/flux/build_kustomization.go b/cmd/flux/build_kustomization.go index 96fb5b9b93..dc60697860 100644 --- a/cmd/flux/build_kustomization.go +++ b/cmd/flux/build_kustomization.go @@ -53,7 +53,12 @@ flux build kustomization my-app --path ./path/to/local/manifests \ # Exclude files by providing a comma separated list of entries that follow the .gitignore pattern fromat. flux build kustomization my-app --path ./path/to/local/manifests \ --kustomization-file ./path/to/local/my-app.yaml \ - --ignore-paths "/to_ignore/**/*.yaml,ignore.yaml"`, + --ignore-paths "/to_ignore/**/*.yaml,ignore.yaml + +# Run recursively on all encountered Kustomizations +flux build kustomization my-app --path ./path/to/local/manifests \ + --recursive \ + --local-sources GitRepository/flux-system/my-repo=./path/to/local/git"`, ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)), RunE: buildKsCmdRun, } @@ -64,6 +69,8 @@ type buildKsFlags struct { ignorePaths []string dryRun bool strictSubst bool + recursive bool + localSources map[string]string } var buildKsArgs buildKsFlags @@ -75,6 +82,8 @@ func init() { buildKsCmd.Flags().BoolVar(&buildKsArgs.dryRun, "dry-run", false, "Dry run mode.") buildKsCmd.Flags().BoolVar(&buildKsArgs.strictSubst, "strict-substitute", false, "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 diff Kustomizations") + buildKsCmd.Flags().StringToStringVar(&buildKsArgs.localSources, "local-sources", nil, "Comma-separated list of repositories in format: Kind/namespace/name=path") buildCmd.AddCommand(buildKsCmd) } @@ -111,6 +120,8 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) { build.WithNamespace(*kubeconfigArgs.Namespace), build.WithIgnore(buildKsArgs.ignorePaths), build.WithStrictSubstitute(buildKsArgs.strictSubst), + build.WithRecursive(buildKsArgs.recursive), + build.WithLocalSources(buildKsArgs.localSources), ) } else { builder, err = build.NewBuilder(name, buildKsArgs.path, @@ -119,6 +130,8 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) { build.WithKustomizationFile(buildKsArgs.kustomizationFile), build.WithIgnore(buildKsArgs.ignorePaths), build.WithStrictSubstitute(buildKsArgs.strictSubst), + build.WithRecursive(buildKsArgs.recursive), + build.WithLocalSources(buildKsArgs.localSources), ) } diff --git a/cmd/flux/build_kustomization_test.go b/cmd/flux/build_kustomization_test.go index 657171a556..f5635977ba 100644 --- a/cmd/flux/build_kustomization_test.go +++ b/cmd/flux/build_kustomization_test.go @@ -70,6 +70,12 @@ func TestBuildKustomization(t *testing.T) { 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", + }, } tmpl := map[string]string{ @@ -157,6 +163,12 @@ spec: resultFile: "./testdata/build-kustomization/podinfo-with-var-substitution-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build with recursive", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --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", + }, } tmpl := map[string]string{ diff --git a/cmd/flux/diff_kustomization.go b/cmd/flux/diff_kustomization.go index 0b85a2b1df..97ad7ba4ac 100644 --- a/cmd/flux/diff_kustomization.go +++ b/cmd/flux/diff_kustomization.go @@ -44,7 +44,12 @@ flux diff kustomization my-app --path ./path/to/local/manifests \ # Exclude files by providing a comma separated list of entries that follow the .gitignore pattern fromat. flux diff kustomization my-app --path ./path/to/local/manifests \ --kustomization-file ./path/to/local/my-app.yaml \ - --ignore-paths "/to_ignore/**/*.yaml,ignore.yaml"`, + --ignore-paths "/to_ignore/**/*.yaml,ignore.yaml + +# Run recursively on all encountered Kustomizations +flux diff kustomization my-app --path ./path/to/local/manifests \ + --recursive \ + --local-sources GitRepository/flux-system/my-repo=./path/to/local/git"`, ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)), RunE: diffKsCmdRun, } @@ -55,6 +60,8 @@ type diffKsFlags struct { ignorePaths []string progressBar bool strictSubst bool + recursive bool + localSources map[string]string } var diffKsArgs diffKsFlags @@ -66,6 +73,8 @@ func init() { diffKsCmd.Flags().StringVar(&diffKsArgs.kustomizationFile, "kustomization-file", "", "Path to the Flux Kustomization YAML file.") diffKsCmd.Flags().BoolVar(&diffKsArgs.strictSubst, "strict-substitute", false, "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") diffCmd.AddCommand(diffKsCmd) } @@ -101,6 +110,9 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { build.WithProgressBar(), build.WithIgnore(diffKsArgs.ignorePaths), build.WithStrictSubstitute(diffKsArgs.strictSubst), + build.WithRecursive(diffKsArgs.recursive), + build.WithLocalSources(diffKsArgs.localSources), + build.WithSingleKustomization(), ) } else { builder, err = build.NewBuilder(name, diffKsArgs.path, @@ -109,6 +121,9 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { build.WithKustomizationFile(diffKsArgs.kustomizationFile), build.WithIgnore(diffKsArgs.ignorePaths), build.WithStrictSubstitute(diffKsArgs.strictSubst), + build.WithRecursive(diffKsArgs.recursive), + build.WithLocalSources(diffKsArgs.localSources), + build.WithSingleKustomization(), ) } @@ -138,6 +153,12 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { select { case <-sigc: + if diffKsArgs.progressBar { + err := builder.StopSpinner() + if err != nil { + return err + } + } fmt.Println("Build cancelled... exiting.") return builder.Cancel() case err := <-errChan: diff --git a/cmd/flux/diff_kustomization_test.go b/cmd/flux/diff_kustomization_test.go index a381a61f0a..b6d4e9afd1 100644 --- a/cmd/flux/diff_kustomization_test.go +++ b/cmd/flux/diff_kustomization_test.go @@ -97,6 +97,12 @@ func TestDiffKustomization(t *testing.T) { objectFile: "", assert: assertGoldenFile("./testdata/diff-kustomization/nothing-is-deployed.golden"), }, + { + name: "diff with recursive", + args: "diff kustomization podinfo --path ./testdata/build-kustomization/podinfo-with-my-app --progress-bar=false --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization", + objectFile: "./testdata/diff-kustomization/my-app.yaml", + assert: assertGoldenFile("./testdata/diff-kustomization/diff-with-recursive.golden"), + }, } tmpl := map[string]string{ diff --git a/cmd/flux/main_test.go b/cmd/flux/main_test.go index 5f2afe6083..b13bb6ce73 100644 --- a/cmd/flux/main_test.go +++ b/cmd/flux/main_test.go @@ -429,7 +429,9 @@ func resetCmdArgs() { tail: -1, fluxNamespace: rootArgs.defaults.Namespace, } - buildKsArgs = buildKsFlags{} + buildKsArgs = buildKsFlags{ + localSources: map[string]string{}, + } checkArgs = checkFlags{} createArgs = createFlags{} deleteArgs = deleteFlags{} diff --git a/cmd/flux/testdata/build-kustomization/my-app/configmap.yaml b/cmd/flux/testdata/build-kustomization/my-app/configmap.yaml new file mode 100644 index 0000000000..6b548db516 --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/my-app/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +data: + var: test +kind: ConfigMap +metadata: + name: my-app diff --git a/cmd/flux/testdata/build-kustomization/podinfo-with-my-app-result.yaml b/cmd/flux/testdata/build-kustomization/podinfo-with-my-app-result.yaml new file mode 100644 index 0000000000..55567c355c --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/podinfo-with-my-app-result.yaml @@ -0,0 +1,29 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: podinfo + kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }} + name: my-app + namespace: default +spec: + force: true + interval: 5m0s + path: ./my-app + prune: true + sourceRef: + kind: GitRepository + name: podinfo + targetNamespace: default +--- +apiVersion: v1 +data: + var: test +kind: ConfigMap +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: my-app + kustomize.toolkit.fluxcd.io/namespace: default + name: my-app + namespace: default +--- diff --git a/cmd/flux/testdata/build-kustomization/podinfo-with-my-app/kustomization.yaml b/cmd/flux/testdata/build-kustomization/podinfo-with-my-app/kustomization.yaml new file mode 100644 index 0000000000..01cd2d4bfa --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/podinfo-with-my-app/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ./my-app.yaml diff --git a/cmd/flux/testdata/build-kustomization/podinfo-with-my-app/my-app.yaml b/cmd/flux/testdata/build-kustomization/podinfo-with-my-app/my-app.yaml new file mode 100644 index 0000000000..794366a753 --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/podinfo-with-my-app/my-app.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: my-app +spec: + interval: 5m0s + path: ./my-app + force: true + prune: true + sourceRef: + kind: GitRepository + name: podinfo + targetNamespace: default diff --git a/cmd/flux/testdata/diff-kustomization/diff-with-recursive.golden b/cmd/flux/testdata/diff-kustomization/diff-with-recursive.golden new file mode 100644 index 0000000000..67056badcc --- /dev/null +++ b/cmd/flux/testdata/diff-kustomization/diff-with-recursive.golden @@ -0,0 +1,2 @@ +📁 Kustomization/default/my-app changed +► ConfigMap/default/my-app created diff --git a/cmd/flux/testdata/diff-kustomization/my-app.yaml b/cmd/flux/testdata/diff-kustomization/my-app.yaml new file mode 100644 index 0000000000..91bb0688cb --- /dev/null +++ b/cmd/flux/testdata/diff-kustomization/my-app.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: podinfo + kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }} + name: my-app + namespace: default +spec: + interval: 5m0s + path: ./my-app + force: true + prune: true + sourceRef: + kind: GitRepository + name: podinfo + targetNamespace: default diff --git a/internal/build/build.go b/internal/build/build.go index d602a11a69..7328a573ef 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -25,11 +25,13 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "sync" "time" "github.com/theckman/yacspin" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -51,13 +53,14 @@ import ( ) const ( - controllerName = "kustomize-controller" - controllerGroup = "kustomize.toolkit.fluxcd.io" - mask = "**SOPS**" - dockercfgSecretType = "kubernetes.io/dockerconfigjson" - typeField = "type" - dataField = "data" - stringDataField = "stringData" + controllerName = "kustomize-controller" + controllerGroup = "kustomize.toolkit.fluxcd.io" + mask = "**SOPS**" + dockercfgSecretType = "kubernetes.io/dockerconfigjson" + typeField = "type" + dataField = "data" + stringDataField = "stringData" + spinnerDryRunMessage = "running dry-run" ) var defaultTimeout = 80 * time.Second @@ -81,6 +84,10 @@ type Builder struct { spinner *yacspin.Spinner dryRun bool strictSubst bool + recursive bool + localSources map[string]string + // diff needs to handle kustomizations one by one + singleKustomization bool } // BuilderOptionFunc is a function that configures a Builder @@ -110,7 +117,7 @@ func WithProgressBar() BuilderOptionFunc { CharSet: yacspin.CharSets[59], Suffix: "Kustomization diffing...", SuffixAutoColon: true, - Message: "running dry-run", + Message: spinnerDryRunMessage, StopCharacter: "✓", StopColors: []string{"fgGreen"}, } @@ -175,6 +182,55 @@ func WithIgnore(ignore []string) BuilderOptionFunc { } } +// WithRecursive sets the recursive field +func WithRecursive(recursive bool) BuilderOptionFunc { + return func(b *Builder) error { + b.recursive = recursive + return nil + } +} + +// WithLocalSources sets the local sources field +func WithLocalSources(localSources map[string]string) BuilderOptionFunc { + return func(b *Builder) error { + b.localSources = localSources + return nil + } +} + +// WithSingleKustomization sets the single kustomization field to true +func WithSingleKustomization() BuilderOptionFunc { + return func(b *Builder) error { + b.singleKustomization = true + return nil + } +} + +// withClientConfigFrom copies client and restMapper fields +func withClientConfigFrom(in *Builder) BuilderOptionFunc { + return func(b *Builder) error { + b.client = in.client + b.restMapper = in.restMapper + return nil + } +} + +// withClientConfigFrom copies spinner field +func withSpinnerFrom(in *Builder) BuilderOptionFunc { + return func(b *Builder) error { + b.spinner = in.spinner + return nil + } +} + +// withKustomization sets the kustomization field +func withKustomization(k *kustomizev1.Kustomization) BuilderOptionFunc { + return func(b *Builder) error { + b.kustomization = k + return nil + } +} + // NewBuilder returns a new Builder // It takes a kustomization name and a path to the resources // It also takes a list of BuilderOptionFunc to configure the builder @@ -269,6 +325,27 @@ func (b *Builder) Build() ([]*unstructured.Unstructured, error) { ssautil.SetCommonMetadata(objects, m.Labels, m.Annotations) } + if b.recursive && !b.singleKustomization { + var objectsToAdd []*unstructured.Unstructured + for _, obj := range objects { + if isKustomization(obj) { + k, err := toKustomization(obj) + if err != nil { + return nil, err + } + + if !kustomizationsEqual(k, b.kustomization) { + subObjects, err := b.kustomizationBuild(k) + if err != nil { + return nil, err + } + objectsToAdd = append(objectsToAdd, subObjects...) + } + } + } + objects = append(objects, objectsToAdd...) + } + return objects, nil } @@ -281,7 +358,11 @@ func (b *Builder) build() (m resmap.ResMap, err error) { if !b.dryRun { liveKus, err = b.getKustomization(ctx) if err != nil { - return nil, fmt.Errorf("failed to get kustomization object: %w", err) + if !apierrors.IsNotFound(err) || b.kustomization == nil { + return nil, fmt.Errorf("failed to get kustomization object: %w", err) + } + // use provided Kustomization + liveKus = b.kustomization } } k, err := b.resolveKustomization(liveKus) @@ -334,6 +415,46 @@ func (b *Builder) build() (m resmap.ResMap, err error) { } +func (b *Builder) kustomizationBuild(k *kustomizev1.Kustomization) ([]*unstructured.Unstructured, error) { + resourcesPath, err := b.kustomizationPath(k) + if err != nil { + return nil, err + } + + subBuilder, err := NewBuilder(k.Name, resourcesPath, + // use same client + withClientConfigFrom(b), + // kustomization will be used if there is no live kustomization + withKustomization(k), + WithTimeout(b.timeout), + WithNamespace(k.Namespace), + WithIgnore(b.ignore), + WithStrictSubstitute(b.strictSubst), + WithRecursive(b.recursive), + WithLocalSources(b.localSources), + ) + if err != nil { + return nil, err + } + + return subBuilder.Build() +} + +func (b *Builder) kustomizationPath(k *kustomizev1.Kustomization) (string, error) { + sourceRef := k.Spec.SourceRef.DeepCopy() + if sourceRef.Namespace == "" { + sourceRef.Namespace = k.Namespace + } + + sourceKey := sourceRef.String() + localPath, ok := b.localSources[sourceKey] + if !ok { + return "", fmt.Errorf("cannot get local path for %s of kustomization %s", sourceKey, k.Name) + } + + return filepath.Join(localPath, k.Spec.Path), nil +} + func (b *Builder) unMarshallKustomization() (*kustomizev1.Kustomization, error) { data, err := os.ReadFile(b.kustomizationFile) if err != nil { @@ -423,6 +544,28 @@ func (b *Builder) do(ctx context.Context, kustomization kustomizev1.Kustomizatio return m, nil } +func isKustomization(object *unstructured.Unstructured) bool { + return strings.HasPrefix(object.GetAPIVersion(), kustomizev1.GroupVersion.Group+"/") && + object.GetKind() == kustomizev1.KustomizationKind +} + +func toKustomization(object *unstructured.Unstructured) (*kustomizev1.Kustomization, error) { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(object) + if err != nil { + return nil, fmt.Errorf("failed to convert to unstructured: %w", err) + } + k := &kustomizev1.Kustomization{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj, k) + if err != nil { + return nil, fmt.Errorf("failed to convert to kustomization: %w", err) + } + return k, nil +} + +func kustomizationsEqual(k1 *kustomizev1.Kustomization, k2 *kustomizev1.Kustomization) bool { + return k1.Name == k2.Name && k1.Namespace == k2.Namespace +} + func (b *Builder) setOwnerLabels(res *resource.Resource) error { labels := res.GetLabels() @@ -583,12 +726,7 @@ func (b *Builder) Cancel() error { b.mu.Lock() defer b.mu.Unlock() - err := b.stopSpinner() - if err != nil { - return err - } - - err = kustomize.CleanDirectory(b.resourcesPath, b.action) + err := kustomize.CleanDirectory(b.resourcesPath, b.action) if err != nil { return err } @@ -596,7 +734,7 @@ func (b *Builder) Cancel() error { return nil } -func (b *Builder) startSpinner() error { +func (b *Builder) StartSpinner() error { if b.spinner == nil { return nil } @@ -609,7 +747,7 @@ func (b *Builder) startSpinner() error { return nil } -func (b *Builder) stopSpinner() error { +func (b *Builder) StopSpinner() error { if b.spinner == nil { return nil } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index be62abc50c..affc0d6984 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -26,6 +26,7 @@ import ( "github.com/fluxcd/pkg/apis/meta" "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/yaml" ) @@ -361,3 +362,242 @@ func Test_ResolveKustomization(t *testing.T) { }) } } + +func Test_isKustomization(t *testing.T) { + tests := []struct { + name string + expected bool + object *unstructured.Unstructured + }{ + { + name: "flux kustomization", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + }, + }, + expected: true, + }, + { + name: "other kustomization", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "kustomize.config.k8s.io/v1beta1", + "kind": "Kustomization", + }, + }, + expected: false, + }, + { + name: "wrong kind", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "ConfigMap", + }, + }, + expected: false, + }, + { + name: "wrong object", + object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := isKustomization(tt.object) + if actual != tt.expected { + t.Fatalf("got '%v', want '%v'", actual, tt.expected) + } + }) + } +} + +func Test_kustomizationsEqual(t *testing.T) { + tests := []struct { + name string + kustomization1 *kustomizev1.Kustomization + kustomization2 *kustomizev1.Kustomization + expected bool + }{ + { + name: "equal", + kustomization1: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podinfo", + Namespace: "flux-system", + }, + }, + kustomization2: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podinfo", + Namespace: "flux-system", + }, + }, + expected: true, + }, + { + name: "wrong name", + kustomization1: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podinfo", + Namespace: "flux-system", + }, + }, + kustomization2: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "flux-system", + }, + }, + expected: false, + }, + { + name: "wrong namespace", + kustomization1: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podinfo", + Namespace: "flux-system", + }, + }, + kustomization2: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podinfo", + Namespace: "my-ns", + }, + }, + expected: false, + }, + { + name: "wrong name and namespace", + kustomization1: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podinfo", + Namespace: "flux-system", + }, + }, + kustomization2: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "my-ns", + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := kustomizationsEqual(tt.kustomization1, tt.kustomization2) + if actual != tt.expected { + t.Fatalf("got '%v', want '%v'", actual, tt.expected) + } + }) + } +} + +func Test_kustomizationPath(t *testing.T) { + tests := []struct { + name string + kustomization *kustomizev1.Kustomization + expected string + wantErr bool + errString string + }{ + { + name: "full repo", + kustomization: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "flux-system", + }, + Spec: kustomizev1.KustomizationSpec{ + Path: "my-path", + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: "GitRepository", + Name: "my-repo", + Namespace: "flux-system", + }, + }, + }, + expected: "path/to/local/git/my-path", + }, + { + name: "repo without namespace", + kustomization: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "flux-system", + }, + Spec: kustomizev1.KustomizationSpec{ + Path: "my-path", + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: "GitRepository", + Name: "my-repo", + Namespace: "", + }, + }, + }, + expected: "path/to/local/git/my-path", + }, + { + name: "repo not found", + kustomization: &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "flux-system", + }, + Spec: kustomizev1.KustomizationSpec{ + Path: "my-path", + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: "GitRepository", + Name: "my-repo", + Namespace: "my-ns", + }, + }, + }, + wantErr: true, + errString: "cannot get local path", + }, + } + + b := &Builder{ + name: "podinfo", + namespace: "flux-system", + localSources: map[string]string{ + "GitRepository/flux-system/my-repo": "./path/to/local/git", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := b.kustomizationPath(tt.kustomization) + if !tt.wantErr { + if err != nil { + t.Fatalf("unexpected err '%s'", err) + } + + if actual != tt.expected { + t.Errorf("got '%v', want '%v'", actual, tt.expected) + } + } else { + if err == nil { + t.Fatal("expected error but got nil") + } + + if !strings.Contains(err.Error(), tt.errString) { + t.Errorf("expected error '%s' to contain string '%s'", err.Error(), tt.errString) + } + } + + }) + } +} diff --git a/internal/build/diff.go b/internal/build/diff.go index 7d87c6c925..e13ecbb440 100644 --- a/internal/build/diff.go +++ b/internal/build/diff.go @@ -57,6 +57,22 @@ func (b *Builder) Manager() (*ssa.ResourceManager, error) { } func (b *Builder) Diff() (string, bool, error) { + err := b.StartSpinner() + if err != nil { + return "", false, err + } + + output, createdOrDrifted, diffErr := b.diff() + + err = b.StopSpinner() + if err != nil { + return "", false, err + } + + return output, createdOrDrifted, diffErr +} + +func (b *Builder) diff() (string, bool, error) { output := strings.Builder{} createdOrDrifted := false objects, err := b.Build() @@ -77,11 +93,6 @@ func (b *Builder) Diff() (string, bool, error) { ctx, cancel := context.WithTimeout(context.Background(), b.timeout) defer cancel() - err = b.startSpinner() - if err != nil { - return "", false, err - } - var diffErrs []error // create an inventory of objects to be reconciled newInventory := newInventory() @@ -127,6 +138,30 @@ func (b *Builder) Diff() (string, bool, error) { } addObjectsToInventory(newInventory, change) + + if b.recursive && isKustomization(obj) && change.Action != ssa.CreatedAction { + k, err := toKustomization(obj) + if err != nil { + return "", createdOrDrifted, err + } + + if !kustomizationsEqual(k, b.kustomization) { + subOutput, subCreatedOrDrifted, err := b.kustomizationDiff(k) + if err != nil { + diffErrs = append(diffErrs, err) + } + if subCreatedOrDrifted { + createdOrDrifted = true + output.WriteString(bunt.Sprint(fmt.Sprintf("📁 %s changed\n", ssautil.FmtUnstructured(obj)))) + output.WriteString(subOutput) + } + + // finished with Kustomization diff + if b.spinner != nil { + b.spinner.Message(spinnerDryRunMessage) + } + } + } } if b.spinner != nil { @@ -149,12 +184,43 @@ func (b *Builder) Diff() (string, bool, error) { } } - err = b.stopSpinner() + return output.String(), createdOrDrifted, errors.Reduce(errors.Flatten(errors.NewAggregate(diffErrs))) +} + +func (b *Builder) kustomizationDiff(kustomization *kustomizev1.Kustomization) (string, bool, error) { + if b.spinner != nil { + b.spinner.Message(fmt.Sprintf("%s in %s", spinnerDryRunMessage, kustomization.Name)) + } + + sourceRef := kustomization.Spec.SourceRef.DeepCopy() + if sourceRef.Namespace == "" { + sourceRef.Namespace = kustomization.Namespace + } + + sourceKey := sourceRef.String() + localPath, ok := b.localSources[sourceKey] + if !ok { + return "", false, fmt.Errorf("cannot get local path for %s of kustomization %s", sourceKey, kustomization.Name) + } + + resourcesPath := filepath.Join(localPath, kustomization.Spec.Path) + subBuilder, err := NewBuilder(kustomization.Name, resourcesPath, + // use same client and spinner + withClientConfigFrom(b), + withSpinnerFrom(b), + WithTimeout(b.timeout), + WithNamespace(kustomization.Namespace), + WithIgnore(b.ignore), + WithStrictSubstitute(b.strictSubst), + WithRecursive(b.recursive), + WithLocalSources(b.localSources), + WithSingleKustomization(), + ) if err != nil { - return "", createdOrDrifted, err + return "", false, err } - return output.String(), createdOrDrifted, errors.Reduce(errors.Flatten(errors.NewAggregate(diffErrs))) + return subBuilder.diff() } func writeYamls(liveObject, mergedObject *unstructured.Unstructured) (string, string, string, error) {