Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitaly.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn Cai <jcai@gitlab.com>2020-04-17 20:18:30 +0300
committerJohn Cai <jcai@gitlab.com>2020-04-21 18:25:21 +0300
commit5bf3a5bd25275990ee4c0fc1a7dcf34320ab742e (patch)
treefe31f174ee056af66807297d841c9c7c517d9872
parentee5eeb28e884a1260b015da3ee31249dffd48a11 (diff)
Add custom hooks implementation in go
-rw-r--r--internal/service/hooks/custom_hooks.go101
-rw-r--r--internal/service/hooks/custom_hooks_test.go332
2 files changed, 433 insertions, 0 deletions
diff --git a/internal/service/hooks/custom_hooks.go b/internal/service/hooks/custom_hooks.go
new file mode 100644
index 000000000..d51a6047b
--- /dev/null
+++ b/internal/service/hooks/custom_hooks.go
@@ -0,0 +1,101 @@
+package hook
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "gitlab.com/gitlab-org/gitaly/internal/command"
+)
+
+// customHooksExecutor executes all custom hooks for a given repository and hook name
+type customHooksExecutor func(ctx context.Context, args, env []string, stdin io.Reader, stdout, stderr io.Writer) error
+
+// lookup hook files in this order:
+//
+// 1. <repository>.git/custom_hooks/<hook_name> - per project hook
+// 2. <repository>.git/custom_hooks/<hook_name>.d/* - per project hooks
+// 3. <repository>.git/hooks/<hook_name>.d/* - global hooks
+func newCustomHooksExecutor(repoPath, customHooksDir, hookName string) (customHooksExecutor, error) {
+ var hookFiles []string
+ projectCustomHookFile := filepath.Join(repoPath, "custom_hooks", hookName)
+ s, err := os.Stat(projectCustomHookFile)
+ if err == nil && s.Mode()&0100 != 0 {
+ hookFiles = append(hookFiles, projectCustomHookFile)
+ }
+
+ projectCustomHookDir := filepath.Join(repoPath, "custom_hooks", fmt.Sprintf("%s.d", hookName))
+ files, err := matchFiles(projectCustomHookDir)
+ if err != nil {
+ return nil, err
+ }
+ hookFiles = append(hookFiles, files...)
+
+ globalCustomHooksDir := filepath.Join(customHooksDir, fmt.Sprintf("%s.d", hookName))
+
+ files, err = matchFiles(globalCustomHooksDir)
+ if err != nil {
+ return nil, err
+ }
+ hookFiles = append(hookFiles, files...)
+
+ return func(ctx context.Context, args, env []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ stdinBytes, err := ioutil.ReadAll(stdin)
+ if err != nil {
+ return err
+ }
+ for _, hookFile := range hookFiles {
+ c, err := command.New(ctx, exec.Command(hookFile, args...), bytes.NewBuffer(stdinBytes), stdout, stderr, env...)
+ if err != nil {
+ return err
+ }
+ if err = c.Wait(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }, nil
+}
+
+// match files from path:
+// 1. file must be executable
+// 2. file must not match backup file
+//
+// the resulting list is sorted
+func matchFiles(dir string) ([]string, error) {
+ fis, err := ioutil.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ var hookFiles []string
+
+ for _, fi := range fis {
+ if fi.IsDir() {
+ continue
+ }
+ if fi.Mode()&0100 == 0 {
+ continue
+ }
+ if strings.HasSuffix(fi.Name(), "~") {
+ continue
+ }
+ hookFiles = append(hookFiles, filepath.Join(dir, fi.Name()))
+ }
+
+ sort.Strings(hookFiles)
+
+ return hookFiles, nil
+}
diff --git a/internal/service/hooks/custom_hooks_test.go b/internal/service/hooks/custom_hooks_test.go
new file mode 100644
index 000000000..13f784eea
--- /dev/null
+++ b/internal/service/hooks/custom_hooks_test.go
@@ -0,0 +1,332 @@
+package hook
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gitlab.com/gitlab-org/gitaly/internal/helper/text"
+ "gitlab.com/gitlab-org/gitaly/internal/testhelper"
+)
+
+// printAllScript is a bash script that prints out stdin, the arguments,
+// and the environment variables in the following format:
+// stdin:old new ref0
+// args: arg1 arg2
+// env: VAR1=VAL1 VAR2=VAL2
+// NOTE: this script only prints one line of stdin
+var printAllScript = []byte(`#!/bin/bash
+read stdin
+echo stdin:$stdin
+echo args:$@
+echo env: $(printenv)`)
+
+// printStdinScript prints stdin line by line
+var printStdinScript = []byte(`#!/bin/bash
+while read line
+do
+ echo "$line"
+done
+`)
+
+// failScript prints the name of the command and exits with exit code 1
+var failScript = []byte(`#!/bin/bash
+echo "$0" >&2
+exit 1`)
+
+// successScript prints the name of the command and exits with exit code 0
+var successScript = []byte(`#!/bin/bash
+echo "$0"
+exit 0`)
+
+func TestCustomHooksSuccess(t *testing.T) {
+ _, testRepoPath, cleanup := testhelper.NewTestRepo(t)
+ defer cleanup()
+
+ testCases := []struct {
+ hookName string
+ stdin string
+ args []string
+ env []string
+ hookDir string
+ }{
+ {
+ hookName: "pre-receive",
+ stdin: "old new ref0",
+ args: nil,
+ env: []string{"GL_ID=user-123", "GL_USERNAME=username123", "GL_PROTOCOL=ssh", "GL_REPOSITORY=repo1"},
+ },
+ {
+ hookName: "update",
+ stdin: "",
+ args: []string{"old", "new", "ref0"},
+ env: []string{"GL_ID=user-123", "GL_USERNAME=username123", "GL_PROTOCOL=ssh", "GL_REPOSITORY=repo1"},
+ },
+ {
+ hookName: "post-receive",
+ stdin: "old new ref1",
+ args: nil,
+ env: []string{"GL_ID=user-123", "GL_USERNAME=username123", "GL_PROTOCOL=ssh", "GL_REPOSITORY=repo1"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.hookName, func(t *testing.T) {
+ globalCustomHooksDir, cleanupGlobalDir := testhelper.TempDir(t)
+ defer cleanupGlobalDir()
+
+ // hook is in project custom hook directory <repository>.git/custom_hooks/<hook_name>
+ hookDir := filepath.Join(testRepoPath, "custom_hooks")
+ callAndVerifyHooks(t, testRepoPath, tc.hookName, globalCustomHooksDir, hookDir, tc.stdin, tc.args, tc.env)
+
+ // hook is in project custom hooks directory <repository>.git/custom_hooks/<hook_name>.d/*
+ hookDir = filepath.Join(testRepoPath, "custom_hooks", fmt.Sprintf("%s.d", tc.hookName))
+ callAndVerifyHooks(t, testRepoPath, tc.hookName, globalCustomHooksDir, hookDir, tc.stdin, tc.args, tc.env)
+
+ // hook is in global custom hooks directory <global_custom_hooks_dir>/<hook_name>.d/*
+ hookDir = filepath.Join(globalCustomHooksDir, fmt.Sprintf("%s.d", tc.hookName))
+ callAndVerifyHooks(t, testRepoPath, tc.hookName, globalCustomHooksDir, hookDir, tc.stdin, tc.args, tc.env)
+ })
+ }
+}
+
+func TestCustomHookPartialFailure(t *testing.T) {
+ _, testRepoPath, cleanup := testhelper.NewTestRepo(t)
+ defer cleanup()
+
+ globalCustomHooksDir, cleanup := testhelper.TempDir(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ // project hook success, global hook failure
+ projectHookPath := filepath.Join(testRepoPath, "custom_hooks")
+ cleanupProjectHook := writeCustomHook(t, "pre-receive", projectHookPath, successScript)
+ globalHookPath := filepath.Join(globalCustomHooksDir, "pre-receive.d")
+ cleanupGlobalHook := writeCustomHook(t, "pre-receive", globalHookPath, failScript)
+
+ caller, err := newCustomHooksExecutor(testRepoPath, globalCustomHooksDir, "pre-receive")
+ require.NoError(t, err)
+ var stdout, stderr bytes.Buffer
+
+ require.Error(t, caller(ctx, nil, nil, &bytes.Buffer{}, &stdout, &stderr))
+
+ // since global hooks execute after project hooks, the project hook should run and succeed
+ require.Equal(t, filepath.Join(projectHookPath, "pre-receive"), text.ChompBytes(stdout.Bytes()))
+ require.Equal(t, filepath.Join(globalHookPath, "pre-receive"), text.ChompBytes(stderr.Bytes()))
+
+ cleanupProjectHook()
+ cleanupGlobalHook()
+
+ // project hook failure, global hook success
+ globalHookPath = filepath.Join(globalCustomHooksDir, "post-receive.d")
+ cleanupProjectHook = writeCustomHook(t, "post-receive", projectHookPath, failScript)
+ cleanupGlobalHook = writeCustomHook(t, "post-receive", globalHookPath, successScript)
+
+ caller, err = newCustomHooksExecutor(testRepoPath, globalCustomHooksDir, "post-receive")
+ require.NoError(t, err)
+ stdout.Reset()
+ stderr.Reset()
+
+ require.Error(t, caller(ctx, nil, nil, &bytes.Buffer{}, &stdout, &stderr))
+
+ // since the global hooks execute after project hooks, the global hook should never have run
+ require.Equal(t, filepath.Join(projectHookPath, "post-receive"), text.ChompBytes(stderr.Bytes()))
+ require.Equal(t, "", text.ChompBytes(stdout.Bytes()))
+
+ cleanupProjectHook()
+ cleanupGlobalHook()
+
+ // project hooks failure, global hooks success
+ globalHookPath = filepath.Join(globalCustomHooksDir, "update.d")
+ projectHooksPath := filepath.Join(testRepoPath, "custom_hooks", "update.d")
+ cleanupProjectHook = writeCustomHook(t, "update", projectHooksPath, failScript)
+ cleanupGlobalHook = writeCustomHook(t, "update", globalHookPath, successScript)
+
+ caller, err = newCustomHooksExecutor(testRepoPath, globalCustomHooksDir, "update")
+ require.NoError(t, err)
+ stdout.Reset()
+ stderr.Reset()
+
+ require.Error(t, caller(ctx, nil, nil, &bytes.Buffer{}, &stdout, &stderr))
+
+ // since the global hooks execute after project hooks, the global hook should never have run
+ require.Equal(t, filepath.Join(projectHooksPath, "update"), text.ChompBytes(stderr.Bytes()))
+ require.Equal(t, "", text.ChompBytes(stdout.Bytes()))
+}
+
+func TestCustomHooksMultipleHooks(t *testing.T) {
+ _, testRepoPath, cleanup := testhelper.NewTestRepo(t)
+ defer cleanup()
+
+ globalCustomHooksDir, cleanup := testhelper.TempDir(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ var expectedExecutedScripts []string
+
+ projectUpdateHooks := 9
+ projectHooksPath := filepath.Join(testRepoPath, "custom_hooks", "update.d")
+
+ for i := 0; i < projectUpdateHooks; i++ {
+ fileName := fmt.Sprintf("update_%d", i)
+ writeCustomHook(t, fileName, projectHooksPath, successScript)
+ expectedExecutedScripts = append(expectedExecutedScripts, filepath.Join(projectHooksPath, fileName))
+ }
+
+ globalUpdateHooks := 6
+ globalHooksPath := filepath.Join(globalCustomHooksDir, "update.d")
+ for i := 0; i < globalUpdateHooks; i++ {
+ fileName := fmt.Sprintf("update_%d", i)
+ writeCustomHook(t, fileName, globalHooksPath, successScript)
+ expectedExecutedScripts = append(expectedExecutedScripts, filepath.Join(globalHooksPath, fileName))
+ }
+
+ hooksExecutor, err := newCustomHooksExecutor(testRepoPath, globalCustomHooksDir, "update")
+ require.NoError(t, err)
+
+ var stdout, stderr bytes.Buffer
+ require.NoError(t, hooksExecutor(ctx, nil, nil, &bytes.Buffer{}, &stdout, &stderr))
+ require.Empty(t, stderr.Bytes())
+
+ outputScanner := bufio.NewScanner(&stdout)
+
+ for _, expectedScript := range expectedExecutedScripts {
+ require.True(t, outputScanner.Scan())
+ require.Equal(t, expectedScript, outputScanner.Text())
+ }
+}
+
+func TestMultilineStdin(t *testing.T) {
+ _, testRepoPath, cleanup := testhelper.NewTestRepo(t)
+ defer cleanup()
+
+ globalCustomHooksDir, cleanup := testhelper.TempDir(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ projectHooksPath := filepath.Join(testRepoPath, "custom_hooks", "pre-receive.d")
+
+ writeCustomHook(t, "pre-receive-script", projectHooksPath, printStdinScript)
+
+ hooksExecutor, err := newCustomHooksExecutor(testRepoPath, globalCustomHooksDir, "pre-receive")
+ require.NoError(t, err)
+
+ changes := `old1 new1 ref1
+old2 new2 ref2
+old3 new3 ref3
+`
+ stdin := bytes.NewBufferString(changes)
+ var stdout, stderr bytes.Buffer
+
+ require.NoError(t, hooksExecutor(ctx, nil, nil, stdin, &stdout, &stderr))
+ require.Equal(t, changes, stdout.String())
+}
+
+func TestMultipleScriptsStdin(t *testing.T) {
+ _, testRepoPath, cleanup := testhelper.NewTestRepo(t)
+ defer cleanup()
+
+ globalCustomHooksDir, cleanup := testhelper.TempDir(t)
+ defer cleanup()
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ projectUpdateHooks := 9
+ projectHooksPath := filepath.Join(testRepoPath, "custom_hooks", "pre-receive.d")
+
+ for i := 0; i < projectUpdateHooks; i++ {
+ fileName := fmt.Sprintf("pre-receive_%d", i)
+ writeCustomHook(t, fileName, projectHooksPath, printStdinScript)
+ }
+
+ hooksExecutor, err := newCustomHooksExecutor(testRepoPath, globalCustomHooksDir, "pre-receive")
+ require.NoError(t, err)
+
+ changes := "oldref11 newref00 ref123445"
+
+ var stdout, stderr bytes.Buffer
+ require.NoError(t, hooksExecutor(ctx, nil, nil, bytes.NewBufferString(changes+"\n"), &stdout, &stderr))
+ require.Empty(t, stderr.Bytes())
+
+ outputScanner := bufio.NewScanner(&stdout)
+
+ for i := 0; i < projectUpdateHooks; i++ {
+ require.True(t, outputScanner.Scan())
+ require.Equal(t, changes, outputScanner.Text())
+ }
+}
+
+func callAndVerifyHooks(t *testing.T, repoPath, hookName, globalHooksDir, hookDir, stdin string, args, env []string) {
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+ var stdout, stderr bytes.Buffer
+
+ cleanup := writeCustomHook(t, hookName, hookDir, printAllScript)
+ defer cleanup()
+
+ callHooks, err := newCustomHooksExecutor(repoPath, globalHooksDir, hookName)
+ require.NoError(t, err)
+
+ require.NoError(t, callHooks(ctx, args, env, bytes.NewBufferString(stdin), &stdout, &stderr))
+ require.Empty(t, stderr.Bytes())
+
+ results := getCustomHookResults(&stdout)
+ assert.Equal(t, stdin, results.stdin)
+ assert.Equal(t, args, results.args)
+ assert.Subset(t, results.env, env)
+}
+
+func getCustomHookResults(stdout *bytes.Buffer) customHookResults {
+ lines := strings.SplitN(stdout.String(), "\n", 3)
+ stdinLine := strings.SplitN(strings.TrimSpace(lines[0]), ":", 2)
+ argsLine := strings.SplitN(strings.TrimSpace(lines[1]), ":", 2)
+ envLine := strings.SplitN(strings.TrimSpace(lines[2]), ":", 2)
+
+ var args, env []string
+ if len(argsLine) == 2 && argsLine[1] != "" {
+ args = strings.Split(argsLine[1], " ")
+ }
+ if len(envLine) == 2 && envLine[1] != "" {
+ env = strings.Split(envLine[1], " ")
+ }
+
+ var stdin string
+ if len(stdinLine) == 2 {
+ stdin = stdinLine[1]
+ }
+
+ return customHookResults{
+ stdin: stdin,
+ args: args,
+ env: env,
+ }
+}
+
+type customHookResults struct {
+ stdin string
+ args []string
+ env []string
+}
+
+func writeCustomHook(t *testing.T, hookName, dir string, content []byte) func() {
+ require.NoError(t, os.MkdirAll(dir, 0755))
+
+ ioutil.WriteFile(filepath.Join(dir, hookName), content, 0755)
+ return func() {
+ os.RemoveAll(dir)
+ }
+}