mirror of
https://github.com/SAP/jenkins-library.git
synced 2024-12-14 11:03:09 +02:00
6676da1f9d
* feat(gitopsUpdateDeployment) forcePush fix(gitopsUpdateDeployment) include registry The push operation in this step can be forced to bypass branch-protection Signed-off-by: Michael Sprauer <Michael.Sprauer@sap.com> * add unit test Signed-off-by: Michael Sprauer <Michael.Sprauer@sap.com> Co-authored-by: Oliver Nocon <33484802+OliverNocon@users.noreply.github.com>
476 lines
15 KiB
Go
476 lines
15 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"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/go-git/go-git/v5/plumbing/object"
|
|
"github.com/pkg/errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const toolKubectl = "kubectl"
|
|
const toolHelm = "helm"
|
|
const toolKustomize = "kustomize"
|
|
|
|
type iGitopsUpdateDeploymentGitUtils interface {
|
|
CommitFiles(filePaths []string, commitMessage, author string) (plumbing.Hash, error)
|
|
PushChangesToRepository(username, password string, force *bool) 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
|
|
Glob(pattern string) ([]string, error)
|
|
}
|
|
|
|
type gitopsUpdateDeploymentExecRunner interface {
|
|
RunExecutable(executable string, params ...string) error
|
|
Stdout(out io.Writer)
|
|
Stderr(err io.Writer)
|
|
SetDir(dir string)
|
|
}
|
|
|
|
type gitopsUpdateDeploymentGitUtils struct {
|
|
worktree *git.Worktree
|
|
repository *git.Repository
|
|
}
|
|
|
|
func (g *gitopsUpdateDeploymentGitUtils) CommitFiles(filePaths []string, commitMessage, author string) (plumbing.Hash, error) {
|
|
for _, path := range filePaths {
|
|
_, err := g.worktree.Add(path)
|
|
|
|
if err != nil {
|
|
return [20]byte{}, errors.Wrap(err, "failed to add file to git")
|
|
}
|
|
}
|
|
|
|
commit, err := g.worktree.Commit(commitMessage, &git.CommitOptions{
|
|
All: true,
|
|
Author: &object.Signature{Name: author, When: time.Now()},
|
|
})
|
|
if err != nil {
|
|
return [20]byte{}, errors.Wrap(err, "failed to commit file")
|
|
}
|
|
|
|
return commit, nil
|
|
}
|
|
|
|
func (g *gitopsUpdateDeploymentGitUtils) PushChangesToRepository(username, password string, force *bool) error {
|
|
return gitUtil.PushChangesToRepository(username, password, force, 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.Wrapf(err, "plain clone failed '%s'", serverURL)
|
|
}
|
|
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, _ *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 {
|
|
err := checkRequiredFieldsForDeployTool(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
temporaryFolder, err := fileUtils.TempDir(".", "temp-")
|
|
temporaryFolder = regexp.MustCompile(`^./`).ReplaceAllString(temporaryFolder, "")
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to create temporary directory")
|
|
}
|
|
|
|
defer func() {
|
|
err = fileUtils.RemoveAll(temporaryFolder)
|
|
if err != nil {
|
|
log.Entry().WithError(err).Error("error during temporary directory deletion")
|
|
}
|
|
}()
|
|
|
|
err = cloneRepositoryAndChangeBranch(config, gitUtils, temporaryFolder)
|
|
if err != nil {
|
|
return errors.Wrap(err, "repository could not get prepared")
|
|
}
|
|
|
|
filePath := filepath.Join(temporaryFolder, config.FilePath)
|
|
if config.Tool == toolHelm {
|
|
filePath = filepath.Join(temporaryFolder, config.ChartPath)
|
|
}
|
|
|
|
allFiles, err := fileUtils.Glob(filePath)
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to expand globbing pattern")
|
|
} else if len(allFiles) == 0 {
|
|
return errors.New("no matching files found for provided globbing pattern")
|
|
}
|
|
command.SetDir("./")
|
|
|
|
var outputBytes []byte
|
|
for _, currentFile := range allFiles {
|
|
if config.Tool == toolKubectl {
|
|
outputBytes, err = executeKubectl(config, command, outputBytes, currentFile)
|
|
if err != nil {
|
|
return errors.Wrap(err, "error on kubectl execution")
|
|
}
|
|
} else if config.Tool == toolHelm {
|
|
|
|
out, err := runHelmCommand(command, config, currentFile)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to apply helm command")
|
|
}
|
|
// join all helm outputs into the same "FilePath"
|
|
outputBytes = append(outputBytes, []byte("---\n")...)
|
|
outputBytes = append(outputBytes, out...)
|
|
currentFile = filepath.Join(temporaryFolder, config.FilePath)
|
|
|
|
} else if config.Tool == toolKustomize {
|
|
_, err = runKustomizeCommand(command, config, currentFile)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to apply kustomize command")
|
|
}
|
|
outputBytes = nil
|
|
} 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")
|
|
}
|
|
|
|
if outputBytes != nil {
|
|
err = fileUtils.FileWrite(currentFile, outputBytes, 0755)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to write file")
|
|
}
|
|
}
|
|
}
|
|
if config.Tool == toolHelm {
|
|
// helm only creates one output file.
|
|
allFiles = []string{config.FilePath}
|
|
} else {
|
|
// git expects the file path relative to its root:
|
|
for i := range allFiles {
|
|
allFiles[i] = strings.ReplaceAll(allFiles[i], temporaryFolder+"/", "")
|
|
}
|
|
}
|
|
|
|
commit, err := commitAndPushChanges(config, gitUtils, allFiles)
|
|
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 checkRequiredFieldsForDeployTool(config *gitopsUpdateDeploymentOptions) error {
|
|
if config.Tool == toolHelm {
|
|
err := checkRequiredFieldsForHelm(config)
|
|
if err != nil {
|
|
return errors.Wrap(err, "missing required fields for helm")
|
|
}
|
|
logNotRequiredButFilledFieldForHelm(config)
|
|
} else if config.Tool == toolKubectl {
|
|
err := checkRequiredFieldsForKubectl(config)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
func checkRequiredFieldsForHelm(config *gitopsUpdateDeploymentOptions) error {
|
|
var missingParameters []string
|
|
if config.ChartPath == "" {
|
|
missingParameters = append(missingParameters, "chartPath")
|
|
}
|
|
if config.DeploymentName == "" {
|
|
missingParameters = append(missingParameters, "deploymentName")
|
|
}
|
|
if len(missingParameters) > 0 {
|
|
log.SetErrorCategory(log.ErrorConfiguration)
|
|
return errors.Errorf("the following parameters are necessary for helm: %v", missingParameters)
|
|
}
|
|
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 == "" {
|
|
missingParameters = append(missingParameters, "containerName")
|
|
}
|
|
if len(missingParameters) > 0 {
|
|
log.SetErrorCategory(log.ErrorConfiguration)
|
|
return errors.Errorf("the following parameters are necessary for kubectl: %v", missingParameters)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func logNotRequiredButFilledFieldForHelm(config *gitopsUpdateDeploymentOptions) {
|
|
if config.ContainerName != "" {
|
|
log.Entry().Info("containerName is not used for helm and can be removed")
|
|
}
|
|
}
|
|
|
|
func logNotRequiredButFilledFieldForKubectl(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")
|
|
}
|
|
if len(config.DeploymentName) > 0 {
|
|
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)
|
|
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")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func executeKubectl(config *gitopsUpdateDeploymentOptions, command gitopsUpdateDeploymentExecRunner, outputBytes []byte, filePath string) ([]byte, error) {
|
|
registryImage, err := buildRegistryPlusImage(config)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to apply kubectl command")
|
|
}
|
|
patchString := "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"" + config.ContainerName + "\",\"image\":\"" + registryImage + "\"}]}}}}"
|
|
|
|
log.Entry().Infof("[kubectl] updating '%s'", filePath)
|
|
outputBytes, err = runKubeCtlCommand(command, patchString, filePath)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to apply kubectl command")
|
|
}
|
|
return outputBytes, nil
|
|
}
|
|
|
|
func buildRegistryPlusImage(config *gitopsUpdateDeploymentOptions) (string, error) {
|
|
registryURL := config.ContainerRegistryURL
|
|
if registryURL == "" {
|
|
return config.ContainerImageNameTag, 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.ContainerImageNameTag, 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(toolKubectl, kubeParams...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to apply kubectl command")
|
|
}
|
|
return kubectlOutput.Bytes(), nil
|
|
}
|
|
|
|
func runHelmCommand(command gitopsUpdateDeploymentExecRunner, config *gitopsUpdateDeploymentOptions, filePath string) ([]byte, error) {
|
|
var helmOutput = bytes.Buffer{}
|
|
command.Stdout(&helmOutput)
|
|
|
|
registryImage, imageTag, err := buildRegistryPlusImageAndTagSeparately(config)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to extract registry URL, image name, and image tag")
|
|
}
|
|
helmParams := []string{
|
|
"template",
|
|
config.DeploymentName,
|
|
filePath,
|
|
"--set=image.repository=" + registryImage,
|
|
"--set=image.tag=" + imageTag,
|
|
}
|
|
|
|
for _, value := range config.HelmValues {
|
|
helmParams = append(helmParams, "--values", value)
|
|
}
|
|
|
|
log.Entry().Infof("[helmn] updating '%s'", filePath)
|
|
err = command.RunExecutable(toolHelm, helmParams...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to execute helm command")
|
|
}
|
|
return helmOutput.Bytes(), nil
|
|
}
|
|
|
|
func runKustomizeCommand(command gitopsUpdateDeploymentExecRunner, config *gitopsUpdateDeploymentOptions, filePath string) ([]byte, error) {
|
|
var kustomizeOutput = bytes.Buffer{}
|
|
command.Stdout(&kustomizeOutput)
|
|
registryImage, imageTag, err := buildRegistryPlusImageAndTagSeparately(config)
|
|
|
|
kustomizeParams := []string{
|
|
"edit",
|
|
"set",
|
|
"image",
|
|
config.DeploymentName + "=" + registryImage + ":" + imageTag,
|
|
}
|
|
|
|
command.SetDir(filepath.Dir(filePath))
|
|
|
|
log.Entry().Infof("[kustomize] updating '%s'", filePath)
|
|
err = command.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) {
|
|
registryURL := config.ContainerRegistryURL
|
|
url := ""
|
|
if registryURL != "" {
|
|
containerURL, err := docker.ContainerRegistryFromURL(registryURL)
|
|
if err != nil {
|
|
return "", "", errors.Wrap(err, "registry URL could not be extracted")
|
|
}
|
|
if containerURL != "" {
|
|
containerURL = containerURL + "/"
|
|
}
|
|
url = containerURL
|
|
}
|
|
|
|
imageNameTag := config.ContainerImageNameTag
|
|
var imageName, imageTag string
|
|
if strings.Contains(imageNameTag, ":") {
|
|
split := strings.Split(imageNameTag, ":")
|
|
if split[0] == "" {
|
|
log.SetErrorCategory(log.ErrorConfiguration)
|
|
return "", "", errors.New("image name could not be extracted")
|
|
}
|
|
if split[1] == "" {
|
|
log.SetErrorCategory(log.ErrorConfiguration)
|
|
return "", "", errors.New("tag could not be extracted")
|
|
}
|
|
imageName = split[0]
|
|
imageTag = split[1]
|
|
return url + imageName, imageTag, nil
|
|
}
|
|
|
|
log.SetErrorCategory(log.ErrorConfiguration)
|
|
return "", "", errors.New("image name and tag could not be extracted")
|
|
|
|
}
|
|
|
|
func commitAndPushChanges(config *gitopsUpdateDeploymentOptions, gitUtils iGitopsUpdateDeploymentGitUtils, filePaths []string) (plumbing.Hash, error) {
|
|
commitMessage := config.CommitMessage
|
|
|
|
if commitMessage == "" {
|
|
commitMessage = defaultCommitMessage(config)
|
|
}
|
|
|
|
commit, err := gitUtils.CommitFiles(filePaths, commitMessage, config.Username)
|
|
if err != nil {
|
|
return [20]byte{}, errors.Wrap(err, "committing changes failed")
|
|
}
|
|
|
|
err = gitUtils.PushChangesToRepository(config.Username, config.Password, &config.ForcePush)
|
|
if err != nil {
|
|
return [20]byte{}, errors.Wrap(err, "pushing changes failed")
|
|
}
|
|
|
|
return commit, nil
|
|
}
|
|
|
|
func defaultCommitMessage(config *gitopsUpdateDeploymentOptions) string {
|
|
image, tag, _ := buildRegistryPlusImageAndTagSeparately(config)
|
|
commitMessage := fmt.Sprintf("Updated %v to version %v", image, tag)
|
|
return commitMessage
|
|
}
|