// Copyright 2023 Harness, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package hook import ( "bytes" "context" "fmt" "strings" "github.com/harness/gitness/errors" "github.com/harness/gitness/git/command" "github.com/harness/gitness/git/sha" "github.com/rs/zerolog/log" ) // CreateRefUpdater creates new RefUpdater object using the provided git hook ClientFactory. func CreateRefUpdater( hookClientFactory ClientFactory, envVars map[string]string, repoPath string, ref string, ) (*RefUpdater, error) { if repoPath == "" { return nil, errors.Internal(nil, "repo path can't be empty") } client, err := hookClientFactory.NewClient(envVars) if err != nil { return nil, fmt.Errorf("failed to create hook.Client: %w", err) } return &RefUpdater{ state: stateInitOld, hookClient: client, envVars: envVars, repoPath: repoPath, ref: ref, oldValue: sha.None, newValue: sha.None, }, nil } // RefUpdater is an entity that is responsible for update of a reference. // It will call pre-receive hook prior to the update and post-receive hook after the update. // It has a state machine to guarantee that methods are called in the correct order (Init, Pre, Update, Post). type RefUpdater struct { state refUpdaterState hookClient Client envVars map[string]string repoPath string ref string oldValue sha.SHA newValue sha.SHA } // refUpdaterState represents state of the ref updater internal state machine. type refUpdaterState byte func (t refUpdaterState) String() string { switch t { case stateInitOld: return "INIT_OLD" case stateInitNew: return "INIT_NEW" case statePre: return "PRE" case stateUpdate: return "UPDATE" case statePost: return "POST" case stateDone: return "DONE" } return "INVALID" } const ( stateInitOld refUpdaterState = iota stateInitNew statePre stateUpdate statePost stateDone ) // Do runs full ref update by executing all methods in the correct order. func (u *RefUpdater) Do(ctx context.Context, oldValue, newValue sha.SHA) error { if err := u.Init(ctx, oldValue, newValue); err != nil { return fmt.Errorf("init failed: %w", err) } if err := u.Pre(ctx); err != nil { return fmt.Errorf("pre failed: %w", err) } if err := u.UpdateRef(ctx); err != nil { return fmt.Errorf("update failed: %w", err) } if err := u.Post(ctx); err != nil { return fmt.Errorf("post failed: %w", err) } return nil } func (u *RefUpdater) Init(ctx context.Context, oldValue, newValue sha.SHA) error { if err := u.InitOld(ctx, oldValue); err != nil { return fmt.Errorf("init old failed: %w", err) } if err := u.InitNew(ctx, newValue); err != nil { return fmt.Errorf("init new failed: %w", err) } return nil } func (u *RefUpdater) InitOld(ctx context.Context, oldValue sha.SHA) error { if u == nil { return nil } if u.state != stateInitOld { return fmt.Errorf("invalid operation order: init old requires state=%s, current state=%s", stateInitOld, u.state) } if oldValue.IsEmpty() { // if no old value was provided, use current value (as required for hooks) val, err := u.getRef(ctx) if errors.IsNotFound(err) { //nolint:gocritic oldValue = sha.Nil } else if err != nil { return fmt.Errorf("failed to get current value of reference: %w", err) } else { oldValue = val } } u.state = stateInitNew u.oldValue = oldValue return nil } func (u *RefUpdater) InitNew(_ context.Context, newValue sha.SHA) error { if u == nil { return nil } if u.state != stateInitNew { return fmt.Errorf("invalid operation order: init new requires state=%s, current state=%s", stateInitNew, u.state) } if newValue.IsEmpty() { // don't break existing interface - user calls with empty value to delete the ref. newValue = sha.Nil } u.state = statePre u.newValue = newValue return nil } // Pre runs the pre-receive git hook. func (u *RefUpdater) Pre(ctx context.Context, alternateDirs ...string) error { if u.state != statePre { return fmt.Errorf("invalid operation order: pre-receive hook requires state=%s, current state=%s", statePre, u.state) } // fail in case someone tries to delete a reference that doesn't exist. if u.oldValue.IsEmpty() && u.newValue.IsNil() { return errors.NotFound("reference %q not found", u.ref) } if u.oldValue.IsNil() && u.newValue.IsNil() { return fmt.Errorf("provided values cannot be both empty") } out, err := u.hookClient.PreReceive(ctx, PreReceiveInput{ RefUpdates: []ReferenceUpdate{ { Ref: u.ref, Old: u.oldValue, New: u.newValue, }, }, Environment: Environment{ AlternateObjectDirs: alternateDirs, }, }) if err != nil { return fmt.Errorf("pre-receive call failed with: %w", err) } if out.Error != nil { log.Ctx(ctx).Debug(). Str("err", *out.Error). Msgf("Pre-receive blocked ref update\nMessages\n%v", out.Messages) return errors.PreconditionFailed("pre-receive hook blocked reference update: %q", *out.Error) } u.state = stateUpdate return nil } // UpdateRef updates the git reference. func (u *RefUpdater) UpdateRef(ctx context.Context) error { if u.state != stateUpdate { return fmt.Errorf("invalid operation order: ref update requires state=%s, current state=%s", stateUpdate, u.state) } cmd := command.New("update-ref") if u.newValue.IsNil() { cmd.Add(command.WithFlag("-d", u.ref)) } else { cmd.Add(command.WithArg(u.ref, u.newValue.String())) } cmd.Add(command.WithArg(u.oldValue.String())) if err := cmd.Run(ctx, command.WithDir(u.repoPath)); err != nil { msg := err.Error() if strings.Contains(msg, "reference already exists") { return errors.Conflict("reference already exists") } return fmt.Errorf("update of ref %q from %q to %q failed: %w", u.ref, u.oldValue, u.newValue, err) } u.state = statePost return nil } // Post runs the pre-receive git hook. func (u *RefUpdater) Post(ctx context.Context, alternateDirs ...string) error { if u.state != statePost { return fmt.Errorf("invalid operation order: post-receive hook requires state=%s, current state=%s", statePost, u.state) } out, err := u.hookClient.PostReceive(ctx, PostReceiveInput{ RefUpdates: []ReferenceUpdate{ { Ref: u.ref, Old: u.oldValue, New: u.newValue, }, }, Environment: Environment{ AlternateObjectDirs: alternateDirs, }, }) if err != nil { return fmt.Errorf("post-receive call failed with: %w", err) } if out.Error != nil { return fmt.Errorf("post-receive call returned error: %q", *out.Error) } u.state = stateDone return nil } func (u *RefUpdater) getRef(ctx context.Context) (sha.SHA, error) { cmd := command.New("show-ref", command.WithFlag("--verify"), command.WithFlag("-s"), command.WithArg(u.ref), ) output := &bytes.Buffer{} err := cmd.Run(ctx, command.WithDir(u.repoPath), command.WithStdout(output)) if err != nil { if command.AsError(err).IsExitCode(128) && strings.Contains(err.Error(), "not a valid ref") { return sha.None, errors.NotFound("reference %q not found", u.ref) } return sha.None, err } return sha.New(output.String()) }