From 1ea965ae6900df05e8394fbc5c36635fe3d8415b Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 14 Feb 2022 07:45:54 +0100 Subject: [PATCH] (feat) support for kustomize in gitopsUpdateDeployment step (#3524) * (feat) support for kustomize in gitopsUpdateDeployment step Signed-off-by: Michael Sprauer * add missing documentation * add another detail in the documentation Signed-off-by: Michael Sprauer * generate again the update doc Signed-off-by: Michael Sprauer Co-authored-by: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com> --- cmd/gitopsUpdateDeployment.go | 57 +++++++ cmd/gitopsUpdateDeployment_generated.go | 14 +- cmd/gitopsUpdateDeployment_test.go | 154 ++++++++++++++++-- .../metadata/gitopsUpdateDeployment.yaml | 27 ++- 4 files changed, 230 insertions(+), 22 deletions(-) diff --git a/cmd/gitopsUpdateDeployment.go b/cmd/gitopsUpdateDeployment.go index dce36838e..01bfdf33a 100644 --- a/cmd/gitopsUpdateDeployment.go +++ b/cmd/gitopsUpdateDeployment.go @@ -20,6 +20,7 @@ import ( const toolKubectl = "kubectl" const toolHelm = "helm" +const toolKustomize = "kustomize" type iGitopsUpdateDeploymentGitUtils interface { CommitSingleFile(filePath, commitMessage, author string) (plumbing.Hash, error) @@ -38,6 +39,7 @@ type gitopsUpdateDeploymentExecRunner interface { RunExecutable(executable string, params ...string) error Stdout(out io.Writer) Stderr(err io.Writer) + SetDir(dir string) } type gitopsUpdateDeploymentGitUtils struct { @@ -121,6 +123,11 @@ func runGitopsUpdateDeployment(config *gitopsUpdateDeploymentOptions, command gi if err != nil { return errors.Wrap(err, "failed to apply helm command") } + } else if config.Tool == toolKustomize { + outputBytes, err = runKustomizeCommand(command, config, filePath) + if err != nil { + return errors.Wrap(err, "failed to apply kustomize command") + } } else { log.SetErrorCategory(log.ErrorConfiguration) return errors.New("tool " + config.Tool + " is not supported") @@ -154,6 +161,12 @@ func checkRequiredFieldsForDeployTool(config *gitopsUpdateDeploymentOptions) err return errors.Wrap(err, "missing required fields for kubectl") } logNotRequiredButFilledFieldForKubectl(config) + } else if config.Tool == toolKustomize { + err := checkRequiredFieldsForKustomize(config) + if err != nil { + return errors.Wrap(err, "missing required fields for kustomize") + } + logNotRequiredButFilledFieldForKustomize(config) } return nil @@ -174,6 +187,21 @@ func checkRequiredFieldsForHelm(config *gitopsUpdateDeploymentOptions) error { return nil } +func checkRequiredFieldsForKustomize(config *gitopsUpdateDeploymentOptions) error { + var missingParameters []string + if config.FilePath == "" { + missingParameters = append(missingParameters, "filePath") + } + if config.DeploymentName == "" { + missingParameters = append(missingParameters, "deploymentName") + } + if len(missingParameters) > 0 { + log.SetErrorCategory(log.ErrorConfiguration) + return errors.Errorf("the following parameters are necessary for kustomize: %v", missingParameters) + } + return nil +} + func checkRequiredFieldsForKubectl(config *gitopsUpdateDeploymentOptions) error { var missingParameters []string if config.ContainerName == "" { @@ -203,6 +231,14 @@ func logNotRequiredButFilledFieldForKubectl(config *gitopsUpdateDeploymentOption log.Entry().Info("deploymentName is not used for kubectl and can be removed") } } +func logNotRequiredButFilledFieldForKustomize(config *gitopsUpdateDeploymentOptions) { + if config.ChartPath != "" { + log.Entry().Info("chartPath is not used for kubectl and can be removed") + } + if len(config.HelmValues) > 0 { + log.Entry().Info("helmValues is not used for kubectl and can be removed") + } +} func cloneRepositoryAndChangeBranch(config *gitopsUpdateDeploymentOptions, gitUtils iGitopsUpdateDeploymentGitUtils, temporaryFolder string) error { err := gitUtils.PlainClone(config.Username, config.Password, config.ServerURL, temporaryFolder) @@ -292,6 +328,27 @@ func runHelmCommand(runner gitopsUpdateDeploymentExecRunner, config *gitopsUpdat return helmOutput.Bytes(), nil } +func runKustomizeCommand(runner gitopsUpdateDeploymentExecRunner, config *gitopsUpdateDeploymentOptions, filePath string) ([]byte, error) { + var kustomizeOutput = bytes.Buffer{} + runner.Stdout(&kustomizeOutput) + + kustomizeParams := []string{ + "edit", + "set", + "image", + config.DeploymentName + "=" + config.ContainerImageNameTag, + } + + runner.SetDir(filepath.Dir(filePath)) + + err := runner.RunExecutable(toolKustomize, kustomizeParams...) + if err != nil { + return nil, errors.Wrap(err, "failed to execute kustomize command") + } + + return kustomizeOutput.Bytes(), nil +} + // buildRegistryPlusImageAndTagSeparately combines the registry together with the image name. Handles the tag separately. // Tag is defined by everything on the right hand side of the colon sign. This looks weird for sha container versions but works for helm. func buildRegistryPlusImageAndTagSeparately(config *gitopsUpdateDeploymentOptions) (string, string, error) { diff --git a/cmd/gitopsUpdateDeployment_generated.go b/cmd/gitopsUpdateDeployment_generated.go index ff2d4ba8a..938af785c 100644 --- a/cmd/gitopsUpdateDeployment_generated.go +++ b/cmd/gitopsUpdateDeployment_generated.go @@ -28,7 +28,7 @@ type gitopsUpdateDeploymentOptions struct { ChartPath string `json:"chartPath,omitempty"` HelmValues []string `json:"helmValues,omitempty"` DeploymentName string `json:"deploymentName,omitempty"` - Tool string `json:"tool,omitempty" validate:"possible-values=kubectl helm"` + Tool string `json:"tool,omitempty" validate:"possible-values=kubectl helm kustomize"` } // GitopsUpdateDeploymentCommand Updates Kubernetes Deployment Manifest in an Infrastructure Git Repository @@ -49,9 +49,10 @@ func GitopsUpdateDeploymentCommand() *cobra.Command { It can for example be used for GitOps scenarios where the update of the manifests triggers an update of the corresponding deployment in Kubernetes. -As of today, it supports the update of deployment yaml files via kubectl patch and update a whole helm template. -For kubectl the container inside the yaml must be described within the following hierarchy: ` + "`" + `{"spec":{"template":{"spec":{"containers":[{...}]}}}}` + "`" + ` -For helm the whole template is generated into a file and uploaded into the repository.`, +As of today, it supports the update of deployment yaml files via kubectl patch, update a whole helm template and kustomize. +For *kubectl* the container inside the yaml must be described within the following hierarchy: ` + "`" + `{"spec":{"template":{"spec":{"containers":[{...}]}}}}` + "`" + ` +For *helm* the whole template is generated into a file and uploaded into the repository. +For *kustomize* the ` + "`" + `images` + "`" + ` section will be update with the current image.`, PreRunE: func(cmd *cobra.Command, _ []string) error { startTime = time.Now() log.SetStepName(STEP_NAME) @@ -133,13 +134,13 @@ func addGitopsUpdateDeploymentFlags(cmd *cobra.Command, stepConfig *gitopsUpdate cmd.Flags().StringVar(&stepConfig.ServerURL, "serverUrl", `https://github.com`, "GitHub server url to the repository.") cmd.Flags().StringVar(&stepConfig.Username, "username", os.Getenv("PIPER_username"), "User name for git authentication") cmd.Flags().StringVar(&stepConfig.Password, "password", os.Getenv("PIPER_password"), "Password/token for git authentication.") - cmd.Flags().StringVar(&stepConfig.FilePath, "filePath", os.Getenv("PIPER_filePath"), "Relative path in the git repository to the deployment descriptor file that shall be updated") + cmd.Flags().StringVar(&stepConfig.FilePath, "filePath", os.Getenv("PIPER_filePath"), "Relative path in the git repository to the deployment descriptor file that shall be updated. For different tools this has different semantics:\n\n * `kubectl` - path to the `deployment.yaml` that should be patched.\n * `helm` - not used. Please use `chartPath` instead.\n * `kustomize` - path to the `kustomization.yaml`.\n") cmd.Flags().StringVar(&stepConfig.ContainerName, "containerName", os.Getenv("PIPER_containerName"), "The name of the container to update") cmd.Flags().StringVar(&stepConfig.ContainerRegistryURL, "containerRegistryUrl", os.Getenv("PIPER_containerRegistryUrl"), "http(s) url of the Container registry where the image is located") cmd.Flags().StringVar(&stepConfig.ContainerImageNameTag, "containerImageNameTag", os.Getenv("PIPER_containerImageNameTag"), "Container image name with version tag to annotate in the deployment configuration.") cmd.Flags().StringVar(&stepConfig.ChartPath, "chartPath", os.Getenv("PIPER_chartPath"), "Defines the chart path for deployments using helm.") cmd.Flags().StringSliceVar(&stepConfig.HelmValues, "helmValues", []string{}, "List of helm values as YAML file reference or URL (as per helm parameter description for `-f` / `--values`)") - cmd.Flags().StringVar(&stepConfig.DeploymentName, "deploymentName", os.Getenv("PIPER_deploymentName"), "Defines the name of the deployment.") + cmd.Flags().StringVar(&stepConfig.DeploymentName, "deploymentName", os.Getenv("PIPER_deploymentName"), "Defines the name of the deployment. In case of `kustomize` this is the name or alias of the image in the `kustomization.yaml`") cmd.Flags().StringVar(&stepConfig.Tool, "tool", `kubectl`, "Defines the tool which should be used to update the deployment description.") cmd.MarkFlagRequired("branchName") @@ -313,6 +314,7 @@ func gitopsUpdateDeploymentMetadata() config.StepData { Containers: []config.Container{ {Image: "dtzar/helm-kubectl:3.3.4", WorkingDir: "/config", Options: []config.Option{{Name: "-u", Value: "0"}}, Conditions: []config.Condition{{ConditionRef: "strings-equal", Params: []config.Param{{Name: "tool", Value: "helm"}}}}}, {Image: "dtzar/helm-kubectl:2.17.0", WorkingDir: "/config", Options: []config.Option{{Name: "-u", Value: "0"}}, Conditions: []config.Condition{{ConditionRef: "strings-equal", Params: []config.Param{{Name: "tool", Value: "kubectl"}}}}}, + {Image: "k8s.gcr.io/kustomize/kustomize:v3.8.7", WorkingDir: "/config", Options: []config.Option{{Name: "-u", Value: "0"}}, Conditions: []config.Condition{{ConditionRef: "strings-equal", Params: []config.Param{{Name: "tool", Value: "kustomize"}}}}}, }, }, } diff --git a/cmd/gitopsUpdateDeployment_test.go b/cmd/gitopsUpdateDeployment_test.go index 8a65a23a5..e44c6398a 100644 --- a/cmd/gitopsUpdateDeployment_test.go +++ b/cmd/gitopsUpdateDeployment_test.go @@ -96,6 +96,7 @@ func TestRunGitopsUpdateDeploymentWithKubectl(t *testing.T) { t.Parallel() gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(validConfiguration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -117,6 +118,7 @@ func TestRunGitopsUpdateDeploymentWithKubectl(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -138,6 +140,7 @@ func TestRunGitopsUpdateDeploymentWithKubectl(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -158,6 +161,7 @@ func TestRunGitopsUpdateDeploymentWithKubectl(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -178,6 +182,7 @@ func TestRunGitopsUpdateDeploymentWithKubectl(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -323,6 +328,7 @@ func TestRunGitopsUpdateDeploymentWithHelm(t *testing.T) { t.Parallel() gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(validConfiguration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -346,6 +352,7 @@ func TestRunGitopsUpdateDeploymentWithHelm(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -369,6 +376,7 @@ func TestRunGitopsUpdateDeploymentWithHelm(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -391,6 +399,7 @@ func TestRunGitopsUpdateDeploymentWithHelm(t *testing.T) { gitUtilsMock := &gitUtilsMock{} runnerMock := &gitOpsExecRunnerMock{} + runnerMock.expectedYaml = expectedYaml err := runGitopsUpdateDeployment(&configuration, runnerMock, gitUtilsMock, &filesMock{}) assert.NoError(t, err) @@ -525,11 +534,74 @@ func TestRunGitopsUpdateDeploymentWithHelm(t *testing.T) { }) } +func TestRunGitopsUpdateDeploymentWithKustomize(t *testing.T) { + var validConfiguration = &gitopsUpdateDeploymentOptions{ + BranchName: "main", + CommitMessage: "This is the commit message", + ServerURL: "https://github.com", + Username: "admin3", + Password: "validAccessToken", + FilePath: "kustomization.yaml", + ContainerRegistryURL: "https://myregistry.com", + ContainerImageNameTag: "registry/containers/myFancyContainer:1337", + Tool: "kustomize", + DeploymentName: "myFancyDeployment", + } + + t.Parallel() + t.Run("successful run", func(t *testing.T) { + t.Parallel() + gitUtilsMock := &gitUtilsMock{} + runnerMock := &gitOpsExecRunnerMock{} + fsMock := &filesMock{} + runnerMock.expectedYaml = expectedKustomize + + err := runGitopsUpdateDeployment(validConfiguration, runnerMock, gitUtilsMock, fsMock) + assert.NoError(t, err) + assert.Equal(t, validConfiguration.BranchName, gitUtilsMock.changedBranch) + assert.Equal(t, expectedKustomize, gitUtilsMock.savedFile) + assert.Equal(t, "This is the commit message", gitUtilsMock.commitMessage) + assert.Equal(t, "kustomize", runnerMock.executable) + assert.Equal(t, "edit", runnerMock.params[0]) + assert.Equal(t, "set", runnerMock.params[1]) + assert.Equal(t, "image", runnerMock.params[2]) + assert.Equal(t, "myFancyDeployment=registry/containers/myFancyContainer:1337", runnerMock.params[3]) + }) + + t.Run("error on kustomize execution", func(t *testing.T) { + t.Parallel() + runner := &gitOpsExecRunnerMock{failOnRunExecutable: true} + + err := runGitopsUpdateDeployment(validConfiguration, runner, &gitUtilsMock{}, &filesMock{}) + assert.EqualError(t, err, "failed to apply kustomize command: failed to execute kustomize command: error happened") + }) + + t.Run("missing FilePath", func(t *testing.T) { + t.Parallel() + var configuration = *validConfiguration + configuration.FilePath = "" + + err := runGitopsUpdateDeployment(&configuration, &gitOpsExecRunnerMock{}, &gitUtilsMock{}, &filesMock{}) + assert.EqualError(t, err, "missing required fields for kustomize: the following parameters are necessary for kustomize: [filePath]") + }) + + t.Run("missing DeploymentName", func(t *testing.T) { + t.Parallel() + var configuration = *validConfiguration + configuration.DeploymentName = "" + + err := runGitopsUpdateDeployment(&configuration, &gitOpsExecRunnerMock{}, &gitUtilsMock{}, &filesMock{}) + assert.EqualError(t, err, "missing required fields for kustomize: the following parameters are necessary for kustomize: [deploymentName]") + }) +} + type gitOpsExecRunnerMock struct { out io.Writer params []string executable string failOnRunExecutable bool + dir string + expectedYaml string } func (e *gitOpsExecRunnerMock) Stdout(out io.Writer) { @@ -540,13 +612,17 @@ func (gitOpsExecRunnerMock) Stderr(io.Writer) { panic("implement me") } +func (e *gitOpsExecRunnerMock) SetDir(d string) { + e.dir = d +} + func (e *gitOpsExecRunnerMock) RunExecutable(executable string, params ...string) error { if e.failOnRunExecutable { return errors.New("error happened") } e.executable = executable e.params = params - _, err := e.out.Write([]byte(expectedYaml)) + _, err := e.out.Write([]byte(e.expectedYaml)) return err } @@ -602,19 +678,19 @@ func (v *gitUtilsMock) ChangeBranch(branchName string) error { return nil } -func (v *gitUtilsMock) CommitSingleFile(_ string, commitMessage string, _ string) (plumbing.Hash, error) { +func (v *gitUtilsMock) CommitSingleFile(newFile string, commitMessage string, _ string) (plumbing.Hash, error) { if v.failOnCommit { return [20]byte{}, errors.New("error on commit") } v.commitMessage = commitMessage - matches, _ := piperutils.Files{}.Glob(v.temporaryDirectory + "/dir1/dir2/depl.yaml") - if len(matches) < 1 { - return [20]byte{}, errors.New("could not find file") + filepath := filepath.Join(v.temporaryDirectory, newFile) + fileContent, err := piperutils.Files{}.FileRead(filepath) + if err != nil { + return [20]byte{}, errors.New("could not find file " + filepath) } - fileRead, _ := piperutils.Files{}.FileRead(matches[0]) - v.savedFile = string(fileRead) + v.savedFile = string(fileContent) return [20]byte{123}, nil } @@ -630,17 +706,73 @@ func (v *gitUtilsMock) PlainClone(_, _, _, directory string) error { return errors.New("error on clone") } v.temporaryDirectory = directory - filePath := filepath.Join(directory, "dir1/dir2/depl.yaml") err := piperutils.Files{}.MkdirAll(filepath.Join(directory, "dir1/dir2"), 0755) if err != nil { return err } - err = piperutils.Files{}.FileWrite(filePath, []byte(existingYaml), 0755) + err = piperutils.Files{}.FileWrite(filepath.Join(directory, "dir1/dir2/depl.yaml"), []byte(existingYaml), 0755) + if err != nil { + return err + } + err = piperutils.Files{}.FileWrite(filepath.Join(directory, "kustomization.yaml"), []byte(existingKustomize), 0755) if err != nil { return err } return nil } -var existingYaml = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: myFancyApp\n labels:\n tier: application\nspec:\n replicas: 4\n selector:\n matchLabels:\n run: myContainer\n template:\n metadata:\n labels:\n run: myContainer\n spec:\n containers:\n - image: myregistry.com/myFancyContainer:1336\n name: myContainer" -var expectedYaml = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: myFancyApp\n labels:\n tier: application\nspec:\n replicas: 4\n selector:\n matchLabels:\n run: myContainer\n template:\n metadata:\n labels:\n run: myContainer\n spec:\n containers:\n - image: myregistry.com/myFancyContainer:1337\n name: myContainer" +var existingYaml = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: myFancyApp + labels: + tier: application +spec: + replicas: 4 + selector: + matchLabels: + run: myContainer + template: + metadata: + labels: + run: myContainer + spec: + containers: + - image: myregistry.com/myFancyContainer:1336 + name: myContainer` + +var expectedYaml = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: myFancyApp + labels: + tier: application +spec: + replicas: 4 + selector: + matchLabels: + run: myContainer + template: + metadata: + labels: + run: myContainer + spec: + containers: + - image: myregistry.com/myFancyContainer:1337 + name: myContainer` + +var existingKustomize = `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +images: +- name: myFancyDeployment + newTag: "0" +` +var expectedKustomize = `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +images: +- name: myFancyDeployment + newName: registry/containers/myFancyContainer + newTag: "1337" +` diff --git a/resources/metadata/gitopsUpdateDeployment.yaml b/resources/metadata/gitopsUpdateDeployment.yaml index fc9969fa9..1f4df67ff 100644 --- a/resources/metadata/gitopsUpdateDeployment.yaml +++ b/resources/metadata/gitopsUpdateDeployment.yaml @@ -6,9 +6,10 @@ metadata: It can for example be used for GitOps scenarios where the update of the manifests triggers an update of the corresponding deployment in Kubernetes. - As of today, it supports the update of deployment yaml files via kubectl patch and update a whole helm template. - For kubectl the container inside the yaml must be described within the following hierarchy: `{"spec":{"template":{"spec":{"containers":[{...}]}}}}` - For helm the whole template is generated into a file and uploaded into the repository. + As of today, it supports the update of deployment yaml files via kubectl patch, update a whole helm template and kustomize. + For *kubectl* the container inside the yaml must be described within the following hierarchy: `{"spec":{"template":{"spec":{"containers":[{...}]}}}}` + For *helm* the whole template is generated into a file and uploaded into the repository. + For *kustomize* the `images` section will be update with the current image. spec: @@ -77,7 +78,12 @@ spec: type: secret param: password - name: filePath - description: Relative path in the git repository to the deployment descriptor file that shall be updated + description: | + Relative path in the git repository to the deployment descriptor file that shall be updated. For different tools this has different semantics: + + * `kubectl` - path to the `deployment.yaml` that should be patched. + * `helm` - not used. Please use `chartPath` instead. + * `kustomize` - path to the `kustomization.yaml`. scope: - PARAMETERS - STAGES @@ -140,7 +146,7 @@ spec: aliases: - name: helmDeploymentName type: string - description: Defines the name of the deployment. + description: Defines the name of the deployment. In case of `kustomize` this is the name or alias of the image in the `kustomization.yaml` scope: - PARAMETERS - STAGES @@ -158,6 +164,7 @@ spec: possibleValues: - kubectl - helm + - kustomize containers: - image: dtzar/helm-kubectl:3.3.4 workingDir: /config @@ -179,3 +186,13 @@ spec: params: - name: tool value: kubectl + - image: k8s.gcr.io/kustomize/kustomize:v3.8.7 + workingDir: /config + options: + - name: -u + value: "0" + conditions: + - conditionRef: strings-equal + params: + - name: tool + value: kustomize