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  }