1
0
mirror of https://github.com/SAP/jenkins-library.git synced 2024-11-24 08:32:32 +02:00

feat(Gitops): new step to update deployment (#2178)

* kanikoExecute: improve user experience

* ensure proper tags

* update permissions

in case a container runs with a different user
we need to make sure that the orchestrator user
can work on the file

* update permissions

* ensure availablility of directories on Jenkins

* (fix) clean up tmp dir in test

* add resilience for incorrect step yaml

* incorporate PR feedback

* Adds piper step to update deployment configuration in external git repository.

https://github.wdf.sap.corp/ContinuousDelivery/piper-ita/issues/21

* Adds handling of branchName as an optional parameter

* Update resources/metadata/gitopsUpdateDeployment.yaml

Feedback about description

Co-authored-by: Christopher Fenner <26137398+CCFenner@users.noreply.github.com>

* Adapt to interface guide

* Refactors to GitopsExecRunner

* Refactors to GitopsExecRunner in test

* Removes unnecessary mocked methods

* Adds tests for git utils

* Adds new step to CommonStepsTest.groovy

* Updates description from yaml

* Restricts visibility of methods and interfaces
Adds comments where necessary

* Updates comments

* Fixes URL name

* updates description

* updates generated file

* Fixes compile issue in CommonStepsTest.groovy

* Updates long description

* Updates test to run green on all kind of OS

* Removes global variables from tests

* Default branch: master

Co-authored-by: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com>

* Typo in Hierarchy

Co-authored-by: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com>

* Refactors test to allow parallel execution

* Renames utility variable in gitopsUpdateDeployment.go

* Renames error variables in gitopsUpdateDeployment.go

* simplified parameters for kubectl

* Refactors util classes to use parameters rather than global variables

* makes username and password mandatory

* remove unnecessary mandatory flag

* remove new methods from mock that are not necessary

* replaces with EqualError

* replaces with NoError

* update generated file

* refactor tests

* refactor tests

* make tests parallel executable

* parallel execution of tests

* Refactors interfaces to stop exposing interfaces

* Feedback from PR

* Simplifies failing mocks

* Renames variables and interfaces

* Fixes error messages

* shorten variable names

* Renames unused parameters in tests

* Cleanup nil parameters

* Typo

* Wrap errors and remove unnecessary logs

* Remove containername and filePath from GENERAL scope

* correct generated file

* corrects expected error messages

Co-authored-by: OliverNocon <oliver.nocon@sap.com>
Co-authored-by: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com>
Co-authored-by: Christopher Fenner <26137398+CCFenner@users.noreply.github.com>
This commit is contained in:
Fabian Reh 2020-10-20 09:05:17 +02:00 committed by GitHub
parent 5003ac09ae
commit 586044192c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1098 additions and 1 deletions

View File

@ -0,0 +1,175 @@
package cmd
import (
"bytes"
"github.com/SAP/jenkins-library/pkg/command"
"github.com/SAP/jenkins-library/pkg/docker"
gitUtil "github.com/SAP/jenkins-library/pkg/git"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/piperutils"
"github.com/SAP/jenkins-library/pkg/telemetry"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/pkg/errors"
"io"
"os"
"path/filepath"
)
type iGitopsUpdateDeploymentGitUtils interface {
CommitSingleFile(filePath, commitMessage string) (plumbing.Hash, error)
PushChangesToRepository(username, password string) error
PlainClone(username, password, serverURL, directory string) error
ChangeBranch(branchName string) error
}
type gitopsUpdateDeploymentFileUtils interface {
TempDir(dir, pattern string) (name string, err error)
RemoveAll(path string) error
FileWrite(path string, content []byte, perm os.FileMode) error
}
type gitopsUpdateDeploymentExecRunner interface {
RunExecutable(executable string, params ...string) error
Stdout(out io.Writer)
Stderr(err io.Writer)
}
type gitopsUpdateDeploymentGitUtils struct {
worktree *git.Worktree
repository *git.Repository
}
func (g *gitopsUpdateDeploymentGitUtils) CommitSingleFile(filePath, commitMessage string) (plumbing.Hash, error) {
return gitUtil.CommitSingleFile(filePath, commitMessage, g.worktree)
}
func (g *gitopsUpdateDeploymentGitUtils) PushChangesToRepository(username, password string) error {
return gitUtil.PushChangesToRepository(username, password, g.repository)
}
func (g *gitopsUpdateDeploymentGitUtils) PlainClone(username, password, serverURL, directory string) error {
var err error
g.repository, err = gitUtil.PlainClone(username, password, serverURL, directory)
if err != nil {
return errors.Wrap(err, "plain clone failed")
}
g.worktree, err = g.repository.Worktree()
return errors.Wrap(err, "failed to retrieve worktree")
}
func (g *gitopsUpdateDeploymentGitUtils) ChangeBranch(branchName string) error {
return gitUtil.ChangeBranch(branchName, g.worktree)
}
func gitopsUpdateDeployment(config gitopsUpdateDeploymentOptions, telemetryData *telemetry.CustomData) {
// for command execution use Command
var c gitopsUpdateDeploymentExecRunner = &command.Command{}
// reroute command output to logging framework
c.Stdout(log.Writer())
c.Stderr(log.Writer())
// for http calls import piperhttp "github.com/SAP/jenkins-library/pkg/http"
// and use a &piperhttp.Client{} in a custom system
// Example: step checkmarxExecuteScan.go
// error situations should stop execution through log.Entry().Fatal() call which leads to an os.Exit(1) in the end
err := runGitopsUpdateDeployment(&config, c, &gitopsUpdateDeploymentGitUtils{}, piperutils.Files{})
if err != nil {
log.Entry().WithError(err).Fatal("step execution failed")
}
}
func runGitopsUpdateDeployment(config *gitopsUpdateDeploymentOptions, command gitopsUpdateDeploymentExecRunner, gitUtils iGitopsUpdateDeploymentGitUtils, fileUtils gitopsUpdateDeploymentFileUtils) error {
temporaryFolder, err := fileUtils.TempDir(".", "temp-")
if err != nil {
return errors.Wrap(err, "failed to create temporary directory")
}
defer fileUtils.RemoveAll(temporaryFolder)
err = gitUtils.PlainClone(config.Username, config.Password, config.ServerURL, temporaryFolder)
if err != nil {
return errors.Wrap(err, "failed to plain clone repository")
}
err = gitUtils.ChangeBranch(config.BranchName)
if err != nil {
return errors.Wrap(err, "failed to change branch")
}
registryImage, err := buildRegistryPlusImage(config)
if err != nil {
return errors.Wrap(err, "failed to apply kubectl command")
}
patchString := "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"" + config.ContainerName + "\",\"image\":\"" + registryImage + "\"}]}}}}"
filePath := filepath.Join(temporaryFolder, config.FilePath)
kubectlOutputBytes, err := runKubeCtlCommand(command, patchString, filePath)
if err != nil {
return errors.Wrap(err, "failed to apply kubectl command")
}
err = fileUtils.FileWrite(filePath, kubectlOutputBytes, 0755)
if err != nil {
return errors.Wrap(err, "failed to write file")
}
commit, err := commitAndPushChanges(config, gitUtils)
if err != nil {
return errors.Wrap(err, "failed to commit and push changes")
}
log.Entry().Infof("Changes committed with %s", commit.String())
return nil
}
func runKubeCtlCommand(command gitopsUpdateDeploymentExecRunner, patchString string, filePath string) ([]byte, error) {
var kubectlOutput = bytes.Buffer{}
command.Stdout(&kubectlOutput)
kubeParams := []string{
"patch",
"--local",
"--output=yaml",
"--patch=" + patchString,
"--filename=" + filePath,
}
err := command.RunExecutable("kubectl", kubeParams...)
if err != nil {
return nil, errors.Wrap(err, "failed to apply kubectl command")
}
return kubectlOutput.Bytes(), nil
}
func buildRegistryPlusImage(config *gitopsUpdateDeploymentOptions) (string, error) {
registryURL := config.ContainerRegistryURL
if registryURL == "" {
return config.ContainerImage, nil
}
url, err := docker.ContainerRegistryFromURL(registryURL)
if err != nil {
return "", errors.Wrap(err, "registry URL could not be extracted")
}
if url != "" {
url = url + "/"
}
return url + config.ContainerImage, nil
}
func commitAndPushChanges(config *gitopsUpdateDeploymentOptions, gitUtils iGitopsUpdateDeploymentGitUtils) (plumbing.Hash, error) {
commit, err := gitUtils.CommitSingleFile(config.FilePath, config.CommitMessage)
if err != nil {
return [20]byte{}, errors.Wrap(err, "committing changes failed")
}
err = gitUtils.PushChangesToRepository(config.Username, config.Password)
if err != nil {
return [20]byte{}, errors.Wrap(err, "pushing changes failed")
}
return commit, nil
}

View File

@ -0,0 +1,218 @@
// Code generated by piper's step-generator. DO NOT EDIT.
package cmd
import (
"fmt"
"os"
"time"
"github.com/SAP/jenkins-library/pkg/config"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/telemetry"
"github.com/spf13/cobra"
)
type gitopsUpdateDeploymentOptions struct {
BranchName string `json:"branchName,omitempty"`
CommitMessage string `json:"commitMessage,omitempty"`
ServerURL string `json:"serverUrl,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
FilePath string `json:"filePath,omitempty"`
ContainerName string `json:"containerName,omitempty"`
ContainerRegistryURL string `json:"containerRegistryUrl,omitempty"`
ContainerImage string `json:"containerImage,omitempty"`
}
// GitopsUpdateDeploymentCommand Updates Kubernetes Deployment Manifest in an Infrastructure Git Repository
func GitopsUpdateDeploymentCommand() *cobra.Command {
const STEP_NAME = "gitopsUpdateDeployment"
metadata := gitopsUpdateDeploymentMetadata()
var stepConfig gitopsUpdateDeploymentOptions
var startTime time.Time
var createGitopsUpdateDeploymentCmd = &cobra.Command{
Use: STEP_NAME,
Short: "Updates Kubernetes Deployment Manifest in an Infrastructure Git Repository",
Long: `This step allows you to update the deployment manifest for Kubernetes in a git repository.
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. The container inside the yaml must be described within the following hierarchy: {"spec":{"template":{"spec":{"containers":[{...}]}}}}`,
PreRunE: func(cmd *cobra.Command, _ []string) error {
startTime = time.Now()
log.SetStepName(STEP_NAME)
log.SetVerbose(GeneralConfig.Verbose)
path, _ := os.Getwd()
fatalHook := &log.FatalHook{CorrelationID: GeneralConfig.CorrelationID, Path: path}
log.RegisterHook(fatalHook)
err := PrepareConfig(cmd, &metadata, STEP_NAME, &stepConfig, config.OpenPiperFile)
if err != nil {
log.SetErrorCategory(log.ErrorConfiguration)
return err
}
log.RegisterSecret(stepConfig.Username)
log.RegisterSecret(stepConfig.Password)
if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 {
sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID)
log.RegisterHook(&sentryHook)
}
return nil
},
Run: func(_ *cobra.Command, _ []string) {
telemetryData := telemetry.CustomData{}
telemetryData.ErrorCode = "1"
handler := func() {
telemetryData.Duration = fmt.Sprintf("%v", time.Since(startTime).Milliseconds())
telemetryData.ErrorCategory = log.GetErrorCategory().String()
telemetry.Send(&telemetryData)
}
log.DeferExitHandler(handler)
defer handler()
telemetry.Initialize(GeneralConfig.NoTelemetry, STEP_NAME)
gitopsUpdateDeployment(stepConfig, &telemetryData)
telemetryData.ErrorCode = "0"
log.Entry().Info("SUCCESS")
},
}
addGitopsUpdateDeploymentFlags(createGitopsUpdateDeploymentCmd, &stepConfig)
return createGitopsUpdateDeploymentCmd
}
func addGitopsUpdateDeploymentFlags(cmd *cobra.Command, stepConfig *gitopsUpdateDeploymentOptions) {
cmd.Flags().StringVar(&stepConfig.BranchName, "branchName", `master`, "The name of the branch where the changes should get pushed into.")
cmd.Flags().StringVar(&stepConfig.CommitMessage, "commitMessage", `Updated {{containerName}} to version {{containerImage}}`, "The commit message of the commit that will be done to do the changes.")
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.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.ContainerImage, "containerImage", os.Getenv("PIPER_containerImage"), "Container image name with version tag to annotate in the deployment configuration.")
cmd.MarkFlagRequired("commitMessage")
cmd.MarkFlagRequired("serverUrl")
cmd.MarkFlagRequired("username")
cmd.MarkFlagRequired("password")
cmd.MarkFlagRequired("filePath")
cmd.MarkFlagRequired("containerName")
cmd.MarkFlagRequired("containerImage")
}
// retrieve step metadata
func gitopsUpdateDeploymentMetadata() config.StepData {
var theMetaData = config.StepData{
Metadata: config.StepMetadata{
Name: "gitopsUpdateDeployment",
Aliases: []config.Alias{},
},
Spec: config.StepSpec{
Inputs: config.StepInputs{
Parameters: []config.StepParameters{
{
Name: "branchName",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
},
{
Name: "commitMessage",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
},
{
Name: "serverUrl",
ResourceRef: []config.ResourceReference{},
Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{{Name: "githubServerUrl"}},
},
{
Name: "username",
ResourceRef: []config.ResourceReference{
{
Name: "gitHttpsCredentialsId",
Param: "username",
Type: "secret",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
},
{
Name: "password",
ResourceRef: []config.ResourceReference{
{
Name: "gitHttpsCredentialsId",
Param: "password",
Type: "secret",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
},
{
Name: "filePath",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
},
{
Name: "containerName",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{},
},
{
Name: "containerRegistryUrl",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "container/registryUrl",
},
},
Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{{Name: "dockerRegistryUrl"}},
},
{
Name: "containerImage",
ResourceRef: []config.ResourceReference{
{
Name: "commonPipelineEnvironment",
Param: "container/imageNameTag",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: true,
Aliases: []config.Alias{{Name: "image"}, {Name: "containerImageNameTag"}},
},
},
},
},
}
return theMetaData
}

View File

@ -0,0 +1,16 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGitopsUpdateDeploymentCommand(t *testing.T) {
testCmd := GitopsUpdateDeploymentCommand()
// only high level testing performed - details are tested in step generation procedure
assert.Equal(t, "gitopsUpdateDeployment", testCmd.Use, "command name incorrect")
}

View File

@ -0,0 +1,227 @@
package cmd
import (
"errors"
"github.com/SAP/jenkins-library/pkg/piperutils"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/assert"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildRegistryPlusImage(t *testing.T) {
t.Parallel()
t.Run("build full image", func(t *testing.T) {
registryImage, err := buildRegistryPlusImage(&gitopsUpdateDeploymentOptions{
ContainerRegistryURL: "https://myregistry.com/registry/containers",
ContainerImage: "myFancyContainer:1337",
})
assert.NoError(t, err)
assert.Equal(t, "myregistry.com/myFancyContainer:1337", registryImage)
})
t.Run("without registry", func(t *testing.T) {
registryImage, err := buildRegistryPlusImage(&gitopsUpdateDeploymentOptions{
ContainerRegistryURL: "",
ContainerImage: "myFancyContainer:1337",
})
assert.NoError(t, err)
assert.Equal(t, "myFancyContainer:1337", registryImage)
})
t.Run("without faulty URL", func(t *testing.T) {
_, err := buildRegistryPlusImage(&gitopsUpdateDeploymentOptions{
ContainerRegistryURL: "//myregistry.com/registry/containers",
ContainerImage: "myFancyContainer:1337",
})
assert.Error(t, err)
assert.EqualError(t, err, "registry URL could not be extracted: invalid registry url")
})
}
func TestRunGitopsUpdateDeployment(t *testing.T) {
t.Parallel()
t.Run("successful run", func(t *testing.T) {
var configuration = &gitopsUpdateDeploymentOptions{
BranchName: "main",
CommitMessage: "This is the commit message",
ServerURL: "https://github.com",
Username: "admin3",
Password: "validAccessToken",
FilePath: "dir1/dir2/depl.yaml",
ContainerName: "myContainer",
ContainerRegistryURL: "https://myregistry.com/registry/containers",
ContainerImage: "myFancyContainer:1337",
}
gitUtilsMock := &validGitUtilsMock{}
runnerMock := gitOpsExecRunnerMock{}
var c gitopsUpdateDeploymentExecRunner = &runnerMock
err := runGitopsUpdateDeployment(configuration, c, gitUtilsMock, piperutils.Files{})
assert.NoError(t, err)
assert.Equal(t, configuration.BranchName, gitUtilsMock.changedBranch)
assert.Equal(t, expectedYaml, gitUtilsMock.savedFile)
assert.Equal(t, "kubectl", runnerMock.executable)
assert.Equal(t, "patch", runnerMock.kubectlParams[0])
assert.Equal(t, "--local", runnerMock.kubectlParams[1])
assert.Equal(t, "--output=yaml", runnerMock.kubectlParams[2])
assert.Equal(t, `--patch={"spec":{"template":{"spec":{"containers":[{"name":"myContainer","image":"myregistry.com/myFancyContainer:1337"}]}}}}`, runnerMock.kubectlParams[3])
assert.True(t, strings.Contains(runnerMock.kubectlParams[4], filepath.Join("dir1/dir2/depl.yaml")))
})
t.Run("invalid URL", func(t *testing.T) {
var configuration = &gitopsUpdateDeploymentOptions{
BranchName: "main",
CommitMessage: "This is the commit message",
ServerURL: "https://github.com",
Username: "admin3",
Password: "validAccessToken",
FilePath: "dir1/dir2/depl.yaml",
ContainerName: "myContainer",
ContainerRegistryURL: "//myregistry.com/registry/containers",
ContainerImage: "myFancyContainer:1337",
}
gitUtilsMock := &validGitUtilsMock{}
err := runGitopsUpdateDeployment(configuration, nil, gitUtilsMock, piperutils.Files{})
assert.EqualError(t, err, "failed to apply kubectl command: registry URL could not be extracted: invalid registry url")
})
t.Run("error on plane clone", func(t *testing.T) {
var configuration = &gitopsUpdateDeploymentOptions{
BranchName: "main",
CommitMessage: "This is the commit message",
ServerURL: "https://github.com",
Username: "admin3",
Password: "validAccessToken",
FilePath: "dir1/dir2/depl.yaml",
ContainerName: "myContainer",
ContainerRegistryURL: "https://myregistry.com/registry/containers",
ContainerImage: "myFancyContainer:1337",
}
err := runGitopsUpdateDeployment(configuration, nil, &gitUtilsMockErrorClone{}, piperutils.Files{})
assert.EqualError(t, err, "failed to plain clone repository: error on clone")
})
t.Run("error on temp dir creation", func(t *testing.T) {
var configuration = &gitopsUpdateDeploymentOptions{
BranchName: "main",
CommitMessage: "This is the commit message",
ServerURL: "https://github.com",
Username: "admin3",
Password: "validAccessToken",
FilePath: "dir1/dir2/depl.yaml",
ContainerName: "myContainer",
ContainerRegistryURL: "https://myregistry.com/registry/containers",
ContainerImage: "myFancyContainer:1337",
}
err := runGitopsUpdateDeployment(configuration, nil, &gitopsUpdateDeploymentGitUtils{}, filesMockErrorTempDirCreation{})
assert.EqualError(t, err, "failed to create temporary directory: error appeared")
})
}
type gitOpsExecRunnerMock struct {
out io.Writer
kubectlParams []string
executable string
}
func (e *gitOpsExecRunnerMock) Stdout(out io.Writer) {
e.out = out
}
func (gitOpsExecRunnerMock) Stderr(io.Writer) {
panic("implement me")
}
func (e *gitOpsExecRunnerMock) RunExecutable(executable string, params ...string) error {
e.executable = executable
e.kubectlParams = params
_, err := e.out.Write([]byte(expectedYaml))
return err
}
type filesMockErrorTempDirCreation struct{}
func (c filesMockErrorTempDirCreation) FileWrite(string, []byte, os.FileMode) error {
panic("implement me")
}
func (filesMockErrorTempDirCreation) TempDir(string, string) (name string, err error) {
return "", errors.New("error appeared")
}
func (filesMockErrorTempDirCreation) RemoveAll(string) error {
panic("implement me")
}
type gitUtilsMockErrorClone struct{}
func (gitUtilsMockErrorClone) CommitSingleFile(string, string) (plumbing.Hash, error) {
panic("implement me")
}
func (gitUtilsMockErrorClone) PushChangesToRepository(string, string) error {
panic("implement me")
}
func (gitUtilsMockErrorClone) PlainClone(string, string, string, string) error {
return errors.New("error on clone")
}
func (gitUtilsMockErrorClone) ChangeBranch(string) error {
panic("implement me")
}
func (gitUtilsMockErrorClone) GetWorktree() (*git.Worktree, error) {
panic("implement me")
}
type validGitUtilsMock struct {
savedFile string
changedBranch string
}
func (validGitUtilsMock) GetWorktree() (*git.Worktree, error) {
return nil, nil
}
func (v *validGitUtilsMock) ChangeBranch(branchName string) error {
v.changedBranch = branchName
return nil
}
func (v *validGitUtilsMock) CommitSingleFile(string, string) (plumbing.Hash, error) {
matches, _ := piperutils.Files{}.Glob("*/dir1/dir2/depl.yaml")
fileRead, _ := piperutils.Files{}.FileRead(matches[0])
v.savedFile = string(fileRead)
return [20]byte{123}, nil
}
func (validGitUtilsMock) PushChangesToRepository(string, string) error {
return nil
}
func (validGitUtilsMock) PlainClone(_, _, _, directory string) error {
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)
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"

View File

@ -76,6 +76,7 @@ func Execute() {
rootCmd.AddCommand(GithubCreatePullRequestCommand())
rootCmd.AddCommand(GithubPublishReleaseCommand())
rootCmd.AddCommand(GithubSetCommitStatusCommand())
rootCmd.AddCommand(GitopsUpdateDeploymentCommand())
rootCmd.AddCommand(CloudFoundryDeleteServiceCommand())
rootCmd.AddCommand(AbapEnvironmentPullGitRepoCommand())
rootCmd.AddCommand(AbapEnvironmentCloneGitRepoCommand())

114
pkg/git/git.go Normal file
View File

@ -0,0 +1,114 @@
package git
import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/pkg/errors"
)
// utilsWorkTree interface abstraction of git.Worktree to enable tests
type utilsWorkTree interface {
Add(path string) (plumbing.Hash, error)
Commit(msg string, opts *git.CommitOptions) (plumbing.Hash, error)
Checkout(opts *git.CheckoutOptions) error
}
// utilsRepository interface abstraction of git.Repository to enable tests
type utilsRepository interface {
Worktree() (*git.Worktree, error)
Push(o *git.PushOptions) error
}
// utilsGit interface abstraction of git to enable tests
type utilsGit interface {
plainClone(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error)
}
// CommitSingleFile Commits the file located in the relative file path with the commitMessage to the given worktree.
// In case of errors, the error is returned. In the successful case the commit is provided.
func CommitSingleFile(filePath, commitMessage string, worktree *git.Worktree) (plumbing.Hash, error) {
return commitSingleFile(filePath, commitMessage, worktree)
}
func commitSingleFile(filePath, commitMessage string, worktree utilsWorkTree) (plumbing.Hash, error) {
_, err := worktree.Add(filePath)
if err != nil {
return [20]byte{}, errors.Wrap(err, "failed to add file to git")
}
commit, err := worktree.Commit(commitMessage, &git.CommitOptions{})
if err != nil {
return [20]byte{}, errors.Wrap(err, "failed to commit file")
}
return commit, nil
}
// PushChangesToRepository Pushes all committed changes in the repository to the remote repository
func PushChangesToRepository(username, password string, repository *git.Repository) error {
return pushChangesToRepository(username, password, repository)
}
func pushChangesToRepository(username, password string, repository utilsRepository) error {
pushOptions := &git.PushOptions{
Auth: &http.BasicAuth{Username: username, Password: password},
}
err := repository.Push(pushOptions)
if err != nil {
return errors.Wrap(err, "failed to push commit")
}
return nil
}
// PlainClone Clones a non-bare repository to the provided directory
func PlainClone(username, password, serverURL, directory string) (*git.Repository, error) {
abstractedGit := &abstractionGit{}
return plainClone(username, password, serverURL, directory, abstractedGit)
}
func plainClone(username, password, serverURL, directory string, abstractionGit utilsGit) (*git.Repository, error) {
gitCloneOptions := git.CloneOptions{
Auth: &http.BasicAuth{Username: username, Password: password},
URL: serverURL,
}
repository, err := abstractionGit.plainClone(directory, false, &gitCloneOptions)
if err != nil {
return nil, errors.Wrap(err, "failed to clone git")
}
return repository, nil
}
// ChangeBranch checkout the provided branch.
// It will create a new branch if the branch does not exist yet.
// It will checkout "master" if no branch name if provided
func ChangeBranch(branchName string, worktree *git.Worktree) error {
return changeBranch(branchName, worktree)
}
func changeBranch(branchName string, worktree utilsWorkTree) error {
if branchName == "" {
branchName = "master"
}
var checkoutOptions = &git.CheckoutOptions{}
checkoutOptions.Branch = plumbing.NewBranchReferenceName(branchName)
checkoutOptions.Create = false
err := worktree.Checkout(checkoutOptions)
if err != nil {
// branch might not exist, try to create branch
checkoutOptions.Create = true
err = worktree.Checkout(checkoutOptions)
if err != nil {
return errors.Wrap(err, "failed to checkout branch")
}
}
return nil
}
type abstractionGit struct{}
func (abstractionGit) plainClone(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error) {
return git.PlainClone(path, isBare, o)
}

214
pkg/git/git_test.go Normal file
View File

@ -0,0 +1,214 @@
package git
import (
"errors"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/assert"
"testing"
)
func TestCommit(t *testing.T) {
t.Parallel()
t.Run("successful run", func(t *testing.T) {
hash, err := commitSingleFile(".", "message", &WorktreeMock{})
assert.NoError(t, err)
assert.Equal(t, plumbing.Hash([20]byte{4, 5, 6}), hash)
})
t.Run("error adding file", func(t *testing.T) {
_, err := commitSingleFile(".", "message", WorktreeMockFailing{
failingAdd: true,
})
assert.Error(t, err)
assert.EqualError(t, err, "failed to add file to git: failed to add file")
})
t.Run("error committing file", func(t *testing.T) {
_, err := commitSingleFile(".", "message", WorktreeMockFailing{
failingCommit: true,
})
assert.Error(t, err)
assert.EqualError(t, err, "failed to commit file: failed to commit file")
})
}
func TestPushChangesToRepository(t *testing.T) {
t.Parallel()
t.Run("successful push", func(t *testing.T) {
err := pushChangesToRepository("user", "password", RepositoryMock{
test: t,
})
assert.NoError(t, err)
})
t.Run("error pushing", func(t *testing.T) {
err := pushChangesToRepository("user", "password", RepositoryMockError{})
assert.Error(t, err)
assert.EqualError(t, err, "failed to push commit: error on push commits")
})
}
func TestPlainClone(t *testing.T) {
t.Parallel()
t.Run("successful clone", func(t *testing.T) {
abstractedGit := &UtilsGitMock{}
_, err := plainClone("user", "password", "URL", "directory", abstractedGit)
assert.NoError(t, err)
assert.Equal(t, "directory", abstractedGit.path)
assert.False(t, abstractedGit.isBare)
assert.Equal(t, "http-basic-auth - user:*******", abstractedGit.authString)
assert.Equal(t, "URL", abstractedGit.URL)
})
t.Run("error on cloning", func(t *testing.T) {
abstractedGit := UtilsGitMockError{}
_, err := plainClone("user", "password", "URL", "directory", abstractedGit)
assert.Error(t, err)
assert.EqualError(t, err, "failed to clone git: error during clone")
})
}
func TestChangeBranch(t *testing.T) {
t.Parallel()
t.Run("checkout existing branch", func(t *testing.T) {
worktreeMock := &WorktreeMock{}
err := changeBranch("otherBranch", worktreeMock)
assert.NoError(t, err)
assert.Equal(t, string(plumbing.NewBranchReferenceName("otherBranch")), worktreeMock.checkedOutBranch)
assert.False(t, worktreeMock.create)
})
t.Run("empty branch defaulted to master", func(t *testing.T) {
worktreeMock := &WorktreeMock{}
err := changeBranch("", worktreeMock)
assert.NoError(t, err)
assert.Equal(t, string(plumbing.NewBranchReferenceName("master")), worktreeMock.checkedOutBranch)
assert.False(t, worktreeMock.create)
})
t.Run("create new branch", func(t *testing.T) {
err := changeBranch("otherBranch", WorktreeUtilsNewBranch{})
assert.NoError(t, err)
})
t.Run("error on new branch", func(t *testing.T) {
err := changeBranch("otherBranch", WorktreeMockFailing{
failingCheckout: true,
})
assert.Error(t, err)
assert.EqualError(t, err, "failed to checkout branch: failed to checkout branch")
})
}
type RepositoryMock struct {
worktree *git.Worktree
test *testing.T
}
func (r RepositoryMock) Worktree() (*git.Worktree, error) {
if r.worktree != nil {
return r.worktree, nil
}
return &git.Worktree{}, nil
}
func (r RepositoryMock) Push(o *git.PushOptions) error {
assert.Equal(r.test, "http-basic-auth - user:*******", o.Auth.String())
return nil
}
type RepositoryMockError struct{}
func (RepositoryMockError) Worktree() (*git.Worktree, error) {
return nil, errors.New("error getting worktree")
}
func (RepositoryMockError) Push(*git.PushOptions) error {
return errors.New("error on push commits")
}
type WorktreeMockFailing struct {
failingAdd bool
failingCommit bool
failingCheckout bool
}
func (w WorktreeMockFailing) Add(string) (plumbing.Hash, error) {
if w.failingAdd {
return [20]byte{}, errors.New("failed to add file")
}
return [20]byte{}, nil
}
func (w WorktreeMockFailing) Commit(string, *git.CommitOptions) (plumbing.Hash, error) {
if w.failingCommit {
return [20]byte{}, errors.New("failed to commit file")
}
return [20]byte{}, nil
}
func (w WorktreeMockFailing) Checkout(*git.CheckoutOptions) error {
if w.failingCheckout {
return errors.New("failed to checkout branch")
}
return nil
}
type WorktreeMock struct {
expectedBranchName string
checkedOutBranch string
create bool
}
func (WorktreeMock) Add(string) (plumbing.Hash, error) {
return [20]byte{1, 2, 3}, nil
}
func (WorktreeMock) Commit(string, *git.CommitOptions) (plumbing.Hash, error) {
return [20]byte{4, 5, 6}, nil
}
func (w *WorktreeMock) Checkout(opts *git.CheckoutOptions) error {
w.checkedOutBranch = string(opts.Branch)
w.create = opts.Create
return nil
}
type WorktreeUtilsNewBranch struct{}
func (WorktreeUtilsNewBranch) Add(string) (plumbing.Hash, error) {
panic("implement me")
}
func (WorktreeUtilsNewBranch) Commit(string, *git.CommitOptions) (plumbing.Hash, error) {
panic("implement me")
}
func (WorktreeUtilsNewBranch) Checkout(opts *git.CheckoutOptions) error {
if opts.Create {
return nil
}
return errors.New("branch already exists")
}
type UtilsGitMock struct {
path string
isBare bool
authString string
URL string
}
func (u *UtilsGitMock) plainClone(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error) {
u.path = path
u.isBare = isBare
u.authString = o.Auth.String()
u.URL = o.URL
return nil, nil
}
type UtilsGitMockError struct{}
func (UtilsGitMockError) plainClone(string, bool, *git.CloneOptions) (*git.Repository, error) {
return nil, errors.New("error during clone")
}

View File

@ -28,6 +28,11 @@ type FileUtils interface {
type Files struct {
}
// TempDir creates a temporary directory
func (f Files) TempDir(dir, pattern string) (name string, err error) {
return ioutil.TempDir(dir, pattern)
}
// FileExists returns true if the file system entry for the given path exists and is not a directory.
func (f Files) FileExists(filename string) (bool, error) {
info, err := os.Stat(filename)

View File

@ -0,0 +1,115 @@
metadata:
name: gitopsUpdateDeployment
description: Updates Kubernetes Deployment Manifest in an Infrastructure Git Repository
longDescription: |
This step allows you to update the deployment manifest for Kubernetes in a git repository.
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. The container inside the yaml must be described within the following hierarchy: {"spec":{"template":{"spec":{"containers":[{...}]}}}}
spec:
inputs:
secrets:
- name: gitHttpsCredentialsId
description: Jenkins 'Username with password' credentials ID containing username/password for http access to your git repository.
type: jenkins
params:
- name: branchName
description: The name of the branch where the changes should get pushed into.
scope:
- PARAMETERS
- STAGES
- STEPS
type: string
default: master
- name: commitMessage
description: The commit message of the commit that will be done to do the changes.
scope:
- PARAMETERS
- STAGES
- STEPS
type: string
mandatory: true
default: Updated {{containerName}} to version {{containerImage}}
- name: serverUrl
aliases:
- name: githubServerUrl
description: GitHub server url to the repository.
scope:
- GENERAL
- PARAMETERS
- STAGES
- STEPS
type: string
default: https://github.com
mandatory: true
- name: username
type: string
description: User name for git authentication
scope:
- PARAMETERS
- STAGES
- STEPS
mandatory: true
secret: true
resourceRef:
- name: gitHttpsCredentialsId
type: secret
param: username
- name: password
type: string
description: Password/token for git authentication.
scope:
- PARAMETERS
- STAGES
- STEPS
mandatory: true
secret: true
resourceRef:
- name: gitHttpsCredentialsId
type: secret
param: password
- name: filePath
description: Relative path in the git repository to the deployment descriptor file that shall be updated
scope:
- PARAMETERS
- STAGES
- STEPS
type: string
mandatory: true
- name: containerName
description: The name of the container to update
scope:
- PARAMETERS
- STAGES
- STEPS
type: string
mandatory: true
- name: containerRegistryUrl
aliases:
- name: dockerRegistryUrl
type: string
description: http(s) url of the Container registry where the image is located
scope:
- GENERAL
- PARAMETERS
- STAGES
- STEPS
resourceRef:
- name: commonPipelineEnvironment
param: container/registryUrl
- name: containerImage
aliases:
- name: image
deprecated: true
- name: containerImageNameTag
type: string
description: Container image name with version tag to annotate in the deployment configuration.
resourceRef:
- name: commonPipelineEnvironment
param: container/imageNameTag
mandatory: true
scope:
- PARAMETERS
- STAGES
- STEPS

View File

@ -164,7 +164,8 @@ public class CommonStepsTest extends BasePiperTest{
'gctsDeploy', //implementing new golang pattern without fields
'containerSaveImage', //implementing new golang pattern without fields
'detectExecuteScan', //implementing new golang pattern without fields
'kanikoExecute' //implementing new golang pattern without fields
'kanikoExecute', //implementing new golang pattern without fields
'gitopsUpdateDeployment' //implementing new golang pattern without fields
]
@Test

View File

@ -0,0 +1,11 @@
import groovy.transform.Field
@Field String STEP_NAME = getClass().getName()
@Field String METADATA_FILE = 'metadata/gitopsUpdateDeployment.yaml'
void call(Map parameters = [:]) {
List credentials = [
[type: 'usernamePassword', id: 'gitHttpsCredentialsId', env: ['PIPER_username', 'PIPER_password']],
]
piperExecuteBin(parameters, STEP_NAME, METADATA_FILE, credentials)
}