github.com/SAP/jenkins-library@v1.362.0/cmd/gitopsUpdateDeployment.go (about)

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