mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-03-31 05:48:47 +02:00
267 lines
9.1 KiB
Go
267 lines
9.1 KiB
Go
package commit
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/argoproj/argo-cd/v3/controller/hydrator"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/argoproj/argo-cd/v3/commitserver/apiclient"
|
|
"github.com/argoproj/argo-cd/v3/commitserver/metrics"
|
|
"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
|
|
"github.com/argoproj/argo-cd/v3/util/git"
|
|
"github.com/argoproj/argo-cd/v3/util/io"
|
|
"github.com/argoproj/argo-cd/v3/util/io/files"
|
|
)
|
|
|
|
// Service is the service that handles commit requests.
|
|
type Service struct {
|
|
metricsServer *metrics.Server
|
|
repoClientFactory RepoClientFactory
|
|
}
|
|
|
|
// NewService returns a new instance of the commit service.
|
|
func NewService(gitCredsStore git.CredsStore, metricsServer *metrics.Server) *Service {
|
|
return &Service{
|
|
metricsServer: metricsServer,
|
|
repoClientFactory: NewRepoClientFactory(gitCredsStore, metricsServer),
|
|
}
|
|
}
|
|
|
|
type hydratorMetadataFile struct {
|
|
RepoURL string `json:"repoURL,omitempty"`
|
|
DrySHA string `json:"drySha,omitempty"`
|
|
Commands []string `json:"commands,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
Date string `json:"date,omitempty"`
|
|
// Subject is the subject line of the DRY commit message, i.e. `git show --format=%s`.
|
|
Subject string `json:"subject,omitempty"`
|
|
// Body is the body of the DRY commit message, excluding the subject line, i.e. `git show --format=%b`.
|
|
// Known Argocd- trailers with valid values are removed, but all other trailers are kept.
|
|
Body string `json:"body,omitempty"`
|
|
References []v1alpha1.RevisionReference `json:"references,omitempty"`
|
|
}
|
|
|
|
// TODO: make this configurable via ConfigMap.
|
|
var manifestHydrationReadmeTemplate = `# Manifest Hydration
|
|
|
|
To hydrate the manifests in this repository, run the following commands:
|
|
|
|
` + "```shell" + `
|
|
git clone {{ .RepoURL }}
|
|
# cd into the cloned directory
|
|
git checkout {{ .DrySHA }}
|
|
{{ range $command := .Commands -}}
|
|
{{ $command }}
|
|
{{ end -}}` + "```" + `
|
|
{{ if .References -}}
|
|
|
|
## References
|
|
|
|
{{ range $ref := .References -}}
|
|
{{ if $ref.Commit -}}
|
|
* [{{ $ref.Commit.SHA | mustRegexFind "[0-9a-f]+" | trunc 7 }}]({{ $ref.Commit.RepoURL }}): {{ $ref.Commit.Subject }} ({{ $ref.Commit.Author }})
|
|
{{ end -}}
|
|
{{ end -}}
|
|
{{ end -}}`
|
|
|
|
// CommitHydratedManifests handles a commit request. It clones the repository, checks out the sync branch, checks out
|
|
// the target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and
|
|
// pushes the changes. It returns the hydrated revision SHA and an error if one occurred.
|
|
func (s *Service) CommitHydratedManifests(_ context.Context, r *apiclient.CommitHydratedManifestsRequest) (*apiclient.CommitHydratedManifestsResponse, error) {
|
|
// This method is intentionally short. It's a wrapper around handleCommitRequest that adds metrics and logging.
|
|
// Keep logic here minimal and put most of the logic in handleCommitRequest.
|
|
startTime := time.Now()
|
|
|
|
// We validate for a nil repo in handleCommitRequest, but we need to check for a nil repo here to get the repo URL
|
|
// for metrics.
|
|
var repoURL string
|
|
if r.Repo != nil {
|
|
repoURL = r.Repo.Repo
|
|
}
|
|
|
|
var err error
|
|
s.metricsServer.IncPendingCommitRequest(repoURL)
|
|
defer func() {
|
|
s.metricsServer.DecPendingCommitRequest(repoURL)
|
|
commitResponseType := metrics.CommitResponseTypeSuccess
|
|
if err != nil {
|
|
commitResponseType = metrics.CommitResponseTypeFailure
|
|
}
|
|
s.metricsServer.IncCommitRequest(repoURL, commitResponseType)
|
|
s.metricsServer.ObserveCommitRequestDuration(repoURL, commitResponseType, time.Since(startTime))
|
|
}()
|
|
|
|
logCtx := log.WithFields(log.Fields{"branch": r.TargetBranch, "drySHA": r.DrySha})
|
|
|
|
out, sha, err := s.handleCommitRequest(logCtx, r)
|
|
if err != nil {
|
|
logCtx.WithError(err).WithField("output", out).Error("failed to handle commit request")
|
|
|
|
// No need to wrap this error, sufficient context is build in handleCommitRequest.
|
|
return &apiclient.CommitHydratedManifestsResponse{}, err
|
|
}
|
|
|
|
logCtx.Info("Successfully handled commit request")
|
|
return &apiclient.CommitHydratedManifestsResponse{
|
|
HydratedSha: sha,
|
|
}, nil
|
|
}
|
|
|
|
// handleCommitRequest handles the commit request. It clones the repository, checks out the sync branch, checks out the
|
|
// target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and pushes
|
|
// the changes. It returns the output of the git commands and an error if one occurred.
|
|
func (s *Service) handleCommitRequest(logCtx *log.Entry, r *apiclient.CommitHydratedManifestsRequest) (string, string, error) {
|
|
if r.Repo == nil {
|
|
return "", "", errors.New("repo is required")
|
|
}
|
|
if r.Repo.Repo == "" {
|
|
return "", "", errors.New("repo URL is required")
|
|
}
|
|
if r.TargetBranch == "" {
|
|
return "", "", errors.New("target branch is required")
|
|
}
|
|
if r.SyncBranch == "" {
|
|
return "", "", errors.New("sync branch is required")
|
|
}
|
|
|
|
logCtx = logCtx.WithField("repo", r.Repo.Repo)
|
|
logCtx.Debug("Initiating git client")
|
|
gitClient, dirPath, cleanup, err := s.initGitClient(logCtx, r)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to init git client: %w", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
root, err := os.OpenRoot(dirPath)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to open root dir: %w", err)
|
|
}
|
|
defer io.Close(root)
|
|
|
|
logCtx.Debugf("Checking out sync branch %s", r.SyncBranch)
|
|
var out string
|
|
out, err = gitClient.CheckoutOrOrphan(r.SyncBranch, false)
|
|
if err != nil {
|
|
return out, "", fmt.Errorf("failed to checkout sync branch: %w", err)
|
|
}
|
|
|
|
logCtx.Debugf("Checking out target branch %s", r.TargetBranch)
|
|
out, err = gitClient.CheckoutOrNew(r.TargetBranch, r.SyncBranch, false)
|
|
if err != nil {
|
|
return out, "", fmt.Errorf("failed to checkout target branch: %w", err)
|
|
}
|
|
|
|
logCtx.Debug("Clearing and preparing paths")
|
|
var pathsToClear []string
|
|
// range over the paths configured and skip those application
|
|
// paths that are referencing to root path
|
|
for _, p := range r.Paths {
|
|
if hydrator.IsRootPath(p.Path) {
|
|
// skip adding paths that are referencing root directory
|
|
logCtx.Debugf("Path %s is referencing root directory, ignoring the path", p.Path)
|
|
continue
|
|
}
|
|
pathsToClear = append(pathsToClear, p.Path)
|
|
}
|
|
|
|
if len(pathsToClear) > 0 {
|
|
logCtx.Debugf("Clearing paths: %v", pathsToClear)
|
|
out, err := gitClient.RemoveContents(pathsToClear)
|
|
if err != nil {
|
|
return out, "", fmt.Errorf("failed to clear paths %v: %w", pathsToClear, err)
|
|
}
|
|
}
|
|
|
|
logCtx.Debug("Writing manifests")
|
|
err = WriteForPaths(root, r.Repo.Repo, r.DrySha, r.DryCommitMetadata, r.Paths)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to write manifests: %w", err)
|
|
}
|
|
|
|
logCtx.Debug("Committing and pushing changes")
|
|
out, err = gitClient.CommitAndPush(r.TargetBranch, r.CommitMessage)
|
|
if err != nil {
|
|
return out, "", fmt.Errorf("failed to commit and push: %w", err)
|
|
}
|
|
|
|
logCtx.Debug("Getting commit SHA")
|
|
sha, err := gitClient.CommitSHA()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to get commit SHA: %w", err)
|
|
}
|
|
|
|
return "", sha, nil
|
|
}
|
|
|
|
// initGitClient initializes a git client for the given repository and returns the client, the path to the directory where
|
|
// the repository is cloned, a cleanup function that should be called when the directory is no longer needed, and an error
|
|
// if one occurred.
|
|
func (s *Service) initGitClient(logCtx *log.Entry, r *apiclient.CommitHydratedManifestsRequest) (git.Client, string, func(), error) {
|
|
dirPath, err := files.CreateTempDir("/tmp/_commit-service")
|
|
if err != nil {
|
|
return nil, "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
|
}
|
|
// Call cleanupOrLog in this function if an error occurs to ensure the temp dir is cleaned up.
|
|
cleanupOrLog := func() {
|
|
err := os.RemoveAll(dirPath)
|
|
if err != nil {
|
|
logCtx.WithError(err).Error("failed to cleanup temp dir")
|
|
}
|
|
}
|
|
|
|
gitClient, err := s.repoClientFactory.NewClient(r.Repo, dirPath)
|
|
if err != nil {
|
|
cleanupOrLog()
|
|
return nil, "", nil, fmt.Errorf("failed to create git client: %w", err)
|
|
}
|
|
|
|
logCtx.Debugf("Initializing repo %s", r.Repo.Repo)
|
|
err = gitClient.Init()
|
|
if err != nil {
|
|
cleanupOrLog()
|
|
return nil, "", nil, fmt.Errorf("failed to init git client: %w", err)
|
|
}
|
|
|
|
logCtx.Debugf("Fetching repo %s", r.Repo.Repo)
|
|
err = gitClient.Fetch("", 0)
|
|
if err != nil {
|
|
cleanupOrLog()
|
|
return nil, "", nil, fmt.Errorf("failed to clone repo: %w", err)
|
|
}
|
|
|
|
// FIXME: make it work for GHE
|
|
// logCtx.Debugf("Getting user info for repo credentials")
|
|
// gitCreds := r.Repo.GetGitCreds(s.gitCredsStore)
|
|
// startTime := time.Now()
|
|
// authorName, authorEmail, err := gitCreds.GetUserInfo(ctx)
|
|
// s.metricsServer.ObserveUserInfoRequestDuration(r.Repo.Repo, getCredentialType(r.Repo), time.Since(startTime))
|
|
// if err != nil {
|
|
// cleanupOrLog()
|
|
// return nil, "", nil, fmt.Errorf("failed to get github app info: %w", err)
|
|
// }
|
|
var authorName, authorEmail string
|
|
|
|
if authorName == "" {
|
|
authorName = "Argo CD"
|
|
}
|
|
if authorEmail == "" {
|
|
logCtx.Warnf("Author email not available, using 'argo-cd@example.com'.")
|
|
authorEmail = "argo-cd@example.com"
|
|
}
|
|
|
|
logCtx.Debugf("Setting author %s <%s>", authorName, authorEmail)
|
|
_, err = gitClient.SetAuthor(authorName, authorEmail)
|
|
if err != nil {
|
|
cleanupOrLog()
|
|
return nil, "", nil, fmt.Errorf("failed to set author: %w", err)
|
|
}
|
|
|
|
return gitClient, dirPath, cleanupOrLog, nil
|
|
}
|