github.com/xgoffin/jenkins-library@v1.154.0/cmd/gitopsUpdateDeployment.go (about)

     1  package cmd
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"github.com/SAP/jenkins-library/pkg/command"
     7  	"github.com/SAP/jenkins-library/pkg/docker"
     8  	gitUtil "github.com/SAP/jenkins-library/pkg/git"
     9  	"github.com/SAP/jenkins-library/pkg/log"
    10  	"github.com/SAP/jenkins-library/pkg/piperutils"
    11  	"github.com/SAP/jenkins-library/pkg/telemetry"
    12  	"github.com/go-git/go-git/v5"
    13  	"github.com/go-git/go-git/v5/plumbing"
    14  	"github.com/go-git/go-git/v5/plumbing/object"
    15  	"github.com/pkg/errors"
    16  	"io"
    17  	"os"
    18  	"path/filepath"
    19  	"regexp"
    20  	"strings"
    21  	"time"
    22  )
    23  
    24  const toolKubectl = "kubectl"
    25  const toolHelm = "helm"
    26  const toolKustomize = "kustomize"
    27  
    28  type iGitopsUpdateDeploymentGitUtils interface {
    29  	CommitFiles(filePaths []string, commitMessage, author string) (plumbing.Hash, error)
    30  	PushChangesToRepository(username, password string) error
    31  	PlainClone(username, password, serverURL, directory string) error
    32  	ChangeBranch(branchName string) error
    33  }
    34  
    35  type gitopsUpdateDeploymentFileUtils interface {
    36  	TempDir(dir, pattern string) (name string, err error)
    37  	RemoveAll(path string) error
    38  	FileWrite(path string, content []byte, perm os.FileMode) error
    39  	Glob(pattern string) ([]string, error)
    40  }
    41  
    42  type gitopsUpdateDeploymentExecRunner interface {
    43  	RunExecutable(executable string, params ...string) error
    44  	Stdout(out io.Writer)
    45  	Stderr(err io.Writer)
    46  	SetDir(dir string)
    47  }
    48  
    49  type gitopsUpdateDeploymentGitUtils struct {
    50  	worktree   *git.Worktree
    51  	repository *git.Repository
    52  }
    53  
    54  func (g *gitopsUpdateDeploymentGitUtils) CommitFiles(filePaths []string, commitMessage, author string) (plumbing.Hash, error) {
    55  	for _, path := range filePaths {
    56  		_, err := g.worktree.Add(path)
    57  
    58  		if err != nil {
    59  			return [20]byte{}, errors.Wrap(err, "failed to add file to git")
    60  		}
    61  	}
    62  
    63  	commit, err := g.worktree.Commit(commitMessage, &git.CommitOptions{
    64  		All:    true,
    65  		Author: &object.Signature{Name: author, When: time.Now()},
    66  	})
    67  	if err != nil {
    68  		return [20]byte{}, errors.Wrap(err, "failed to commit file")
    69  	}
    70  
    71  	return commit, nil
    72  }
    73  
    74  func (g *gitopsUpdateDeploymentGitUtils) PushChangesToRepository(username, password string) error {
    75  	return gitUtil.PushChangesToRepository(username, password, g.repository)
    76  }
    77  
    78  func (g *gitopsUpdateDeploymentGitUtils) PlainClone(username, password, serverURL, directory string) error {
    79  	var err error
    80  	g.repository, err = gitUtil.PlainClone(username, password, serverURL, directory)
    81  	if err != nil {
    82  		return errors.Wrapf(err, "plain clone failed '%s'", serverURL)
    83  	}
    84  	g.worktree, err = g.repository.Worktree()
    85  	return errors.Wrap(err, "failed to retrieve worktree")
    86  }
    87  
    88  func (g *gitopsUpdateDeploymentGitUtils) ChangeBranch(branchName string) error {
    89  	return gitUtil.ChangeBranch(branchName, g.worktree)
    90  }
    91  
    92  func gitopsUpdateDeployment(config gitopsUpdateDeploymentOptions, _ *telemetry.CustomData) {
    93  	// for command execution use Command
    94  	var c gitopsUpdateDeploymentExecRunner = &command.Command{}
    95  	// reroute command output to logging framework
    96  	c.Stdout(log.Writer())
    97  	c.Stderr(log.Writer())
    98  
    99  	// for http calls import  piperhttp "github.com/SAP/jenkins-library/pkg/http"
   100  	// and use a  &piperhttp.Client{} in a custom system
   101  	// Example: step checkmarxExecuteScan.go
   102  
   103  	// error situations should stop execution through log.Entry().Fatal() call which leads to an os.Exit(1) in the end
   104  	err := runGitopsUpdateDeployment(&config, c, &gitopsUpdateDeploymentGitUtils{}, piperutils.Files{})
   105  	if err != nil {
   106  		log.Entry().WithError(err).Fatal("step execution failed")
   107  	}
   108  }
   109  
   110  func runGitopsUpdateDeployment(config *gitopsUpdateDeploymentOptions, command gitopsUpdateDeploymentExecRunner, gitUtils iGitopsUpdateDeploymentGitUtils, fileUtils gitopsUpdateDeploymentFileUtils) error {
   111  	err := checkRequiredFieldsForDeployTool(config)
   112  	if err != nil {
   113  		return err
   114  	}
   115  
   116  	temporaryFolder, err := fileUtils.TempDir(".", "temp-")
   117  	temporaryFolder = regexp.MustCompile(`^./`).ReplaceAllString(temporaryFolder, "")
   118  	if err != nil {
   119  		return errors.Wrap(err, "failed to create temporary directory")
   120  	}
   121  
   122  	defer func() {
   123  		err = fileUtils.RemoveAll(temporaryFolder)
   124  		if err != nil {
   125  			log.Entry().WithError(err).Error("error during temporary directory deletion")
   126  		}
   127  	}()
   128  
   129  	err = cloneRepositoryAndChangeBranch(config, gitUtils, temporaryFolder)
   130  	if err != nil {
   131  		return errors.Wrap(err, "repository could not get prepared")
   132  	}
   133  
   134  	filePath := filepath.Join(temporaryFolder, config.FilePath)
   135  	if config.Tool == toolHelm {
   136  		filePath = filepath.Join(temporaryFolder, config.ChartPath)
   137  	}
   138  
   139  	allFiles, err := fileUtils.Glob(filePath)
   140  	if err != nil {
   141  		return errors.Wrap(err, "unable to expand globbing pattern")
   142  	} else if len(allFiles) == 0 {
   143  		return errors.New("no matching files found for provided globbing pattern")
   144  	}
   145  	command.SetDir("./")
   146  
   147  	var outputBytes []byte
   148  	for _, currentFile := range allFiles {
   149  		if config.Tool == toolKubectl {
   150  			outputBytes, err = executeKubectl(config, command, outputBytes, currentFile)
   151  			if err != nil {
   152  				return errors.Wrap(err, "error on kubectl execution")
   153  			}
   154  		} else if config.Tool == toolHelm {
   155  
   156  			out, err := runHelmCommand(command, config, currentFile)
   157  			if err != nil {
   158  				return errors.Wrap(err, "failed to apply helm command")
   159  			}
   160  			// join all helm outputs into the same "FilePath"
   161  			outputBytes = append(outputBytes, []byte("---\n")...)
   162  			outputBytes = append(outputBytes, out...)
   163  			currentFile = filepath.Join(temporaryFolder, config.FilePath)
   164  
   165  		} else if config.Tool == toolKustomize {
   166  			_, err = runKustomizeCommand(command, config, currentFile)
   167  			if err != nil {
   168  				return errors.Wrap(err, "failed to apply kustomize command")
   169  			}
   170  			outputBytes = nil
   171  		} else if config.Tool == toolKustomize {
   172  			outputBytes, err = runKustomizeCommand(command, config, filePath)
   173  			if err != nil {
   174  				return errors.Wrap(err, "failed to apply kustomize command")
   175  			}
   176  		} else {
   177  			log.SetErrorCategory(log.ErrorConfiguration)
   178  			return errors.New("tool " + config.Tool + " is not supported")
   179  		}
   180  
   181  		if outputBytes != nil {
   182  			err = fileUtils.FileWrite(currentFile, outputBytes, 0755)
   183  			if err != nil {
   184  				return errors.Wrap(err, "failed to write file")
   185  			}
   186  		}
   187  	}
   188  	if config.Tool == toolHelm {
   189  		// helm only creates one output file.
   190  		allFiles = []string{config.FilePath}
   191  	} else {
   192  		// git expects the file path relative to its root:
   193  		for i := range allFiles {
   194  			allFiles[i] = strings.ReplaceAll(allFiles[i], temporaryFolder+"/", "")
   195  		}
   196  	}
   197  
   198  	commit, err := commitAndPushChanges(config, gitUtils, allFiles)
   199  	if err != nil {
   200  		return errors.Wrap(err, "failed to commit and push changes")
   201  	}
   202  
   203  	log.Entry().Infof("Changes committed with %s", commit.String())
   204  
   205  	return nil
   206  }
   207  
   208  func checkRequiredFieldsForDeployTool(config *gitopsUpdateDeploymentOptions) error {
   209  	if config.Tool == toolHelm {
   210  		err := checkRequiredFieldsForHelm(config)
   211  		if err != nil {
   212  			return errors.Wrap(err, "missing required fields for helm")
   213  		}
   214  		logNotRequiredButFilledFieldForHelm(config)
   215  	} else if config.Tool == toolKubectl {
   216  		err := checkRequiredFieldsForKubectl(config)
   217  		if err != nil {
   218  			return errors.Wrap(err, "missing required fields for kubectl")
   219  		}
   220  		logNotRequiredButFilledFieldForKubectl(config)
   221  	} else if config.Tool == toolKustomize {
   222  		err := checkRequiredFieldsForKustomize(config)
   223  		if err != nil {
   224  			return errors.Wrap(err, "missing required fields for kustomize")
   225  		}
   226  		logNotRequiredButFilledFieldForKustomize(config)
   227  	}
   228  
   229  	return nil
   230  }
   231  
   232  func checkRequiredFieldsForHelm(config *gitopsUpdateDeploymentOptions) error {
   233  	var missingParameters []string
   234  	if config.ChartPath == "" {
   235  		missingParameters = append(missingParameters, "chartPath")
   236  	}
   237  	if config.DeploymentName == "" {
   238  		missingParameters = append(missingParameters, "deploymentName")
   239  	}
   240  	if len(missingParameters) > 0 {
   241  		log.SetErrorCategory(log.ErrorConfiguration)
   242  		return errors.Errorf("the following parameters are necessary for helm: %v", missingParameters)
   243  	}
   244  	return nil
   245  }
   246  
   247  func checkRequiredFieldsForKustomize(config *gitopsUpdateDeploymentOptions) error {
   248  	var missingParameters []string
   249  	if config.FilePath == "" {
   250  		missingParameters = append(missingParameters, "filePath")
   251  	}
   252  	if config.DeploymentName == "" {
   253  		missingParameters = append(missingParameters, "deploymentName")
   254  	}
   255  	if len(missingParameters) > 0 {
   256  		log.SetErrorCategory(log.ErrorConfiguration)
   257  		return errors.Errorf("the following parameters are necessary for kustomize: %v", missingParameters)
   258  	}
   259  	return nil
   260  }
   261  
   262  func checkRequiredFieldsForKubectl(config *gitopsUpdateDeploymentOptions) error {
   263  	var missingParameters []string
   264  	if config.ContainerName == "" {
   265  		missingParameters = append(missingParameters, "containerName")
   266  	}
   267  	if len(missingParameters) > 0 {
   268  		log.SetErrorCategory(log.ErrorConfiguration)
   269  		return errors.Errorf("the following parameters are necessary for kubectl: %v", missingParameters)
   270  	}
   271  	return nil
   272  }
   273  
   274  func logNotRequiredButFilledFieldForHelm(config *gitopsUpdateDeploymentOptions) {
   275  	if config.ContainerName != "" {
   276  		log.Entry().Info("containerName is not used for helm and can be removed")
   277  	}
   278  }
   279  
   280  func logNotRequiredButFilledFieldForKubectl(config *gitopsUpdateDeploymentOptions) {
   281  	if config.ChartPath != "" {
   282  		log.Entry().Info("chartPath is not used for kubectl and can be removed")
   283  	}
   284  	if len(config.HelmValues) > 0 {
   285  		log.Entry().Info("helmValues is not used for kubectl and can be removed")
   286  	}
   287  	if len(config.DeploymentName) > 0 {
   288  		log.Entry().Info("deploymentName is not used for kubectl and can be removed")
   289  	}
   290  }
   291  func logNotRequiredButFilledFieldForKustomize(config *gitopsUpdateDeploymentOptions) {
   292  	if config.ChartPath != "" {
   293  		log.Entry().Info("chartPath is not used for kubectl and can be removed")
   294  	}
   295  	if len(config.HelmValues) > 0 {
   296  		log.Entry().Info("helmValues is not used for kubectl and can be removed")
   297  	}
   298  }
   299  
   300  func cloneRepositoryAndChangeBranch(config *gitopsUpdateDeploymentOptions, gitUtils iGitopsUpdateDeploymentGitUtils, temporaryFolder string) error {
   301  	err := gitUtils.PlainClone(config.Username, config.Password, config.ServerURL, temporaryFolder)
   302  	if err != nil {
   303  		return errors.Wrap(err, "failed to plain clone repository")
   304  	}
   305  
   306  	err = gitUtils.ChangeBranch(config.BranchName)
   307  	if err != nil {
   308  		return errors.Wrap(err, "failed to change branch")
   309  	}
   310  	return nil
   311  }
   312  
   313  func executeKubectl(config *gitopsUpdateDeploymentOptions, command gitopsUpdateDeploymentExecRunner, outputBytes []byte, filePath string) ([]byte, error) {
   314  	registryImage, err := buildRegistryPlusImage(config)
   315  	if err != nil {
   316  		return nil, errors.Wrap(err, "failed to apply kubectl command")
   317  	}
   318  	patchString := "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"" + config.ContainerName + "\",\"image\":\"" + registryImage + "\"}]}}}}"
   319  
   320  	log.Entry().Infof("[kubectl] updating '%s'", filePath)
   321  	outputBytes, err = runKubeCtlCommand(command, patchString, filePath)
   322  	if err != nil {
   323  		return nil, errors.Wrap(err, "failed to apply kubectl command")
   324  	}
   325  	return outputBytes, nil
   326  }
   327  
   328  func buildRegistryPlusImage(config *gitopsUpdateDeploymentOptions) (string, error) {
   329  	registryURL := config.ContainerRegistryURL
   330  	if registryURL == "" {
   331  		return config.ContainerImageNameTag, nil
   332  	}
   333  
   334  	url, err := docker.ContainerRegistryFromURL(registryURL)
   335  	if err != nil {
   336  		return "", errors.Wrap(err, "registry URL could not be extracted")
   337  	}
   338  	if url != "" {
   339  		url = url + "/"
   340  	}
   341  	return url + config.ContainerImageNameTag, nil
   342  }
   343  
   344  func runKubeCtlCommand(command gitopsUpdateDeploymentExecRunner, patchString string, filePath string) ([]byte, error) {
   345  	var kubectlOutput = bytes.Buffer{}
   346  	command.Stdout(&kubectlOutput)
   347  
   348  	kubeParams := []string{
   349  		"patch",
   350  		"--local",
   351  		"--output=yaml",
   352  		"--patch=" + patchString,
   353  		"--filename=" + filePath,
   354  	}
   355  	err := command.RunExecutable(toolKubectl, kubeParams...)
   356  	if err != nil {
   357  		return nil, errors.Wrap(err, "failed to apply kubectl command")
   358  	}
   359  	return kubectlOutput.Bytes(), nil
   360  }
   361  
   362  func runHelmCommand(command gitopsUpdateDeploymentExecRunner, config *gitopsUpdateDeploymentOptions, filePath string) ([]byte, error) {
   363  	var helmOutput = bytes.Buffer{}
   364  	command.Stdout(&helmOutput)
   365  
   366  	registryImage, imageTag, err := buildRegistryPlusImageAndTagSeparately(config)
   367  	if err != nil {
   368  		return nil, errors.Wrap(err, "failed to extract registry URL, image name, and image tag")
   369  	}
   370  	helmParams := []string{
   371  		"template",
   372  		config.DeploymentName,
   373  		filePath,
   374  		"--set=image.repository=" + registryImage,
   375  		"--set=image.tag=" + imageTag,
   376  	}
   377  
   378  	for _, value := range config.HelmValues {
   379  		helmParams = append(helmParams, "--values", value)
   380  	}
   381  
   382  	log.Entry().Infof("[helmn] updating '%s'", filePath)
   383  	err = command.RunExecutable(toolHelm, helmParams...)
   384  	if err != nil {
   385  		return nil, errors.Wrap(err, "failed to execute helm command")
   386  	}
   387  	return helmOutput.Bytes(), nil
   388  }
   389  
   390  func runKustomizeCommand(command gitopsUpdateDeploymentExecRunner, config *gitopsUpdateDeploymentOptions, filePath string) ([]byte, error) {
   391  	var kustomizeOutput = bytes.Buffer{}
   392  	command.Stdout(&kustomizeOutput)
   393  
   394  	kustomizeParams := []string{
   395  		"edit",
   396  		"set",
   397  		"image",
   398  		config.DeploymentName + "=" + config.ContainerImageNameTag,
   399  	}
   400  
   401  	command.SetDir(filepath.Dir(filePath))
   402  
   403  	log.Entry().Infof("[kustomize] updating '%s'", filePath)
   404  	err := command.RunExecutable(toolKustomize, kustomizeParams...)
   405  	if err != nil {
   406  		return nil, errors.Wrap(err, "failed to execute kustomize command")
   407  	}
   408  
   409  	return kustomizeOutput.Bytes(), nil
   410  }
   411  
   412  // buildRegistryPlusImageAndTagSeparately combines the registry together with the image name. Handles the tag separately.
   413  // 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.
   414  func buildRegistryPlusImageAndTagSeparately(config *gitopsUpdateDeploymentOptions) (string, string, error) {
   415  	registryURL := config.ContainerRegistryURL
   416  	url := ""
   417  	if registryURL != "" {
   418  		containerURL, err := docker.ContainerRegistryFromURL(registryURL)
   419  		if err != nil {
   420  			return "", "", errors.Wrap(err, "registry URL could not be extracted")
   421  		}
   422  		if containerURL != "" {
   423  			containerURL = containerURL + "/"
   424  		}
   425  		url = containerURL
   426  	}
   427  
   428  	imageNameTag := config.ContainerImageNameTag
   429  	var imageName, imageTag string
   430  	if strings.Contains(imageNameTag, ":") {
   431  		split := strings.Split(imageNameTag, ":")
   432  		if split[0] == "" {
   433  			log.SetErrorCategory(log.ErrorConfiguration)
   434  			return "", "", errors.New("image name could not be extracted")
   435  		}
   436  		if split[1] == "" {
   437  			log.SetErrorCategory(log.ErrorConfiguration)
   438  			return "", "", errors.New("tag could not be extracted")
   439  		}
   440  		imageName = split[0]
   441  		imageTag = split[1]
   442  		return url + imageName, imageTag, nil
   443  	}
   444  
   445  	log.SetErrorCategory(log.ErrorConfiguration)
   446  	return "", "", errors.New("image name and tag could not be extracted")
   447  
   448  }
   449  
   450  func commitAndPushChanges(config *gitopsUpdateDeploymentOptions, gitUtils iGitopsUpdateDeploymentGitUtils, filePaths []string) (plumbing.Hash, error) {
   451  	commitMessage := config.CommitMessage
   452  
   453  	if commitMessage == "" {
   454  		commitMessage = defaultCommitMessage(config)
   455  	}
   456  
   457  	commit, err := gitUtils.CommitFiles(filePaths, commitMessage, config.Username)
   458  	if err != nil {
   459  		return [20]byte{}, errors.Wrap(err, "committing changes failed")
   460  	}
   461  
   462  	err = gitUtils.PushChangesToRepository(config.Username, config.Password)
   463  	if err != nil {
   464  		return [20]byte{}, errors.Wrap(err, "pushing changes failed")
   465  	}
   466  
   467  	return commit, nil
   468  }
   469  
   470  func defaultCommitMessage(config *gitopsUpdateDeploymentOptions) string {
   471  	image, tag, _ := buildRegistryPlusImageAndTagSeparately(config)
   472  	commitMessage := fmt.Sprintf("Updated %v to version %v", image, tag)
   473  	return commitMessage
   474  }