package gitclient import ( "context" "errors" "fmt" "os" "strings" "time" gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/storage/memory" "github.com/aws/eks-anywhere/pkg/git" "github.com/aws/eks-anywhere/pkg/logger" "github.com/aws/eks-anywhere/pkg/retrier" ) const ( gitTimeout = 30 * time.Second maxRetries = 5 backOffPeriod = 5 * time.Second emptyRepoError = "remote repository is empty" ) type GitClient struct { Auth transport.AuthMethod Client GoGit RepoUrl string RepoDirectory string Retrier *retrier.Retrier } type Opt func(*GitClient) func New(opts ...Opt) *GitClient { c := &GitClient{ Client: &goGit{}, Retrier: retrier.NewWithMaxRetries(maxRetries, backOffPeriod), } for _, opt := range opts { opt(c) } return c } func WithAuth(auth transport.AuthMethod) Opt { return func(c *GitClient) { c.Auth = auth } } func WithRepositoryUrl(repoUrl string) Opt { return func(c *GitClient) { c.RepoUrl = repoUrl } } func WithRepositoryDirectory(repoDir string) Opt { return func(c *GitClient) { c.RepoDirectory = repoDir } } func (g *GitClient) Clone(ctx context.Context) error { _, err := g.Client.Clone(ctx, g.RepoDirectory, g.RepoUrl, g.Auth) if err != nil && strings.Contains(err.Error(), emptyRepoError) { return &git.RepositoryIsEmptyError{ Repository: g.RepoDirectory, } } return err } func (g *GitClient) Add(filename string) error { logger.V(3).Info("Opening directory", "directory", g.RepoDirectory) r, err := g.Client.OpenDir(g.RepoDirectory) if err != nil { return err } logger.V(3).Info("Opening working tree") w, err := g.Client.OpenWorktree(r) if err != nil { return err } logger.V(3).Info("Tracking specified files", "file", filename) err = g.Client.AddGlob(filename, w) return err } func (g *GitClient) Remove(filename string) error { logger.V(3).Info("Opening directory", "directory", g.RepoDirectory) r, err := g.Client.OpenDir(g.RepoDirectory) if err != nil { return err } logger.V(3).Info("Opening working tree") w, err := g.Client.OpenWorktree(r) if err != nil { return err } logger.V(3).Info("Removing specified files", "file", filename) _, err = g.Client.Remove(filename, w) return err } func (g *GitClient) Commit(message string) error { logger.V(3).Info("Opening directory", "directory", g.RepoDirectory) r, err := g.Client.OpenDir(g.RepoDirectory) if err != nil { logger.Info("Failed while attempting to open repo") return err } logger.V(3).Info("Opening working tree") w, err := g.Client.OpenWorktree(r) if err != nil { return err } logger.V(3).Info("Generating Commit object...") commitSignature := &object.Signature{ Name: "EKS-A", When: time.Now(), } commit, err := g.Client.Commit(message, commitSignature, w) if err != nil { return err } logger.V(3).Info("Committing Object to local repo", "repo", g.RepoDirectory) finalizedCommit, err := g.Client.CommitObject(r, commit) logger.Info("Finalized commit and committed to local repository", "hash", finalizedCommit.Hash) return err } func (g *GitClient) Push(ctx context.Context) error { logger.V(3).Info("Pushing to remote", "repo", g.RepoDirectory) r, err := g.Client.OpenDir(g.RepoDirectory) if err != nil { return fmt.Errorf("err pushing: %v", err) } err = g.Client.PushWithContext(ctx, r, g.Auth) if err != nil { return fmt.Errorf("pushing: %v", err) } return err } func (g *GitClient) Pull(ctx context.Context, branch string) error { logger.V(3).Info("Pulling from remote", "repo", g.RepoDirectory, "remote", gogit.DefaultRemoteName) r, err := g.Client.OpenDir(g.RepoDirectory) if err != nil { return fmt.Errorf("pulling from remote: %v", err) } w, err := g.Client.OpenWorktree(r) if err != nil { return fmt.Errorf("pulling from remote: %v", err) } branchRef := plumbing.NewBranchReferenceName(branch) err = g.Client.PullWithContext(ctx, w, g.Auth, branchRef) if errors.Is(err, gogit.NoErrAlreadyUpToDate) { logger.V(3).Info("Local repo already up-to-date", "repo", g.RepoDirectory, "remote", gogit.DefaultRemoteName) return &git.RepositoryUpToDateError{} } if err != nil { return fmt.Errorf("pulling from remote: %v", err) } ref, err := g.Client.Head(r) if err != nil { return fmt.Errorf("pulling from remote: %v", err) } commit, err := g.Client.CommitObject(r, ref.Hash()) if err != nil { return fmt.Errorf("accessing latest commit after pulling from remote: %v", err) } logger.V(3).Info("Successfully pulled from remote", "repo", g.RepoDirectory, "remote", gogit.DefaultRemoteName, "latest commit", commit.Hash) return nil } func (g *GitClient) Init() error { r, err := g.Client.Init(g.RepoDirectory) if err != nil { return err } if _, err = g.Client.Create(r, g.RepoUrl); err != nil { return fmt.Errorf("initializing repository: %v", err) } return nil } func (g *GitClient) Branch(name string) error { r, err := g.Client.OpenDir(g.RepoDirectory) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } localBranchRef := plumbing.NewBranchReferenceName(name) branchOpts := &config.Branch{ Name: name, Remote: gogit.DefaultRemoteName, Merge: localBranchRef, Rebase: "true", } err = g.Client.CreateBranch(r, branchOpts) branchExistsLocally := errors.Is(err, gogit.ErrBranchExists) if err != nil && !branchExistsLocally { return fmt.Errorf("creating branch %s: %v", name, err) } if branchExistsLocally { logger.V(3).Info("Branch already exists locally", "branch", name) } if !branchExistsLocally { logger.V(3).Info("Branch does not exist locally", "branch", name) headref, err := g.Client.Head(r) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } h := headref.Hash() err = g.Client.SetRepositoryReference(r, plumbing.NewHashReference(localBranchRef, h)) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } } w, err := g.Client.OpenWorktree(r) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } err = g.Client.Checkout(w, &gogit.CheckoutOptions{ Branch: plumbing.ReferenceName(localBranchRef.String()), Force: true, }) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } err = g.pullIfRemoteExists(r, w, name, localBranchRef) if err != nil { return fmt.Errorf("creating branch %s: %v", name, err) } return nil } func (g *GitClient) ValidateRemoteExists(ctx context.Context) error { logger.V(3).Info("Validating git setup", "repoUrl", g.RepoUrl) remote := g.Client.NewRemote(g.RepoUrl, gogit.DefaultRemoteName) // Check if we are able to make a connection to the remote by attempting to list refs _, err := g.Client.ListWithContext(ctx, remote, g.Auth) if err != nil { return fmt.Errorf("connecting with remote %v for repository: %v", gogit.DefaultRemoteName, err) } return nil } func (g *GitClient) pullIfRemoteExists(r *gogit.Repository, w *gogit.Worktree, branchName string, localBranchRef plumbing.ReferenceName) error { err := g.Retrier.Retry(func() error { remoteExists, err := g.remoteBranchExists(r, localBranchRef) if err != nil { return fmt.Errorf("checking if remote branch exists %s: %v", branchName, err) } if remoteExists { err = g.Client.PullWithContext(context.Background(), w, g.Auth, localBranchRef) if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) && !errors.Is(err, gogit.ErrRemoteNotFound) { return fmt.Errorf("pulling from remote when checking out existing branch %s: %v", branchName, err) } } return nil }) return err } func (g *GitClient) remoteBranchExists(r *gogit.Repository, localBranchRef plumbing.ReferenceName) (bool, error) { reflist, err := g.Client.ListRemotes(r, g.Auth) if err != nil { if strings.Contains(err.Error(), emptyRepoError) { return false, nil } return false, fmt.Errorf("listing remotes: %v", err) } lb := localBranchRef.String() for _, ref := range reflist { if ref.Name().String() == lb { return true, nil } } return false, nil } type GoGit interface { AddGlob(f string, w *gogit.Worktree) error Checkout(w *gogit.Worktree, opts *gogit.CheckoutOptions) error Clone(ctx context.Context, dir string, repoUrl string, auth transport.AuthMethod) (*gogit.Repository, error) Commit(m string, sig *object.Signature, w *gogit.Worktree) (plumbing.Hash, error) CommitObject(r *gogit.Repository, h plumbing.Hash) (*object.Commit, error) Create(r *gogit.Repository, url string) (*gogit.Remote, error) CreateBranch(r *gogit.Repository, config *config.Branch) error Head(r *gogit.Repository) (*plumbing.Reference, error) NewRemote(url, remoteName string) *gogit.Remote Init(dir string) (*gogit.Repository, error) OpenDir(dir string) (*gogit.Repository, error) OpenWorktree(r *gogit.Repository) (*gogit.Worktree, error) PushWithContext(ctx context.Context, r *gogit.Repository, auth transport.AuthMethod) error PullWithContext(ctx context.Context, w *gogit.Worktree, auth transport.AuthMethod, ref plumbing.ReferenceName) error ListRemotes(r *gogit.Repository, auth transport.AuthMethod) ([]*plumbing.Reference, error) ListWithContext(ctx context.Context, r *gogit.Remote, auth transport.AuthMethod) ([]*plumbing.Reference, error) Remove(f string, w *gogit.Worktree) (plumbing.Hash, error) SetRepositoryReference(r *gogit.Repository, p *plumbing.Reference) error } type goGit struct{} func (gg *goGit) Clone(ctx context.Context, dir string, repourl string, auth transport.AuthMethod) (*gogit.Repository, error) { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() return gogit.PlainCloneContext(ctx, dir, false, &gogit.CloneOptions{ Auth: auth, URL: repourl, Progress: os.Stdout, }) } func (gg *goGit) OpenDir(dir string) (*gogit.Repository, error) { return gogit.PlainOpen(dir) } func (gg *goGit) OpenWorktree(r *gogit.Repository) (*gogit.Worktree, error) { return r.Worktree() } func (gg *goGit) AddGlob(f string, w *gogit.Worktree) error { return w.AddGlob(f) } func (gg *goGit) Commit(m string, sig *object.Signature, w *gogit.Worktree) (plumbing.Hash, error) { return w.Commit(m, &gogit.CommitOptions{ Author: sig, }) } func (gg *goGit) CommitObject(r *gogit.Repository, h plumbing.Hash) (*object.Commit, error) { return r.CommitObject(h) } func (gg *goGit) PushWithContext(ctx context.Context, r *gogit.Repository, auth transport.AuthMethod) error { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() return r.PushContext(ctx, &gogit.PushOptions{ Auth: auth, }) } func (gg *goGit) PullWithContext(ctx context.Context, w *gogit.Worktree, auth transport.AuthMethod, ref plumbing.ReferenceName) error { ctx, cancel := context.WithTimeout(ctx, gitTimeout) defer cancel() return w.PullContext(ctx, &gogit.PullOptions{RemoteName: gogit.DefaultRemoteName, Auth: auth, ReferenceName: ref}) } func (gg *goGit) Head(r *gogit.Repository) (*plumbing.Reference, error) { return r.Head() } func (gg *goGit) Init(dir string) (*gogit.Repository, error) { return gogit.PlainInit(dir, false) } func (ggc *goGit) NewRemote(url, remoteName string) *gogit.Remote { return gogit.NewRemote(memory.NewStorage(), &config.RemoteConfig{ Name: remoteName, URLs: []string{url}, }) } func (gg *goGit) Checkout(worktree *gogit.Worktree, opts *gogit.CheckoutOptions) error { return worktree.Checkout(opts) } func (gg *goGit) Create(r *gogit.Repository, url string) (*gogit.Remote, error) { return r.CreateRemote(&config.RemoteConfig{ Name: gogit.DefaultRemoteName, URLs: []string{url}, }) } func (gg *goGit) CreateBranch(repo *gogit.Repository, config *config.Branch) error { return repo.CreateBranch(config) } func (gg *goGit) ListRemotes(r *gogit.Repository, auth transport.AuthMethod) ([]*plumbing.Reference, error) { remote, err := r.Remote(gogit.DefaultRemoteName) if err != nil { if errors.Is(err, gogit.ErrRemoteNotFound) { return []*plumbing.Reference{}, nil } return nil, err } refList, err := remote.List(&gogit.ListOptions{Auth: auth}) if err != nil { return nil, err } return refList, nil } func (gg *goGit) Remove(f string, w *gogit.Worktree) (plumbing.Hash, error) { return w.Remove(f) } func (ggc *goGit) ListWithContext(ctx context.Context, r *gogit.Remote, auth transport.AuthMethod) ([]*plumbing.Reference, error) { refList, err := r.ListContext(ctx, &gogit.ListOptions{Auth: auth}) if err != nil { return nil, err } return refList, nil } func (gg *goGit) SetRepositoryReference(r *gogit.Repository, p *plumbing.Reference) error { return r.Storer.SetReference(p) }