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:
authorToon Claes <toon@gitlab.com>2023-04-26 17:00:53 +0300
committerToon Claes <toon@gitlab.com>2023-04-26 17:00:53 +0300
commitfbda20484771e0bdd0e29ffa6715cc0bc6dc6ce4 (patch)
tree61238ebed2192b14f792ead0fc4b51b8369c8360
parent2135a63c3742d0ddbf0164e7071e1d1af52e166d (diff)
parent035e7980765fb866710faa41a31e4a9b8bc2a495 (diff)
Merge branch 'repoutil_hooks' into 'master'
Extract GetCustomHooks/SetCustomHooks from repository service See merge request https://gitlab.com/gitlab-org/gitaly/-/merge_requests/5657 Merged-by: Toon Claes <toon@gitlab.com> Approved-by: Toon Claes <toon@gitlab.com> Reviewed-by: James Fargher <proglottis@gmail.com> Co-authored-by: James Fargher <jfargher@gitlab.com>
-rw-r--r--internal/gitaly/hook/extract_hooks.go61
-rw-r--r--internal/gitaly/hook/extract_hooks_test.go134
-rw-r--r--internal/gitaly/repoutil/custom_hooks.go281
-rw-r--r--internal/gitaly/repoutil/custom_hooks_test.go415
-rw-r--r--internal/gitaly/service/repository/get_custom_hooks.go31
-rw-r--r--internal/gitaly/service/repository/replicate.go2
-rw-r--r--internal/gitaly/service/repository/replicate_test.go7
-rw-r--r--internal/gitaly/service/repository/set_custom_hooks.go197
-rw-r--r--internal/gitaly/service/repository/set_custom_hooks_test.go76
-rw-r--r--internal/gitaly/transaction_manager.go4
10 files changed, 711 insertions, 497 deletions
diff --git a/internal/gitaly/hook/extract_hooks.go b/internal/gitaly/hook/extract_hooks.go
deleted file mode 100644
index b4b586c2b..000000000
--- a/internal/gitaly/hook/extract_hooks.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package hook
-
-import (
- "bufio"
- "context"
- "fmt"
- "io"
- "strings"
-
- "gitlab.com/gitlab-org/gitaly/v15/internal/command"
- "gitlab.com/gitlab-org/gitaly/v15/internal/structerr"
-)
-
-// CustomHooksDir is the directory in which the custom hooks are stored in the repository.
-// It's also the directory where the hooks are stored in the TAR archive containing the hooks.
-const CustomHooksDir = "custom_hooks"
-
-// ExtractHooks unpacks a tar file containing custom hooks into a `custom_hooks`
-// directory at the specified path. If stripPrefix is set, the hooks are extracted directly
-// to the target directory instead of in a `custom_hooks` directory in the target directory.
-func ExtractHooks(ctx context.Context, reader io.Reader, path string, stripPrefix bool) error {
- // GNU tar does not accept an empty file as a valid tar archive and produces
- // an error. Since an empty hooks tar is symbolic of a repository having no
- // hooks, the reader is peeked to check if there is any data present.
- buf := bufio.NewReader(reader)
- if _, err := buf.Peek(1); err == io.EOF {
- return nil
- }
-
- stripComponents := "0"
- if stripPrefix {
- stripComponents = "1"
- }
-
- cmdArgs := []string{"-xf", "-", "-C", path, "--strip-components", stripComponents, CustomHooksDir}
-
- var stderrBuilder strings.Builder
- cmd, err := command.New(ctx, append([]string{"tar"}, cmdArgs...),
- command.WithStdin(buf),
- command.WithStderr(&stderrBuilder))
- if err != nil {
- return fmt.Errorf("executing tar command: %w", err)
- }
-
- if err := cmd.Wait(); err != nil {
- stderr := stderrBuilder.String()
-
- // GNU and BSD tar versions have differing errors when attempting to
- // extract specified members from a valid tar archive. If the tar
- // archive is valid the errors for GNU and BSD tar should have the
- // same prefix, which can be checked to validate whether the expected
- // content is present in the archive for extraction.
- if strings.HasPrefix(stderr, "tar: custom_hooks: Not found in archive") {
- return nil
- }
-
- return structerr.New("waiting for tar command completion: %w", err).WithMetadata("stderr", stderr)
- }
-
- return nil
-}
diff --git a/internal/gitaly/hook/extract_hooks_test.go b/internal/gitaly/hook/extract_hooks_test.go
deleted file mode 100644
index f9755fa8f..000000000
--- a/internal/gitaly/hook/extract_hooks_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package hook
-
-import (
- "archive/tar"
- "bytes"
- "io"
- "io/fs"
- "strings"
- "testing"
-
- "github.com/stretchr/testify/require"
- "gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
- "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper"
-)
-
-func TestExtractHooks(t *testing.T) {
- umask := perm.GetUmask()
-
- writeFile := func(writer *tar.Writer, path string, mode fs.FileMode, content string) {
- require.NoError(t, writer.WriteHeader(&tar.Header{
- Name: path,
- Mode: int64(mode),
- Size: int64(len(content)),
- }))
- _, err := writer.Write([]byte(content))
- require.NoError(t, err)
- }
-
- validArchive := func() io.Reader {
- var buffer bytes.Buffer
- writer := tar.NewWriter(&buffer)
- writeFile(writer, "custom_hooks/pre-receive", fs.ModePerm, "pre-receive content")
- require.NoError(t, writer.WriteHeader(&tar.Header{
- Name: "custom_hooks/subdirectory/",
- Mode: int64(perm.PrivateDir),
- }))
- writeFile(writer, "custom_hooks/subdirectory/supporting-file", perm.PrivateFile, "supporting-file content")
- writeFile(writer, "ignored_file", fs.ModePerm, "ignored content")
- writeFile(writer, "ignored_directory/ignored_file", fs.ModePerm, "ignored content")
- defer testhelper.MustClose(t, writer)
- return &buffer
- }
-
- for _, tc := range []struct {
- desc string
- archive io.Reader
- stripPrefix bool
- expectedState testhelper.DirectoryState
- expectedErrorMessage string
- }{
- {
- desc: "empty reader",
- archive: strings.NewReader(""),
- expectedState: testhelper.DirectoryState{
- "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- },
- },
- {
- desc: "empty archive",
- archive: func() io.Reader {
- var buffer bytes.Buffer
- writer := tar.NewWriter(&buffer)
- defer testhelper.MustClose(t, writer)
- return &buffer
- }(),
- expectedState: testhelper.DirectoryState{
- "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- },
- },
- {
- desc: "just custom_hooks directory",
- archive: func() io.Reader {
- var buffer bytes.Buffer
- writer := tar.NewWriter(&buffer)
- require.NoError(t, writer.WriteHeader(&tar.Header{
- Name: "custom_hooks/",
- Mode: int64(fs.ModePerm),
- }))
- defer testhelper.MustClose(t, writer)
- return &buffer
- }(),
- expectedState: testhelper.DirectoryState{
- "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- "/custom_hooks": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- },
- },
- {
- desc: "custom_hooks dir extracted",
- archive: validArchive(),
- expectedState: testhelper.DirectoryState{
- "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- "/custom_hooks": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- "/custom_hooks/pre-receive": {Mode: umask.Mask(fs.ModePerm), Content: []byte("pre-receive content")},
- "/custom_hooks/subdirectory": {Mode: umask.Mask(fs.ModeDir | perm.PrivateDir)},
- "/custom_hooks/subdirectory/supporting-file": {Mode: umask.Mask(perm.PrivateFile), Content: []byte("supporting-file content")},
- },
- },
- {
- desc: "custom_hooks dir extracted with prefix stripped",
- archive: validArchive(),
- stripPrefix: true,
- expectedState: testhelper.DirectoryState{
- "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- "/pre-receive": {Mode: umask.Mask(fs.ModePerm), Content: []byte("pre-receive content")},
- "/subdirectory": {Mode: umask.Mask(fs.ModeDir | perm.PrivateDir)},
- "/subdirectory/supporting-file": {Mode: umask.Mask(perm.PrivateFile), Content: []byte("supporting-file content")},
- },
- },
- {
- desc: "corrupted archive",
- archive: strings.NewReader("invalid tar content"),
- expectedErrorMessage: "waiting for tar command completion: exit status",
- expectedState: testhelper.DirectoryState{
- "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
- },
- },
- } {
- tc := tc
- t.Run(tc.desc, func(t *testing.T) {
- t.Parallel()
-
- ctx := testhelper.Context(t)
-
- tmpDir := t.TempDir()
- err := ExtractHooks(ctx, tc.archive, tmpDir, tc.stripPrefix)
- if tc.expectedErrorMessage != "" {
- require.ErrorContains(t, err, tc.expectedErrorMessage)
- } else {
- require.NoError(t, err)
- }
- testhelper.RequireDirectoryState(t, tmpDir, "", tc.expectedState)
- })
- }
-}
diff --git a/internal/gitaly/repoutil/custom_hooks.go b/internal/gitaly/repoutil/custom_hooks.go
new file mode 100644
index 000000000..483ae8c09
--- /dev/null
+++ b/internal/gitaly/repoutil/custom_hooks.go
@@ -0,0 +1,281 @@
+package repoutil
+
+import (
+ "bufio"
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/archive"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/command"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/git/repository"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/storage"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/safe"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/structerr"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/tempdir"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/transaction/txinfo"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/transaction/voting"
+)
+
+// CustomHooksDir is the directory in which the custom hooks are stored in the repository.
+// It's also the directory where the hooks are stored in the TAR archive containing the hooks.
+const CustomHooksDir = "custom_hooks"
+
+// GetCustomHooks fetches the git hooks for a repository. The hooks are written
+// to writer as a tar archive containing a `custom_hooks` directory. If no
+// hooks are present in the repository, the response will have no data.
+func GetCustomHooks(
+ ctx context.Context,
+ locator storage.Locator,
+ writer io.Writer,
+ repo repository.GitRepo,
+) error {
+ repoPath, err := locator.GetRepoPath(repo)
+ if err != nil {
+ return fmt.Errorf("getting repo path: %w", err)
+ }
+
+ if _, err := os.Lstat(filepath.Join(repoPath, CustomHooksDir)); os.IsNotExist(err) {
+ return nil
+ }
+
+ if err := archive.WriteTarball(ctx, writer, repoPath, CustomHooksDir); err != nil {
+ return structerr.NewInternal("archiving hooks: %w", err)
+ }
+
+ return nil
+}
+
+// ExtractHooks unpacks a tar file containing custom hooks into a `custom_hooks`
+// directory at the specified path. If stripPrefix is set, the hooks are extracted directly
+// to the target directory instead of in a `custom_hooks` directory in the target directory.
+func ExtractHooks(ctx context.Context, reader io.Reader, path string, stripPrefix bool) error {
+ // GNU tar does not accept an empty file as a valid tar archive and produces
+ // an error. Since an empty hooks tar is symbolic of a repository having no
+ // hooks, the reader is peeked to check if there is any data present.
+ buf := bufio.NewReader(reader)
+ if _, err := buf.Peek(1); err == io.EOF {
+ return nil
+ }
+
+ stripComponents := "0"
+ if stripPrefix {
+ stripComponents = "1"
+ }
+
+ cmdArgs := []string{"-xf", "-", "-C", path, "--strip-components", stripComponents, CustomHooksDir}
+
+ var stderrBuilder strings.Builder
+ cmd, err := command.New(ctx, append([]string{"tar"}, cmdArgs...),
+ command.WithStdin(buf),
+ command.WithStderr(&stderrBuilder))
+ if err != nil {
+ return fmt.Errorf("executing tar command: %w", err)
+ }
+
+ if err := cmd.Wait(); err != nil {
+ stderr := stderrBuilder.String()
+
+ // GNU and BSD tar versions have differing errors when attempting to
+ // extract specified members from a valid tar archive. If the tar
+ // archive is valid the errors for GNU and BSD tar should have the
+ // same prefix, which can be checked to validate whether the expected
+ // content is present in the archive for extraction.
+ if strings.HasPrefix(stderr, "tar: custom_hooks: Not found in archive") {
+ return nil
+ }
+
+ return structerr.New("waiting for tar command completion: %w", err).WithMetadata("stderr", stderr)
+ }
+
+ return nil
+}
+
+// SetCustomHooks transactionally and atomically sets custom hooks for a
+// repository. The provided reader should be a tarball containing the custom
+// hooks to be extracted to the specified Git repository.
+func SetCustomHooks(
+ ctx context.Context,
+ locator storage.Locator,
+ txManager transaction.Manager,
+ reader io.Reader,
+ repo repository.GitRepo,
+) error {
+ repoPath, err := locator.GetRepoPath(repo)
+ if err != nil {
+ return fmt.Errorf("getting repo path: %w", err)
+ }
+
+ // The `custom_hooks` directory in the repository is locked to prevent
+ // concurrent modification of hooks.
+ hooksLock, err := safe.NewLockingDirectory(repoPath, CustomHooksDir)
+ if err != nil {
+ return fmt.Errorf("creating hooks lock: %w", err)
+ }
+
+ if err := hooksLock.Lock(); err != nil {
+ return fmt.Errorf("locking hooks: %w", err)
+ }
+ defer func() {
+ // If the `.lock` file is not removed from the `custom_hooks` directory,
+ // future modifications to the repository's hooks will be prevented. If
+ // this occurs, the `.lock` file will have to be manually removed.
+ if err := hooksLock.Unlock(); err != nil {
+ ctxlogrus.Extract(ctx).WithError(err).Error("failed to unlock hooks")
+ }
+ }()
+
+ // Create a temporary directory to write the new hooks to and also
+ // temporarily store the current repository hooks. This enables "atomic"
+ // directory swapping by acting as an intermediary storage location between
+ // moves.
+ tmpDir, err := tempdir.NewWithoutContext(repo.GetStorageName(), locator)
+ if err != nil {
+ return fmt.Errorf("creating temp directory: %w", err)
+ }
+
+ defer func() {
+ if err := os.RemoveAll(tmpDir.Path()); err != nil {
+ ctxlogrus.Extract(ctx).WithError(err).Warn("failed to remove temporary directory")
+ }
+ }()
+
+ if err := ExtractHooks(ctx, reader, tmpDir.Path(), false); err != nil {
+ return fmt.Errorf("extracting hooks: %w", err)
+ }
+
+ tempHooksPath := filepath.Join(tmpDir.Path(), CustomHooksDir)
+
+ // No hooks will be extracted if the tar archive is empty. If this happens
+ // it means the repository should be set with an empty `custom_hooks`
+ // directory. Create `custom_hooks` in the temporary directory so that any
+ // existing repository hooks will be replaced with this empty directory.
+ if err := os.Mkdir(tempHooksPath, perm.PublicDir); err != nil && !errors.Is(err, fs.ErrExist) {
+ return fmt.Errorf("making temp hooks directory: %w", err)
+ }
+
+ preparedVote, err := newDirectoryVote(tempHooksPath)
+ if err != nil {
+ return fmt.Errorf("generating prepared vote: %w", err)
+ }
+
+ // Cast prepared vote with hash of the extracted archive in the temporary
+ // `custom_hooks` directory.
+ if err := voteCustomHooks(ctx, txManager, preparedVote, voting.Prepared); err != nil {
+ return fmt.Errorf("casting prepared vote: %w", err)
+ }
+
+ repoHooksPath := filepath.Join(repoPath, CustomHooksDir)
+ prevHooksPath := filepath.Join(tmpDir.Path(), "previous_hooks")
+
+ // If the `custom_hooks` directory exists in the repository, move the
+ // current hooks to `previous_hooks` in the temporary directory.
+ if err := os.Rename(repoHooksPath, prevHooksPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("moving current hooks to temp: %w", err)
+ }
+
+ // Move `custom_hooks` from the temporary directory to the repository.
+ if err := os.Rename(tempHooksPath, repoHooksPath); err != nil {
+ return fmt.Errorf("moving new hooks to repo: %w", err)
+ }
+
+ committedVote, err := newDirectoryVote(repoHooksPath)
+ if err != nil {
+ return fmt.Errorf("generating committed vote: %w", err)
+ }
+
+ // Cast committed vote with hash of the extracted archive in the repository
+ // `custom_hooks` directory.
+ if err := voteCustomHooks(ctx, txManager, committedVote, voting.Committed); err != nil {
+ return fmt.Errorf("casting committed vote: %w", err)
+ }
+
+ return nil
+}
+
+// newDirectoryVote creates a voting.VoteHash by walking the specified path and
+// generating a hash based on file name, permissions, and data.
+func newDirectoryVote(basePath string) (*voting.VoteHash, error) {
+ voteHash := voting.NewVoteHash()
+
+ if err := filepath.WalkDir(basePath, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ relPath, err := filepath.Rel(basePath, path)
+ if err != nil {
+ return fmt.Errorf("getting relative path: %w", err)
+ }
+
+ // Write file relative path to hash.
+ _, _ = voteHash.Write([]byte(relPath))
+
+ info, err := entry.Info()
+ if err != nil {
+ return fmt.Errorf("getting file info: %w", err)
+ }
+
+ // Write file permissions to hash.
+ permBytes := make([]byte, 4)
+ binary.BigEndian.PutUint32(permBytes, uint32(info.Mode()))
+ _, _ = voteHash.Write(permBytes)
+
+ if entry.IsDir() {
+ return nil
+ }
+
+ file, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("opening file: %w", err)
+ }
+ defer file.Close()
+
+ // Copy file data to hash.
+ if _, err = io.Copy(voteHash, file); err != nil {
+ return fmt.Errorf("copying file to hash: %w", err)
+ }
+
+ return nil
+ }); err != nil {
+ return nil, fmt.Errorf("walking directory: %w", err)
+ }
+
+ return &voteHash, nil
+}
+
+// voteCustomHooks casts a vote symbolic of the custom hooks received. If there
+// is no transaction voting is skipped.
+func voteCustomHooks(
+ ctx context.Context,
+ txManager transaction.Manager,
+ v *voting.VoteHash,
+ phase voting.Phase,
+) error {
+ tx, err := txinfo.TransactionFromContext(ctx)
+ if errors.Is(err, txinfo.ErrTransactionNotFound) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+
+ vote, err := v.Vote()
+ if err != nil {
+ return err
+ }
+
+ if err := txManager.Vote(ctx, tx, vote, phase); err != nil {
+ return fmt.Errorf("vote failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/gitaly/repoutil/custom_hooks_test.go b/internal/gitaly/repoutil/custom_hooks_test.go
new file mode 100644
index 000000000..eb2f85f2d
--- /dev/null
+++ b/internal/gitaly/repoutil/custom_hooks_test.go
@@ -0,0 +1,415 @@
+package repoutil
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/archive"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/transaction/txinfo"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/transaction/voting"
+ "google.golang.org/grpc/peer"
+)
+
+func TestGetCustomHooks_successful(t *testing.T) {
+ t.Parallel()
+
+ ctx := testhelper.Context(t)
+ cfg := testcfg.Build(t)
+ locator := config.NewLocator(cfg)
+ repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
+ SkipCreationViaService: true,
+ })
+
+ expectedTarResponse := []string{
+ "custom_hooks/",
+ "custom_hooks/pre-commit.sample",
+ "custom_hooks/prepare-commit-msg.sample",
+ "custom_hooks/pre-push.sample",
+ }
+ require.NoError(t, os.Mkdir(filepath.Join(repoPath, "custom_hooks"), perm.PrivateDir), "Could not create custom_hooks dir")
+ for _, fileName := range expectedTarResponse[1:] {
+ require.NoError(t, os.WriteFile(filepath.Join(repoPath, fileName), []byte("Some hooks"), perm.PrivateExecutable), fmt.Sprintf("Could not create %s", fileName))
+ }
+
+ var hooks bytes.Buffer
+ require.NoError(t, GetCustomHooks(ctx, locator, &hooks, repo))
+
+ reader := tar.NewReader(&hooks)
+ fileLength := 0
+ for {
+ file, err := reader.Next()
+ if err == io.EOF {
+ break
+ }
+ require.NoError(t, err)
+ fileLength++
+ require.Contains(t, expectedTarResponse, file.Name)
+ }
+ require.Equal(t, fileLength, len(expectedTarResponse))
+}
+
+func TestGetCustomHooks_symlink(t *testing.T) {
+ t.Parallel()
+
+ ctx := testhelper.Context(t)
+ cfg := testcfg.Build(t)
+ locator := config.NewLocator(cfg)
+ repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
+ SkipCreationViaService: true,
+ })
+
+ linkTarget := "/var/empty"
+ require.NoError(t, os.Symlink(linkTarget, filepath.Join(repoPath, "custom_hooks")), "Could not create custom_hooks symlink")
+
+ var hooks bytes.Buffer
+ require.NoError(t, GetCustomHooks(ctx, locator, &hooks, repo))
+
+ reader := tar.NewReader(&hooks)
+ file, err := reader.Next()
+ require.NoError(t, err)
+
+ require.Equal(t, "custom_hooks", file.Name, "tar entry name")
+ require.Equal(t, byte(tar.TypeSymlink), file.Typeflag, "tar entry type")
+ require.Equal(t, linkTarget, file.Linkname, "link target")
+
+ _, err = reader.Next()
+ require.Equal(t, io.EOF, err, "custom_hooks should have been the only entry")
+}
+
+func TestGetCustomHooks_nonexistentHooks(t *testing.T) {
+ t.Parallel()
+
+ ctx := testhelper.Context(t)
+ cfg := testcfg.Build(t)
+ locator := config.NewLocator(cfg)
+ repo, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
+ SkipCreationViaService: true,
+ })
+
+ var hooks bytes.Buffer
+ require.NoError(t, GetCustomHooks(ctx, locator, &hooks, repo))
+
+ reader := tar.NewReader(&hooks)
+ buf := bytes.NewBuffer(nil)
+ _, err := io.Copy(buf, reader)
+ require.NoError(t, err)
+
+ require.Empty(t, buf.String(), "Returned stream should be empty")
+}
+
+func TestExtractHooks(t *testing.T) {
+ umask := perm.GetUmask()
+
+ writeFile := func(writer *tar.Writer, path string, mode fs.FileMode, content string) {
+ require.NoError(t, writer.WriteHeader(&tar.Header{
+ Name: path,
+ Mode: int64(mode),
+ Size: int64(len(content)),
+ }))
+ _, err := writer.Write([]byte(content))
+ require.NoError(t, err)
+ }
+
+ validArchive := func() io.Reader {
+ var buffer bytes.Buffer
+ writer := tar.NewWriter(&buffer)
+ writeFile(writer, "custom_hooks/pre-receive", fs.ModePerm, "pre-receive content")
+ require.NoError(t, writer.WriteHeader(&tar.Header{
+ Name: "custom_hooks/subdirectory/",
+ Mode: int64(perm.PrivateDir),
+ }))
+ writeFile(writer, "custom_hooks/subdirectory/supporting-file", perm.PrivateFile, "supporting-file content")
+ writeFile(writer, "ignored_file", fs.ModePerm, "ignored content")
+ writeFile(writer, "ignored_directory/ignored_file", fs.ModePerm, "ignored content")
+ defer testhelper.MustClose(t, writer)
+ return &buffer
+ }
+
+ for _, tc := range []struct {
+ desc string
+ archive io.Reader
+ stripPrefix bool
+ expectedState testhelper.DirectoryState
+ expectedErrorMessage string
+ }{
+ {
+ desc: "empty reader",
+ archive: strings.NewReader(""),
+ expectedState: testhelper.DirectoryState{
+ "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ },
+ },
+ {
+ desc: "empty archive",
+ archive: func() io.Reader {
+ var buffer bytes.Buffer
+ writer := tar.NewWriter(&buffer)
+ defer testhelper.MustClose(t, writer)
+ return &buffer
+ }(),
+ expectedState: testhelper.DirectoryState{
+ "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ },
+ },
+ {
+ desc: "just custom_hooks directory",
+ archive: func() io.Reader {
+ var buffer bytes.Buffer
+ writer := tar.NewWriter(&buffer)
+ require.NoError(t, writer.WriteHeader(&tar.Header{
+ Name: "custom_hooks/",
+ Mode: int64(fs.ModePerm),
+ }))
+ defer testhelper.MustClose(t, writer)
+ return &buffer
+ }(),
+ expectedState: testhelper.DirectoryState{
+ "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ "/custom_hooks": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ },
+ },
+ {
+ desc: "custom_hooks dir extracted",
+ archive: validArchive(),
+ expectedState: testhelper.DirectoryState{
+ "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ "/custom_hooks": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ "/custom_hooks/pre-receive": {Mode: umask.Mask(fs.ModePerm), Content: []byte("pre-receive content")},
+ "/custom_hooks/subdirectory": {Mode: umask.Mask(fs.ModeDir | perm.PrivateDir)},
+ "/custom_hooks/subdirectory/supporting-file": {Mode: umask.Mask(perm.PrivateFile), Content: []byte("supporting-file content")},
+ },
+ },
+ {
+ desc: "custom_hooks dir extracted with prefix stripped",
+ archive: validArchive(),
+ stripPrefix: true,
+ expectedState: testhelper.DirectoryState{
+ "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ "/pre-receive": {Mode: umask.Mask(fs.ModePerm), Content: []byte("pre-receive content")},
+ "/subdirectory": {Mode: umask.Mask(fs.ModeDir | perm.PrivateDir)},
+ "/subdirectory/supporting-file": {Mode: umask.Mask(perm.PrivateFile), Content: []byte("supporting-file content")},
+ },
+ },
+ {
+ desc: "corrupted archive",
+ archive: strings.NewReader("invalid tar content"),
+ expectedErrorMessage: "waiting for tar command completion: exit status",
+ expectedState: testhelper.DirectoryState{
+ "/": {Mode: umask.Mask(fs.ModeDir | fs.ModePerm)},
+ },
+ },
+ } {
+ tc := tc
+ t.Run(tc.desc, func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testhelper.Context(t)
+
+ tmpDir := t.TempDir()
+ err := ExtractHooks(ctx, tc.archive, tmpDir, tc.stripPrefix)
+ if tc.expectedErrorMessage != "" {
+ require.ErrorContains(t, err, tc.expectedErrorMessage)
+ } else {
+ require.NoError(t, err)
+ }
+ testhelper.RequireDirectoryState(t, tmpDir, "", tc.expectedState)
+ })
+ }
+}
+
+func TestSetCustomHooks_success(t *testing.T) {
+ t.Parallel()
+
+ ctx := testhelper.Context(t)
+ cfg := testcfg.Build(t)
+ locator := config.NewLocator(cfg)
+ txManager := transaction.NewTrackingManager()
+
+ repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
+ SkipCreationViaService: true,
+ })
+
+ archivePath := mustCreateCustomHooksArchive(t, ctx, []testFile{
+ {name: "pre-commit.sample", content: "foo", mode: 0o755},
+ {name: "pre-push.sample", content: "bar", mode: 0o755},
+ }, CustomHooksDir)
+
+ file, err := os.Open(archivePath)
+ require.NoError(t, err)
+
+ ctx = peer.NewContext(ctx, &peer.Peer{})
+ ctx, err = txinfo.InjectTransaction(ctx, 1, "node", true)
+ require.NoError(t, err)
+
+ require.NoError(t, SetCustomHooks(ctx, locator, txManager, file, repo))
+
+ voteHash, err := newDirectoryVote(filepath.Join(repoPath, CustomHooksDir))
+ require.NoError(t, err)
+
+ testhelper.MustClose(t, file)
+
+ expectedVote, err := voteHash.Vote()
+ require.NoError(t, err)
+
+ require.FileExists(t, filepath.Join(repoPath, "custom_hooks", "pre-push.sample"))
+ require.Equal(t, 2, len(txManager.Votes()))
+ assert.Equal(t, voting.Prepared, txManager.Votes()[0].Phase)
+ assert.Equal(t, expectedVote, txManager.Votes()[1].Vote)
+ assert.Equal(t, voting.Committed, txManager.Votes()[1].Phase)
+}
+
+func TestSetCustomHooks_corruptTar(t *testing.T) {
+ t.Parallel()
+
+ ctx := testhelper.Context(t)
+ cfg := testcfg.Build(t)
+ locator := config.NewLocator(cfg)
+ txManager := &transaction.MockManager{}
+
+ repo, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
+ SkipCreationViaService: true,
+ })
+
+ archivePath := mustCreateCorruptHooksArchive(t)
+
+ file, err := os.Open(archivePath)
+ require.NoError(t, err)
+ defer testhelper.MustClose(t, file)
+
+ err = SetCustomHooks(ctx, locator, txManager, file, repo)
+ require.ErrorContains(t, err, "extracting hooks: waiting for tar command completion: exit status ")
+}
+
+type testFile struct {
+ name string
+ content string
+ mode os.FileMode
+}
+
+func TestNewDirectoryVote(t *testing.T) {
+ // The vote hash depends on the permission bits, so we must make sure that the files we
+ // write have the same permission bits on all systems. As the umask can get in our way we
+ // reset it to a known value here and restore it after the test. This also means that we
+ // cannot parallelize this test.
+ currentUmask := syscall.Umask(0)
+ defer func() {
+ syscall.Umask(currentUmask)
+ }()
+ syscall.Umask(0o022)
+
+ for _, tc := range []struct {
+ desc string
+ files []testFile
+ expectedHash string
+ }{
+ {
+ desc: "generated hash matches",
+ files: []testFile{
+ {name: "pre-commit.sample", content: "foo", mode: perm.SharedExecutable},
+ {name: "pre-push.sample", content: "bar", mode: perm.SharedExecutable},
+ },
+ expectedHash: "8ca11991268de4c9278488a674fc1a88db449566",
+ },
+ {
+ desc: "generated hash matches with changed file name",
+ files: []testFile{
+ {name: "pre-commit.sample.diff", content: "foo", mode: perm.SharedExecutable},
+ {name: "pre-push.sample", content: "bar", mode: perm.SharedExecutable},
+ },
+ expectedHash: "b5ed58ced84103da1ed9d7813a9e39b3b5daf7d7",
+ },
+ {
+ desc: "generated hash matches with changed file content",
+ files: []testFile{
+ {name: "pre-commit.sample", content: "foo", mode: perm.SharedExecutable},
+ {name: "pre-push.sample", content: "bar.diff", mode: perm.SharedExecutable},
+ },
+ expectedHash: "178083848c8a08e36c4f86c2d318a84b0bb845f2",
+ },
+ {
+ desc: "generated hash matches with changed file mode",
+ files: []testFile{
+ {name: "pre-commit.sample", content: "foo", mode: perm.SharedFile},
+ {name: "pre-push.sample", content: "bar", mode: perm.SharedExecutable},
+ },
+ expectedHash: "c69574241b83496bb4005b4f7a0dfcda96cb317e",
+ },
+ } {
+ t.Run(tc.desc, func(t *testing.T) {
+ path := mustWriteCustomHookDirectory(t, tc.files, CustomHooksDir)
+
+ voteHash, err := newDirectoryVote(path)
+ require.NoError(t, err)
+
+ vote, err := voteHash.Vote()
+ require.NoError(t, err)
+
+ hash := vote.String()
+ require.Equal(t, tc.expectedHash, hash)
+ })
+ }
+}
+
+func mustWriteCustomHookDirectory(t *testing.T, files []testFile, dirName string) string {
+ t.Helper()
+
+ tmpDir := testhelper.TempDir(t)
+ hooksPath := filepath.Join(tmpDir, dirName)
+
+ err := os.Mkdir(hooksPath, perm.SharedDir)
+ require.NoError(t, err)
+
+ for _, f := range files {
+ err = os.WriteFile(filepath.Join(hooksPath, f.name), []byte(f.content), f.mode)
+ require.NoError(t, err)
+ }
+
+ return hooksPath
+}
+
+func mustCreateCustomHooksArchive(t *testing.T, ctx context.Context, files []testFile, dirName string) string {
+ t.Helper()
+
+ hooksPath := mustWriteCustomHookDirectory(t, files, dirName)
+ hooksDir := filepath.Dir(hooksPath)
+
+ tmpDir := testhelper.TempDir(t)
+ archivePath := filepath.Join(tmpDir, "custom_hooks.tar")
+
+ file, err := os.Create(archivePath)
+ require.NoError(t, err)
+
+ err = archive.WriteTarball(ctx, file, hooksDir, dirName)
+ require.NoError(t, err)
+
+ return archivePath
+}
+
+func mustCreateCorruptHooksArchive(t *testing.T) string {
+ t.Helper()
+
+ tmpDir := testhelper.TempDir(t)
+ archivePath := filepath.Join(tmpDir, "corrupt_hooks.tar")
+
+ err := os.WriteFile(archivePath, []byte("This is a corrupted tar file"), 0o755)
+ require.NoError(t, err)
+
+ return archivePath
+}
diff --git a/internal/gitaly/service/repository/get_custom_hooks.go b/internal/gitaly/service/repository/get_custom_hooks.go
index 69c1e0749..3cb11ed05 100644
--- a/internal/gitaly/service/repository/get_custom_hooks.go
+++ b/internal/gitaly/service/repository/get_custom_hooks.go
@@ -1,15 +1,7 @@
package repository
import (
- "context"
- "fmt"
- "io"
- "os"
- "path/filepath"
-
- "gitlab.com/gitlab-org/gitaly/v15/internal/archive"
- "gitlab.com/gitlab-org/gitaly/v15/internal/git/repository"
- "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/hook"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/repoutil"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/service"
"gitlab.com/gitlab-org/gitaly/v15/internal/structerr"
"gitlab.com/gitlab-org/gitaly/v15/proto/go/gitalypb"
@@ -30,7 +22,7 @@ func (s *server) GetCustomHooks(in *gitalypb.GetCustomHooksRequest, stream gital
return stream.Send(&gitalypb.GetCustomHooksResponse{Data: p})
})
- if err := s.getCustomHooks(ctx, writer, in.Repository); err != nil {
+ if err := repoutil.GetCustomHooks(ctx, s.locator, writer, in.Repository); err != nil {
return structerr.NewInternal("reading custom hooks: %w", err)
}
@@ -51,26 +43,9 @@ func (s *server) BackupCustomHooks(in *gitalypb.BackupCustomHooksRequest, stream
return stream.Send(&gitalypb.BackupCustomHooksResponse{Data: p})
})
- if err := s.getCustomHooks(ctx, writer, in.Repository); err != nil {
+ if err := repoutil.GetCustomHooks(ctx, s.locator, writer, in.Repository); err != nil {
return structerr.NewInternal("reading custom hooks: %w", err)
}
return nil
}
-
-func (s *server) getCustomHooks(ctx context.Context, writer io.Writer, repo repository.GitRepo) error {
- repoPath, err := s.locator.GetRepoPath(repo)
- if err != nil {
- return fmt.Errorf("getting repo path: %w", err)
- }
-
- if _, err := os.Lstat(filepath.Join(repoPath, hook.CustomHooksDir)); os.IsNotExist(err) {
- return nil
- }
-
- if err := archive.WriteTarball(ctx, writer, repoPath, hook.CustomHooksDir); err != nil {
- return structerr.NewInternal("archiving hooks: %w", err)
- }
-
- return nil
-}
diff --git a/internal/gitaly/service/repository/replicate.go b/internal/gitaly/service/repository/replicate.go
index cc5675fd7..35377ca57 100644
--- a/internal/gitaly/service/repository/replicate.go
+++ b/internal/gitaly/service/repository/replicate.go
@@ -283,7 +283,7 @@ func (s *server) syncCustomHooks(ctx context.Context, in *gitalypb.ReplicateRepo
return request.GetData(), err
})
- if err := s.setCustomHooks(ctx, reader, in.GetRepository()); err != nil {
+ if err := repoutil.SetCustomHooks(ctx, s.locator, s.txManager, reader, in.GetRepository()); err != nil {
return fmt.Errorf("setting custom hooks: %w", err)
}
diff --git a/internal/gitaly/service/repository/replicate_test.go b/internal/gitaly/service/repository/replicate_test.go
index f3d9d8412..9e9f7f11b 100644
--- a/internal/gitaly/service/repository/replicate_test.go
+++ b/internal/gitaly/service/repository/replicate_test.go
@@ -23,6 +23,7 @@ import (
"gitlab.com/gitlab-org/gitaly/v15/internal/git/localrepo"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config"
gitalyhook "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/hook"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/repoutil"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/storage"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
"gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
@@ -678,12 +679,12 @@ func TestReplicateRepository_hooks(t *testing.T) {
archivePath := mustCreateCustomHooksArchive(t, ctx, []testFile{
{name: "pre-commit.sample", content: "foo", mode: 0o755},
{name: "pre-push.sample", content: "bar", mode: 0o755},
- }, gitalyhook.CustomHooksDir)
+ }, repoutil.CustomHooksDir)
hooks, err := os.Open(archivePath)
require.NoError(t, err)
- err = gitalyhook.ExtractHooks(ctx, hooks, sourceRepoPath, false)
+ err = repoutil.ExtractHooks(ctx, hooks, sourceRepoPath, false)
require.NoError(t, err)
targetRepo := proto.Clone(sourceRepo).(*gitalypb.Repository)
@@ -698,7 +699,7 @@ func TestReplicateRepository_hooks(t *testing.T) {
require.NoError(t, err)
targetRepoPath := filepath.Join(cfg.Storages[1].Path, gittest.GetReplicaPath(t, ctx, cfg, targetRepo))
- targetHooksPath := filepath.Join(targetRepoPath, gitalyhook.CustomHooksDir)
+ targetHooksPath := filepath.Join(targetRepoPath, repoutil.CustomHooksDir)
// Make sure target repo contains replicated custom hooks from source repository.
require.FileExists(t, filepath.Join(targetHooksPath, "pre-push.sample"))
diff --git a/internal/gitaly/service/repository/set_custom_hooks.go b/internal/gitaly/service/repository/set_custom_hooks.go
index 3616b8136..58776777b 100644
--- a/internal/gitaly/service/repository/set_custom_hooks.go
+++ b/internal/gitaly/service/repository/set_custom_hooks.go
@@ -1,26 +1,9 @@
package repository
import (
- "context"
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "os"
- "path/filepath"
-
- "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus"
- "gitlab.com/gitlab-org/gitaly/v15/internal/git/repository"
- "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/hook"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/repoutil"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/service"
- "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
- "gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
- "gitlab.com/gitlab-org/gitaly/v15/internal/safe"
"gitlab.com/gitlab-org/gitaly/v15/internal/structerr"
- "gitlab.com/gitlab-org/gitaly/v15/internal/tempdir"
- "gitlab.com/gitlab-org/gitaly/v15/internal/transaction/txinfo"
- "gitlab.com/gitlab-org/gitaly/v15/internal/transaction/voting"
"gitlab.com/gitlab-org/gitaly/v15/proto/go/gitalypb"
"gitlab.com/gitlab-org/gitaly/v15/streamio"
)
@@ -52,7 +35,7 @@ func (s *server) SetCustomHooks(stream gitalypb.RepositoryService_SetCustomHooks
return request.GetData(), err
})
- if err := s.setCustomHooks(ctx, reader, repo); err != nil {
+ if err := repoutil.SetCustomHooks(ctx, s.locator, s.txManager, reader, repo); err != nil {
return structerr.NewInternal("setting custom hooks: %w", err)
}
@@ -86,183 +69,9 @@ func (s *server) RestoreCustomHooks(stream gitalypb.RepositoryService_RestoreCus
return request.GetData(), err
})
- if err := s.setCustomHooks(ctx, reader, repo); err != nil {
+ if err := repoutil.SetCustomHooks(ctx, s.locator, s.txManager, reader, repo); err != nil {
return structerr.NewInternal("setting custom hooks: %w", err)
}
return stream.SendAndClose(&gitalypb.RestoreCustomHooksResponse{})
}
-
-// setCustomHooks transactionally and atomically sets custom hooks for a
-// repository. The provided reader should be a tarball containing the custom
-// hooks to be extracted to the specified Git repository.
-func (s *server) setCustomHooks(ctx context.Context, reader io.Reader, repo repository.GitRepo) error {
- repoPath, err := s.locator.GetRepoPath(repo)
- if err != nil {
- return fmt.Errorf("getting repo path: %w", err)
- }
-
- // The `custom_hooks` directory in the repository is locked to prevent
- // concurrent modification of hooks.
- hooksLock, err := safe.NewLockingDirectory(repoPath, hook.CustomHooksDir)
- if err != nil {
- return fmt.Errorf("creating hooks lock: %w", err)
- }
-
- if err := hooksLock.Lock(); err != nil {
- return fmt.Errorf("locking hooks: %w", err)
- }
- defer func() {
- // If the `.lock` file is not removed from the `custom_hooks` directory,
- // future modifications to the repository's hooks will be prevented. If
- // this occurs, the `.lock` file will have to be manually removed.
- if err := hooksLock.Unlock(); err != nil {
- ctxlogrus.Extract(ctx).WithError(err).Error("failed to unlock hooks")
- }
- }()
-
- // Create a temporary directory to write the new hooks to and also
- // temporarily store the current repository hooks. This enables "atomic"
- // directory swapping by acting as an intermediary storage location between
- // moves.
- tmpDir, err := tempdir.NewWithoutContext(repo.GetStorageName(), s.locator)
- if err != nil {
- return fmt.Errorf("creating temp directory: %w", err)
- }
-
- defer func() {
- if err := os.RemoveAll(tmpDir.Path()); err != nil {
- ctxlogrus.Extract(ctx).WithError(err).Warn("failed to remove temporary directory")
- }
- }()
-
- if err := hook.ExtractHooks(ctx, reader, tmpDir.Path(), false); err != nil {
- return fmt.Errorf("extracting hooks: %w", err)
- }
-
- tempHooksPath := filepath.Join(tmpDir.Path(), hook.CustomHooksDir)
-
- // No hooks will be extracted if the tar archive is empty. If this happens
- // it means the repository should be set with an empty `custom_hooks`
- // directory. Create `custom_hooks` in the temporary directory so that any
- // existing repository hooks will be replaced with this empty directory.
- if err := os.Mkdir(tempHooksPath, perm.PublicDir); err != nil && !errors.Is(err, fs.ErrExist) {
- return fmt.Errorf("making temp hooks directory: %w", err)
- }
-
- preparedVote, err := newDirectoryVote(tempHooksPath)
- if err != nil {
- return fmt.Errorf("generating prepared vote: %w", err)
- }
-
- // Cast prepared vote with hash of the extracted archive in the temporary
- // `custom_hooks` directory.
- if err := voteCustomHooks(ctx, s.txManager, preparedVote, voting.Prepared); err != nil {
- return fmt.Errorf("casting prepared vote: %w", err)
- }
-
- repoHooksPath := filepath.Join(repoPath, hook.CustomHooksDir)
- prevHooksPath := filepath.Join(tmpDir.Path(), "previous_hooks")
-
- // If the `custom_hooks` directory exists in the repository, move the
- // current hooks to `previous_hooks` in the temporary directory.
- if err := os.Rename(repoHooksPath, prevHooksPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
- return fmt.Errorf("moving current hooks to temp: %w", err)
- }
-
- // Move `custom_hooks` from the temporary directory to the repository.
- if err := os.Rename(tempHooksPath, repoHooksPath); err != nil {
- return fmt.Errorf("moving new hooks to repo: %w", err)
- }
-
- committedVote, err := newDirectoryVote(repoHooksPath)
- if err != nil {
- return fmt.Errorf("generating committed vote: %w", err)
- }
-
- // Cast committed vote with hash of the extracted archive in the repository
- // `custom_hooks` directory.
- if err := voteCustomHooks(ctx, s.txManager, committedVote, voting.Committed); err != nil {
- return fmt.Errorf("casting committed vote: %w", err)
- }
-
- return nil
-}
-
-// newDirectoryVote creates a voting.VoteHash by walking the specified path and
-// generating a hash based on file name, permissions, and data.
-func newDirectoryVote(basePath string) (*voting.VoteHash, error) {
- voteHash := voting.NewVoteHash()
-
- if err := filepath.WalkDir(basePath, func(path string, entry fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
-
- relPath, err := filepath.Rel(basePath, path)
- if err != nil {
- return fmt.Errorf("getting relative path: %w", err)
- }
-
- // Write file relative path to hash.
- _, _ = voteHash.Write([]byte(relPath))
-
- info, err := entry.Info()
- if err != nil {
- return fmt.Errorf("getting file info: %w", err)
- }
-
- // Write file permissions to hash.
- permBytes := make([]byte, 4)
- binary.BigEndian.PutUint32(permBytes, uint32(info.Mode()))
- _, _ = voteHash.Write(permBytes)
-
- if entry.IsDir() {
- return nil
- }
-
- file, err := os.Open(path)
- if err != nil {
- return fmt.Errorf("opening file: %w", err)
- }
- defer file.Close()
-
- // Copy file data to hash.
- if _, err = io.Copy(voteHash, file); err != nil {
- return fmt.Errorf("copying file to hash: %w", err)
- }
-
- return nil
- }); err != nil {
- return nil, fmt.Errorf("walking directory: %w", err)
- }
-
- return &voteHash, nil
-}
-
-// voteCustomHooks casts a vote symbolic of the custom hooks received. If there
-// is no transaction voting is skipped.
-func voteCustomHooks(
- ctx context.Context,
- txManager transaction.Manager,
- v *voting.VoteHash,
- phase voting.Phase,
-) error {
- tx, err := txinfo.TransactionFromContext(ctx)
- if errors.Is(err, txinfo.ErrTransactionNotFound) {
- return nil
- } else if err != nil {
- return err
- }
-
- vote, err := v.Vote()
- if err != nil {
- return err
- }
-
- if err := txManager.Vote(ctx, tx, vote, phase); err != nil {
- return fmt.Errorf("vote failed: %w", err)
- }
-
- return nil
-}
diff --git a/internal/gitaly/service/repository/set_custom_hooks_test.go b/internal/gitaly/service/repository/set_custom_hooks_test.go
index 15c9df6e4..e3f405eba 100644
--- a/internal/gitaly/service/repository/set_custom_hooks_test.go
+++ b/internal/gitaly/service/repository/set_custom_hooks_test.go
@@ -7,14 +7,13 @@ import (
"io"
"os"
"path/filepath"
- "syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitaly/v15/internal/archive"
"gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest"
- "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/hook"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/repoutil"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
"gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
"gitlab.com/gitlab-org/gitaly/v15/internal/metadata"
@@ -113,7 +112,7 @@ func TestSetCustomHooksRequest_success(t *testing.T) {
archivePath := mustCreateCustomHooksArchive(t, ctx, []testFile{
{name: "pre-commit.sample", content: "foo", mode: 0o755},
{name: "pre-push.sample", content: "bar", mode: 0o755},
- }, hook.CustomHooksDir)
+ }, repoutil.CustomHooksDir)
file, err := os.Open(archivePath)
require.NoError(t, err)
@@ -122,18 +121,11 @@ func TestSetCustomHooksRequest_success(t *testing.T) {
require.NoError(t, err)
closeStream()
- voteHash, err := newDirectoryVote(filepath.Join(repoPath, hook.CustomHooksDir))
- require.NoError(t, err)
-
testhelper.MustClose(t, file)
- expectedVote, err := voteHash.Vote()
- require.NoError(t, err)
-
require.FileExists(t, filepath.Join(repoPath, "custom_hooks", "pre-push.sample"))
require.Equal(t, 2, len(txManager.Votes()))
assert.Equal(t, voting.Prepared, txManager.Votes()[0].Phase)
- assert.Equal(t, expectedVote, txManager.Votes()[1].Vote)
assert.Equal(t, voting.Committed, txManager.Votes()[1].Phase)
})
}
@@ -271,70 +263,6 @@ type testFile struct {
mode os.FileMode
}
-func TestNewDirectoryVote(t *testing.T) {
- // The vote hash depends on the permission bits, so we must make sure that the files we
- // write have the same permission bits on all systems. As the umask can get in our way we
- // reset it to a known value here and restore it after the test. This also means that we
- // cannot parallelize this test.
- currentUmask := syscall.Umask(0)
- defer func() {
- syscall.Umask(currentUmask)
- }()
- syscall.Umask(0o022)
-
- for _, tc := range []struct {
- desc string
- files []testFile
- expectedHash string
- }{
- {
- desc: "generated hash matches",
- files: []testFile{
- {name: "pre-commit.sample", content: "foo", mode: perm.SharedExecutable},
- {name: "pre-push.sample", content: "bar", mode: perm.SharedExecutable},
- },
- expectedHash: "8ca11991268de4c9278488a674fc1a88db449566",
- },
- {
- desc: "generated hash matches with changed file name",
- files: []testFile{
- {name: "pre-commit.sample.diff", content: "foo", mode: perm.SharedExecutable},
- {name: "pre-push.sample", content: "bar", mode: perm.SharedExecutable},
- },
- expectedHash: "b5ed58ced84103da1ed9d7813a9e39b3b5daf7d7",
- },
- {
- desc: "generated hash matches with changed file content",
- files: []testFile{
- {name: "pre-commit.sample", content: "foo", mode: perm.SharedExecutable},
- {name: "pre-push.sample", content: "bar.diff", mode: perm.SharedExecutable},
- },
- expectedHash: "178083848c8a08e36c4f86c2d318a84b0bb845f2",
- },
- {
- desc: "generated hash matches with changed file mode",
- files: []testFile{
- {name: "pre-commit.sample", content: "foo", mode: perm.SharedFile},
- {name: "pre-push.sample", content: "bar", mode: perm.SharedExecutable},
- },
- expectedHash: "c69574241b83496bb4005b4f7a0dfcda96cb317e",
- },
- } {
- t.Run(tc.desc, func(t *testing.T) {
- path := mustWriteCustomHookDirectory(t, tc.files, hook.CustomHooksDir)
-
- voteHash, err := newDirectoryVote(path)
- require.NoError(t, err)
-
- vote, err := voteHash.Vote()
- require.NoError(t, err)
-
- hash := vote.String()
- require.Equal(t, tc.expectedHash, hash)
- })
- }
-}
-
func mustWriteCustomHookDirectory(t *testing.T, files []testFile, dirName string) string {
t.Helper()
diff --git a/internal/gitaly/transaction_manager.go b/internal/gitaly/transaction_manager.go
index a05d1a7a2..d63c3c326 100644
--- a/internal/gitaly/transaction_manager.go
+++ b/internal/gitaly/transaction_manager.go
@@ -18,7 +18,7 @@ import (
"gitlab.com/gitlab-org/gitaly/v15/internal/git"
"gitlab.com/gitlab-org/gitaly/v15/internal/git/localrepo"
"gitlab.com/gitlab-org/gitaly/v15/internal/git/updateref"
- "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/hook"
+ "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/repoutil"
"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
"gitlab.com/gitlab-org/gitaly/v15/internal/safe"
"gitlab.com/gitlab-org/gitaly/v15/proto/go/gitalypb"
@@ -816,7 +816,7 @@ func (mgr *TransactionManager) applyCustomHooks(ctx context.Context, logIndex Lo
}
}
- if err := hook.ExtractHooks(ctx, bytes.NewReader(update.CustomHooksTar), targetDirectory, true); err != nil {
+ if err := repoutil.ExtractHooks(ctx, bytes.NewReader(update.CustomHooksTar), targetDirectory, true); err != nil {
return fmt.Errorf("extract hooks: %w", err)
}