package processmanager import ( "context" "fmt" "sync" "time" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/util/wait" ) const ( startupTimeout = time.Second * 30 exitTimeout = time.Second * 10 ) type hookType string const ( preStart hookType = "pre-start" postStart hookType = "post-start" preStop hookType = "pre-stop" postStop hookType = "post-stop" ) // A hook function that can be called during the process lifecycle. // // The following process manager methods can configure hooks: // - WithPreStartHooks(...HookFunc) // - WithPostStartHooks(...HookFunc) // - WithPreStopHooks(...HookFunc) // - WithPostStopHooks(...HookFunc) // // If the function creates asynchronous goroutines, they should // exit should the context be cancelled. type HookFunc func(ctx context.Context) error // ProcessManager handles the running of processes. type ProcessManager interface { Name() string // Start starts processes, watching stdout and stderr. // This does nothing if it is already running. // // The process manager will be stopped if the context is // cancelled, unless disabled via WithContextHandler(). Start(context.Context) error // Stop sends SIGTERM to processes and waits for it to stop. // This does nothing if it is already stopped. Stop(context.Context) error // Restart stops the process then starts it again. Restart(context.Context) error // Result returns the channel the process manager uses to report // the result of the process on exiting. Result() <-chan error // WithLogger sets the logger. If unset, there will be no logging // from the process manager. The processes will still output // to stdout. WithLogger(log logr.Logger, verbose bool) // WithPreStartHooks sets the start hooks to be ran before the // process is started. // // Start hooks will be cancelled if the process manager is stopped. WithPreStartHooks(...HookFunc) // WithPostStartHooks sets the start hooks to be ran after the // process is started. // // Start hooks will be cancelled if the process manager is stopped. WithPostStartHooks(...HookFunc) // WithPreStopHooks sets the stop hooks to be ran before the // process is stopped. WithPreStopHooks(...HookFunc) // WithPostStopHooks sets the stop hooks to be ran after the // process is stopped. WithPostStopHooks(...HookFunc) // WithContextHandler disables the process manager stopping when // the context that it was started with is cancelled. WithNoContextHandler() // IsReady checks whether the process is running and ready. If // WithReadyCheck() has not been called, then the process is ready // as soon as it starts running. IsReady(context.Context) (bool, error) // WithReadyCheck provides a function which returns whether the // process is ready. The process must be running to be ready. WithReadyCheck(ReadyCheckFunc) // WithWaitUntilReady will wait until IsReady() passes once the // process is started. If a timeout is reached, an error will // be returned. WithWaitUntilReady(timeout time.Duration) // WaitUntilReady() blocks until IsReady() returns true, or // context is cancelled. WaitUntilReady(context.Context) error // WaitUntilStopped blocks until IsRunning() returns false, or // context is cancelled. WaitUntilStopped(context.Context) error } type processManager struct { // Useful name for the process manager. name string // Hook functions to be called prior to starting the process manager. preStartHooks []HookFunc // Hook functions to be called after starting the process manager. postStartHooks []HookFunc // Hook functions to be called prior to stopping the process manager. preStopHooks []HookFunc // Hook functions to be called after stopping the process manager. postStopHooks []HookFunc // The process manager's logger. log logr.Logger // The process manager's verbose logger. vlog logr.Logger // Used to externally report the result of the process manager // process exiting via the Result() method. resultChan chan error // Used to cancel any asynchronous processes when the process // manager is stopped. cancel context.CancelFunc // Configures whether the process manager should call stop if // the context it as started with is cancelled. skipContextHandling bool // A function to check if the process is running and ready. readyCheck ReadyCheckFunc // Whether we should wait for IsReady() to pass after the // process starts running. waitUntilReady bool // Timeout duration for ready check wait. waitUntilReadyTimeout time.Duration // Whether the process manager is currently running. isRunning bool // Ensures start and stop calls cannot be made concurrently. sync.Mutex } func (pm *processManager) Name() string { return pm.name } func (pm *processManager) Result() <-chan error { return pm.resultChan } func (pm *processManager) WithPreStartHooks(hooks ...HookFunc) { pm.preStartHooks = hooks } func (pm *processManager) WithPostStartHooks(hooks ...HookFunc) { pm.postStartHooks = hooks } func (pm *processManager) WithPreStopHooks(hooks ...HookFunc) { pm.preStopHooks = hooks } func (pm *processManager) WithPostStopHooks(hooks ...HookFunc) { pm.postStopHooks = hooks } // Executes each hook in order, returning as soon as an error is received. func (pm *processManager) executeHooks(ctx context.Context, typ hookType) error { switch typ { case preStart: return executeHookFuncs(ctx, pm.Name(), preStart, pm.preStartHooks) case postStart: return executeHookFuncs(ctx, pm.Name(), postStart, pm.postStartHooks) case preStop: return executeHookFuncs(ctx, pm.Name(), preStop, pm.preStopHooks) case postStop: return executeHookFuncs(ctx, pm.Name(), postStop, pm.postStopHooks) } return nil } func executeHookFuncs(ctx context.Context, name string, typ hookType, hooks []HookFunc) error { for idx, hook := range hooks { if err := hook(ctx); err != nil { return fmt.Errorf("failed to execute %s hook %d for %s: %w", typ, idx+1, name, err) } } return nil } func (pm *processManager) WithNoContextHandler() { pm.skipContextHandling = true } func (pm *processManager) IsReady(ctx context.Context) (bool, error) { if pm.readyCheck != nil { return pm.readyCheck(ctx) } if !pm.isRunning { return false, fmt.Errorf("%s process is not running", pm.Name()) } return true, nil } func (pm *processManager) WithReadyCheck(readyCheck ReadyCheckFunc) { pm.readyCheck = readyCheck } func (pm *processManager) WithWaitUntilReady(timeout time.Duration) { pm.waitUntilReady = true pm.waitUntilReadyTimeout = timeout } func (pm *processManager) waitUntilReadyWithTimeout(ctx context.Context) error { if !pm.waitUntilReady || pm.readyCheck == nil { return nil } // use timeout so we don't hang forever waitCtx, cancel := context.WithTimeout(ctx, pm.waitUntilReadyTimeout) defer cancel() return pm.WaitUntilReady(waitCtx) } func (pm *processManager) WaitUntilReady(ctx context.Context) (err error) { pollErr := wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (ready bool, pollErr error) { if ready, err = pm.IsReady(ctx); ready { return true, nil } return false, nil }) if pollErr != nil { return err } return nil } func (pm *processManager) WaitUntilStopped(ctx context.Context) error { if err := wait.PollUntilContextCancel(ctx, time.Second, true, func(context.Context) (bool, error) { ready, err := pm.IsReady(ctx) return !ready, err }); err != nil { return fmt.Errorf("%s did not stop running: %w", pm.Name(), err) } return nil } // If the context the process manager was started with is cancelled, stop it. func contextHandler(ctx, startCtx context.Context, pm ProcessManager, log logr.Logger) error { // ctx is a child of startCtx, so this blocks until either // context is cancelled <-ctx.Done() select { case <-startCtx.Done(): // if the start context was cancelled, stop the process manager // using a new (uncancelled) context with timeout log.Info("context cancelled: shutting down") stopCtx, cancel := context.WithTimeout(context.Background(), exitTimeout) defer cancel() return pm.Stop(stopCtx) default: // if ctx was cancelled, Stop() has been called so exit // without stopping return nil } } func resultError(name string, result error, expectNoExit bool) error { if result != nil { return fmt.Errorf("%s exited with error: %w", name, result) } if expectNoExit { return fmt.Errorf("%s exited unexpectedly", name) } return nil }