Skip to content

add --hooks-async and --hooks-before-symlink flags #954

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions _test_tools/exechook_command_with_sleep.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@

sleep 3
cat file > exechook
cat exechook
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

if [[ "$(pwd)" != "$(pwd -P)" ]]; then echo "true" > delaycheck; fi
echo "ENVKEY=$ENVKEY" > exechook-env
74 changes: 59 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ func main() {
flGroupWrite := pflag.Bool("group-write",
envBool(false, "GITSYNC_GROUP_WRITE", "GIT_SYNC_GROUP_WRITE"),
"ensure that all data (repo, worktrees, etc.) is group writable")
flStaleWorktreeTimeout := pflag.Duration("stale-worktree-timeout", envDuration(0, "GITSYNC_STALE_WORKTREE_TIMEOUT"),
flStaleWorktreeTimeout := pflag.Duration("stale-worktree-timeout",
envDuration(0, "GITSYNC_STALE_WORKTREE_TIMEOUT"),
"how long to retain non-current worktrees")

flExechookCommand := pflag.String("exechook-command",
Expand Down Expand Up @@ -236,6 +237,13 @@ func main() {
envDuration(3*time.Second, "GITSYNC_WEBHOOK_BACKOFF", "GIT_SYNC_WEBHOOK_BACKOFF"),
"the time to wait before retrying a failed webhook")

flHooksAsync := pflag.Bool("hooks-async",
envBool(true, "GITSYNC_HOOKS_ASYNC", "GIT_SYNC_HOOKS_ASYNC"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need "GIT_SYNC_HOOKS_ASYNC" - some flags have old names for back-compat, but this is net-new :)

"run hooks asynchronously")
flHooksBeforeSymlink := pflag.Bool("hooks-before-symlink",
envBool(false, "GITSYNC_HOOKS_BEFORE_SYMLINK", "GIT_SYNC_HOOKS_BEFORE_SYMLINK"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as with the other, you only need the "GITSYNC_" name

"run hooks before creating the symlink (defaults to false)")

flUsername := pflag.String("username",
envString("", "GITSYNC_USERNAME", "GIT_SYNC_USERNAME"),
"the username to use for git auth")
Expand Down Expand Up @@ -844,6 +852,7 @@ func main() {
hook.NewHookData(),
log,
*flOneTime,
*flHooksAsync,
)
go webhookRunner.Run(context.Background())
}
Expand All @@ -868,10 +877,30 @@ func main() {
hook.NewHookData(),
log,
*flOneTime,
*flHooksAsync,
)
go exechookRunner.Run(context.Background())
}

runHooks := func(hash string) error {
var err error
if exechookRunner != nil {
log.V(3).Info("sending exechook")
err = exechookRunner.Send(hash)
if err != nil {
return err
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to Send() on both, even if an error happens.

}
}
if webhookRunner != nil {
log.V(3).Info("sending webhook")
err = webhookRunner.Send(hash)
}
if err != nil {
return err
}
return nil
}

// Setup signal notify channel
sigChan := make(chan os.Signal, 1)
if syncSig != 0 {
Expand Down Expand Up @@ -912,11 +941,12 @@ func main() {

failCount := 0
syncCount := uint64(0)

for {
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), *flSyncTimeout)

if changed, hash, err := git.SyncRepo(ctx, refreshCreds); err != nil {
if changed, hash, err := git.SyncRepo(ctx, refreshCreds, runHooks, *flHooksBeforeSymlink); err != nil {
failCount++
updateSyncMetrics(metricKeyError, start)
if *flMaxFailures >= 0 && failCount >= *flMaxFailures {
Expand All @@ -937,11 +967,10 @@ func main() {
log.V(3).Info("touched touch-file", "path", absTouchFile)
}
}
if webhookRunner != nil {
webhookRunner.Send(hash)
}
if exechookRunner != nil {
exechookRunner.Send(hash)
// if --hooks-before-symlink is set, these will have already been sent and completed.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we already pass runhooks to SyncRepo(), why not let it be part of the impl there - call it from inside SyncRepo, either before or after the symlink.

// otherwise, we send them now.
if !*flHooksBeforeSymlink {
runHooks(hash)
}
updateSyncMetrics(metricKeySuccess, start)
} else {
Expand All @@ -961,14 +990,17 @@ func main() {
// Assumes that if hook channels are not nil, they will have at
// least one value before getting closed
exitCode := 0 // is 0 if all hooks succeed, else is 1
if exechookRunner != nil {
if err := exechookRunner.WaitForCompletion(); err != nil {
exitCode = 1
// This will not be needed if async == false, because the Send func for the hookRunners will wait
if *flHooksAsync {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This smells funny.

Why not validate early (with all the other flag validation) that flOneTime and flHooksAsync are mutually exclusive - you can't ask for both one-time and synchrous?

Or maybe it's better to decompose a bit more. Instead of having the HookRunner do the Wait internally, have main() do the wait when it's in synchronous mode. Then the anonymous function you build here can check the flag.

That also means that Send() doesn't need to return an error, which simplifies things when you have both web and exec hooks.

Does that make sense? I am a bit jetlagged, let me know if I am not being clear :)

if exechookRunner != nil {
if err := exechookRunner.WaitForCompletion(); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at it more, this is a bit tricky - you might get a result on the channel that indicates that the hook failed, but it will be retried (async). For one-time that's fine, but is it OK in non-one-time mode? If you ask for synchronous hooks, and the hook fails -- do we think that's "good enough" or do we wait for a success? What if success never comes?

exitCode = 1
}
}
}
if webhookRunner != nil {
if err := webhookRunner.WaitForCompletion(); err != nil {
exitCode = 1
if webhookRunner != nil {
if err := webhookRunner.WaitForCompletion(); err != nil {
exitCode = 1
}
}
}
log.DeleteErrorFile()
Expand Down Expand Up @@ -1716,7 +1748,7 @@ func (git *repoSync) currentWorktree() (worktree, error) {
// SyncRepo syncs the repository to the desired ref, publishes it via the link,
// and tries to clean up any detritus. This function returns whether the
// current hash has changed and what the new hash is.
func (git *repoSync) SyncRepo(ctx context.Context, refreshCreds func(context.Context) error) (bool, string, error) {
func (git *repoSync) SyncRepo(ctx context.Context, refreshCreds func(context.Context) error, runHooks func(hash string) error, flHooksBeforeSymlink bool) (bool, string, error) {
git.log.V(3).Info("syncing", "repo", redactURL(git.repo))

if err := refreshCreds(ctx); err != nil {
Expand Down Expand Up @@ -1771,6 +1803,11 @@ func (git *repoSync) SyncRepo(ctx context.Context, refreshCreds func(context.Con
// path was different.
changed := (currentHash != remoteHash) || (currentWorktree != git.worktreeFor(currentHash))

// Fire hooks if needed.
if flHooksBeforeSymlink {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is running before the new hash is even checked out?

runHooks(remoteHash)
}

// We have to do at least one fetch, to ensure that parameters like depth
// are set properly. This is cheap when we already have the target hash.
if changed || git.syncCount == 0 {
Expand Down Expand Up @@ -2537,6 +2574,13 @@ OPTIONS
-?, -h, --help
Print help text and exit.

--hooks-async, $GITSYNC_HOOKS_ASYNC
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also need to update the manual in README.md

Whether to run the --exechook-command asynchronously.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exec and web


--hooks-before-symlink, $GITSYNC_HOOKS_BEFORE_SYMLINK
Whether to run the --exechook-command before updating the symlink. Use in combination with --hooks-async set
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exec and web

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linewrap to 80 please

to false if you need the hook to finish before the symlink is updated.

--http-bind <string>, $GITSYNC_HTTP_BIND
The bind address (including port) for git-sync's HTTP endpoint.
The '/' URL of this endpoint is suitable for Kubernetes startup and
Expand Down
65 changes: 41 additions & 24 deletions pkg/hook/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ func (d *hookData) send(newHash string) {
}

// NewHookRunner returns a new HookRunner.
func NewHookRunner(hook Hook, backoff time.Duration, data *hookData, log logintf, oneTime bool) *HookRunner {
hr := &HookRunner{hook: hook, backoff: backoff, data: data, log: log}
if oneTime {
hr.oneTimeResult = make(chan bool, 1)
func NewHookRunner(hook Hook, backoff time.Duration, data *hookData, log logintf, oneTime bool, async bool) *HookRunner {
hr := &HookRunner{hook: hook, backoff: backoff, data: data, log: log, oneTime: oneTime, async: async}
if oneTime || !async {
hr.result = make(chan bool, 1)
}
return hr
}
Expand All @@ -107,9 +107,13 @@ type HookRunner struct {
data *hookData
// Logger
log logintf
// Used to send a status result when running in one-time mode.
// Used to send a status result when running in one-time or non-async mode.
// Should be initialised to a buffered channel of size 1.
oneTimeResult chan bool
result chan bool
// Bool for whether this is a one-time hook or not.
oneTime bool
// Bool for whether this is an async hook or not.
async bool
}

// Just the logr methods we need in this package.
Expand All @@ -120,8 +124,17 @@ type logintf interface {
}

// Send sends hash to hookdata.
func (r *HookRunner) Send(hash string) {
func (r *HookRunner) Send(hash string) error {
r.data.send(hash)
if !r.async {
r.log.V(1).Info("waiting for completion", "hash", hash, "name", r.hook.Name())
err := r.WaitForCompletion()
r.log.V(1).Info("hook completed", "hash", hash, "err", err, "name", r.hook.Name())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already log below - I don't think we need logging here execpt:

if err := wait(); err != nil {
    log.Error(err, "hook failed", "hash", hash, "name", r.hook.Name())
}

if err != nil {
return err
}
}
return nil
}

// Run waits for trigger events from the channel, and run hook when triggered.
Expand All @@ -144,45 +157,49 @@ func (r *HookRunner) Run(ctx context.Context) {
r.log.Error(err, "hook failed", "hash", hash, "retry", r.backoff)
updateHookRunCountMetric(r.hook.Name(), "error")
// don't want to sleep unnecessarily terminating anyways
r.sendOneTimeResultAndTerminate(false)
r.sendResult(false)
time.Sleep(r.backoff)
} else {
updateHookRunCountMetric(r.hook.Name(), "success")
lastHash = hash
r.sendOneTimeResultAndTerminate(true)
r.sendResult(true)
break
}
}
}
}

// If oneTimeResult is nil, does nothing. Otherwise, forwards the caller
// provided success status (as a boolean) of HookRunner to receivers of
// oneTimeResult, closes said chanel, and terminates this goroutine.
// Using this function to write to oneTimeResult ensures it is only ever
// written to once.
func (r *HookRunner) sendOneTimeResultAndTerminate(completedSuccessfully bool) {
if r.oneTimeResult != nil {
r.oneTimeResult <- completedSuccessfully
close(r.oneTimeResult)
func (r *HookRunner) sendResult(completedSuccessfully bool) {
// if onetime is true, we send the result then exit
if r.oneTime {
r.result <- completedSuccessfully
close(r.result)
runtime.Goexit()
} else if !r.async {
// if onetime is false, and we've set non-async we send but don't exit.
r.result <- completedSuccessfully
}
// if neither oneTime nor !async, we do nothing here.
}

// WaitForCompletion waits for HookRunner to send completion message to
// calling thread and returns either true if HookRunner executed successfully
// and some error otherwise.
// Assumes that r.oneTimeResult is not nil, but if it is, returns an error.
// Assumes that either r.oneTime or !r.async, otherwise returns an error.
func (r *HookRunner) WaitForCompletion() error {
// Make sure function should be called
if r.oneTimeResult == nil {
if r.result == nil {
return fmt.Errorf("HookRunner.WaitForCompletion called on async runner")
}

// Perform wait on HookRunner
hookRunnerFinishedSuccessfully := <-r.oneTimeResult
if !hookRunnerFinishedSuccessfully {
return fmt.Errorf("hook completed with error")
// If oneTimeResult is not nil, we wait for its result.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/oneTimeResult/result

if r.result != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you already checked this and returned above

hookRunnerFinishedSuccessfully := <-r.result
r.log.V(1).Info("hook completed", "success", hookRunnerFinishedSuccessfully,
"oneTime", r.oneTime, "async", r.async, "name", r.hook.Name())
if !hookRunnerFinishedSuccessfully {
return fmt.Errorf("hook completed with error")
}
}

return nil
Expand Down
53 changes: 51 additions & 2 deletions test_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function assert_file_eq() {
if [[ $(cat "$1") == "$2" ]]; then
return
fi
fail "$1 does not contain '$2': $(cat "$1")"
fail "$1 does not equal '$2': $(cat "$1")"
}

function assert_file_contains() {
Expand Down Expand Up @@ -2406,7 +2406,7 @@ function e2e::exechook_fail_retry() {
--exechook-command="/$EXECHOOK_COMMAND_FAIL" \
--exechook-backoff=1s \
&
sleep 3 # give it time to retry
sleep 4 # give it time to retry

# Check that exechook was called
assert_file_lines_ge "$RUNLOG" 2
Expand Down Expand Up @@ -2486,6 +2486,55 @@ function e2e::exechook_startup_after_crash() {
assert_file_lines_eq "$RUNLOG" 1
}

##############################################
# Test exechook-success with --hooks-async=false
##############################################
function e2e::exechook_success_hooks_non_async() {
cat /dev/null > "$RUNLOG"

GIT_SYNC \
--hooks-async=false \
--period=100ms \
--repo="file://$REPO" \
--root="$ROOT" \
--link="link" \
--exechook-command="/$EXECHOOK_COMMAND_SLEEPY" \
&
sleep 5 # give it time to run
assert_link_exists "$ROOT/link"
assert_file_exists "$ROOT/link/file"
assert_file_exists "$ROOT/link/exechook"
assert_file_eq "$ROOT/link/file" "${FUNCNAME[0]}"
assert_file_eq "$ROOT/link/exechook" "${FUNCNAME[0]}"
assert_file_eq "$ROOT/link/exechook-env" "$EXECHOOK_ENVKEY=$EXECHOOK_ENVVAL"
}

##############################################
# Test exechook-success with --hooks-async=false and --hooks-before-symlink
##############################################
function e2e::exechook_success_hooks_before_symlink_non_async() {
cat /dev/null > "$RUNLOG"

GIT_SYNC \
--hooks-async=false \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args-under-test go at the end of the list

--hooks-before-symlink \
--period=100ms \
--repo="file://$REPO" \
--root="$ROOT" \
--link="link" \
--exechook-command="/$EXECHOOK_COMMAND_SLEEPY" \
--exechook-backoff=1s \
&
sleep 5 # give it time to run
assert_link_exists "$ROOT/link"
assert_file_exists "$ROOT/link/file"
assert_file_exists "$ROOT/link/exechook"
assert_file_eq "$ROOT/link/file" "${FUNCNAME[0]}"
assert_file_eq "$ROOT/link/exechook" "${FUNCNAME[0]}"
assert_file_eq "$ROOT/link/exechook-env" "$EXECHOOK_ENVKEY=$EXECHOOK_ENVVAL"
assert_file_absent "$ROOT/link/delaycheck"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unclear on what this is actually proving? Can you explain?

}

##############################################
# Test webhook success
##############################################
Expand Down