Skip to content

Commit

Permalink
HTTP API for receiving signal requests and passing it to the wrapped …
Browse files Browse the repository at this point in the history
…command
  • Loading branch information
jarnfast committed Apr 20, 2023
1 parent e79b449 commit f0e0679
Show file tree
Hide file tree
Showing 10 changed files with 376 additions and 66 deletions.
23 changes: 18 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ VERSION ?= $(shell git describe --tags 2> /dev/null || echo main)
#optimize flags -w -s
#LDFLAGS = -w -X main.version=$(VERSION) -X main.build=$(COMMIT)
LDFLAGS = -w -X jarnfast/signalman/pkg.Version=$(VERSION) -X jarnfast/signalman/pkg.Build=$(COMMIT)
XBUILD = CGO_ENABLED=0 $(GO) build -a -tags netgo -ldflags '$(LDFLAGS)'
#XBUILD = CGO_ENABLED=0 $(GO) build -a -tags netgo -ldflags '$(LDFLAGS)'
XBUILD = CGO_ENABLED=0 $(GO) build -a -ldflags '$(LDFLAGS)'

PLATFORM ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
Expand All @@ -25,6 +26,12 @@ else
FILE_EXT=
endif

ifeq ($(ARCH),arm)
ARMVERSION=v$(GOARM)
else
ARMVERSION=
endif

.PHONY: build
build:
mkdir -p $(BINDIR)
Expand All @@ -34,16 +41,22 @@ build:
run: build
./$(BINDIR)

xbuild-all:
xbuild-freebsd:
rm bin/main/signalman-freebsd-amd64
$(MAKE) $(MAKE_OPTS) PLATFORM=freebsd ARCH=amd64 xbuild;

xbuild-all:
$(MAKE) $(MAKE_OPTS) PLATFORM=windows ARCH=amd64 xbuild;
$(MAKE) $(MAKE_OPTS) PLATFORM=darwin ARCH=amd64 xbuild;
$(MAKE) $(MAKE_OPTS) PLATFORM=linux ARCH=amd64 xbuild;
$(MAKE) $(MAKE_OPTS) PLATFORM=linux ARCH=arm64 xbuild;
$(MAKE) $(MAKE_OPTS) PLATFORM=linux ARCH=arm GOARM=6 xbuild;
$(MAKE) $(MAKE_OPTS) PLATFORM=linux ARCH=arm GOARM=7 xbuild;

xbuild: $(BINDIR)/$(VERSION)/$(MIXIN)-$(PLATFORM)-$(ARCH)$(FILE_EXT)
$(BINDIR)/$(VERSION)/$(MIXIN)-$(PLATFORM)-$(ARCH)$(FILE_EXT):
xbuild: $(BINDIR)/$(VERSION)/$(MIXIN)-$(PLATFORM)-$(ARCH)$(ARMVERSION)$(FILE_EXT)
$(BINDIR)/$(VERSION)/$(MIXIN)-$(PLATFORM)-$(ARCH)$(ARMVERSION)$(FILE_EXT):
mkdir -p $(dir $@)
GOOS=$(PLATFORM) GOARCH=$(ARCH) $(XBUILD) -o $@ ./cmd/$(MIXIN)
GOOS=$(PLATFORM) GOARCH=$(ARCH) GOARM=$(GOARM) $(XBUILD) -o $@ ./cmd/$(MIXIN)

test: test-unit
$(BINDIR)/$(MIXIN)$(FILE_EXT) version
Expand Down
126 changes: 65 additions & 61 deletions cmd/signalman/main.go
Original file line number Diff line number Diff line change
@@ -1,90 +1,94 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"io"
"os"
"os/exec"
"path"
"os/signal"

"jarnfast/signalman/pkg"
"jarnfast/signalman/pkg/api"
"jarnfast/signalman/pkg/cmdwrapper"
"jarnfast/signalman/pkg/utl"

"go.uber.org/zap"
)

