Skip to content

Commit

Permalink
Merge pull request #48 from hashicorp/pglass/app-entrypoint
Browse files Browse the repository at this point in the history
Add app-entrypoint command
  • Loading branch information
Paul Glass committed Nov 30, 2021
2 parents d1a37d7 + c157f42 commit 02ccd8a
Show file tree
Hide file tree
Showing 11 changed files with 429 additions and 24 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## UNRELEASED

FEATURES
* Add a `app-entrypoint` subcommand which can be used to delay application
shutdown after receing a TERM signal to support graceful shutdown in ECS.
[[GH-48](https://github.com/hashicorp/consul-ecs/pull/48)]

## 0.2.0 (November 16, 2021)

BREAKING CHANGES
Expand Down
4 changes: 4 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"

cmdController "github.com/hashicorp/consul-ecs/subcommand/acl-controller"
cmdAppEntrypoint "github.com/hashicorp/consul-ecs/subcommand/app-entrypoint"
cmdEnvoyEntrypoint "github.com/hashicorp/consul-ecs/subcommand/envoy-entrypoint"
cmdHealthSync "github.com/hashicorp/consul-ecs/subcommand/health-sync"
cmdMeshInit "github.com/hashicorp/consul-ecs/subcommand/mesh-init"
Expand Down Expand Up @@ -34,6 +35,9 @@ func init() {
"envoy-entrypoint": func() (cli.Command, error) {
return &cmdEnvoyEntrypoint.Command{UI: ui}, nil
},
"app-entrypoint": func() (cli.Command, error) {
return &cmdAppEntrypoint.Command{UI: ui}, nil
},
}
}

Expand Down
22 changes: 13 additions & 9 deletions subcommand/envoy-entrypoint/envoy.go → entrypoint/cmd.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//go:build !windows
// +build !windows

package envoyentrypoint
package entrypoint

import (
"os"
Expand All @@ -11,15 +11,19 @@ import (
"github.com/hashicorp/go-hclog"
)

type EnvoyCmd struct {
// Cmd runs a command in a subprocess (asynchronously).
// Call `go cmd.Run()` to run the command asynchronously.
// Use the Started() channel to wait for the command to start.
// Use the Done() channel to wait for the command to complete.
type Cmd struct {
*exec.Cmd

log hclog.Logger
doneCh chan struct{}
startedCh chan struct{}
}

func NewEnvoyCmd(log hclog.Logger, args []string) *EnvoyCmd {
func NewCmd(log hclog.Logger, args []string) *Cmd {
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
Expand All @@ -29,7 +33,7 @@ func NewEnvoyCmd(log hclog.Logger, args []string) *EnvoyCmd {
Setpgid: true,
}

return &EnvoyCmd{
return &Cmd{
Cmd: cmd,
log: log,
doneCh: make(chan struct{}, 1),
Expand All @@ -39,12 +43,12 @@ func NewEnvoyCmd(log hclog.Logger, args []string) *EnvoyCmd {

// Run the command. The Started() and Done() functions can be used
// to wait for the process to start and exit, respectively.
func (e *EnvoyCmd) Run() {
func (e *Cmd) Run() {
defer close(e.doneCh)
defer close(e.startedCh)

if err := e.Cmd.Start(); err != nil {
e.log.Error("starting Envoy process", "error", err.Error())
e.log.Error("starting process", "error", err.Error())
// Closed channels (in defers) indicate the command failed to start.
return
}
Expand All @@ -53,16 +57,16 @@ func (e *EnvoyCmd) Run() {
if err := e.Cmd.Wait(); err != nil {
if _, ok := err.(*exec.ExitError); !ok {
// Do not log if it is only a non-zero exit code.
e.log.Error("waiting for Envoy process to finish", "error", err.Error())
e.log.Error("waiting for process to finish", "error", err.Error())
}
}
e.doneCh <- struct{}{}
}

func (e *EnvoyCmd) Started() chan struct{} {
func (e *Cmd) Started() chan struct{} {
return e.startedCh
}

func (e *EnvoyCmd) Done() chan struct{} {
func (e *Cmd) Done() chan struct{} {
return e.doneCh
}
9 changes: 9 additions & 0 deletions subcommand/app-entrypoint/command_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package appentrypoint

func (c *Command) Help() string {
return ""
}

func (c *Command) Synopsis() string {
return "Entrypoint for running a command in ECS"
}
153 changes: 153 additions & 0 deletions subcommand/app-entrypoint/command_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//go:build !windows
// +build !windows

package appentrypoint

import (
"flag"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"

"github.com/hashicorp/consul-ecs/entrypoint"
"github.com/hashicorp/go-hclog"
"github.com/mitchellh/cli"
)

const (
flagShutdownDelay = "shutdown-delay"
)

type Command struct {
UI cli.Ui
log hclog.Logger
once sync.Once
flagSet *flag.FlagSet

sigs chan os.Signal
appCmd *entrypoint.Cmd
shutdownDelay time.Duration
}

func (c *Command) init() {
c.log = hclog.New(&hclog.LoggerOptions{Name: "consul-ecs"})
c.flagSet = flag.NewFlagSet("", flag.ContinueOnError)
c.flagSet.DurationVar(&c.shutdownDelay, flagShutdownDelay, 0,
`Continue running for this long after receiving SIGTERM. Must be a duration (e.g. "10s").`)
c.log = hclog.New(nil)

}

func (c *Command) Run(args []string) int {
c.once.Do(c.init)

// Flag parsing stops just before the first non-flag argument ("-" is a non-flag argument)
// or after the terminator "--"
err := c.flagSet.Parse(args)
if err != nil {
c.UI.Error(fmt.Sprint(err))
return 1
}

// Remaining args for the application command, after parsing our flags
args = c.flagSet.Args()

if len(args) == 0 {
c.UI.Error("command is required")
return 1
}

c.sigs = make(chan os.Signal, 1)
c.appCmd = entrypoint.NewCmd(c.log, args)

return c.realRun()
}

func (c *Command) realRun() int {
signal.Notify(c.sigs)
defer c.cleanup()

go c.appCmd.Run()
if _, ok := <-c.appCmd.Started(); !ok {
return 1
}

if exitCode, exited := c.waitForSigterm(); exited {
return exitCode
}
if c.shutdownDelay > 0 {
c.log.Info(fmt.Sprintf("consul-ecs: received sigterm. waiting %s before terminating application.", c.shutdownDelay))
if exitCode, exited := c.waitForShutdownDelay(); exited {
return exitCode
}
}
// We've signaled for the process to exit, so wait until it does.
c.waitForAppExit()
return c.appCmd.ProcessState.ExitCode()
}

// waitForSigterm waits until c.appCmd has exited, or until a sigterm is received.
// It returns (exitCode, exited), where if exited=true, then c.appCmd has exited.
func (c *Command) waitForSigterm() (int, bool) {
for {
select {
case <-c.appCmd.Done():
return c.appCmd.ProcessState.ExitCode(), true
case sig := <-c.sigs:
if sig == syscall.SIGTERM {
return -1, false
}
c.forwardSignal(sig)
}
}
}

// waitForShutdownDelay waits for c.appCmd to exit for `delay` seconds.
// After the delay has passed, it sends a sigterm to c.appCmd.
// It returns (exitCode, exited), where if exited=true, then c.appCmd has exited.
func (c *Command) waitForShutdownDelay() (int, bool) {
timer := time.After(c.shutdownDelay)
for {
select {
case <-c.appCmd.Done():
return c.appCmd.ProcessState.ExitCode(), true
case sig := <-c.sigs:
c.forwardSignal(sig)
case <-timer:
if err := syscall.Kill(-c.appCmd.Process.Pid, syscall.SIGTERM); err != nil {
c.log.Warn("error sending sigterm to application", "error", err.Error())
}
}
}

}

func (c *Command) waitForAppExit() {
for {
select {
case <-c.appCmd.Done():
return
case sig := <-c.sigs:
c.forwardSignal(sig)
}
}
}

func (c *Command) forwardSignal(sig os.Signal) {
switch sig {
case syscall.SIGCHLD, syscall.SIGURG:
return
default:
if err := c.appCmd.Process.Signal(sig); err != nil {
c.log.Warn("forwarding signal", "err", err.Error())
}
}
}

func (c *Command) cleanup() {
signal.Stop(c.sigs)
<-c.appCmd.Done()
}
Loading

0 comments on commit 02ccd8a

Please sign in to comment.