Skip to content

Commit

Permalink
Waiter.
Browse files Browse the repository at this point in the history
  • Loading branch information
otaviof committed Nov 3, 2021
1 parent ddbc9b1 commit 7c061e3
Show file tree
Hide file tree
Showing 6 changed files with 378 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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; -*-
Expand Down
20 changes: 20 additions & 0 deletions cmd/waiter/README.md
Original file line number Diff line number Diff line change
@@ -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
```
110 changes: 110 additions & 0 deletions cmd/waiter/main.go
Original file line number Diff line number Diff line change
@@ -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 <lock-file>
## 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)
}
13 changes: 13 additions & 0 deletions cmd/waiter/main_suite_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
131 changes: 131 additions & 0 deletions cmd/waiter/main_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
})
})
103 changes: 103 additions & 0 deletions cmd/waiter/waiter.go
Original file line number Diff line number Diff line change
@@ -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}
}

0 comments on commit 7c061e3

Please sign in to comment.