MM-21722 - Repository synchronization tool (#86)

This commit is contained in:
Domas Monkus 2020-09-13 16:30:09 +03:00 committed by GitHub
parent 4e7a2d3734
commit 0688e8df4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1589 additions and 0 deletions

214
build/sync/plan/actions.go Normal file
View file

@ -0,0 +1,214 @@
package plan
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// ActionConditions adds condition support to actions.
type ActionConditions struct {
// Conditions are checkers run before executing the
// action. If any one fails (returns an error), the action
// itself is not executed.
Conditions []Check
}
// Check runs the conditions associated with the action and returns
// the first error (if any).
func (c ActionConditions) Check(path string, setup Setup) error {
if len(c.Conditions) > 0 {
setup.Logf("checking action conditions")
}
for _, condition := range c.Conditions {
err := condition.Check(path, setup)
if err != nil {
return err
}
}
return nil
}
// OverwriteFileAction is used to overwrite a file.
type OverwriteFileAction struct {
ActionConditions
Params struct {
// Create determines whether the target directory
// will be created if it does not exist.
Create bool `json:"create"`
}
}
// Run implements plan.Action.Run.
func (a OverwriteFileAction) Run(path string, setup Setup) error {
setup.Logf("overwriting file %q", path)
src := setup.PathInRepo(SourceRepo, path)
dst := setup.PathInRepo(TargetRepo, path)
dstInfo, err := os.Stat(dst)
switch {
case os.IsNotExist(err):
if !a.Params.Create {
return fmt.Errorf("path %q does not exist, not creating", dst)
}
case err != nil:
return fmt.Errorf("failed to check path %q: %v", dst, err)
case dstInfo.IsDir():
return fmt.Errorf("path %q is a directory", dst)
}
srcInfo, err := os.Stat(src)
if os.IsNotExist(err) {
return fmt.Errorf("file %q does not exist", src)
} else if err != nil {
return fmt.Errorf("failed to check path %q: %v", src, err)
}
if srcInfo.IsDir() {
return fmt.Errorf("path %q is a directory", src)
}
srcF, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open %q: %v", src, err)
}
defer srcF.Close()
dstF, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, srcInfo.Mode())
if err != nil {
return fmt.Errorf("failed to open %q: %v", src, err)
}
defer dstF.Close()
_, err = io.Copy(dstF, srcF)
if err != nil {
return fmt.Errorf("failed to copy file %q: %v", path, err)
}
return nil
}
// OverwriteDirectoryAction is used to completely overwrite directories.
// If the target directory exists, it will be removed first.
type OverwriteDirectoryAction struct {
ActionConditions
Params struct {
// Create determines whether the target directory
// will be created if it does not exist.
Create bool `json:"create"`
}
}
// Run implements plan.Action.Run.
func (a OverwriteDirectoryAction) Run(path string, setup Setup) error {
setup.Logf("overwriting directory %q", path)
src := setup.PathInRepo(SourceRepo, path)
dst := setup.PathInRepo(TargetRepo, path)
dstInfo, err := os.Stat(dst)
switch {
case os.IsNotExist(err):
if !a.Params.Create {
return fmt.Errorf("path %q does not exist, not creating", dst)
}
case err != nil:
return fmt.Errorf("failed to check path %q: %v", dst, err)
default:
if !dstInfo.IsDir() {
return fmt.Errorf("path %q is not a directory", dst)
}
err = os.RemoveAll(dst)
if err != nil {
return fmt.Errorf("failed to remove directory %q: %v", dst, err)
}
}
srcInfo, err := os.Stat(src)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", src)
} else if err != nil {
return fmt.Errorf("failed to check path %q: %v", src, err)
}
if !srcInfo.IsDir() {
return fmt.Errorf("path %q is not a directory", src)
}
err = CopyDirectory(src, dst)
if err != nil {
return fmt.Errorf("failed to copy path %q: %v", path, err)
}
return nil
}
// CopyDirectory copies the directory src to dst so that after
// a successful operation the contents of src and dst are equal.
func CopyDirectory(src, dst string) error {
copier := dirCopier{dst: dst, src: src}
return filepath.Walk(src, copier.Copy)
}
type dirCopier struct {
dst string
src string
}
// Convert a path in the source directory to a path in the destination
// directory.
func (d dirCopier) srcToDst(path string) (string, error) {
suff := strings.TrimPrefix(path, d.src)
if suff == path {
return "", fmt.Errorf("path %q is not in %q", path, d.src)
}
return filepath.Join(d.dst, suff), nil
}
// Copy is an implementation of filepatch.WalkFunc that copies the
// source directory to target with all subdirectories.
func (d dirCopier) Copy(srcPath string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("failed to copy directory: %v", err)
}
trgPath, err := d.srcToDst(srcPath)
if err != nil {
return err
}
if info.IsDir() {
err = os.MkdirAll(trgPath, info.Mode())
if err != nil {
return fmt.Errorf("failed to create directory %q: %v", trgPath, err)
}
err = os.Chtimes(trgPath, info.ModTime(), info.ModTime())
if err != nil {
return fmt.Errorf("failed to create directory %q: %v", trgPath, err)
}
return nil
}
err = copyFile(srcPath, trgPath, info)
if err != nil {
return fmt.Errorf("failed to copy file %q: %v", srcPath, err)
}
return nil
}
func copyFile(src, dst string, info os.FileInfo) error {
srcF, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file %q: %v", src, err)
}
defer srcF.Close()
dstF, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, info.Mode())
if err != nil {
return fmt.Errorf("failed to open destination file %q: %v", dst, err)
}
_, err = io.Copy(dstF, srcF)
if err != nil {
dstF.Close()
return fmt.Errorf("failed to copy file %q: %v", src, err)
}
if err = dstF.Close(); err != nil {
return fmt.Errorf("failed to close file %q: %v", dst, err)
}
err = os.Chtimes(dst, info.ModTime(), info.ModTime())
if err != nil {
return fmt.Errorf("failed to adjust file modification time for %q: %v", dst, err)
}
return nil
}

