internal/git/updateref/updateref.go (385 lines of code) (raw):

package updateref import ( "bufio" "bytes" "context" "errors" "fmt" "gitlab.com/gitlab-org/gitaly/v16/internal/command" "gitlab.com/gitlab-org/gitaly/v16/internal/git" "gitlab.com/gitlab-org/gitaly/v16/internal/git/gitcmd" "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" ) var ( // errClosed is returned when accessing an updater that has already been closed. errClosed = errors.New("closed") // ErrPackedRefsLocked indicates an operation failed due to the 'packed-refs' being locked. This is // the case if either `packed-refs.new` or `packed-refs.lock` exists in the repository. ErrPackedRefsLocked = errors.New("packed-refs locked") ) // MultipleUpdatesError indicates that a reference cannot have multiple updates // within the same transaction. type MultipleUpdatesError struct { // ReferenceName is the name of the reference that has multiple updates. ReferenceName string } func (e MultipleUpdatesError) Error() string { return "reference has been updated multiple times within a transaction" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the reference that // has multiple updates. func (e MultipleUpdatesError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, } } // AlreadyLockedError indicates a reference cannot be locked because another // process has already locked it. type AlreadyLockedError struct { // ReferenceName is the name of the reference that is already locked. ReferenceName string } func (e AlreadyLockedError) Error() string { return "reference is already locked" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the reference that was // locked already. func (e AlreadyLockedError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, } } // ReferenceAlreadyExistsError is returned when attempting to create a reference // that already exists. type ReferenceAlreadyExistsError struct { // ReferenceName is the name of the reference that already exists. ReferenceName string } func (e ReferenceAlreadyExistsError) Error() string { return "reference already exists" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the // reference that already existed. func (e ReferenceAlreadyExistsError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, } } // InvalidReferenceFormatError indicates a reference name was invalid. type InvalidReferenceFormatError struct { // ReferenceName is the invalid reference name. ReferenceName string } func (e InvalidReferenceFormatError) Error() string { return "invalid reference format" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the reference that was // invalid. func (e InvalidReferenceFormatError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, } } // FileDirectoryConflictError is returned when an operation would causes a file-directory conflict // in the reference store. type FileDirectoryConflictError struct { // ConflictingReferenceName is the name of the reference that would have conflicted. ConflictingReferenceName string // ExistingReferenceName is the name of the already existing reference. ExistingReferenceName string } func (e FileDirectoryConflictError) Error() string { return "file directory conflict" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of preexisting and // conflicting reference names. func (e FileDirectoryConflictError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "conflicting_reference", Value: e.ConflictingReferenceName}, {Key: "existing_reference", Value: e.ExistingReferenceName}, } } // InTransactionConflictError is returned when creating two F/D or D/F conflicting references // in the same transaction. For example creation of 'refs/heads/parent' and creation of // 'refs/heads/parent/child' is not allowed in the same transaction. type InTransactionConflictError struct { // FirstReferenceName is the name of the first reference that was created. FirstReferenceName string // SecondReferenceName is the name of the second reference that was created. SecondReferenceName string } func (e InTransactionConflictError) Error() string { return "conflicting reference updates in the same transaction" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the first and second // conflicting reference names. func (e InTransactionConflictError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "first_reference", Value: e.FirstReferenceName}, {Key: "second_reference", Value: e.SecondReferenceName}, } } // NonExistentObjectError is returned when attempting to point a reference to an object that does not // exist in the object database. type NonExistentObjectError struct { // ReferenceName is the name of the reference that was being updated. ReferenceName string // ObjectID is the object ID of the non-existent object. ObjectID string } func (e NonExistentObjectError) Error() string { return "target object missing" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the missing object as well as the // reference that should have been updated to point to it. func (e NonExistentObjectError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, {Key: "missing_object", Value: e.ObjectID}, } } // NonCommitObjectError is returned when attempting to point a branch to an object that is not an object. type NonCommitObjectError struct { // ReferenceName is the name of the branch that was being updated. ReferenceName string // ObjectID is the object ID of the non-commit object. ObjectID string } func (e NonCommitObjectError) Error() string { return "target object not a commit" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the object that is not a commit as // well as the reference that should have been updated to point to it. func (e NonCommitObjectError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, {Key: "non_commit_object", Value: e.ObjectID}, } } // MismatchingStateError is returned when attempting to update a reference where the expected object ID does not match // the actual object ID that the reference currently points to. type MismatchingStateError struct { // ReferenceName is the name of the reference that was being updated. ReferenceName string // ExpectedObjectID is the expected object ID as specified by the caller. ExpectedObjectID string // ActualObjectID is the actual object ID that the reference was pointing to. ActualObjectID string } func (e MismatchingStateError) Error() string { return "reference does not point to expected object" } // ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides error metadata about the expected and // actual object ID of the failed reference update. func (e MismatchingStateError) ErrorMetadata() []structerr.MetadataItem { return []structerr.MetadataItem{ {Key: "reference", Value: e.ReferenceName}, {Key: "expected_object_id", Value: e.ExpectedObjectID}, {Key: "actual_object_id", Value: e.ActualObjectID}, } } // state represents a possible state the updater can be in. type state string const ( // stateIdle means the updater is ready for a new transaction to start. stateIdle state = "idle" // stateStarted means the updater has an open transaction and accepts // new reference changes. stateStarted state = "started" // statePrepared means the updater has prepared a transaction and no longer // accepts reference changes until the current transaction is committed and // a new one started. statePrepared state = "prepared" ) // invalidStateTransitionError is returned when the updater is used incorrectly. type invalidStateTransitionError struct { // expected is the state the updater was expected to be in. expected state // actual is the state the updater was actually in. actual state } // Error returns the formatted error string. func (err invalidStateTransitionError) Error() string { return fmt.Sprintf("expected state %q but it was %q", err.expected, err.actual) } // Updater wraps a `git update-ref --stdin` process, presenting an interface // that allows references to be easily updated in bulk. It is not suitable for // concurrent use. // // Correct usage of the Updater is as follows: // 1. Transaction must be started before anything else. // 2. Transaction can't be started if there is an active transaction. // 3. Updates can be staged only when there is an unprepared transaction. // 4. Prepare can be called only with an unprepared transaction. // 5. Commit can be called only with an active transaction. The transaction // can be committed unprepared or prepared. // 7. Close can be called at any time. The active transaction is aborted. // 8. Any sort of error causes the updater to close. type Updater struct { repo gitcmd.RepositoryExecutor cmd *command.Command closeErr error stdout *bufio.Reader stderr *bytes.Buffer objectHash git.ObjectHash referenceBackend git.ReferenceBackend ctx context.Context // state tracks the current state of the updater to ensure correct calling semantics. state state } // UpdaterOpt is a type representing options for the Updater. type UpdaterOpt func(*updaterConfig) type updaterConfig struct { disableTransactions bool noDeref bool } // WithDisabledTransactions disables hooks such that no reference-transactions // are used for the updater. func WithDisabledTransactions() UpdaterOpt { return func(cfg *updaterConfig) { cfg.disableTransactions = true } } // WithNoDeref disables de-reference while updating ref. If this option is turned on, // <ref> itself is overwritten, rather than the result of following the symbolic ref. func WithNoDeref() UpdaterOpt { return func(cfg *updaterConfig) { cfg.noDeref = true } } // New returns a new bulk updater, wrapping a `git update-ref` process. Call the // various methods to enqueue updates, then call Commit() to attempt to apply all // the updates at once. // // It is important that ctx gets canceled somewhere. If it doesn't, the process // spawned by New() may never terminate. func New(ctx context.Context, repo gitcmd.RepositoryExecutor, opts ...UpdaterOpt) (*Updater, error) { var cfg updaterConfig for _, opt := range opts { opt(&cfg) } objectHash, err := repo.ObjectHash(ctx) if err != nil { return nil, fmt.Errorf("detecting object hash: %w", err) } txOption := gitcmd.WithRefTxHook(objectHash, repo) if cfg.disableTransactions { txOption = gitcmd.WithDisabledHooks() } cmdFlags := []gitcmd.Option{gitcmd.Flag{Name: "-z"}, gitcmd.Flag{Name: "--stdin"}} if cfg.noDeref { cmdFlags = append(cmdFlags, gitcmd.Flag{Name: "--no-deref"}) } var stderr bytes.Buffer cmd, err := repo.Exec(ctx, gitcmd.Command{ Name: "update-ref", Flags: cmdFlags, }, txOption, gitcmd.WithSetupStdin(), gitcmd.WithSetupStdout(), gitcmd.WithStderr(&stderr), ) if err != nil { return nil, err } referenceBackend, err := repo.ReferenceBackend(ctx) if err != nil { return nil, fmt.Errorf("detecting reference backend: %w", err) } return &Updater{ repo: repo, cmd: cmd, stderr: &stderr, stdout: bufio.NewReader(cmd), objectHash: objectHash, referenceBackend: referenceBackend, state: stateIdle, ctx: ctx, }, nil } // expectState returns an error and closes the updater if it is not in the expected state. func (u *Updater) expectState(expected state) error { if u.closeErr != nil { return u.closeErr } if err := u.checkState(expected); err != nil { return u.closeWithError(err) } return nil } // checkState returns an error if the updater is not in the expected state. func (u *Updater) checkState(expected state) error { if u.state != expected { return invalidStateTransitionError{expected: expected, actual: u.state} } return nil } // Start begins a new reference transaction. The reference changes are not performed until Commit // is explicitly called. func (u *Updater) Start() error { if err := u.expectState(stateIdle); err != nil { return err } u.state = stateStarted return u.setState("start") } // Update commands the reference to be updated to point at the object ID specified in newOID. If // newOID is the zero OID, then the branch will be deleted. If oldOID is a non-empty string, then // the reference will only be updated if its current value matches the old value. If the old value // is the zero OID, then the branch must not exist. // // A reference transaction must be started before calling Update. func (u *Updater) Update(reference git.ReferenceName, newOID, oldOID git.ObjectID) error { if err := u.expectState(stateStarted); err != nil { return err } return u.write("update %s\x00%s\x00%s\x00", reference.String(), newOID, oldOID) } // UpdateSymbolicReference is used to do a symbolic reference update. We can potentially provide the oldTarget // or the oldOID. func (u *Updater) UpdateSymbolicReference(version git.Version, reference, newTarget git.ReferenceName) error { if err := u.expectState(stateStarted); err != nil { return err } return u.write("symref-update %s\x00%s\x00\x00\x00", reference.String(), newTarget) } // Create commands the reference to be created with the given object ID. The ref must not exist. // // A reference transaction must be started before calling Create. func (u *Updater) Create(reference git.ReferenceName, oid git.ObjectID) error { return u.Update(reference, oid, u.objectHash.ZeroOID) } // Delete commands the reference to be removed from the repository. This command will ignore any old // state of the reference and just force-remove it. // // A reference transaction must be started before calling Delete. func (u *Updater) Delete(reference git.ReferenceName) error { return u.Update(reference, u.objectHash.ZeroOID, "") } // Prepare prepares the reference transaction by locking all references and determining their // current values. The updates are not yet committed and will be rolled back in case there is no // call to `Commit()`. This call is optional. func (u *Updater) Prepare() error { if err := u.expectState(stateStarted); err != nil { return err } u.state = statePrepared return u.setState("prepare") } // Commit applies the commands specified in other calls to the Updater. Commit finishes the // reference transaction and another one must be started before further changes can be staged. func (u *Updater) Commit() error { // Commit can be called without preparing the transactions. if err := u.checkState(statePrepared); err != nil { if err := u.expectState(stateStarted); err != nil { return err } } u.state = stateIdle if err := u.setState("commit"); err != nil { return err } return nil } // Close closes the updater and aborts a possible open transaction. No changes will be written // to disk, all lockfiles will be cleaned up and the process will exit. func (u *Updater) Close() error { return u.closeWithError(nil) } // closeWithError closes the updater with the given error. The passed in error is only used // if the updater closes successfully. This is used to close the Updater with errors raised // by our logic when the command itself hasn't errored. All subsequent method calls return // the error returned from first closeWithError call. func (u *Updater) closeWithError(closeErr error) error { if u.closeErr != nil { return u.closeErr } if err := u.cmd.Wait(); err != nil { err = structerr.New("%w", err).WithMetadataItems( structerr.MetadataItem{Key: "stderr", Value: u.stderr.String()}, structerr.MetadataItem{Key: "close_error", Value: closeErr}, ) if parsedErr := u.parseStderr(); parsedErr != nil { // If stderr contained a specific error, return it instead. err = parsedErr } u.closeErr = err return err } if u.ctx.Err() != nil { u.closeErr = u.ctx.Err() return u.closeErr } if closeErr != nil { u.closeErr = closeErr return closeErr } u.closeErr = errClosed return nil } func (u *Updater) write(format string, args ...interface{}) error { if _, err := fmt.Fprintf(u.cmd, format, args...); err != nil { return u.closeWithError(err) } return nil } func (u *Updater) setState(state string) error { if err := u.write("%s\x00", state); err != nil { return err } // For each state-changing command, git-update-ref(1) will report successful execution via // "<command>: ok" lines printed to its stdout. Ideally, we should thus verify here whether // the command was successfully executed by checking for exactly this line, otherwise we // cannot be sure whether the command has correctly been processed by Git or if an error was // raised. line, err := u.stdout.ReadString('\n') if err != nil { return u.closeWithError(fmt.Errorf("state update to %q failed: %w", state, err)) } if line != fmt.Sprintf("%s: ok\n", state) { return u.closeWithError(fmt.Errorf("state update to %q not successful: expected ok, got %q", state, line)) } return nil } func (u *Updater) parseStderr() error { stderr := u.stderr.Bytes() matches := u.referenceBackend.RefLockedRegex.FindSubmatch(stderr) // Reftable locks are on an entire table instead of per reference, so // git doesn't output the name of the individual ref. if u.referenceBackend == git.ReferenceBackendReftables && len(matches) == 2 { return AlreadyLockedError{} } else if len(matches) > 2 { return AlreadyLockedError{ReferenceName: string(matches[2])} } matches = u.referenceBackend.PackedRefsLockedRegex.FindSubmatch(stderr) if len(matches) > 1 { return ErrPackedRefsLocked } matches = u.referenceBackend.RefInvalidFormatRegex.FindSubmatch(stderr) if len(matches) > 1 { return InvalidReferenceFormatError{ReferenceName: string(matches[1])} } matches = u.referenceBackend.ReferenceExistsConflictRegex.FindSubmatch(stderr) if len(matches) > 1 { return FileDirectoryConflictError{ ExistingReferenceName: string(matches[1]), ConflictingReferenceName: string(matches[2]), } } matches = u.referenceBackend.InTransactionConflictRegex.FindSubmatch(stderr) if len(matches) > 1 { return InTransactionConflictError{ FirstReferenceName: string(matches[1]), SecondReferenceName: string(matches[2]), } } matches = u.referenceBackend.NonExistentObjectRegex.FindSubmatch(stderr) if len(matches) > 1 { return NonExistentObjectError{ ReferenceName: string(matches[1]), ObjectID: string(matches[2]), } } matches = u.referenceBackend.NonCommitObjectRegex.FindSubmatch(stderr) if len(matches) > 1 { return NonCommitObjectError{ ReferenceName: string(matches[2]), ObjectID: string(matches[1]), } } matches = u.referenceBackend.MismatchingStateRegex.FindSubmatch(stderr) if len(matches) > 2 { return MismatchingStateError{ ReferenceName: string(matches[1]), ExpectedObjectID: string(matches[3]), ActualObjectID: string(matches[2]), } } matches = u.referenceBackend.ReferenceAlreadyExistsRegex.FindSubmatch(stderr) if len(matches) > 1 { return ReferenceAlreadyExistsError{ ReferenceName: string(matches[1]), } } matches = u.referenceBackend.MultipleUpdatesRegex.FindSubmatch(stderr) if len(matches) > 1 { return MultipleUpdatesError{ ReferenceName: string(matches[1]), } } return nil }