github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/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, 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, directory string, caCerts []byte) error { 103 var err error 104 g.repository, err = gitUtil.PlainClone(username, password, serverURL, 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, 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 }