View file

@ -0,0 +1,107 @@
package plan_test
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
func TestCopyDirectory(t *testing.T) {
assert := assert.New(t)
// Create a temporary directory to copy to.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
wd, err := os.Getwd()
assert.Nil(err)
srcDir := filepath.Join(wd, "testdata")
err = plan.CopyDirectory(srcDir, dir)
assert.Nil(err)
compareDirectories(assert, dir, srcDir)
}
func TestOverwriteFileAction(t *testing.T) {
assert := assert.New(t)
// Create a temporary directory to copy to.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
wd, err := os.Getwd()
assert.Nil(err)
setup := plan.Setup{
Source: plan.RepoSetup{
Git: nil,
Path: filepath.Join(wd, "testdata", "b"),
},
Target: plan.RepoSetup{
Git: nil,
Path: dir,
},
}
action := plan.OverwriteFileAction{}
action.Params.Create = true
err = action.Run("c", setup)
assert.Nil(err)
compareDirectories(assert, dir, filepath.Join(wd, "testdata", "b"))
}
func TestOverwriteDirectoryAction(t *testing.T) {
assert := assert.New(t)
// Create a temporary directory to copy to.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
wd, err := os.Getwd()
assert.Nil(err)
setup := plan.Setup{
Source: plan.RepoSetup{
Git: nil,
Path: wd,
},
Target: plan.RepoSetup{
Git: nil,
Path: dir,
},
}
action := plan.OverwriteDirectoryAction{}
action.Params.Create = true
err = action.Run("testdata", setup)
assert.Nil(err)
destDir := filepath.Join(dir, "testdata")
srcDir := filepath.Join(wd, "testdata")
compareDirectories(assert, destDir, srcDir)
}
func compareDirectories(assert *assert.Assertions, pathA, pathB string) {
aContents, err := ioutil.ReadDir(pathA)
assert.Nil(err)
bContents, err := ioutil.ReadDir(pathB)
assert.Nil(err)
assert.Len(aContents, len(bContents))
// Check the directory contents are equal.
for i, aFInfo := range aContents {
bFInfo := bContents[i]
assert.Equal(aFInfo.Name(), bFInfo.Name())
assert.Equal(aFInfo.Size(), bFInfo.Size())
assert.Equal(aFInfo.Mode(), bFInfo.Mode())
assert.Equal(aFInfo.IsDir(), bFInfo.IsDir())
}
}

