github.com/argoproj/argo-cd/v3@v3.2.1/commitserver/commit/commit.go (about)

     1  package commit
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"time"
     9  
    10  	"github.com/argoproj/argo-cd/v3/controller/hydrator"
    11  
    12  	log "github.com/sirupsen/logrus"
    13  
    14  	"github.com/argoproj/argo-cd/v3/commitserver/apiclient"
    15  	"github.com/argoproj/argo-cd/v3/commitserver/metrics"
    16  	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
    17  	"github.com/argoproj/argo-cd/v3/util/git"
    18  	"github.com/argoproj/argo-cd/v3/util/io"
    19  	"github.com/argoproj/argo-cd/v3/util/io/files"
    20  )
    21  
    22  // Service is the service that handles commit requests.
    23  type Service struct {
    24  	metricsServer     *metrics.Server
    25  	repoClientFactory RepoClientFactory
    26  }
    27  
    28  // NewService returns a new instance of the commit service.
    29  func NewService(gitCredsStore git.CredsStore, metricsServer *metrics.Server) *Service {
    30  	return &Service{
    31  		metricsServer:     metricsServer,
    32  		repoClientFactory: NewRepoClientFactory(gitCredsStore, metricsServer),
    33  	}
    34  }
    35  
    36  type hydratorMetadataFile struct {
    37  	RepoURL  string   `json:"repoURL,omitempty"`
    38  	DrySHA   string   `json:"drySha,omitempty"`
    39  	Commands []string `json:"commands,omitempty"`
    40  	Author   string   `json:"author,omitempty"`
    41  	Date     string   `json:"date,omitempty"`
    42  	// Subject is the subject line of the DRY commit message, i.e. `git show --format=%s`.
    43  	Subject string `json:"subject,omitempty"`
    44  	// Body is the body of the DRY commit message, excluding the subject line, i.e. `git show --format=%b`.
    45  	// Known Argocd- trailers with valid values are removed, but all other trailers are kept.
    46  	Body       string                       `json:"body,omitempty"`
    47  	References []v1alpha1.RevisionReference `json:"references,omitempty"`
    48  }
    49  
    50  // TODO: make this configurable via ConfigMap.
    51  var manifestHydrationReadmeTemplate = `# Manifest Hydration
    52  
    53  To hydrate the manifests in this repository, run the following commands:
    54  
    55  ` + "```shell" + `
    56  git clone {{ .RepoURL }}
    57  # cd into the cloned directory
    58  git checkout {{ .DrySHA }}
    59  {{ range $command := .Commands -}}
    60  {{ $command }}
    61  {{ end -}}` + "```" + `
    62  {{ if .References -}}
    63  
    64  ## References
    65  
    66  {{ range $ref := .References -}}
    67  {{ if $ref.Commit -}}
    68  * [{{ $ref.Commit.SHA | mustRegexFind "[0-9a-f]+" | trunc 7 }}]({{ $ref.Commit.RepoURL }}): {{ $ref.Commit.Subject }} ({{ $ref.Commit.Author }})
    69  {{ end -}}
    70  {{ end -}}
    71  {{ end -}}`
    72  
    73  // CommitHydratedManifests handles a commit request. It clones the repository, checks out the sync branch, checks out
    74  // the target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and
    75  // pushes the changes. It returns the hydrated revision SHA and an error if one occurred.
    76  func (s *Service) CommitHydratedManifests(_ context.Context, r *apiclient.CommitHydratedManifestsRequest) (*apiclient.CommitHydratedManifestsResponse, error) {
    77  	// This method is intentionally short. It's a wrapper around handleCommitRequest that adds metrics and logging.
    78  	// Keep logic here minimal and put most of the logic in handleCommitRequest.
    79  	startTime := time.Now()
    80  
    81  	// We validate for a nil repo in handleCommitRequest, but we need to check for a nil repo here to get the repo URL
    82  	// for metrics.
    83  	var repoURL string
    84  	if r.Repo != nil {
    85  		repoURL = r.Repo.Repo
    86  	}
    87  
    88  	var err error
    89  	s.metricsServer.IncPendingCommitRequest(repoURL)
    90  	defer func() {
    91  		s.metricsServer.DecPendingCommitRequest(repoURL)
    92  		commitResponseType := metrics.CommitResponseTypeSuccess
    93  		if err != nil {
    94  			commitResponseType = metrics.CommitResponseTypeFailure
    95  		}
    96  		s.metricsServer.IncCommitRequest(repoURL, commitResponseType)
    97  		s.metricsServer.ObserveCommitRequestDuration(repoURL, commitResponseType, time.Since(startTime))
    98  	}()
    99  
   100  	logCtx := log.WithFields(log.Fields{"branch": r.TargetBranch, "drySHA": r.DrySha})
   101  
   102  	out, sha, err := s.handleCommitRequest(logCtx, r)
   103  	if err != nil {
   104  		logCtx.WithError(err).WithField("output", out).Error("failed to handle commit request")
   105  
   106  		// No need to wrap this error, sufficient context is build in handleCommitRequest.
   107  		return &apiclient.CommitHydratedManifestsResponse{}, err
   108  	}
   109  
   110  	logCtx.Info("Successfully handled commit request")
   111  	return &apiclient.CommitHydratedManifestsResponse{
   112  		HydratedSha: sha,
   113  	}, nil
   114  }
   115  
   116  // handleCommitRequest handles the commit request. It clones the repository, checks out the sync branch, checks out the
   117  // target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and pushes
   118  // the changes. It returns the output of the git commands and an error if one occurred.
   119  func (s *Service) handleCommitRequest(logCtx *log.Entry, r *apiclient.CommitHydratedManifestsRequest) (string, string, error) {
   120  	if r.Repo == nil {
   121  		return "", "", errors.New("repo is required")
   122  	}
   123  	if r.Repo.Repo == "" {
   124  		return "", "", errors.New("repo URL is required")
   125  	}
   126  	if r.TargetBranch == "" {
   127  		return "", "", errors.New("target branch is required")
   128  	}
   129  	if r.SyncBranch == "" {
   130  		return "", "", errors.New("sync branch is required")
   131  	}
   132  
   133  	logCtx = logCtx.WithField("repo", r.Repo.Repo)
   134  	logCtx.Debug("Initiating git client")
   135  	gitClient, dirPath, cleanup, err := s.initGitClient(logCtx, r)
   136  	if err != nil {
   137  		return "", "", fmt.Errorf("failed to init git client: %w", err)
   138  	}
   139  	defer cleanup()
   140  
   141  	root, err := os.OpenRoot(dirPath)
   142  	if err != nil {
   143  		return "", "", fmt.Errorf("failed to open root dir: %w", err)
   144  	}
   145  	defer io.Close(root)
   146  
   147  	logCtx.Debugf("Checking out sync branch %s", r.SyncBranch)
   148  	var out string
   149  	out, err = gitClient.CheckoutOrOrphan(r.SyncBranch, false)
   150  	if err != nil {
   151  		return out, "", fmt.Errorf("failed to checkout sync branch: %w", err)
   152  	}
   153  
   154  	logCtx.Debugf("Checking out target branch %s", r.TargetBranch)
   155  	out, err = gitClient.CheckoutOrNew(r.TargetBranch, r.SyncBranch, false)
   156  	if err != nil {
   157  		return out, "", fmt.Errorf("failed to checkout target branch: %w", err)
   158  	}
   159  
   160  	logCtx.Debug("Clearing and preparing paths")
   161  	var pathsToClear []string
   162  	// range over the paths configured and skip those application
   163  	// paths that are referencing to root path
   164  	for _, p := range r.Paths {
   165  		if hydrator.IsRootPath(p.Path) {
   166  			// skip adding paths that are referencing root directory
   167  			logCtx.Debugf("Path %s is referencing root directory, ignoring the path", p.Path)
   168  			continue
   169  		}
   170  		pathsToClear = append(pathsToClear, p.Path)
   171  	}
   172  
   173  	if len(pathsToClear) > 0 {
   174  		logCtx.Debugf("Clearing paths: %v", pathsToClear)
   175  		out, err := gitClient.RemoveContents(pathsToClear)
   176  		if err != nil {
   177  			return out, "", fmt.Errorf("failed to clear paths %v: %w", pathsToClear, err)
   178  		}
   179  	}
   180  
   181  	logCtx.Debug("Writing manifests")
   182  	err = WriteForPaths(root, r.Repo.Repo, r.DrySha, r.DryCommitMetadata, r.Paths)
   183  	if err != nil {
   184  		return "", "", fmt.Errorf("failed to write manifests: %w", err)
   185  	}
   186  
   187  	logCtx.Debug("Committing and pushing changes")
   188  	out, err = gitClient.CommitAndPush(r.TargetBranch, r.CommitMessage)
   189  	if err != nil {
   190  		return out, "", fmt.Errorf("failed to commit and push: %w", err)
   191  	}
   192  
   193  	logCtx.Debug("Getting commit SHA")
   194  	sha, err := gitClient.CommitSHA()
   195  	if err != nil {
   196  		return "", "", fmt.Errorf("failed to get commit SHA: %w", err)
   197  	}
   198  
   199  	return "", sha, nil
   200  }
   201  
   202  // initGitClient initializes a git client for the given repository and returns the client, the path to the directory where
   203  // the repository is cloned, a cleanup function that should be called when the directory is no longer needed, and an error
   204  // if one occurred.
   205  func (s *Service) initGitClient(logCtx *log.Entry, r *apiclient.CommitHydratedManifestsRequest) (git.Client, string, func(), error) {
   206  	dirPath, err := files.CreateTempDir("/tmp/_commit-service")
   207  	if err != nil {
   208  		return nil, "", nil, fmt.Errorf("failed to create temp dir: %w", err)
   209  	}
   210  	// Call cleanupOrLog in this function if an error occurs to ensure the temp dir is cleaned up.
   211  	cleanupOrLog := func() {
   212  		err := os.RemoveAll(dirPath)
   213  		if err != nil {
   214  			logCtx.WithError(err).Error("failed to cleanup temp dir")
   215  		}
   216  	}
   217  
   218  	gitClient, err := s.repoClientFactory.NewClient(r.Repo, dirPath)
   219  	if err != nil {
   220  		cleanupOrLog()
   221  		return nil, "", nil, fmt.Errorf("failed to create git client: %w", err)
   222  	}
   223  
   224  	logCtx.Debugf("Initializing repo %s", r.Repo.Repo)
   225  	err = gitClient.Init()
   226  	if err != nil {
   227  		cleanupOrLog()
   228  		return nil, "", nil, fmt.Errorf("failed to init git client: %w", err)
   229  	}
   230  
   231  	logCtx.Debugf("Fetching repo %s", r.Repo.Repo)
   232  	err = gitClient.Fetch("")
   233  	if err != nil {
   234  		cleanupOrLog()
   235  		return nil, "", nil, fmt.Errorf("failed to clone repo: %w", err)
   236  	}
   237  
   238  	// FIXME: make it work for GHE
   239  	// logCtx.Debugf("Getting user info for repo credentials")
   240  	// gitCreds := r.Repo.GetGitCreds(s.gitCredsStore)
   241  	// startTime := time.Now()
   242  	// authorName, authorEmail, err := gitCreds.GetUserInfo(ctx)
   243  	// s.metricsServer.ObserveUserInfoRequestDuration(r.Repo.Repo, getCredentialType(r.Repo), time.Since(startTime))
   244  	// if err != nil {
   245  	//	 cleanupOrLog()
   246  	//	 return nil, "", nil, fmt.Errorf("failed to get github app info: %w", err)
   247  	// }
   248  	var authorName, authorEmail string
   249  
   250  	if authorName == "" {
   251  		authorName = "Argo CD"
   252  	}
   253  	if authorEmail == "" {
   254  		logCtx.Warnf("Author email not available, using 'argo-cd@example.com'.")
   255  		authorEmail = "argo-cd@example.com"
   256  	}
   257  
   258  	logCtx.Debugf("Setting author %s <%s>", authorName, authorEmail)
   259  	_, err = gitClient.SetAuthor(authorName, authorEmail)
   260  	if err != nil {
   261  		cleanupOrLog()
   262  		return nil, "", nil, fmt.Errorf("failed to set author: %w", err)
   263  	}
   264  
   265  	return gitClient, dirPath, cleanupOrLog, nil
   266  }