From 7c061e3c275e7118a6c4c66538dc866f7f6fcee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ota=CC=81vio=20Fernandes?= Date: Wed, 3 Nov 2021 12:56:12 +0100 Subject: [PATCH] Waiter. --- .gitignore | 1 + cmd/waiter/README.md | 20 ++++++ cmd/waiter/main.go | 110 ++++++++++++++++++++++++++++ cmd/waiter/main_suite_test.go | 13 ++++ cmd/waiter/main_test.go | 131 ++++++++++++++++++++++++++++++++++ cmd/waiter/waiter.go | 103 ++++++++++++++++++++++++++ 6 files changed, 378 insertions(+) create mode 100644 cmd/waiter/README.md create mode 100644 cmd/waiter/main.go create mode 100644 cmd/waiter/main_suite_test.go create mode 100644 cmd/waiter/main_test.go create mode 100644 cmd/waiter/waiter.go diff --git a/.gitignore b/.gitignore index 2baa7086ea..9f892d26f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ shipwright-build-controller build/_output build/_test hub-linux-* +/waiter # Created by https://www.gitignore.io/api/go,vim,emacs,visualstudiocode ### Emacs ### # -*- mode: gitignore; -*- diff --git a/cmd/waiter/README.md b/cmd/waiter/README.md new file mode 100644 index 0000000000..3584ae1232 --- /dev/null +++ b/cmd/waiter/README.md @@ -0,0 +1,20 @@ +`waiter` +-------- + +In a nutshell, it waits until the lock-file is removed. When starting the application a lock-file +(`--lock-file`) is created, and when the file is removed the `waiter` stops gracefully. When timeout +is reached, the application exits on error. + +## Usage + +Please consider `--help` to see the possible flags, the possible sub-commands are: + +```sh +waiter start +``` + +And: + +```sh +waiter done +``` \ No newline at end of file diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go new file mode 100644 index 0000000000..5b5b7c4621 --- /dev/null +++ b/cmd/waiter/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/spf13/cobra" +) + +// settings composed by command-line flag values. +type settings struct { + lockFile string // path to lock file + timeout time.Duration // how long wait for 'done' +} + +const longDesc = ` +# waiter + +Idle loop to hold a container (possibly a Kubernetes POD) running while some other +action happens in the background. It is started by issuing "waiter start" and can +be stopped with "waiter done", or after timeout. + +## Usage + +Start the waiting, use --timeout to change how long: + + $ waiter start + +You can signal "done" by running: + + $ waiter done + +Or, alternatively: + + $ rm -f + +## Return-Code + +In the case of timeout, the waiter will return error, it only exist gracefully via +"waiter done", or the removal of the lock-file (before timeout). +` + +var ( + rootCmd = newRootCmd() + startCmd = newStartCmd() + doneCmd = newDoneCmd() +) + +// defaultTimeout default timeout duration. +var defaultTimeout = 60 * time.Second + +// defaultLockFile default location of the lock-file. +var defaultLockFile = "/tmp/waiter.lock" + +// flagValues receives the command-line flag values. +var flagValues = settings{} + +// init assembles the flags and the cobra sub-commands. +func init() { + flags := rootCmd.PersistentFlags() + + flags.StringVar(&flagValues.lockFile, "lock-file", defaultLockFile, "lock file full path") + flags.DurationVar(&flagValues.timeout, "timeout", defaultTimeout, "how long to wait until 'done'") + + rootCmd.AddCommand(startCmd) + rootCmd.AddCommand(doneCmd) +} + +func newRootCmd() *cobra.Command { + return &cobra.Command{ + Use: "waiter [flags]", + Short: "Will wait until `done` issued", + Long: longDesc, + } +} + +func newStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Starts the wait, and holds until `done` is issued.", + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + w := NewWaiter(flagValues) + return w.Wait() + }, + } +} + +func newDoneCmd() *cobra.Command { + return &cobra.Command{ + Use: "done", + Aliases: []string{"stop"}, + Short: "Interrupts the waiting.", + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + w := NewWaiter(flagValues) + return w.Done() + }, + } +} + +// main waiter's entrypoint. +func main() { + if err := rootCmd.Execute(); err != nil { + log.Fatalf("[ERROR] %v\n", err) + os.Exit(1) + } + os.Exit(0) +} diff --git a/cmd/waiter/main_suite_test.go b/cmd/waiter/main_suite_test.go new file mode 100644 index 0000000000..3acbc9b641 --- /dev/null +++ b/cmd/waiter/main_suite_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestWaiterCmd(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Waiter") +} diff --git a/cmd/waiter/main_test.go b/cmd/waiter/main_test.go new file mode 100644 index 0000000000..84a7008c58 --- /dev/null +++ b/cmd/waiter/main_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "time" + + "github.com/onsi/gomega/gbytes" + "github.com/onsi/gomega/gexec" + "github.com/onsi/gomega/types" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Waiter", func() { + // executable path to the waiter executable file. + var executable string + + // run creates a exec.Command instance using the arguments informed. + var run = func(args ...string) *gexec.Session { + cmd := exec.Command(executable) + cmd.Args = append(cmd.Args, args...) + stdin := &bytes.Buffer{} + cmd.Stdin = stdin + + session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) + Expect(err).ToNot(HaveOccurred()) + + // when "start" sub-command is issued, a graceful wait takes place to wait for the + // asynchronous instantiation of the command-line, and the creation of lock-file + for _, arg := range args { + if arg == "start" { + time.Sleep(3 * time.Second) + } + } + return session + } + + // inspectSession inspect the informed session to identify if the informed matcher is true, + // after inspection it closes the informed channel. + var inspectSession = func( + session *gexec.Session, + doneCh chan interface{}, + matcher types.GomegaMatcher, + ) { + defer GinkgoRecover() + + Eventually(session, defaultTimeout).Should(matcher) + close(doneCh) + } + + // building the command-line application before starting the test suite, it will populate the + // global variable with the path to the waiter binary compiled. + BeforeSuite(func() { + var err error + executable, err = gexec.Build("github.com/shipwright-io/build/cmd/waiter") + Expect(err).ToNot(HaveOccurred()) + }) + + AfterSuite(func() { + gexec.CleanupBuildArtifacts() + _ = os.RemoveAll(defaultLockFile) + }) + + When("--help is passed", func() { + var session *gexec.Session + + BeforeEach(func() { + session = run("--help") + }) + + It("shows the general help message", func() { + Eventually(session).Should(gbytes.Say("Usage:")) + }) + }) + + Describe("expect to succeed when lock-file removed before timeout", func() { + var startCh = make(chan interface{}) + + BeforeEach(func() { + session := run("start") + + go inspectSession(session, startCh, gexec.Exit(0)) + }) + + It("stops when lock-file is removed", func() { + err := os.RemoveAll(defaultLockFile) + Expect(err).ToNot(HaveOccurred()) + + Eventually(startCh, defaultTimeout).Should(BeClosed()) + }) + }) + + Describe("expect to succeed when `done` is issued before timeout", func() { + var startCh = make(chan interface{}) + var doneCh = make(chan interface{}) + + BeforeEach(func() { + session := run("start") + + go inspectSession(session, startCh, gexec.Exit(0)) + }) + + BeforeEach(func() { + session := run("done") + + go inspectSession(session, doneCh, gexec.Exit(0)) + }) + + It("stops when done is issued", func() { + Eventually(startCh, defaultTimeout).Should(BeClosed()) + Eventually(doneCh, defaultTimeout).Should(BeClosed()) + }) + }) + + Describe("expect to fail when timeout is reached", func() { + var startCh = make(chan interface{}) + + BeforeEach(func() { + session := run("start", "--timeout", "2s") + + go inspectSession(session, startCh, gexec.Exit(1)) + }) + + It("stops when timeout is reached", func() { + Eventually(startCh, defaultTimeout).Should(BeClosed()) + }) + }) +}) diff --git a/cmd/waiter/waiter.go b/cmd/waiter/waiter.go new file mode 100644 index 0000000000..2f04d3f0e7 --- /dev/null +++ b/cmd/waiter/waiter.go @@ -0,0 +1,103 @@ +package main + +import ( + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "strconv" + "time" +) + +// Waiter represents the actor that will wait for timeout, using a lock-file to keep it actively +// waiting. When "done" is issued the lock-file is removed and the waiter ends. +type Waiter struct { + flagValues *settings // command-line flags +} + +// ErrTimeout emitted when timeout is reached. +var ErrTimeout = errors.New("timeout waiting for condition") + +// save writes the lock-file with informed PID. +func (w *Waiter) save(pid int) error { + f, err := os.Create(w.flagValues.lockFile) + if err != nil { + return err + } + defer f.Close() + + log.Printf("Saving '%d' (PID) on '%s' lock-file", pid, w.flagValues.lockFile) + pidStr := strconv.Itoa(pid) + if _, err = f.WriteString(pidStr); err != nil { + return err + } + return f.Sync() +} + +// read reads the lock-file, must contain an integer. +func (w *Waiter) read() (int, error) { + if _, err := os.Stat(w.flagValues.lockFile); err != nil { + return -1, err + } + data, err := ioutil.ReadFile(w.flagValues.lockFile) + if err != nil { + return -1, err + } + pid, err := strconv.Atoi(string(data)) + if err != nil { + return -1, err + } + return pid, nil +} + +// retry re-execute the informed function once a second until it returns true. +func retry(timeout time.Duration, fn func() bool) error { + attempts := int(timeout.Seconds()) + log.Printf("Will retry '%d' times (sleep 1s)...\n", attempts) + for i := attempts; i > 0; i-- { + if fn() { + log.Printf("Done! Condition has been reached on '%d' attempt\n", attempts-i) + return nil + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("%w: elapsed %v", ErrTimeout, timeout) +} + +// Wait wait for the lock-file to be removed, or timeout. +func (w *Waiter) Wait() error { + pid := os.Getpid() + if err := w.save(pid); err != nil { + return err + } + + // waiting for the lock-file removal... + err := retry(w.flagValues.timeout, func() bool { + _, err := os.Stat(w.flagValues.lockFile) + return err != nil && os.IsNotExist(err) + }) + if err != nil { + _ = os.RemoveAll(w.flagValues.lockFile) + } + return err +} + +// Done removes the lock-file. +func (w *Waiter) Done() error { + pid, err := w.read() + if err != nil { + return err + } + log.Printf("Removing lock-file at '%s' (%d PID)", w.flagValues.lockFile, pid) + return os.Remove(w.flagValues.lockFile) +} + +// NewWaiter instantiate a new waiter, making sure the timeout informed is acceptable. +func NewWaiter(flagValues settings) *Waiter { + if flagValues.timeout <= time.Second { + log.Printf("Warning! The timeout informed '%s' is lower than 1s!\n", flagValues.timeout) + flagValues.timeout = defaultTimeout + } + return &Waiter{flagValues: &flagValues} +}