147
build/sync/plan/checks.go Normal file
View file

@ -0,0 +1,147 @@
package plan
import (
"fmt"
"os"
"sort"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan/git"
)
// CheckFail is a custom error type used to indicate a
// check that did not pass (but did not fail due to external
// causes.
// Use `IsCheckFail` to check if an error is a check failure.
type CheckFail string
func (e CheckFail) Error() string {
return string(e)
}
// CheckFailf creates an error with the specified message string.
// The error will pass the IsCheckFail filter.
func CheckFailf(msg string, args ...interface{}) CheckFail {
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
return CheckFail(msg)
}
// IsCheckFail determines if an error is a check fail error.
func IsCheckFail(err error) bool {
if err == nil {
return false
}
_, ok := err.(CheckFail)
return ok
}
// RepoIsCleanChecker checks whether the git repository is clean.
type RepoIsCleanChecker struct {
Params struct {
Repo RepoID
}
}
// Check implements the Checker interface.
// The path parameter is ignored because this checker checks the state of a repository.
func (r RepoIsCleanChecker) Check(_ string, ctx Setup) error {
ctx.Logf("checking if repository %q is clean", r.Params.Repo)
rc := ctx.GetRepo(r.Params.Repo)
repo := rc.Git
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %v", err)
}
status, err := worktree.Status()
if err != nil {
return fmt.Errorf("failed to get worktree status: %v", err)
}
if !status.IsClean() {
return CheckFailf("%q repository is not clean", r.Params.Repo)
}
return nil
}
// PathExistsChecker checks whether the fle or directory with the
// path exists. If it does not, an error is returned.
type PathExistsChecker struct {
Params struct {
Repo RepoID
}
}
// Check implements the Checker interface.
func (r PathExistsChecker) Check(path string, ctx Setup) error {
repo := r.Params.Repo
if repo == "" {
repo = TargetRepo
}
ctx.Logf("checking if path %q exists in repo %q", path, repo)
absPath := ctx.PathInRepo(repo, path)
_, err := os.Stat(absPath)
if os.IsNotExist(err) {
return CheckFailf("path %q does not exist", path)
} else if err != nil {
return fmt.Errorf("failed to stat path %q: %v", absPath, err)
}
return nil
}
// FileUnalteredChecker checks whether the file in Repo is
// an unaltered version of that same file in ReferenceRepo.
//
// Its purpose is to check that a file has not been changed after forking a repository.
// It could be an old unaltered version, so the git history of the file is traversed
// until a matching version is found.
//
// If the repositories in the parameters are not specified,
// reference will default to the source repository and repo - to the target.
type FileUnalteredChecker struct {
Params struct {
ReferenceRepo RepoID `json:"compared-to"`
Repo RepoID `json:"in"`
}
}
// Check implements the Checker interface.
func (f FileUnalteredChecker) Check(path string, setup Setup) error {
setup.Logf("checking if file %q has not been altered", path)
repo := f.Params.Repo
if repo == "" {
repo = TargetRepo
}
reference := f.Params.ReferenceRepo
if reference == "" {
reference = SourceRepo
}
absPath := setup.PathInRepo(repo, path)
info, err := os.Stat(absPath)
if os.IsNotExist(err) {
return CheckFailf("file %q has been deleted", absPath)
}
if err != nil {
return fmt.Errorf("failed to get stat for %q: %v", absPath, err)
}
if info.IsDir() {
return fmt.Errorf("%q is a directory", absPath)
}
fileHashes, err := git.FileHistory(path, setup.GetRepo(reference).Git)
if err != nil {
return err
}
currentHash, err := git.GetFileHash(absPath)
if err != nil {
return err
}
sort.Strings(fileHashes)
idx := sort.SearchStrings(fileHashes, currentHash)
if idx < len(fileHashes) && fileHashes[idx] == currentHash {
return nil
}
return CheckFailf("file %q has been altered", absPath)
}

