From 457c5195529c808ad6a21030d9589144bb383514 Mon Sep 17 00:00:00 2001 From: Soule BA Date: Mon, 4 Apr 2022 14:11:10 +0200 Subject: [PATCH] Add an option to diff with a local kustomization file If implemented, users will be able to provide a local kustomization file to `flux build/diff`. Signed-off-by: Soule BA --- cmd/flux/build_kustomization.go | 21 ++++-- cmd/flux/build_kustomization_test.go | 103 ++++++++++++++++++++++++++- cmd/flux/diff_kustomization.go | 21 ++++-- internal/build/build.go | 67 ++++++++++++++--- 4 files changed, 194 insertions(+), 18 deletions(-) diff --git a/cmd/flux/build_kustomization.go b/cmd/flux/build_kustomization.go index 4128097673..10917d1896 100644 --- a/cmd/flux/build_kustomization.go +++ b/cmd/flux/build_kustomization.go @@ -33,21 +33,28 @@ var buildKsCmd = &cobra.Command{ Short: "Build Kustomization", Long: `The build command queries the Kubernetes API and fetches the specified Flux Kustomization. It then uses the fetched in cluster flux kustomization to perform needed transformation on the local kustomization.yaml -pointed at by --path. The local kustomization.yaml is generated if it does not exist. Finally it builds the overlays using the local kustomization.yaml, and write the resulting multi-doc YAML to stdout.`, +pointed at by --path. The local kustomization.yaml is generated if it does not exist. Finally it builds the overlays using the local kustomization.yaml, and write the resulting multi-doc YAML to stdout. + +It is possible to specify a kustomization.yaml path using --kustomization-path. If specified, the kustomization.yaml name is expected to be the same as the name of the resource with a .yaml extension.`, Example: `# Build the local manifests as they were built on the cluster -flux build kustomization my-app --path ./path/to/local/manifests`, +flux build kustomization my-app --path ./path/to/local/manifests + +# Build using a local flux kustomization file +flux build kustomization my-app --path ./path/to/local/manifests --kustomization-path ./path/to/kustomization.yaml`, ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)), RunE: buildKsCmdRun, } type buildKsFlags struct { - path string + kustomizationPath string + path string } var buildKsArgs buildKsFlags func init() { buildKsCmd.Flags().StringVar(&buildKsArgs.path, "path", "", "Path to the manifests location.)") + buildKsCmd.Flags().StringVar(&buildKsArgs.kustomizationPath, "kustomization-path", "", "Path to the kustomization location.)") buildCmd.AddCommand(buildKsCmd) } @@ -65,7 +72,13 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid resource path %q", buildKsArgs.path) } - builder, err := build.NewBuilder(kubeconfigArgs, kubeclientOptions, name, buildKsArgs.path, build.WithTimeout(rootArgs.timeout)) + if buildKsArgs.kustomizationPath != "" { + if fs, err := os.Stat(buildKsArgs.kustomizationPath); err != nil || !fs.IsDir() { + return fmt.Errorf("invalid kustomization path %q", buildKsArgs.kustomizationPath) + } + } + + builder, err := build.NewBuilder(kubeconfigArgs, kubeclientOptions, name, buildKsArgs.path, build.WithTimeout(rootArgs.timeout), build.WithKustomizationPath(buildKsArgs.kustomizationPath)) if err != nil { return err } diff --git a/cmd/flux/build_kustomization_test.go b/cmd/flux/build_kustomization_test.go index 03826d0471..9387aff011 100644 --- a/cmd/flux/build_kustomization_test.go +++ b/cmd/flux/build_kustomization_test.go @@ -20,7 +20,10 @@ limitations under the License. package main import ( + "bytes" + "os" "testing" + "text/template" ) func setup(t *testing.T, tmpl map[string]string) { @@ -55,7 +58,7 @@ func TestBuildKustomization(t *testing.T) { assertFunc: "assertGoldenTemplateFile", }, { - name: "build deployment and configmpa with var substitution", + name: "build deployment and configmap with var substitution", args: "build kustomization podinfo --path ./testdata/build-kustomization/var-substitution", resultFile: "./testdata/build-kustomization/podinfo-with-var-substitution-result.yaml", assertFunc: "assertGoldenTemplateFile", @@ -87,3 +90,101 @@ func TestBuildKustomization(t *testing.T) { }) } } + +func TestBuildLocalKustomization(t *testing.T) { + podinfo := `apiVersion: kustomize.toolkit.fluxcd.io/v1beta2 +kind: Kustomization +metadata: + name: podinfo + namespace: {{ .fluxns }} +spec: + interval: 5m0s + path: ./kustomize + force: true + prune: true + sourceRef: + kind: GitRepository + name: podinfo + targetNamespace: default + postBuild: + substitute: + cluster_env: "prod" + cluster_region: "eu-central-1" +` + + tests := []struct { + name string + args string + resultFile string + assertFunc string + }{ + { + name: "no args", + args: "build kustomization podinfo --kustomization-path ./wrongpath/ --path ./testdata/build-kustomization/podinfo", + resultFile: "invalid kustomization path \"./wrongpath/\"", + assertFunc: "assertError", + }, + { + name: "build podinfo", + args: "build kustomization podinfo --kustomization-path ./testdata/build-kustomization --path ./testdata/build-kustomization/podinfo", + resultFile: "./testdata/build-kustomization/podinfo-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, + { + name: "build podinfo without service", + args: "build kustomization podinfo --kustomization-path ./testdata/build-kustomization --path ./testdata/build-kustomization/delete-service", + resultFile: "./testdata/build-kustomization/podinfo-without-service-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, + { + name: "build deployment and configmap with var substitution", + args: "build kustomization podinfo --kustomization-path ./testdata/build-kustomization --path ./testdata/build-kustomization/var-substitution", + resultFile: "./testdata/build-kustomization/podinfo-with-var-substitution-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, + } + + tmpl := map[string]string{ + "fluxns": allocateNamespace("flux-system"), + } + + testEnv.CreateObjectFile("./testdata/build-kustomization/podinfo-source.yaml", tmpl, t) + + temp, err := template.New("podinfo").Parse(podinfo) + if err != nil { + t.Fatal(err) + } + + var b bytes.Buffer + err = temp.Execute(&b, tmpl) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile("./testdata/build-kustomization/podinfo.yaml", b.Bytes(), 0666) + if err != nil { + t.Fatal(err) + } + + defer os.Remove("./testdata/build-kustomization/podinfo.yaml") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var assert assertFunc + + switch tt.assertFunc { + case "assertGoldenTemplateFile": + assert = assertGoldenTemplateFile(tt.resultFile, tmpl) + case "assertError": + assert = assertError(tt.resultFile) + } + + cmd := cmdTestCase{ + args: tt.args + " -n " + tmpl["fluxns"], + assert: assert, + } + + cmd.runTestCmd(t) + }) + } +} diff --git a/cmd/flux/diff_kustomization.go b/cmd/flux/diff_kustomization.go index 8be50fc1c3..929367fc9d 100644 --- a/cmd/flux/diff_kustomization.go +++ b/cmd/flux/diff_kustomization.go @@ -34,14 +34,18 @@ var diffKsCmd = &cobra.Command{ Long: `The diff command does a build, then it performs a server-side dry-run and prints the diff. Exit status: 0 No differences were found. 1 Differences were found. >1 diff failed with an error.`, Example: `# Preview local changes as they were applied on the cluster -flux diff kustomization my-app --path ./path/to/local/manifests`, +flux diff kustomization my-app --path ./path/to/local/manifests + +# Preview using a local flux kustomization file +flux diff kustomization my-app --path ./path/to/local/manifests --kustomization-path ./path/to/kustomization.yaml`, ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)), RunE: diffKsCmdRun, } type diffKsFlags struct { - path string - progressBar bool + kustomizationPath string + path string + progressBar bool } var diffKsArgs diffKsFlags @@ -49,6 +53,7 @@ var diffKsArgs diffKsFlags func init() { diffKsCmd.Flags().StringVar(&diffKsArgs.path, "path", "", "Path to a local directory that matches the specified Kustomization.spec.path.)") diffKsCmd.Flags().BoolVar(&diffKsArgs.progressBar, "progress-bar", true, "Boolean to set the progress bar. The default value is true.") + diffKsCmd.Flags().StringVar(&diffKsArgs.kustomizationPath, "kustomization-path", "", "Path to the kustomization location.)") diffCmd.AddCommand(diffKsCmd) } @@ -66,12 +71,18 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { return &RequestError{StatusCode: 2, Err: fmt.Errorf("invalid resource path %q", diffKsArgs.path)} } + if diffKsArgs.kustomizationPath != "" { + if fs, err := os.Stat(diffKsArgs.kustomizationPath); err != nil || !fs.IsDir() { + return fmt.Errorf("invalid kustomization path %q", diffKsArgs.kustomizationPath) + } + } + var builder *build.Builder var err error if diffKsArgs.progressBar { - builder, err = build.NewBuilder(kubeconfigArgs, kubeclientOptions, name, diffKsArgs.path, build.WithTimeout(rootArgs.timeout), build.WithProgressBar()) + builder, err = build.NewBuilder(kubeconfigArgs, kubeclientOptions, name, diffKsArgs.path, build.WithTimeout(rootArgs.timeout), build.WithKustomizationPath(diffKsArgs.kustomizationPath), build.WithProgressBar()) } else { - builder, err = build.NewBuilder(kubeconfigArgs, kubeclientOptions, name, diffKsArgs.path, build.WithTimeout(rootArgs.timeout)) + builder, err = build.NewBuilder(kubeconfigArgs, kubeclientOptions, name, diffKsArgs.path, build.WithTimeout(rootArgs.timeout), build.WithKustomizationPath(diffKsArgs.kustomizationPath)) } if err != nil { diff --git a/internal/build/build.go b/internal/build/build.go index 3eb0844c7d..4368124184 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -22,14 +22,18 @@ import ( "encoding/base64" "encoding/json" "fmt" + "os" + "path/filepath" "sync" "time" + "github.com/hashicorp/go-multierror" "github.com/theckman/yacspin" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/kustomize/api/resmap" @@ -60,11 +64,12 @@ var defaultTimeout = 80 * time.Second // It retrieves the kustomization object from the k8s cluster // and overlays the manifests with the resources specified in the resourcesPath type Builder struct { - client client.WithWatch - restMapper meta.RESTMapper - name string - namespace string - resourcesPath string + client client.WithWatch + restMapper meta.RESTMapper + name string + namespace string + resourcesPath string + kustomizationPath string // mu is used to synchronize access to the kustomization file mu sync.Mutex action kustomize.Action @@ -75,6 +80,13 @@ type Builder struct { type BuilderOptionFunc func(b *Builder) error +func WithKustomizationPath(path string) BuilderOptionFunc { + return func(b *Builder) error { + b.kustomizationPath = path + return nil + } +} + func WithTimeout(timeout time.Duration) BuilderOptionFunc { return func(b *Builder) error { b.timeout = timeout @@ -176,9 +188,18 @@ func (b *Builder) build() (m resmap.ResMap, err error) { defer cancel() // Get the kustomization object - k, err := b.getKustomization(ctx) - if err != nil { - return + k := &kustomizev1.Kustomization{} + if b.kustomizationPath != "" { + k, err = b.unMarshallKustomization() + if err != nil { + return + } + } else { + k, err = b.getKustomization(ctx) + if err != nil { + err = fmt.Errorf("failed to get kustomization object: %w", err) + return + } } // store the kustomization object @@ -225,6 +246,36 @@ func (b *Builder) build() (m resmap.ResMap, err error) { } +func (b *Builder) unMarshallKustomization() (*kustomizev1.Kustomization, error) { + var err error + var path string + for _, ext := range []string{"yaml", "yml"} { + if info, errf := os.Stat(filepath.Join(b.kustomizationPath, fmt.Sprintf("%s.%s", b.name, ext))); os.IsNotExist(errf) || info.IsDir() { + err = multierror.Append(err, fmt.Errorf("kustomization file %s not found: %w", b.kustomizationPath, errf)) + continue + } + path = filepath.Join(b.kustomizationPath, fmt.Sprintf("%s.%s", b.name, ext)) + break + } + + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read kustomization file in path %s: %w", b.kustomizationPath, err) + } + + k := &kustomizev1.Kustomization{} + decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewBuffer(data), len(data)) + err = decoder.Decode(k) + if err != nil { + return nil, fmt.Errorf("failed to unmarshall kustomization file %s: %w", b.kustomizationPath, err) + } + return k, nil +} + func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath string) (kustomize.Action, error) { data, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&kustomization) if err != nil {