diff options
author | Toon Claes <toon@gitlab.com> | 2023-04-26 17:00:53 +0300 |
---|---|---|
committer | Toon Claes <toon@gitlab.com> | 2023-04-26 17:00:53 +0300 |
commit | fbda20484771e0bdd0e29ffa6715cc0bc6dc6ce4 (patch) | |
tree | 61238ebed2192b14f792ead0fc4b51b8369c8360 | |
parent | 2135a63c3742d0ddbf0164e7071e1d1af52e166d (diff) | |
parent | 035e7980765fb866710faa41a31e4a9b8bc2a495 (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.go | 61 | ||||
-rw-r--r-- | internal/gitaly/hook/extract_hooks_test.go | 134 | ||||
-rw-r--r-- | internal/gitaly/repoutil/custom_hooks.go | 281 | ||||
-rw-r--r-- | internal/gitaly/repoutil/custom_hooks_test.go | 415 | ||||
-rw-r--r-- | internal/gitaly/service/repository/get_custom_hooks.go | 31 | ||||
-rw-r--r-- | internal/gitaly/service/repository/replicate.go | 2 | ||||
-rw-r--r-- | internal/gitaly/service/repository/replicate_test.go | 7 | ||||
-rw-r--r-- | internal/gitaly/service/repository/set_custom_hooks.go | 197 | ||||
-rw-r--r-- | internal/gitaly/service/repository/set_custom_hooks_test.go | 76 | ||||
-rw-r--r-- | internal/gitaly/transaction_manager.go | 4 |
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) } |