View file

@ -0,0 +1,122 @@
package plan_test
import (
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
git "gopkg.in/src-d/go-git.v4"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
// Tests for the RepoIsClean checker.
func TestRepoIsCleanChecker(t *testing.T) {
assert := assert.New(t)
// Create a git repository in a temporary dir.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
repo, err := git.PlainInit(dir, false)
assert.Nil(err)
// Repo should be clean.
checker := plan.RepoIsCleanChecker{}
checker.Params.Repo = plan.TargetRepo
ctx := plan.Setup{
Target: plan.RepoSetup{
Path: dir,
Git: repo,
},
}
assert.Nil(checker.Check("", ctx))
// Create a file in the repository.
err = ioutil.WriteFile(path.Join(dir, "data.txt"), []byte("lorem ipsum"), 0666)
assert.Nil(err)
err = checker.Check("", ctx)
assert.EqualError(err, "\"target\" repository is not clean")
assert.True(plan.IsCheckFail(err))
}
func TestPathExistsChecker(t *testing.T) {
assert := assert.New(t)
wd, err := os.Getwd()
assert.Nil(err)
checker := plan.PathExistsChecker{}
checker.Params.Repo = plan.SourceRepo
ctx := plan.Setup{
Source: plan.RepoSetup{
Path: wd,
},
}
// Check with existing directory.
assert.Nil(checker.Check("testdata", ctx))
// Check with existing file.
assert.Nil(checker.Check("testdata/a", ctx))
err = checker.Check("nosuchpath", ctx)
assert.NotNil(err)
assert.True(plan.IsCheckFail(err))
}
func TestUnalteredChecker(t *testing.T) {
assert := assert.New(t)
// Path to the root of the repo.
wd, err := filepath.Abs("../../../")
assert.Nil(err)
gitRepo, err := git.PlainOpen(wd)
assert.Nil(err)
ctx := plan.Setup{
Source: plan.RepoSetup{
Path: wd,
Git: gitRepo,
},
Target: plan.RepoSetup{
Path: wd,
},
}
checker := plan.FileUnalteredChecker{}
checker.Params.ReferenceRepo = plan.SourceRepo
checker.Params.Repo = plan.TargetRepo
// Check with the same file - check should succeed
hashPath := "build/sync/plan/testdata/a"
err = checker.Check(hashPath, ctx)
assert.Nil(err)
// Create a file with the same suffix path, but different contents.
tmpDir, err := ioutil.TempDir("", "test")
assert.Nil(err)
//defer os.RemoveAll(tmpDir)
fullPath := filepath.Join(tmpDir, "build/sync/plan/testdata")
err = os.MkdirAll(fullPath, 0777)
assert.Nil(err)
file, err := os.OpenFile(filepath.Join(fullPath, "a"), os.O_CREATE|os.O_WRONLY, 0755)
assert.Nil(err)
_, err = file.WriteString("this file has different contents")
assert.Nil(err)
assert.Nil(file.Close())
// Set the plugin path to the temporary directory.
ctx.Target.Path = tmpDir
err = checker.Check(hashPath, ctx)
assert.True(plan.IsCheckFail(err))
assert.EqualError(err, fmt.Sprintf("file %q has been altered", filepath.Join(tmpDir, hashPath)))
}

View file

@ -0,0 +1,106 @@
package git
import (
"crypto/sha1" //nolint
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
git "gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
// ErrNotFound signifies the file was not found.
var ErrNotFound = fmt.Errorf("not found")
// FileHistory will trace all the versions of a file in the git repository
// and return a list of sha1 hashes of that file.
func FileHistory(path string, repo *git.Repository) ([]string, error) {
logOpts := git.LogOptions{
FileName: &path,
}
commits, err := repo.Log(&logOpts)
if err != nil {
return nil, fmt.Errorf("failed to get commits for path %q: %v", path, err)
}
defer commits.Close()
hashHistory := []string{}
cerr := commits.ForEach(func(c *object.Commit) error {
root, err := repo.TreeObject(c.TreeHash)
if err != nil {
return fmt.Errorf("failed to get commit tree: %v", err)
}
f, err := traverseTree(root, path)
if err == object.ErrFileNotFound || err == object.ErrDirectoryNotFound {
// Ignoring file not found errors.
return nil
} else if err != nil {
return err
}
sum, err := getReaderHash(f)
f.Close()
if err != nil {
return err
}
hashHistory = append(hashHistory, sum)
return nil
})
if cerr != nil && cerr != io.EOF {
return nil, cerr
}
if len(hashHistory) == 0 {
return nil, ErrNotFound
}
return hashHistory, nil
}
func traverseTree(root *object.Tree, path string) (io.ReadCloser, error) {
dirName, fileName := filepath.Split(path)
var err error
t := root
if dirName != "" {
t, err = root.Tree(filepath.Clean(dirName))
if err == object.ErrDirectoryNotFound {
return nil, err
} else if err != nil {
return nil, fmt.Errorf("failed to traverse tree to %q: %v", dirName, err)
}
}
f, err := t.File(fileName)
if err == object.ErrFileNotFound {
return nil, err
} else if err != nil {
return nil, fmt.Errorf("failed to lookup file %q: %v", fileName, err)
}
reader, err := f.Reader()
if err != nil {
return nil, fmt.Errorf("failed to open %q: %v", path, err)
}
return reader, nil
}
func getReaderHash(r io.Reader) (string, error) {
h := sha1.New() // nolint
_, err := io.Copy(h, r)
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// GetFileHash calculates the sha1 hash sum of the file.
func GetFileHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
sum, err := getReaderHash(f)
if err != nil {
return "", err
}
return sum, nil
}

View file

@ -0,0 +1,27 @@
package git_test
import (
"testing"
"github.com/stretchr/testify/assert"
git "gopkg.in/src-d/go-git.v4"
gitutil "github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan/git"
)
func TestFileHistory(t *testing.T) {
assert := assert.New(t)
repo, err := git.PlainOpenWithOptions("./", &git.PlainOpenOptions{
DetectDotGit: true,
})
assert.Nil(err)
sums, err := gitutil.FileHistory("build/sync/plan/git/testdata/testfile.txt", repo)
assert.Nil(err)
assert.Equal([]string{"ba7192052d7cf77c55d3b7bf40b350b8431b208b"}, sums)
// Calling with a non-existent file returns error.
sums, err = gitutil.FileHistory("build/sync/plan/git/testdata/nosuch_testfile.txt", repo)
assert.Equal(gitutil.ErrNotFound, err)
assert.Nil(sums)
}

View file

@ -0,0 +1 @@
This file is used to test file history tracking.

245
build/sync/plan/plan.go Normal file
View file

@ -0,0 +1,245 @@
// Package plan handles the synchronization plan.
//
// Each synchronization plan is a set of checks and actions to perform on specified paths
// that will result in the "plugin" repository being updated.
package plan
import (
"encoding/json"
"fmt"
"os"
"sort"
)
// Plan defines the plan for synchronizing a target and a source directory.
type Plan struct {
Checks []Check `json:"checks"`
// Each set of paths has multiple actions associated, each a fallback for the one
// previous to it.
Actions []ActionSet
}
// UnmarshalJSON implements the `json.Unmarshaler` interface.
func (p *Plan) UnmarshalJSON(raw []byte) error {
var t jsonPlan
if err := json.Unmarshal(raw, &t); err != nil {
return err
}
p.Checks = make([]Check, len(t.Checks))
for i, check := range t.Checks {
c, err := parseCheck(check.Type, check.Params)
if err != nil {
return fmt.Errorf("failed to parse check %q: %v", check.Type, err)
}
p.Checks[i] = c
}
if len(t.Actions) > 0 {
p.Actions = make([]ActionSet, len(t.Actions))
}
for i, actionSet := range t.Actions {
var err error
pathActions := make([]Action, len(actionSet.Actions))
for i, action := range actionSet.Actions {
var actionConditions []Check
if len(action.Conditions) > 0 {
actionConditions = make([]Check, len(action.Conditions))
}
for j, check := range action.Conditions {
actionConditions[j], err = parseCheck(check.Type, check.Params)
if err != nil {
return err
}
}
pathActions[i], err = parseAction(action.Type, action.Params, actionConditions)
if err != nil {
return err
}
}
p.Actions[i] = ActionSet{
Paths: actionSet.Paths,
Actions: pathActions,
}
}
return nil
}
// Execute executes the synchronization plan.
func (p *Plan) Execute(c Setup) error {
c.Logf("running pre-checks")
for _, check := range p.Checks {
err := check.Check("", c) // For pre-sync checks, the path is ignored.
if err != nil {
return fmt.Errorf("failed check: %v", err)
}
}
result := []pathResult{}
c.Logf("running actions")
for _, actions := range p.Actions {
PATHS_LOOP:
for _, path := range actions.Paths {
c.Logf("syncing path %q", path)
ACTIONS_LOOP:
for i, action := range actions.Actions {
c.Logf("running action for path %q", path)
err := action.Check(path, c)
if IsCheckFail(err) {
c.Logf("check failed, not running action: %v", err)
// If a check for an action fails, we switch to
// the next action associated with the path.
if i == len(actions.Actions)-1 { // no actions to fallback to.
c.Logf("path %q not handled - no more fallbacks", path)
result = append(result,
pathResult{
Path: path,
Status: statusFailed,
Message: fmt.Sprintf("check failed, %s", err.Error()),
})
}
continue ACTIONS_LOOP
} else if err != nil {
c.LogErrorf("unexpected error when running check: %v", err)
return fmt.Errorf("failed to run checks for action: %v", err)
}
err = action.Run(path, c)
if err != nil {
c.LogErrorf("action failed: %v", err)
return fmt.Errorf("action failed: %v", err)
}
c.Logf("path %q sync'ed successfully", path)
result = append(result,
pathResult{
Path: path,
Status: statusUpdated,
})
continue PATHS_LOOP
}
}
}
// Print execution result.
sort.SliceStable(result, func(i, j int) bool { return result[i].Path < result[j].Path })
for _, res := range result {
if res.Message != "" {
fmt.Fprintf(os.Stdout, "%s\t%s: %s\n", res.Status, res.Path, res.Message)
} else {
fmt.Fprintf(os.Stdout, "%s\t%s\n", res.Status, res.Path)
}
}
return nil
}
// Check returns an error if the condition fails.
type Check interface {
Check(string, Setup) error
}
// ActionSet is a set of actions along with a set of paths to
// perform those actions on.
type ActionSet struct {
Paths []string
Actions []Action
}
// Action runs the defined action.
type Action interface {
// Run performs the action on the specified path.
Run(string, Setup) error
// Check runs checks associated with the action
// before running it.
Check(string, Setup) error
}
// jsonPlan is used to unmarshal Plan structures.
type jsonPlan struct {
Checks []struct {
Type string `json:"type"`
Params json.RawMessage `json:"params,omitempty"`
}
Actions []struct {
Paths []string `json:"paths"`
Actions []struct {
Type string `json:"type"`
Params json.RawMessage `json:"params,omitempty"`
Conditions []struct {
Type string `json:"type"`
Params json.RawMessage `json:"params"`
}
}
}
}
func parseCheck(checkType string, rawParams json.RawMessage) (Check, error) {
var c Check
var params interface{}
switch checkType {
case "repo_is_clean":
tc := RepoIsCleanChecker{}
params = &tc.Params
c = &tc
case "exists":
tc := PathExistsChecker{}
params = &tc.Params
c = &tc
case "file_unaltered":
tc := FileUnalteredChecker{}
params = &tc.Params
c = &tc
default:
return nil, fmt.Errorf("unknown checker type %q", checkType)
}
if len(rawParams) > 0 {
err := json.Unmarshal(rawParams, params)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal params for %s: %v", checkType, err)
}
}
return c, nil
}
func parseAction(actionType string, rawParams json.RawMessage, checks []Check) (Action, error) {
var a Action
var params interface{}
switch actionType {
case "overwrite_file":
ta := OverwriteFileAction{}
ta.Conditions = checks
params = &ta.Params
a = &ta
case "overwrite_directory":
ta := OverwriteDirectoryAction{}
ta.Conditions = checks
params = &ta.Params
a = &ta
default:
return nil, fmt.Errorf("unknown action type %q", actionType)
}
if len(rawParams) > 0 {
err := json.Unmarshal(rawParams, params)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal params for %s: %v", actionType, err)
}
}
return a, nil
}
// pathResult contains the result of synchronizing a path.
type pathResult struct {
Path string
Status status
Message string
}
type status string
const (
statusUpdated status = "UPDATED"
statusFailed status = "FAILED"
)

