468 lines
12 KiB
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
|
|
}
|