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