github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/cmd/artifactPrepareVersion.go (about) 1 package cmd 2 3 import ( 4 "bytes" 5 "fmt" 6 "io" 7 netHttp "net/http" 8 "os" 9 "strings" 10 "text/template" 11 "time" 12 13 "github.com/SAP/jenkins-library/pkg/certutils" 14 piperhttp "github.com/SAP/jenkins-library/pkg/http" 15 "github.com/SAP/jenkins-library/pkg/piperutils" 16 17 "github.com/SAP/jenkins-library/pkg/command" 18 gitUtils "github.com/SAP/jenkins-library/pkg/git" 19 "github.com/SAP/jenkins-library/pkg/log" 20 "github.com/SAP/jenkins-library/pkg/orchestrator" 21 "github.com/SAP/jenkins-library/pkg/telemetry" 22 "github.com/SAP/jenkins-library/pkg/versioning" 23 "github.com/pkg/errors" 24 25 "github.com/go-git/go-git/v5" 26 gitConfig "github.com/go-git/go-git/v5/config" 27 "github.com/go-git/go-git/v5/plumbing" 28 "github.com/go-git/go-git/v5/plumbing/object" 29 "github.com/go-git/go-git/v5/plumbing/transport/http" 30 "github.com/go-git/go-git/v5/plumbing/transport/ssh" 31 ) 32 33 type gitRepository interface { 34 CommitObject(plumbing.Hash) (*object.Commit, error) 35 CreateTag(string, plumbing.Hash, *git.CreateTagOptions) (*plumbing.Reference, error) 36 CreateRemote(*gitConfig.RemoteConfig) (*git.Remote, error) 37 DeleteRemote(string) error 38 Push(*git.PushOptions) error 39 Remote(string) (*git.Remote, error) 40 ResolveRevision(plumbing.Revision) (*plumbing.Hash, error) 41 Worktree() (*git.Worktree, error) 42 } 43 44 type gitWorktree interface { 45 Checkout(*git.CheckoutOptions) error 46 Commit(string, *git.CommitOptions) (plumbing.Hash, error) 47 } 48 49 func getGitWorktree(repository gitRepository) (gitWorktree, error) { 50 return repository.Worktree() 51 } 52 53 type artifactPrepareVersionUtils interface { 54 Stdout(out io.Writer) 55 Stderr(err io.Writer) 56 RunExecutable(e string, p ...string) error 57 58 DownloadFile(url, filename string, header netHttp.Header, cookies []*netHttp.Cookie) error 59 piperhttp.Sender 60 61 Glob(pattern string) (matches []string, err error) 62 FileExists(filename string) (bool, error) 63 Copy(src, dest string) (int64, error) 64 MkdirAll(path string, perm os.FileMode) error 65 FileWrite(path string, content []byte, perm os.FileMode) error 66 FileRead(path string) ([]byte, error) 67 FileRemove(path string) error 68 69 NewOrchestratorSpecificConfigProvider() (orchestrator.OrchestratorSpecificConfigProviding, error) 70 } 71 72 type artifactPrepareVersionUtilsBundle struct { 73 *command.Command 74 *piperutils.Files 75 *piperhttp.Client 76 } 77 78 func (a *artifactPrepareVersionUtilsBundle) NewOrchestratorSpecificConfigProvider() (orchestrator.OrchestratorSpecificConfigProviding, error) { 79 return orchestrator.NewOrchestratorSpecificConfigProvider() 80 } 81 82 func newArtifactPrepareVersionUtilsBundle() artifactPrepareVersionUtils { 83 utils := artifactPrepareVersionUtilsBundle{ 84 Command: &command.Command{}, 85 Files: &piperutils.Files{}, 86 Client: &piperhttp.Client{}, 87 } 88 utils.Stdout(log.Writer()) 89 utils.Stderr(log.Writer()) 90 return &utils 91 } 92 93 func artifactPrepareVersion(config artifactPrepareVersionOptions, telemetryData *telemetry.CustomData, commonPipelineEnvironment *artifactPrepareVersionCommonPipelineEnvironment) { 94 utils := newArtifactPrepareVersionUtilsBundle() 95 96 // open local .git repository 97 repository, err := openGit() 98 if err != nil { 99 log.Entry().WithError(err).Fatal("git repository required - none available") 100 } 101 102 err = runArtifactPrepareVersion(&config, telemetryData, commonPipelineEnvironment, nil, utils, repository, getGitWorktree) 103 if err != nil { 104 log.Entry().WithError(err).Fatal("artifactPrepareVersion failed") 105 } 106 } 107 108 var sshAgentAuth = ssh.NewSSHAgentAuth 109 110 func runArtifactPrepareVersion(config *artifactPrepareVersionOptions, telemetryData *telemetry.CustomData, commonPipelineEnvironment *artifactPrepareVersionCommonPipelineEnvironment, artifact versioning.Artifact, utils artifactPrepareVersionUtils, repository gitRepository, getWorktree func(gitRepository) (gitWorktree, error)) error { 111 112 telemetryData.Custom1Label = "buildTool" 113 telemetryData.Custom1 = config.BuildTool 114 telemetryData.Custom2Label = "filePath" 115 telemetryData.Custom2 = config.FilePath 116 117 // Options for artifact 118 artifactOpts := versioning.Options{ 119 GlobalSettingsFile: config.GlobalSettingsFile, 120 M2Path: config.M2Path, 121 ProjectSettingsFile: config.ProjectSettingsFile, 122 VersionField: config.CustomVersionField, 123 VersionSection: config.CustomVersionSection, 124 VersioningScheme: config.CustomVersioningScheme, 125 VersionSource: config.DockerVersionSource, 126 } 127 128 var err error 129 if artifact == nil { 130 artifact, err = versioning.GetArtifact(config.BuildTool, config.FilePath, &artifactOpts, utils) 131 if err != nil { 132 log.SetErrorCategory(log.ErrorConfiguration) 133 return errors.Wrap(err, "failed to retrieve artifact") 134 } 135 } 136 137 // support former groovy versioning template and translate into new options 138 if len(config.VersioningTemplate) > 0 { 139 config.VersioningType, _, config.IncludeCommitID = templateCompatibility(config.VersioningTemplate) 140 } 141 142 version, err := artifact.GetVersion() 143 if err != nil { 144 log.SetErrorCategory(log.ErrorConfiguration) 145 return errors.Wrap(err, "failed to retrieve version") 146 } 147 log.Entry().Infof("Version before automatic versioning: %v", version) 148 149 gitCommit, gitCommitMessage, err := getGitCommitID(repository) 150 if err != nil { 151 log.SetErrorCategory(log.ErrorConfiguration) 152 return err 153 } 154 gitCommitID := gitCommit.String() 155 156 commonPipelineEnvironment.git.headCommitID = gitCommitID 157 newVersion := version 158 now := time.Now() 159 160 if config.VersioningType == "cloud" || config.VersioningType == "cloud_noTag" { 161 // make sure that versioning does not create tags (when set to "cloud") 162 // for PR pipelines, optimized pipelines (= no build) 163 provider, err := utils.NewOrchestratorSpecificConfigProvider() 164 if err != nil { 165 log.Entry().WithError(err).Warning("Cannot infer config from CI environment") 166 } 167 if provider.IsPullRequest() || config.IsOptimizedAndScheduled { 168 config.VersioningType = "cloud_noTag" 169 } 170 171 newVersion, err = calculateCloudVersion(artifact, config, version, gitCommitID, now) 172 if err != nil { 173 return err 174 } 175 176 worktree, err := getWorktree(repository) 177 if err != nil { 178 log.SetErrorCategory(log.ErrorConfiguration) 179 return errors.Wrap(err, "failed to retrieve git worktree") 180 } 181 182 // opening repository does not seem to consider already existing files properly 183 // behavior in case we do not run initializeWorktree: 184 // git.Add(".") will add the complete workspace instead of only changed files 185 err = initializeWorktree(gitCommit, worktree) 186 if err != nil { 187 return err 188 } 189 190 // only update version in build descriptor if required in order to save prossing time (e.g. maven case) 191 if newVersion != version { 192 err = artifact.SetVersion(newVersion) 193 if err != nil { 194 log.SetErrorCategory(log.ErrorConfiguration) 195 return errors.Wrap(err, "failed to write version") 196 } 197 } 198 199 // propagate version information to additional descriptors 200 if len(config.AdditionalTargetTools) > 0 { 201 err = propagateVersion(config, utils, &artifactOpts, version, gitCommitID, now) 202 if err != nil { 203 return err 204 } 205 } 206 207 if config.VersioningType == "cloud" { 208 certs, err := certutils.CertificateDownload(config.CustomTLSCertificateLinks, utils) 209 // commit changes and push to repository (including new version tag) 210 gitCommitID, err = pushChanges(config, newVersion, repository, worktree, now, certs) 211 if err != nil { 212 if strings.Contains(fmt.Sprint(err), "reference already exists") { 213 log.SetErrorCategory(log.ErrorCustom) 214 } 215 return errors.Wrapf(err, "failed to push changes for version '%v'", newVersion) 216 } 217 } 218 } else { 219 // propagate version information to additional descriptors 220 if len(config.AdditionalTargetTools) > 0 { 221 err = propagateVersion(config, utils, &artifactOpts, version, gitCommitID, now) 222 if err != nil { 223 return err 224 } 225 } 226 } 227 228 log.Entry().Infof("New version: '%v'", newVersion) 229 230 commonPipelineEnvironment.git.commitID = gitCommitID // this commitID changes and is not necessarily the HEAD commitID 231 commonPipelineEnvironment.artifactVersion = newVersion 232 commonPipelineEnvironment.originalArtifactVersion = version 233 commonPipelineEnvironment.git.commitMessage = gitCommitMessage 234 235 // we may replace GetVersion() above with GetCoordinates() at some point ... 236 coordinates, err := artifact.GetCoordinates() 237 if err != nil && !config.FetchCoordinates { 238 log.Entry().Warnf("fetchCoordinates is false and failed get artifact Coordinates") 239 } else if err != nil && config.FetchCoordinates { 240 return fmt.Errorf("failed to get coordinates: %w", err) 241 } else { 242 commonPipelineEnvironment.artifactID = coordinates.ArtifactID 243 commonPipelineEnvironment.groupID = coordinates.GroupID 244 commonPipelineEnvironment.packaging = coordinates.Packaging 245 } 246 247 return nil 248 } 249 250 func openGit() (gitRepository, error) { 251 workdir, _ := os.Getwd() 252 return gitUtils.PlainOpen(workdir) 253 } 254 255 func getGitCommitID(repository gitRepository) (plumbing.Hash, string, error) { 256 commitID, err := repository.ResolveRevision(plumbing.Revision("HEAD")) 257 if err != nil { 258 return plumbing.Hash{}, "", errors.Wrap(err, "failed to retrieve git commit ID") 259 } 260 // ToDo not too elegant to retrieve the commit message here, must be refactored sooner than later 261 // but to quickly address https://github.com/SAP/jenkins-library/pull/1515 let's revive this 262 commitObject, err := repository.CommitObject(*commitID) 263 if err != nil { 264 return *commitID, "", errors.Wrap(err, "failed to retrieve git commit message") 265 } 266 return *commitID, commitObject.Message, nil 267 } 268 269 func versioningTemplate(scheme string) (string, error) { 270 // generally: timestamp acts as build number providing a proper order 271 switch scheme { 272 case "docker": 273 // from Docker documentation: 274 // A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes. 275 // A tag name may not start with a period or a dash and may contain a maximum of 128 characters. 276 return "{{.Version}}{{if .Timestamp}}-{{.Timestamp}}{{if .CommitID}}-{{.CommitID}}{{end}}{{end}}", nil 277 case "maven": 278 // according to https://www.mojohaus.org/versions-maven-plugin/version-rules.html 279 return "{{.Version}}{{if .Timestamp}}-{{.Timestamp}}{{if .CommitID}}_{{.CommitID}}{{end}}{{end}}", nil 280 case "pep440": 281 // according to https://www.python.org/dev/peps/pep-0440/ 282 return "{{.Version}}{{if .Timestamp}}.{{.Timestamp}}{{if .CommitID}}+{{.CommitID}}{{end}}{{end}}", nil 283 case "semver2": 284 // according to https://semver.org/spec/v2.0.0.html 285 return "{{.Version}}{{if .Timestamp}}-{{.Timestamp}}{{if .CommitID}}+{{.CommitID}}{{end}}{{end}}", nil 286 } 287 return "", fmt.Errorf("versioning scheme '%v' not supported", scheme) 288 } 289 290 func calculateNewVersion(versioningTemplate, currentVersion, commitID string, includeCommitID, shortCommitID, unixTimestamp bool, t time.Time) (string, error) { 291 tmpl, err := template.New("version").Parse(versioningTemplate) 292 if err != nil { 293 return "", errors.Wrapf(err, "failed to create version template: %v", versioningTemplate) 294 } 295 296 timestamp := t.Format("20060102150405") 297 if unixTimestamp { 298 timestamp = fmt.Sprint(t.Unix()) 299 } 300 301 buf := new(bytes.Buffer) 302 versionParts := struct { 303 Version string 304 Timestamp string 305 CommitID string 306 }{ 307 Version: currentVersion, 308 Timestamp: timestamp, 309 } 310 311 if includeCommitID { 312 versionParts.CommitID = commitID 313 if shortCommitID { 314 versionParts.CommitID = commitID[0:7] 315 } 316 } 317 318 err = tmpl.Execute(buf, versionParts) 319 if err != nil { 320 return "", errors.Wrapf(err, "failed to execute versioning template: %v", versioningTemplate) 321 } 322 323 newVersion := buf.String() 324 if len(newVersion) == 0 { 325 return "", fmt.Errorf("failed calculate version, new version is '%v'", newVersion) 326 } 327 return buf.String(), nil 328 } 329 330 func initializeWorktree(gitCommit plumbing.Hash, worktree gitWorktree) error { 331 // checkout current revision in order to work on that 332 err := worktree.Checkout(&git.CheckoutOptions{Hash: gitCommit, Keep: true}) 333 if err != nil { 334 return errors.Wrap(err, "failed to initialize worktree") 335 } 336 337 return nil 338 } 339 340 func pushChanges(config *artifactPrepareVersionOptions, newVersion string, repository gitRepository, worktree gitWorktree, t time.Time, certs []byte) (string, error) { 341 342 var commitID string 343 344 commit, err := addAndCommit(config, worktree, newVersion, t) 345 if err != nil { 346 return commit.String(), err 347 } 348 349 commitID = commit.String() 350 351 tag := fmt.Sprintf("%v%v", config.TagPrefix, newVersion) 352 _, err = repository.CreateTag(tag, commit, nil) 353 if err != nil { 354 return commitID, err 355 } 356 357 ref := gitConfig.RefSpec(fmt.Sprintf("refs/tags/%v:refs/tags/%v", tag, tag)) 358 359 pushOptions := git.PushOptions{ 360 RefSpecs: []gitConfig.RefSpec{gitConfig.RefSpec(ref)}, 361 CABundle: certs, 362 } 363 364 currentRemoteOrigin, err := repository.Remote("origin") 365 if err != nil { 366 return commitID, errors.Wrap(err, "failed to retrieve current remote origin") 367 } 368 var updatedRemoteOrigin *git.Remote 369 370 urls := originUrls(repository) 371 if len(urls) == 0 { 372 log.SetErrorCategory(log.ErrorConfiguration) 373 return commitID, fmt.Errorf("no remote url maintained") 374 } 375 if strings.HasPrefix(urls[0], "http") { 376 if len(config.Username) == 0 || len(config.Password) == 0 { 377 // handling compatibility: try to use ssh in case no credentials are available 378 log.Entry().Info("git username/password missing - switching to ssh") 379 380 remoteURL := convertHTTPToSSHURL(urls[0]) 381 382 // update remote origin url to point to ssh url instead of http(s) url 383 err = repository.DeleteRemote("origin") 384 if err != nil { 385 return commitID, errors.Wrap(err, "failed to update remote origin - remove") 386 } 387 updatedRemoteOrigin, err = repository.CreateRemote(&gitConfig.RemoteConfig{Name: "origin", URLs: []string{remoteURL}}) 388 if err != nil { 389 return commitID, errors.Wrap(err, "failed to update remote origin - create") 390 } 391 392 pushOptions.Auth, err = sshAgentAuth("git") 393 if err != nil { 394 log.SetErrorCategory(log.ErrorConfiguration) 395 return commitID, errors.Wrap(err, "failed to retrieve ssh authentication") 396 } 397 log.Entry().Infof("using remote '%v'", remoteURL) 398 } else { 399 pushOptions.Auth = &http.BasicAuth{Username: config.Username, Password: config.Password} 400 } 401 } else { 402 pushOptions.Auth, err = sshAgentAuth("git") 403 if err != nil { 404 log.SetErrorCategory(log.ErrorConfiguration) 405 return commitID, errors.Wrap(err, "failed to retrieve ssh authentication") 406 } 407 } 408 409 err = repository.Push(&pushOptions) 410 if err != nil { 411 errText := fmt.Sprint(err) 412 switch { 413 case strings.Contains(errText, "ssh: handshake failed"): 414 log.SetErrorCategory(log.ErrorConfiguration) 415 case strings.Contains(errText, "Permission"): 416 log.SetErrorCategory(log.ErrorConfiguration) 417 case strings.Contains(errText, "authorization failed"): 418 log.SetErrorCategory(log.ErrorConfiguration) 419 case strings.Contains(errText, "authentication required"): 420 log.SetErrorCategory(log.ErrorConfiguration) 421 case strings.Contains(errText, "knownhosts:"): 422 err = errors.Wrap(err, "known_hosts file seems invalid") 423 log.SetErrorCategory(log.ErrorConfiguration) 424 case strings.Contains(errText, "unable to find any valid known_hosts file"): 425 log.SetErrorCategory(log.ErrorConfiguration) 426 case strings.Contains(errText, "connection timed out"): 427 log.SetErrorCategory(log.ErrorInfrastructure) 428 } 429 return commitID, err 430 } 431 432 if updatedRemoteOrigin != currentRemoteOrigin { 433 err = repository.DeleteRemote("origin") 434 if err != nil { 435 return commitID, errors.Wrap(err, "failed to restore remote origin - remove") 436 } 437 _, err := repository.CreateRemote(currentRemoteOrigin.Config()) 438 if err != nil { 439 return commitID, errors.Wrap(err, "failed to restore remote origin - create") 440 } 441 } 442 443 return commitID, nil 444 } 445 446 func addAndCommit(config *artifactPrepareVersionOptions, worktree gitWorktree, newVersion string, t time.Time) (plumbing.Hash, error) { 447 //maybe more options are required: https://github.com/go-git/go-git/blob/master/_examples/commit/main.go 448 commit, err := worktree.Commit(fmt.Sprintf("update version %v", newVersion), &git.CommitOptions{All: true, Author: &object.Signature{Name: config.CommitUserName, When: t}}) 449 if err != nil { 450 return commit, errors.Wrap(err, "failed to commit new version") 451 } 452 return commit, nil 453 } 454 455 func originUrls(repository gitRepository) []string { 456 remote, err := repository.Remote("origin") 457 if err != nil || remote == nil { 458 return []string{} 459 } 460 return remote.Config().URLs 461 } 462 463 func convertHTTPToSSHURL(url string) string { 464 sshURL := strings.Replace(url, "https://", "git@", 1) 465 return strings.Replace(sshURL, "/", ":", 1) 466 } 467 468 func templateCompatibility(groovyTemplate string) (versioningType string, useTimestamp bool, useCommitID bool) { 469 useTimestamp = strings.Contains(groovyTemplate, "${timestamp}") 470 useCommitID = strings.Contains(groovyTemplate, "${commitId") 471 472 versioningType = "library" 473 474 if useTimestamp { 475 versioningType = "cloud" 476 } 477 478 return 479 } 480 481 func calculateCloudVersion(artifact versioning.Artifact, config *artifactPrepareVersionOptions, version, gitCommitID string, timestamp time.Time) (string, error) { 482 versioningTempl, err := versioningTemplate(artifact.VersioningScheme()) 483 if err != nil { 484 log.SetErrorCategory(log.ErrorConfiguration) 485 return "", errors.Wrapf(err, "failed to get versioning template for scheme '%v'", artifact.VersioningScheme()) 486 } 487 488 newVersion, err := calculateNewVersion(versioningTempl, version, gitCommitID, config.IncludeCommitID, config.ShortCommitID, config.UnixTimestamp, timestamp) 489 if err != nil { 490 return "", errors.Wrap(err, "failed to calculate new version") 491 } 492 return newVersion, nil 493 } 494 495 func propagateVersion(config *artifactPrepareVersionOptions, utils artifactPrepareVersionUtils, artifactOpts *versioning.Options, version, gitCommitID string, now time.Time) error { 496 var err error 497 498 if len(config.AdditionalTargetDescriptors) > 0 && len(config.AdditionalTargetTools) != len(config.AdditionalTargetDescriptors) { 499 log.SetErrorCategory(log.ErrorConfiguration) 500 return fmt.Errorf("additionalTargetDescriptors cannot have a different number of entries than additionalTargetTools") 501 } 502 503 for i, targetTool := range config.AdditionalTargetTools { 504 505 var buildDescriptors []string 506 if len(config.AdditionalTargetDescriptors) > 0 { 507 buildDescriptors, err = utils.Glob(config.AdditionalTargetDescriptors[i]) 508 if err != nil { 509 log.SetErrorCategory(log.ErrorConfiguration) 510 return fmt.Errorf("failed to retrieve build descriptors: %w", err) 511 } 512 } 513 514 if len(buildDescriptors) == 0 { 515 buildDescriptors = append(buildDescriptors, "") 516 } 517 518 // in case of helm, make sure that app version is adapted as well 519 artifactOpts.HelmUpdateAppVersion = true 520 521 for _, buildDescriptor := range buildDescriptors { 522 targetArtifact, err := versioning.GetArtifact(targetTool, buildDescriptor, artifactOpts, utils) 523 if err != nil { 524 log.SetErrorCategory(log.ErrorConfiguration) 525 return fmt.Errorf("failed to retrieve artifact: %w", err) 526 } 527 528 // Make sure that version type fits to target artifact 529 descriptorVersion := version 530 if config.VersioningType == "cloud" || config.VersioningType == "cloud_noTag" { 531 descriptorVersion, err = calculateCloudVersion(targetArtifact, config, version, gitCommitID, now) 532 if err != nil { 533 return err 534 } 535 } 536 err = targetArtifact.SetVersion(descriptorVersion) 537 if err != nil { 538 return fmt.Errorf("failed to set additional target version for '%v': %w", targetTool, err) 539 } 540 } 541 } 542 return nil 543 }