Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use github app to commit #22

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
61 changes: 39 additions & 22 deletions controller/appcontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
goerrors "errors"
"fmt"
"github.com/argoproj/argo-cd/v2/controller/commit"
"math"
"math/rand"
"net/http"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -1605,6 +1621,8 @@ func (ctrl *ApplicationController) processAppRefreshQueueItem() (processNext boo
destinationBranch: destinationBranch,
}
ctrl.hydrationQueue.Add(key)
} else {
logCtx.Debug("No reason to re-hydrate")
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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: "[email protected]",
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)
Expand Down
174 changes: 139 additions & 35 deletions controller/commit/commit.go
Original file line number Diff line number Diff line change
@@ -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"
)

/**
Expand All @@ -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 {
Expand All @@ -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+%[email protected]", 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 {
Expand All @@ -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://%[email protected]/%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://%[email protected]/\".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") {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions util/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading