litmus/litmus-portal/graphql-server/pkg/gitops/gitops.go

686 lines
19 KiB
Go

package gitops
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/ghodss/yaml"
"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/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"go.mongodb.org/mongo-driver/bson"
ssh2 "golang.org/x/crypto/ssh"
"github.com/litmuschaos/litmus/litmus-portal/graphql-server/graph/model"
"github.com/litmuschaos/litmus/litmus-portal/graphql-server/pkg/authorization"
"github.com/litmuschaos/litmus/litmus-portal/graphql-server/pkg/chaos-workflow/ops"
store "github.com/litmuschaos/litmus/litmus-portal/graphql-server/pkg/data-store"
dbOperationsGitOps "github.com/litmuschaos/litmus/litmus-portal/graphql-server/pkg/database/mongodb/gitops"
dbSchemaGitOps "github.com/litmuschaos/litmus/litmus-portal/graphql-server/pkg/database/mongodb/gitops"
dbOperationsWorkflow "github.com/litmuschaos/litmus/litmus-portal/graphql-server/pkg/database/mongodb/workflow"
)
// 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 dbSchemaGitOps.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 := ioutil.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 = ioutil.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: os.Stdout,
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) {
switch c.AuthType {
case model.AuthTypeToken:
return &http.BasicAuth{
Username: "litmus", // this can be anything except an empty string
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 uncommited 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.Print("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 instanes 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 && 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.Print(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: os.Stdout,
})
if 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{}
lastFile := ""
commitIter, err := r.Log(&git.LogOptions{
PathFilter: func(file string) bool {
if (strings.HasSuffix(path, "/") && strings.HasPrefix(file, path)) || (path == file) {
visited[file] += 1
lastFile = file
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
}
}
if err != io.EOF && visited[lastFile] == 1 {
delete(visited, lastFile)
}
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 gitops 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
}
// SyncDBToGit syncs the DB with the GitRepo for the project
func SyncDBToGit(ctx context.Context, config GitConfig) error {
repositoryExists, err := PathExists(config.LocalPath)
if err != nil {
return fmt.Errorf("Error while checking repo exists, err: %s", err)
}
if !repositoryExists {
err = config.setupGitRepo(GitUserFromContext(ctx))
} else {
err = config.GitPull()
if err != nil {
return errors.New("Error syncing DB : " + err.Error())
}
}
latestCommit, files, err := config.GetChanges()
if err != nil {
return errors.New("Error Getting File Changes : " + err.Error())
}
if latestCommit == config.LatestCommit {
return nil
}
log.Print(latestCommit, " ", config.LatestCommit, "File Changes: ", files)
newWorkflows := false
for file := range files {
if !strings.HasSuffix(file, ".yaml") {
continue
}
// check if file was deleted or not
exists, err := PathExists(config.LocalPath + "/" + file)
if err != nil {
return errors.New("Error checking file in local repo : " + file + " | " + err.Error())
}
if !exists {
err = deleteWorkflow(file, config)
if err != nil {
log.Print("Error while deleting workflow db entry : " + file + " | " + err.Error())
continue
}
continue
}
// read changes [new additions/updates]
data, err := ioutil.ReadFile(config.LocalPath + "/" + file)
if err != nil {
log.Print("Error reading data from git file : " + file + " | " + err.Error())
continue
}
data, err = yaml.YAMLToJSON(data)
if err != nil {
log.Print("Error unmarshalling data from git file : " + file + " | " + err.Error())
continue
}
wfID := gjson.Get(string(data), "metadata.labels.workflow_id").String()
kind := strings.ToLower(gjson.Get(string(data), "kind").String())
if kind != "cronworkflow" && kind != "workflow" && kind != "chaosengine" {
continue
}
log.Print("WFID in changed File :", wfID)
if wfID == "" {
log.Print("New Workflow pushed to git : " + file)
flag, err := createWorkflow(string(data), file, config)
if err != nil {
log.Print("Error while creating new workflow db entry : " + file + " | " + err.Error())
continue
}
if flag {
newWorkflows = true
}
} else {
err = updateWorkflow(string(data), wfID, file, config)
if err != nil {
log.Print("Error while creating new workflow db entry : " + file + " | " + err.Error())
continue
}
}
}
// push workflows with workflow_id added
if newWorkflows {
latestCommit, err = config.GitCommit(GitUserFromContext(ctx), "Updated New Workflows", nil)
if err != nil {
return errors.New("Cannot commit workflows to git : " + err.Error())
}
err = config.GitPush()
if err != nil {
return errors.New("Cannot push workflows to git : " + err.Error())
}
}
query := bson.D{{"project_id", config.ProjectID}}
update := bson.D{{"$set", bson.D{{"latest_commit", latestCommit}}}}
if ctx == nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
err = dbOperationsGitOps.UpdateGitConfig(ctx, query, update)
} else {
err = dbOperationsGitOps.UpdateGitConfig(ctx, query, update)
}
if err != nil {
return errors.New("Failed to update git config : " + err.Error())
}
return nil
}
// createWorkflow helps in creating a new workflow during the SyncDBToGit operation
func createWorkflow(data, file string, config GitConfig) (bool, error) {
_, fileName := filepath.Split(file)
fileName = strings.Replace(fileName, ".yaml", "", -1)
wfName := gjson.Get(data, "metadata.name").String()
clusterID := gjson.Get(data, "metadata.labels.cluster_id").String()
log.Print("Workflow Details | wf_name: ", wfName, " cluster_id: ", clusterID)
if wfName == "" || clusterID == "" {
return false, nil
}
if fileName != wfName {
return false, errors.New("file name doesn't match workflow name")
}
workflow := model.ChaosWorkFlowInput{
WorkflowID: nil,
WorkflowManifest: data,
CronSyntax: "",
WorkflowName: wfName,
WorkflowDescription: "",
Weightages: nil,
IsCustomWorkflow: true,
ProjectID: config.ProjectID,
ClusterID: clusterID,
}
input, wfType, err := ops.ProcessWorkflow(&workflow)
if err != nil {
return false, err
}
err = ops.ProcessWorkflowCreation(input, wfType, store.Store)
if err != nil {
return false, err
}
workflowPath := config.LocalPath + "/" + file
yamlData, err := yaml.JSONToYAML([]byte(input.WorkflowManifest))
if err != nil {
return false, errors.New("Cannot convert manifest to yaml : " + err.Error())
}
err = ioutil.WriteFile(workflowPath, yamlData, 0644)
if err != nil {
return false, errors.New("Cannot write workflow to git : " + err.Error())
}
return true, nil
}
// updateWorkflow helps in updating a existing workflow during the SyncDBToGit operation
func updateWorkflow(data, wfID, file string, config GitConfig) error {
_, fileName := filepath.Split(file)
fileName = strings.Replace(fileName, ".yaml", "", -1)
wfName := gjson.Get(data, "metadata.name").String()
clusterID := gjson.Get(data, "metadata.labels.cluster_id").String()
log.Print("Workflow Details | wf_name: ", wfName, " cluster_id: ", clusterID)
if wfName == "" || clusterID == "" {
log.Print("Cannot Update workflow missing workflow name or cluster id")
return nil
}
if fileName != wfName {
return errors.New("file name doesn't match workflow name")
}
workflow, err := dbOperationsWorkflow.GetWorkflows(bson.D{{"workflow_id", wfID}, {"project_id", config.ProjectID}, {"isRemoved", false}})
if len(workflow) == 0 {
return errors.New("No such workflow found : " + wfID)
}
if clusterID != workflow[0].ClusterID {
log.Print("Cannot change cluster id for existing workflow")
return nil
}
workflowData := model.ChaosWorkFlowInput{
WorkflowID: &workflow[0].WorkflowID,
WorkflowManifest: data,
CronSyntax: workflow[0].CronSyntax,
WorkflowName: wfName,
WorkflowDescription: workflow[0].WorkflowDescription,
Weightages: nil,
IsCustomWorkflow: workflow[0].IsCustomWorkflow,
ProjectID: config.ProjectID,
ClusterID: workflow[0].ClusterID,
}
input, wfType, err := ops.ProcessWorkflow(&workflowData)
if err != nil {
return err
}
return ops.ProcessWorkflowUpdate(input, wfType, store.Store)
}
// deleteWorkflow helps in deleting a workflow from DB during the SyncDBToGit operation
func deleteWorkflow(file string, config GitConfig) error {
_, fileName := filepath.Split(file)
fileName = strings.Replace(fileName, ".yaml", "", -1)
query := bson.D{{"workflow_name", fileName}, {"project_id", config.ProjectID}}
workflow, err := dbOperationsWorkflow.GetWorkflow(query)
if err != nil {
return err
}
return ops.ProcessWorkflowDelete(query, workflow, store.Store)
}