diff --git a/common/common.go b/common/common.go index b825ccddef91f..de6b6cbdb6b05 100644 --- a/common/common.go +++ b/common/common.go @@ -163,6 +163,8 @@ const ( LabelValueSecretTypeRepository = "repository" // LabelValueSecretTypeRepoCreds indicates a secret type of repository credentials LabelValueSecretTypeRepoCreds = "repo-creds" + // LabelValueSecretTypeRepoCreds indicates a secret type of repository credentials + LabelValueSecretTypeHydrator = "hydrator" // AnnotationKeyAppInstance is the Argo CD application name is used as the instance name AnnotationKeyAppInstance = "argocd.argoproj.io/tracking-id" diff --git a/controller/appcontroller.go b/controller/appcontroller.go index e9c6fb682b1b8..6411a11876b89 100644 --- a/controller/appcontroller.go +++ b/controller/appcontroller.go @@ -5,7 +5,6 @@ import ( "encoding/json" goerrors "errors" "fmt" - "github.com/argoproj/argo-cd/v2/controller/commit" "math" "math/rand" "net/http" @@ -17,6 +16,8 @@ import ( "sync" "time" + "github.com/argoproj/argo-cd/v2/controller/commit" + clustercache "github.com/argoproj/gitops-engine/pkg/cache" "github.com/argoproj/gitops-engine/pkg/diff" "github.com/argoproj/gitops-engine/pkg/health" @@ -1586,15 +1587,30 @@ func (ctrl *ApplicationController) processAppRefreshQueueItem() (processNext boo logCtx.Errorf("Dry source has not been resolved, skipping") return } - if app.Status.SourceHydrator.Revision != revision { - app.Status.SourceHydrator.Revision = revision - app.Status.SourceHydrator.HydrateOperation = &appv1.HydrateOperation{ - StartedAt: metav1.Now(), - FinishedAt: nil, - Status: appv1.HydrateOperationPhaseRunning, + if app.Status.SourceHydrator.Revision != revision || (app.Status.SourceHydrator.HydrateOperation != nil && app.Status.SourceHydrator.HydrateOperation.Status != appv1.HydrateOperationPhaseSucceeded) { + restart := false + if app.Status.SourceHydrator.Revision != revision { + restart = true } - ctrl.persistAppStatus(origApp, &app.Status) - origApp.Status.SourceHydrator = app.Status.SourceHydrator + + if app.Status.SourceHydrator.HydrateOperation != nil && app.Status.SourceHydrator.HydrateOperation.Status == appv1.HydrateOperationPhaseFailed { + retryWaitPeriod := 2 * 60 * time.Second + if metav1.Now().Sub(app.Status.SourceHydrator.HydrateOperation.FinishedAt.Time) > retryWaitPeriod { + logCtx.Info("Retrying failed hydration") + restart = true + } + } + + if restart { + app.Status.SourceHydrator.HydrateOperation = &appv1.HydrateOperation{ + StartedAt: metav1.Now(), + FinishedAt: nil, + Status: appv1.HydrateOperationPhaseRunning, + } + ctrl.persistAppStatus(origApp, &app.Status) + origApp.Status.SourceHydrator = app.Status.SourceHydrator + } + destinationBranch := app.Spec.SourceHydrator.SyncSource.TargetRevision if app.Spec.SourceHydrator.HydrateTo != nil { destinationBranch = app.Spec.SourceHydrator.HydrateTo.TargetRevision @@ -1605,6 +1621,8 @@ func (ctrl *ApplicationController) processAppRefreshQueueItem() (processNext boo destinationBranch: destinationBranch, } ctrl.hydrationQueue.Add(key) + } else { + logCtx.Debug("No reason to re-hydrate") } } @@ -1785,7 +1803,7 @@ func (ctrl *ApplicationController) processHydrationQueueItem() (processNext bool app.Status.SourceHydrator.HydrateOperation.Status = appv1.HydrateOperationPhaseFailed failedAt := metav1.Now() app.Status.SourceHydrator.HydrateOperation.FinishedAt = &failedAt - app.Status.SourceHydrator.HydrateOperation.Message = err.Error() + app.Status.SourceHydrator.HydrateOperation.Message = fmt.Sprintf("Failed to hydrated revision %s: %v", revision, err.Error()) ctrl.persistAppStatus(origApp, &app.Status) logCtx.Errorf("Failed to hydrate app: %v", err) return @@ -1800,6 +1818,7 @@ func (ctrl *ApplicationController) processHydrationQueueItem() (processNext bool Status: appv1.HydrateOperationPhaseSucceeded, Message: "", } + app.Status.SourceHydrator.Revision = revision app.Status.SourceHydrator.HydrateOperation = operation ctrl.persistAppStatus(origApp, &app.Status) origApp.Status.SourceHydrator = app.Status.SourceHydrator @@ -1855,18 +1874,16 @@ func (ctrl *ApplicationController) hydrate(apps []*appv1.Application, refreshTyp } manifestsRequest := commit.ManifestsRequest{ - RepoURL: repoURL, - SyncBranch: syncBranch, - TargetBranch: targetBranch, - DrySHA: revision, - CommitAuthorName: "Michael Crenshaw", - CommitAuthorEmail: "350466+crenshaw-dev@users.noreply.github.com", - CommitMessage: fmt.Sprintf("[Argo CD Bot] hydrate %s", revision), - CommitTime: time.Now(), - Paths: paths, - } - - commitService := commit.NewService() + RepoURL: repoURL, + SyncBranch: syncBranch, + TargetBranch: targetBranch, + DrySHA: revision, + CommitMessage: fmt.Sprintf("[Argo CD Bot] hydrate %s", revision), + CommitTime: time.Now(), + Paths: paths, + } + + commitService := commit.NewService(ctrl.db) _, err := commitService.Commit(manifestsRequest) if err != nil { return fmt.Errorf("failed to commit hydrated manifests: %w", err) diff --git a/controller/commit/commit.go b/controller/commit/commit.go index 2dbfecf232e86..3771388076985 100644 --- a/controller/commit/commit.go +++ b/controller/commit/commit.go @@ -1,19 +1,27 @@ package commit import ( + "context" "encoding/json" "fmt" - securejoin "github.com/cyphar/filepath-securejoin" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "net/http" "os" "os/exec" "path" - "sigs.k8s.io/yaml" "strings" "text/template" "time" + + "github.com/argoproj/argo-cd/v2/applicationset/services/github_app_auth" + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/argoproj/argo-cd/v2/util/db" + "github.com/bradleyfalzon/ghinstallation/v2" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/google/go-github/v35/github" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" ) /** @@ -25,15 +33,13 @@ type Service interface { } type ManifestsRequest struct { - RepoURL string - SyncBranch string - TargetBranch string - DrySHA string - CommitAuthorName string - CommitAuthorEmail string - CommitMessage string - CommitTime time.Time - Paths []PathDetails + RepoURL string + SyncBranch string + TargetBranch string + DrySHA string + CommitMessage string + CommitTime time.Time + Paths []PathDetails } type PathDetails struct { @@ -54,16 +60,93 @@ type ManifestsResponse struct { RequestId string } -func NewService() Service { - return &service{} +func NewService(db db.ArgoDB) Service { + return &service{db: db} } type service struct { + db db.ArgoDB +} + +func isGitHubApp(cred *v1alpha1.Repository) bool { + return cred.GithubAppPrivateKey != "" && cred.GithubAppId != 0 && cred.GithubAppInstallationId != 0 +} + +// Client builds a github client for the given app authentication. +func getAppInstallation(g github_app_auth.Authentication) (*ghinstallation.Transport, error) { + rt, err := ghinstallation.New(http.DefaultTransport, g.Id, g.InstallationId, []byte(g.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("failed to create github app install: %w", err) + } + return rt, nil +} + +func getGitHubInstallationClient(rt *ghinstallation.Transport) *github.Client { + httpClient := http.Client{Transport: rt} + client := github.NewClient(&httpClient) + return client +} + +func getGitHubAppClient(g github_app_auth.Authentication) (*github.Client, error) { + var client *github.Client + var err error + + // This creates the app authenticated with the bearer JWT, not the installation token. + rt, err := ghinstallation.NewAppsTransport(http.DefaultTransport, g.Id, []byte(g.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("failed to create github app: %w", err) + } + + httpClient := http.Client{Transport: rt} + client = github.NewClient(&httpClient) + return client, err + } func (s *service) Commit(r ManifestsRequest) (ManifestsResponse, error) { + var authorName, authorEmail, basicAuth string + + ctx := context.TODO() logCtx := log.WithFields(log.Fields{"repo": r.RepoURL, "branch": r.TargetBranch, "drySHA": r.DrySHA}) + repo, err := s.db.GetHydratorCredentials(ctx, r.RepoURL) + if err != nil { + return ManifestsResponse{}, fmt.Errorf("failed to get git credentials: %w", err) + } + if isGitHubApp(repo) { + info := github_app_auth.Authentication{ + Id: repo.GithubAppId, + InstallationId: repo.GithubAppInstallationId, + PrivateKey: repo.GithubAppPrivateKey, + } + appInstall, err := getAppInstallation(info) + if err != nil { + return ManifestsResponse{}, err + } + token, err := appInstall.Token(ctx) + if err != nil { + return ManifestsResponse{}, fmt.Errorf("failed to get access token: %w", err) + } + client, err := getGitHubAppClient(info) + if err != nil { + return ManifestsResponse{}, fmt.Errorf("cannot create github client: %w", err) + } + app, _, err := client.Apps.Get(ctx, "") + if err != nil { + return ManifestsResponse{}, fmt.Errorf("cannot get app info: %w", err) + } + appLogin := fmt.Sprintf("%s[bot]", app.GetSlug()) + user, _, err := getGitHubInstallationClient(appInstall).Users.Get(ctx, appLogin) + if err != nil { + return ManifestsResponse{}, fmt.Errorf("cannot get app user info: %w", err) + } + authorName = user.GetLogin() + authorEmail = fmt.Sprintf("%d+%s@users.noreply.github.com", user.GetID(), user.GetLogin()) + basicAuth = fmt.Sprintf("x-access-token:%s", token) + } else { + logCtx.Warn("No github app credentials were found") + } + logCtx.Debug("Creating temp dir") dirName, err := uuid.NewRandom() if err != nil { @@ -84,36 +167,57 @@ func (s *service) Commit(r ManifestsRequest) (ManifestsResponse, error) { // Clone the repo into the temp dir using the git CLI logCtx.Debugf("Cloning repo %s", r.RepoURL) - err = exec.Command("git", "clone", r.RepoURL, dirPath).Run() + authRepoUrl := r.RepoURL + if basicAuth != "" && strings.HasPrefix(authRepoUrl, "https://github.com/") { + authRepoUrl = fmt.Sprintf("https://%s@github.com/%s", basicAuth, strings.TrimPrefix(authRepoUrl, "https://github.com/")) + } + err = exec.Command("git", "clone", authRepoUrl, dirPath).Run() if err != nil { return ManifestsResponse{}, fmt.Errorf("failed to clone repo: %w", err) } - // Set author name - logCtx.Debugf("Setting author name %s", r.CommitAuthorName) - authorCmd := exec.Command("git", "config", "user.name", r.CommitAuthorName) - authorCmd.Dir = dirPath - out, err := authorCmd.CombinedOutput() - if err != nil { - logCtx.WithError(err).WithField("output", string(out)).Error("failed to set author name") - return ManifestsResponse{}, fmt.Errorf("failed to set author name: %w", err) + if basicAuth != "" { + // This is the dumbest kind of auth and should never make it in main branch + // git config url."https://${TOKEN}@github.com/".insteadOf "https://github.com/" + logCtx.Debugf("Setting auth") + authCmd := exec.Command("git", "config", fmt.Sprintf("url.\"https://%s@github.com/\".insteadOf", basicAuth), "https://github.com/") + authCmd.Dir = dirPath + out, err := authCmd.CombinedOutput() + if err != nil { + logCtx.WithError(err).WithField("output", string(out)).Error("failed to set auth") + return ManifestsResponse{}, fmt.Errorf("failed to set auth: %w", err) + } } - // Set author email - logCtx.Debugf("Setting author email %s", r.CommitAuthorEmail) - emailCmd := exec.Command("git", "config", "user.email", r.CommitAuthorEmail) - emailCmd.Dir = dirPath - out, err = emailCmd.CombinedOutput() - if err != nil { - logCtx.WithError(err).WithField("output", string(out)).Error("failed to set author email") - return ManifestsResponse{}, fmt.Errorf("failed to set author email: %w", err) + if authorName != "" { + // Set author name + logCtx.Debugf("Setting author name %s", authorName) + authorCmd := exec.Command("git", "config", "user.name", authorName) + authorCmd.Dir = dirPath + out, err := authorCmd.CombinedOutput() + if err != nil { + logCtx.WithError(err).WithField("output", string(out)).Error("failed to set author name") + return ManifestsResponse{}, fmt.Errorf("failed to set author name: %w", err) + } + } + + if authorEmail != "" { + // Set author email + logCtx.Debugf("Setting author email %s", authorEmail) + emailCmd := exec.Command("git", "config", "user.email", authorEmail) + emailCmd.Dir = dirPath + out, err := emailCmd.CombinedOutput() + if err != nil { + logCtx.WithError(err).WithField("output", string(out)).Error("failed to set author email") + return ManifestsResponse{}, fmt.Errorf("failed to set author email: %w", err) + } } // Checkout the sync branch logCtx.Debugf("Checking out sync branch %s", r.SyncBranch) checkoutCmd := exec.Command("git", "checkout", r.SyncBranch) checkoutCmd.Dir = dirPath - out, err = checkoutCmd.CombinedOutput() + out, err := checkoutCmd.CombinedOutput() if err != nil { // If the sync branch doesn't exist, create it as an orphan branch. if strings.Contains(string(out), "did not match any file(s) known to git") { @@ -185,7 +289,7 @@ func (s *service) Commit(r ManifestsRequest) (ManifestsResponse, error) { // Clear the repo contents using git rm logCtx.Debug("Clearing repo contents") - rmCmd := exec.Command("git", "rm", "-r", ".") + rmCmd := exec.Command("git", "rm", "-r", "--ignore-unmatch", ".") rmCmd.Dir = dirPath out, err = rmCmd.CombinedOutput() if err != nil { diff --git a/util/db/db.go b/util/db/db.go index edae9f76a958d..962843828d227 100644 --- a/util/db/db.go +++ b/util/db/db.go @@ -80,6 +80,9 @@ type ArgoDB interface { // GetAllHelmRepositoryCredentials gets all repo credentials GetAllHelmRepositoryCredentials(ctx context.Context) ([]*appv1.RepoCreds, error) + // GetHydratorCredentials gets repo credentials specific to the hydrator for given URL + GetHydratorCredentials(ctx context.Context, repoURL string) (*appv1.Repository, error) + // ListHelmRepositories lists repositories ListHelmRepositories(ctx context.Context) ([]*appv1.Repository, error) diff --git a/util/db/hydrator.go b/util/db/hydrator.go new file mode 100644 index 0000000000000..b2d4630d06d7d --- /dev/null +++ b/util/db/hydrator.go @@ -0,0 +1,41 @@ +package db + +import ( + "context" + + "github.com/argoproj/argo-cd/v2/common" + appsv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + corev1 "k8s.io/api/core/v1" +) + +func (db *db) GetHydratorCredentials(ctx context.Context, repoURL string) (*appsv1.Repository, error) { + secret, err := db.getRepoCredsSecret(repoURL) + if err != nil { + if status.Code(err) == codes.NotFound { + return nil, nil + } + + return nil, err + } + + return secretToRepository(secret) +} + +func (db *db) getRepoCredsSecret(repoURL string) (*corev1.Secret, error) { + // Should reuse stuff from repo secrets backend... + secretBackend := (&secretsRepositoryBackend{db: db}) + + secrets, err := db.listSecretsByType(common.LabelValueSecretTypeHydrator) + if err != nil { + return nil, err + } + + index := secretBackend.getRepositoryCredentialIndex(secrets, repoURL) + if index < 0 { + return nil, status.Errorf(codes.NotFound, "repository credentials %q not found", repoURL) + } + + return secrets[index], nil +}