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 }