View file

@ -0,0 +1,253 @@
package plan_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
func TestUnmarshalPlan(t *testing.T) {
assert := assert.New(t)
rawJSON := []byte(`
{
"checks": [
{"type": "repo_is_clean", "params": {"repo": "template"}}
],
"actions": [
{
"paths": ["abc"],
"actions": [{
"type": "overwrite_file",
"params": {"create": true},
"conditions": [{
"type": "exists",
"params": {"repo": "plugin"}
}]
}]
}
]
}`)
var p plan.Plan
err := json.Unmarshal(rawJSON, &p)
assert.Nil(err)
expectedCheck := plan.RepoIsCleanChecker{}
expectedCheck.Params.Repo = "template"
expectedAction := plan.OverwriteFileAction{}
expectedAction.Params.Create = true
expectedActionCheck := plan.PathExistsChecker{}
expectedActionCheck.Params.Repo = "plugin"
expectedAction.Conditions = []plan.Check{&expectedActionCheck}
expected := plan.Plan{
Checks: []plan.Check{&expectedCheck},
Actions: []plan.ActionSet{{
Paths: []string{"abc"},
Actions: []plan.Action{
&expectedAction,
},
}},
}
assert.Equal(expected, p)
}
type mockCheck struct {
returnErr error
calledWith string // Path parameter the check was called with.
}
// Check implements the plan.Check interface.
func (m *mockCheck) Check(path string, c plan.Setup) error {
m.calledWith = path
return m.returnErr
}
type mockAction struct {
runErr error // Error to be returned by Run.
checkErr error // Error to be returned by Check.
calledWith string
}
// Check implements plan.Action interface.
func (m *mockAction) Check(path string, c plan.Setup) error {
return m.checkErr
}
// Run implements plan.Action interface.
func (m *mockAction) Run(path string, c plan.Setup) error {
m.calledWith = path
return m.runErr
}
// TestRunPlanSuccessfully tests a successful execution of a sync plan.
func TestRunPlanSuccessfully(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{}
action1 := &mockAction{}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.Nil(err)
assert.Equal("", preCheck.calledWith)
assert.Equal("somepath", action1.calledWith)
assert.Equal("", action2.calledWith) // second action was not called.
}
// TestRunPlanPreCheckFail checks the scenario where a sync plan precheck
// fails, aborting the whole operation.
func TestRunPlanPreCheckFail(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{returnErr: plan.CheckFailf("check failed")}
action1 := &mockAction{}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.EqualError(err, "failed check: check failed")
assert.Equal("", preCheck.calledWith)
// None of the actions were executed.
assert.Equal("", action1.calledWith)
assert.Equal("", action2.calledWith)
}
// TestRunPlanActionCheckFails tests the situation where an action's
// check returns a recoverable error, forcing the plan to execute the fallback action.
func TestRunPlanActionCheckFails(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
action1 := &mockAction{checkErr: plan.CheckFailf("action check failed")}
action2 := &mockAction{}
p := &plan.Plan{
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.Nil(err)
assert.Equal("", action1.calledWith) // First action was not run.
assert.Equal("somepath", action2.calledWith) // Second action was run.
}
// TestRunPlanNoFallbacks tests the case where an action's check fails,
// but there are not more fallback actions for that path.
func TestRunPlanNoFallbacks(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
action1 := &mockAction{checkErr: plan.CheckFailf("fail")}
action2 := &mockAction{checkErr: plan.CheckFailf("fail")}
p := &plan.Plan{
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.Nil(err)
// both actions were not executed.
assert.Equal("", action1.calledWith)
assert.Equal("", action2.calledWith)
}
// TestRunPlanCheckError tests the scenario where a plan check fails with
// an unexpected error. Plan execution is aborted.
func TestRunPlanCheckError(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{returnErr: fmt.Errorf("fail")}
action1 := &mockAction{}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.EqualError(err, "failed check: fail")
assert.Equal("", preCheck.calledWith)
// Actions were not run.
assert.Equal("", action1.calledWith)
assert.Equal("", action2.calledWith)
}
// TestRunPlanActionError tests the scenario where an action fails,
// aborting the whole sync process.
func TestRunPlanActionError(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{}
action1 := &mockAction{runErr: fmt.Errorf("fail")}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.EqualError(err, "action failed: fail")
assert.Equal("", preCheck.calledWith)
assert.Equal("somepath", action1.calledWith)
assert.Equal("", action2.calledWith) // second action was not called.
}

80
build/sync/plan/setup.go Normal file
View file

@ -0,0 +1,80 @@
package plan
import (
"fmt"
"os"
"path/filepath"
git "gopkg.in/src-d/go-git.v4"
)
// RepoID identifies a repository - either plugin or template.
type RepoID string
const (
// SourceRepo is the id of the template repository (source).
SourceRepo RepoID = "source"
// TargetRepo is the id of the plugin repository (target).
TargetRepo RepoID = "target"
)
// Setup contains information about both parties
// in the sync: the plugin repository being updated
// and the source of the update - the template repo.
type Setup struct {
Source RepoSetup
Target RepoSetup
VerboseLogging bool
}
// Logf logs the provided message.
// If verbose output is not enabled, the message will not be printed.
func (c Setup) Logf(tpl string, args ...interface{}) {
if c.VerboseLogging {
fmt.Fprintf(os.Stderr, tpl+"\n", args...)
}
}
// LogErrorf logs the provided error message.
func (c Setup) LogErrorf(tpl string, args ...interface{}) {
fmt.Fprintf(os.Stderr, tpl+"\n", args...)
}
// GetRepo is a helper to get the required repo setup.
// If the target parameter is not one of "plugin" or "template",
// the function panics.
func (c Setup) GetRepo(r RepoID) RepoSetup {
switch r {
case TargetRepo:
return c.Target
case SourceRepo:
return c.Source
default:
panic(fmt.Sprintf("cannot get repository setup %q", r))
}
}
// PathInRepo returns the full path of a file in the specified repository.
func (c Setup) PathInRepo(repo RepoID, path string) string {
r := c.GetRepo(repo)
return filepath.Join(r.Path, path)
}
// RepoSetup contains relevant information
// about a single repository (either source or target).
type RepoSetup struct {
Git *git.Repository
Path string
}
// GetRepoSetup returns the repository setup for the specified path.
func GetRepoSetup(path string) (RepoSetup, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return RepoSetup{}, fmt.Errorf("failed to access git repository at %q: %v", path, err)
}
return RepoSetup{
Git: repo,
Path: path,
}, nil
}

1
build/sync/plan/testdata/a vendored Normal file
View file

@ -0,0 +1 @@
a

1
build/sync/plan/testdata/b/c vendored Normal file
View file

@ -0,0 +1 @@
c