litmus/chaoscenter/graphql/server/pkg/gitops/gitops.go

468 lines
12 KiB
Go

package gitops
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/litmuschaos/litmus/chaoscenter/graphql/server/utils"
"github.com/go-git/go-git/v5"
"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/protocol/packp/capability"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/golang-jwt/jwt"
"github.com/litmuschaos/litmus/chaoscenter/graphql/server/graph/model"
"github.com/litmuschaos/litmus/chaoscenter/graphql/server/pkg/authorization"
"github.com/litmuschaos/litmus/chaoscenter/graphql/server/pkg/database/mongodb/gitops"
log "github.com/sirupsen/logrus"
ssh2 "golang.org/x/crypto/ssh"
)
// GitConfig structure for the GitOps settings
type GitConfig struct {
ProjectID string
RepositoryURL string
LocalPath string
RemoteName string
Branch string
LatestCommit string
UserName *string
Password *string
AuthType model.AuthType
Token *string
SSHPrivateKey *string
}
type GitUser struct {
username string
email string
}
const (
DefaultPath = "/tmp/gitops/"
ProjectDataPath = "litmus"
)
func GitUserFromContext(ctx context.Context) GitUser {
defaultUser := GitUser{
username: "gitops@litmus-chaos",
email: "gitops@litmus.chaos",
}
if ctx == nil {
return defaultUser
}
userClaims := ctx.Value(authorization.UserClaim)
if userClaims == nil {
return defaultUser
}
if _, ok := userClaims.(jwt.MapClaims)["email"]; !ok {
return GitUser{
username: userClaims.(jwt.MapClaims)["username"].(string) + "@litmus-chaos",
email: userClaims.(jwt.MapClaims)["username"].(string) + "@litmus-chaos",
}
}
return GitUser{
username: userClaims.(jwt.MapClaims)["username"].(string) + "@litmus-chaos",
email: userClaims.(jwt.MapClaims)["email"].(string),
}
}
// GetGitOpsConfig is used for constructing the GitConfig from dbSchemaGitOps.GitConfigDB
func GetGitOpsConfig(repoData gitops.GitConfigDB) GitConfig {
gitConfig := GitConfig{
ProjectID: repoData.ProjectID,
RepositoryURL: repoData.RepositoryURL,
RemoteName: "origin",
Branch: repoData.Branch,
LocalPath: DefaultPath + repoData.ProjectID,
LatestCommit: repoData.LatestCommit,
UserName: repoData.UserName,
Password: repoData.Password,
AuthType: repoData.AuthType,
Token: repoData.Token,
SSHPrivateKey: repoData.SSHPrivateKey,
}
return gitConfig
}
// setupGitRepo helps clones and sets up the repo for GitOps
func (c GitConfig) setupGitRepo(user GitUser) error {
projectPath := c.LocalPath + "/" + ProjectDataPath + "/" + c.ProjectID
// clone repo
_, err := c.GitClone()
if err != nil {
return err
}
// check if project dir already present in repo
exists, err := PathExists(projectPath + "/.info")
if err != nil {
return err
}
gitInfo := map[string]string{"projectID": c.ProjectID, "revision": "1"}
if exists {
data, err := os.ReadFile(projectPath + "/.info")
if err != nil {
return errors.New("can't read existing git info file " + err.Error())
}
err = json.Unmarshal(data, &gitInfo)
if err != nil {
return errors.New("can't read existing git info file " + err.Error())
}
newRev, err := strconv.Atoi(gitInfo["revision"])
if err != nil {
return errors.New("can't read existing git info file[failed to parse revision] " + err.Error())
}
gitInfo["revision"] = strconv.Itoa(newRev + 1)
} else {
// create project dir and add config if not already present
err = os.MkdirAll(projectPath, 0755)
if err != nil {
return err
}
}
data, err := json.Marshal(gitInfo)
if err != nil {
return err
}
err = os.WriteFile(projectPath+"/.info", data, 0644)
if err != nil {
return err
}
// commit and push
_, err = c.GitCommit(user, "Setup Litmus Chaos GitOps for Project :"+c.ProjectID, nil)
if err != nil {
return err
}
return c.GitPush()
}
// GitClone clones the repo
func (c GitConfig) GitClone() (*git.Repository, error) {
// clean the local path
err := os.RemoveAll(c.LocalPath)
if err != nil {
return nil, err
}
auth, err := c.getAuthMethod()
if err != nil {
return nil, err
}
return git.PlainClone(c.LocalPath, false, &git.CloneOptions{
Auth: auth,
URL: c.RepositoryURL,
Progress: nil,
ReferenceName: plumbing.NewBranchReferenceName(c.Branch),
SingleBranch: true,
})
}
// getAuthMethod returns the AuthMethod instance required for the current repo access [read/writes]
func (c GitConfig) getAuthMethod() (transport.AuthMethod, error) {
// Azure DevOps requires the 'multi_ack' and 'multi_ack_detailed' capabilities,
// which are not fully implemented in the go-git package. By default, these
// capabilities are included in 'transport.UnsupportedCapabilities'.
transport.UnsupportedCapabilities = []capability.Capability{
capability.ThinPack,
}
switch c.AuthType {
case model.AuthTypeToken:
return &http.BasicAuth{
Username: utils.Config.GitUsername, // must be a non-empty string or 'x-token-auth' for Bitbucket
Password: *c.Token,
}, nil
case model.AuthTypeBasic:
return &http.BasicAuth{
Username: *c.UserName,
Password: *c.Password,
}, nil
case model.AuthTypeSSH:
publicKey, err := ssh.NewPublicKeys("git", []byte(*c.SSHPrivateKey), "")
if err != nil {
return nil, err
}
publicKey.HostKeyCallback = ssh2.InsecureIgnoreHostKey()
return publicKey, nil
case model.AuthTypeNone:
return nil, nil
default:
return nil, errors.New("no Matching Auth Type Found")
}
}
// UnsafeGitPull executes git pull after a hard reset when uncommitted changes are present in repo. Not safe.
func (c GitConfig) UnsafeGitPull() error {
cleanStatus, err := c.GitGetStatus()
if err != nil {
return err
}
log.WithFields(log.Fields{"cleanStatus": cleanStatus}).Info("executed GitGetStatus()... ")
if !cleanStatus {
log.Info("resetting Repo...: " + c.ProjectID)
return c.handlerForDirtyStatus()
}
return c.GitPull()
}
// GitGetStatus executes "git get status --porcelain" for the provided Repository Path,
// returns true if the repository is clean
// and false if the repository is dirty
func (c GitConfig) GitGetStatus() (bool, error) {
_, workTree, err := c.getRepositoryWorktreeReference()
if err != nil {
return true, err
}
status, err := workTree.Status()
if err != nil {
return false, err
}
return status.IsClean(), nil
}
// getRepositoryWorktreeReference returns the git.Repository and git.Worktree instances for the repo
func (c GitConfig) getRepositoryWorktreeReference() (*git.Repository, *git.Worktree, error) {
repo, err := git.PlainOpen(c.LocalPath)
if err != nil {
return nil, nil, err
}
workTree, err := repo.Worktree()
if err != nil {
return nil, nil, err
}
return repo, workTree, nil
}
// handlerForDirtyStatus calls relative functions if the GitGetStatus gives a clean status as a result
func (c GitConfig) handlerForDirtyStatus() error {
if err := c.GitHardReset(); err != nil {
return err
}
return c.GitPull()
}
// GitHardReset executes "git reset --hard HEAD" in provided Repository Path
func (c GitConfig) GitHardReset() error {
_, workTree, err := c.getRepositoryWorktreeReference()
if err != nil {
return err
}
if workTree.Reset(&git.ResetOptions{Mode: git.HardReset}) != nil {
return fmt.Errorf("error in executing Reset: %s", err)
}
return nil
}
// GitPull updates the repository in provided Path
func (c GitConfig) GitPull() error {
_, workTree, err := c.getRepositoryWorktreeReference()
if err != nil {
return err
}
auth, err := c.getAuthMethod()
if err != nil {
return err
}
err = workTree.Pull(&git.PullOptions{
Auth: auth,
RemoteName: c.RemoteName,
ReferenceName: plumbing.NewBranchReferenceName(c.Branch),
SingleBranch: true,
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return err
}
return nil
}
// GitCheckout changes the current active branch to specified branch in GitConfig
func (c GitConfig) GitCheckout() error {
r, w, err := c.getRepositoryWorktreeReference()
if err != nil {
return err
}
_, err = r.Storer.Reference(plumbing.NewBranchReferenceName(c.Branch))
create := true
log.Error(err)
if err == nil {
create = false
}
return w.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(c.Branch),
Create: create,
})
}
// GitPush pushes the current changes to remote set in GitConfig, always needs auth credentials
func (c GitConfig) GitPush() error {
if c.AuthType == model.AuthTypeNone {
return errors.New("cannot write/push without credentials, auth type = none")
}
r, _, err := c.getRepositoryWorktreeReference()
if err != nil {
return err
}
auth, err := c.getAuthMethod()
if err != nil {
return err
}
err = r.Push(&git.PushOptions{
RemoteName: c.RemoteName,
Auth: auth,
Progress: nil,
})
if errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil
}
return err
}
// GitCommit saves the changes in the repo and commits them with the message provided
func (c GitConfig) GitCommit(user GitUser, message string, deleteFile *string) (string, error) {
_, w, err := c.getRepositoryWorktreeReference()
if err != nil {
return "", err
}
if deleteFile == nil {
_, err = w.Add("./litmus/" + c.ProjectID + "/")
if err != nil {
return "", err
}
} else {
_, err := w.Remove(*deleteFile)
if err != nil {
return "", err
}
}
hash, err := w.Commit(message, &git.CommitOptions{
Author: &object.Signature{
Name: user.username,
Email: user.email,
When: time.Now(),
},
})
if err != nil {
return "", err
}
return hash.String(), nil
}
// GetChanges returns the LatestCommit and list of files changed(since previous LatestCommit) in the project directory mentioned in GitConfig
func (c GitConfig) GetChanges() (string, map[string]int, error) {
path := ProjectDataPath + "/" + c.ProjectID + "/"
r, _, err := c.getRepositoryWorktreeReference()
if err != nil {
return "", nil, err
}
var knownCommit *object.Commit = nil
if c.LatestCommit != "" {
knownCommit, err = r.CommitObject(plumbing.NewHash(c.LatestCommit))
if err != nil {
return "", nil, err
}
}
visited := map[string]int{}
commitIter, err := r.Log(&git.LogOptions{
PathFilter: func(file string) bool {
if (strings.HasSuffix(path, "/") && strings.HasPrefix(file, path)) || (path == file) {
visited[file] += 1
return true
}
return false
},
Order: git.LogOrderCommitterTime,
})
if err != nil {
return "", nil, errors.New("failed to get commit Iterator :" + err.Error())
}
commit, err := commitIter.Next()
if err != nil && err != io.EOF {
return "", nil, err
}
if commit != nil {
c.LatestCommit = commit.Hash.String()
}
for err != io.EOF && commit != nil {
if knownCommit != nil {
ancestor, er := commit.IsAncestor(knownCommit)
if er != nil {
return "", nil, er
}
if knownCommit.Hash == commit.Hash || ancestor {
break
}
}
commit, err = commitIter.Next()
if err != nil && err != io.EOF {
return "", nil, err
}
}
return c.LatestCommit, visited, nil
}
// GetLatestCommitHash returns the latest commit hash in the local repo for the project directory
func (c GitConfig) GetLatestCommitHash() (string, error) {
path := ProjectDataPath + "/" + c.ProjectID + "/"
r, _, err := c.getRepositoryWorktreeReference()
if err != nil {
return "", err
}
commitIter, err := r.Log(&git.LogOptions{
PathFilter: func(file string) bool {
return (strings.HasSuffix(path, "/") && strings.HasPrefix(file, path)) || (path == file)
},
Order: git.LogOrderCommitterTime,
})
if err != nil {
return "", errors.New("failed to get latest commit hash :" + err.Error())
}
commit, err := commitIter.Next()
if err != nil {
return "", errors.New("failed to get latest commit hash:" + err.Error())
}
return commit.Hash.String(), nil
}
// SetupGitOps clones and sets up the repo for git ops and returns the LatestCommit
func SetupGitOps(user GitUser, gitConfig GitConfig) (string, error) {
err := gitConfig.setupGitRepo(user)
if err != nil {
return "", err
}
commitHash, err := gitConfig.GetLatestCommitHash()
if err != nil {
return "", err
}
return commitHash, err
}