var version string = "1.0"
var build string = "n/a"
func createLogger() *zap.SugaredLogger {
var config zap.Config

type handlers struct {
}
configFilename := os.Getenv("SIGNALMAN_LOG_CONFIGFILE")

type adminPortal struct {
}
if configFilename != "" {
configFile, err := os.Open(configFilename)
if err != nil {
panic(fmt.Sprintf("Unable to open config file: %v", err))

func newAdminPortal() *adminPortal {
return &adminPortal{}
}
}
rawJSON, err := io.ReadAll(configFile)
if err != nil {
panic(fmt.Sprintf("Unable to read config file: %v", err))
}

func (h *handlers) status(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusOK)
response.Write([]byte(versionString()))
response.Write([]byte(fmt.Sprintf("Args: %d, %s", len(os.Args[1:]), os.Args[1:])))
}
if err := json.Unmarshal(rawJSON, &config); err != nil {
panic(fmt.Sprintf("Unable to parse config file as JSON: %v", err))
}

func (h *handlers) signal(response http.ResponseWriter, request *http.Request) {
if request.Method != "GET" {
response.WriteHeader(http.StatusMethodNotAllowed)
return
} else {
config = zap.NewProductionConfig()
var err error
config.Level, err = zap.ParseAtomicLevel(utl.GetenvDefault("SIGNALMAN_LOG_LEVEL", "info"))
if err != nil {
panic(err)
}
}
fmt.Println("Get request", request.URL)
}
func (h *handlers) kill(response http.ResponseWriter, request *http.Request) {
os.Exit(1337)
}
func versionString() string {
//return fmt.Sprintf("%s version %s (build %s)", path.Base(os.Args[0]), version, build)
return fmt.Sprintf("%s version %s (build %s)", path.Base(os.Args[0]), pkg.Version, build)

logger, _ := config.Build()

defer logger.Sync()

sugar := logger.Sugar()
return sugar
}

func main() {
fmt.Println("Starting", versionString())
logger := createLogger()

addr := os.Getenv("SIGNALMAN_LISTEN_ADDRESS")
if addr == "" {
addr = "localhost:30000"
}
logger.Infof("Starting %s", pkg.VersionString())

h := &handlers{}
http.HandleFunc("/status", h.status)
http.HandleFunc("/kill", h.kill)
http.HandleFunc("/signal/", h.signal)
sigs := make(chan os.Signal, 10)

go func() {
err := http.ListenAndServe(addr, nil)
if err != nil {
panic(err)
}
}()
// Relay incoming signals to process
signal.Notify(sigs)

fmt.Println("Listening for commands on", addr)
a := api.NewApi(logger)
// Relay signals received on HTTP
a.Notify(sigs)
a.ListenAndServe()

args := os.Args[1:]
binary, err := exec.LookPath(args[0])
if err != nil {
fmt.Println("Command not found", binary)
panic(err)
}
c := cmdwrapper.NewCmdWrapper(logger)
// Subscribe on the received signals
c.Subscribe(sigs)
c.Start()

exitCode, err := c.Wait()

cmd := exec.Command(binary, args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
close(sigs)

err = cmd.Start()
if err != nil {
fmt.Println("Unable to run command", err)
panic(err)
exitCode := -1
// try to get the original exit code
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode = exitError.ExitCode()
}
logger.Warnf("Wrapped command finished with error: %v", err)
os.Exit(exitCode)
} else {
logger.Info("Wrapped command finished without errors")
os.Exit(exitCode)
}

cmd.Wait()

fmt.Println("Command terminated")
}
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
module jarnfast/signalman

go 1.19

require (
github.com/spf13/cast v1.5.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
)
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
133 changes: 133 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package api

import (
"fmt"
"jarnfast/signalman/pkg"
"jarnfast/signalman/pkg/utl"
"os"
"strconv"
"strings"
"syscall"
"time"

"net/http"

"github.com/spf13/cast"
"go.uber.org/zap"
)

type Api struct {
addr string
logger *zap.SugaredLogger
sigs chan<- os.Signal
}

func NewApi(logger *zap.SugaredLogger) *Api {
addr := utl.GetenvDefault("SIGNALMAN_LISTEN_ADDRESS", "localhost:30000")

return &Api{
addr: addr,
logger: logger,
}
}

// Notify causes api to relay signal received on HTTP to c.
func (a *Api) Notify(c chan<- os.Signal) {
a.sigs = c
}

func (a *Api) debugLogRequest(r *http.Request) {
a.logger.Debugf("HTTP Request: %s %s", r.Method, r.URL)
}

func (a *Api) handleStatus(w http.ResponseWriter, r *http.Request) {
a.debugLogRequest(r)

if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(pkg.VersionString()))
}

func (a *Api) handleSignal(w http.ResponseWriter, r *http.Request) {
a.debugLogRequest(r)

if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

parts := strings.Split(r.URL.String(), "/")

if len(parts) != 3 {
w.WriteHeader(http.StatusBadRequest)
return
}

i, err := strconv.Atoi(parts[2])
if err != nil {
m := fmt.Sprintf("Unable to parse signal as int: %s", parts[2])
a.logger.Debug(m)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(m))
return
}

w.WriteHeader(http.StatusAccepted)

s := syscall.Signal(i)

a.sigs <- s

w.Write([]byte(fmt.Sprintf("Got signal (%d) %s\r\n", len(parts), parts[2])))
w.Write([]byte("This signal is: "))
w.Write([]byte(strconv.Itoa(i)))
}

func (a *Api) handleTerm(w http.ResponseWriter, r *http.Request) {
a.debugLogRequest(r)

w.WriteHeader(http.StatusAccepted)

a.sigs <- syscall.SIGTERM

timeval := cast.ToDuration(utl.GetenvDefault("SIGNALMAN_TERM_TIMEOUT", "10"))
<-time.After(timeval * time.Second)

a.logger.Warnf("Wrapped command did not react to TERM within %d seconds. Sending KILL.", timeval)

a.sigs <- syscall.SIGKILL
}

func (a *Api) handleKill(w http.ResponseWriter, r *http.Request) {
a.debugLogRequest(r)

w.WriteHeader(http.StatusAccepted)
a.sigs <- syscall.SIGKILL
}

// ListenAndServe listens on the configured TCP network address and relays received
// signals to the configured channel
func (a *Api) ListenAndServe() {

if a.sigs == nil {
a.logger.Panic("No signal channel found")
}

mux := http.NewServeMux()
mux.HandleFunc("/status", a.handleStatus)
mux.HandleFunc("/signal/", a.handleSignal)
mux.HandleFunc("/term", a.handleTerm)
mux.HandleFunc("/kill", a.handleKill)

go func() {
err := http.ListenAndServe(a.addr, mux)
if err != nil {
a.logger.Panic(err)
}
}()

a.logger.Infof("Listening for commands on %s", a.addr)
}
Loading

0 comments on commit f0e0679

Please sign in to comment.