├── Dockerfile ├── .gitignore ├── Makefile ├── go.mod ├── go.sum ├── sentinel.go ├── LICENSE.md ├── .goreleaser.yml ├── .circleci └── config.yml ├── README.md └── main.go /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | COPY ./go-init-sentinel /go-init-sentinel -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .DS_Store 3 | .vscode/ 4 | go-init-sentinel 5 | Attic 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deps: 2 | @go get 3 | 4 | lint: 5 | @golangci-lint run -v 6 | 7 | test: 8 | @go test -v . 9 | 10 | build: 11 | @CGO_ENABLED=0 go build . 12 | 13 | build-linux: 14 | @CGO_ENABLED=0 GOOS=linux go build . 15 | 16 | snapshot: 17 | @goreleaser --snapshot --rm-dist --debug -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joemiller/go-init-sentinel 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 7 | github.com/ramr/go-reaper v0.0.0-20170814234526-35f6a64e44ff 8 | golang.org/x/sys v0.0.0-20190516110030-61b9204099cb 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= 2 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 3 | github.com/ramr/go-reaper v0.0.0-20170814234526-35f6a64e44ff h1:kXSTJRId8WwwqEfN0iQtzQu+jof2jTguzP2y12ULXvc= 4 | github.com/ramr/go-reaper v0.0.0-20170814234526-35f6a64e44ff/go.mod h1:DFg2AhfQCvkJwRKUfsycOSSZELGBA9gt46ne3SOecJM= 5 | golang.org/x/sys v0.0.0-20190516110030-61b9204099cb h1:k07iPOt0d6nEnwXF+kHB+iEg+WSuKe/SOQuFM2QoD+E= 6 | golang.org/x/sys v0.0.0-20190516110030-61b9204099cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 7 | -------------------------------------------------------------------------------- /sentinel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | "time" 7 | ) 8 | 9 | func newSignalSentinel(done chan struct{}, file string, interval time.Duration, sig syscall.Signal) (<-chan syscall.Signal, <-chan error) { 10 | sigCh := make(chan syscall.Signal, 1) 11 | errCh := make(chan error, 1) 12 | 13 | go func() { 14 | ticker := time.NewTicker(interval) 15 | defer ticker.Stop() 16 | 17 | initialStat, err := os.Stat(file) 18 | if err != nil { 19 | errCh <- err 20 | } 21 | 22 | for { 23 | select { 24 | 25 | case <-done: 26 | log.Debugf("sentinel '%s' shutting down", file) 27 | return 28 | 29 | case <-ticker.C: 30 | stat, err := os.Stat(file) 31 | if err != nil { 32 | errCh <- err 33 | continue 34 | } 35 | 36 | if stat.Size() != initialStat.Size() || stat.ModTime() != initialStat.ModTime() { 37 | log.Debugf("%s: change detected", file) 38 | sigCh <- sig 39 | initialStat = stat 40 | } 41 | } 42 | } 43 | }() 44 | 45 | return sigCh, errCh 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Joe Miller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | project_name: go-init-sentinel 3 | builds: 4 | - binary: go-init-sentinel 5 | env: 6 | - CGO_ENABLED=0 7 | # Default is `-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}`. 8 | ldflags: -s -w -X main.version={{.Version}}+{{.ShortCommit}} 9 | goos: 10 | - linux 11 | - darwin 12 | - freebsd 13 | - openbsd 14 | - dragonfly 15 | - netbsd 16 | goarch: 17 | - "386" 18 | - amd64 19 | - arm 20 | - arm64 21 | goarm: 22 | - '' 23 | ignore: 24 | - goos: darwin 25 | goarch: "386" 26 | 27 | archives: 28 | - id: go-init-sentinel 29 | format: binary 30 | replacements: 31 | 386: i386 32 | 33 | checksum: 34 | name_template: 'checksums.txt' 35 | 36 | snapshot: 37 | name_template: "{{ .Tag }}-next" 38 | 39 | changelog: 40 | sort: asc 41 | filters: 42 | exclude: 43 | - Merge pull request 44 | - Merge branch 45 | 46 | dockers: 47 | # primary docker image for amd64 arch 48 | - 49 | dockerfile: Dockerfile 50 | binaries: 51 | - go-init-sentinel 52 | goos: linux 53 | goarch: amd64 54 | image_templates: 55 | - "joemiller/go-init-sentinel:{{ .Tag }}" # v1.0.0 56 | - "joemiller/go-init-sentinel:v{{ .Major }}" # v1 57 | - "joemiller/go-init-sentinel:v{{ .Major }}.{{ .Minor }}" # v1.0 58 | - "joemiller/go-init-sentinel:latest" 59 | # build a docker image for arm64 arch 60 | - 61 | dockerfile: Dockerfile 62 | binaries: 63 | - go-init-sentinel 64 | goos: linux 65 | goarch: arm64 66 | goarm: '' 67 | image_templates: 68 | - "joemiller/go-init-sentinel:{{ .Tag }}-arm64" # v1.0.0-arm64 69 | - "joemiller/go-init-sentinel:v{{ .Major }}-arm64" # v1-arm64 70 | - "joemiller/go-init-sentinel:v{{ .Major }}.{{ .Minor }}-arm64" # v1.0-arm64 71 | - "joemiller/go-init-sentinel:latest-arm64" 72 | # build a docker image for arm 73 | - 74 | dockerfile: Dockerfile 75 | binaries: 76 | - go-init-sentinel 77 | goos: linux 78 | goarch: arm 79 | goarm: '' 80 | image_templates: 81 | - "joemiller/go-init-sentinel:{{ .Tag }}-arm" # v1.0.0-arm 82 | - "joemiller/go-init-sentinel:v{{ .Major }}-arm" # v1-arm 83 | - "joemiller/go-init-sentinel:v{{ .Major }}.{{ .Minor }}-arm" # v1.0-arm 84 | - "joemiller/go-init-sentinel:latest-arm64" -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2.1 3 | 4 | executors: 5 | go-build: 6 | docker: 7 | - image: circleci/golang:1.12 8 | 9 | commands: 10 | save-go-mod-cache: 11 | steps: 12 | - save_cache: 13 | key: v4-dependencies-{{ checksum "go.mod" }} 14 | paths: 15 | - /go/pkg/mod 16 | 17 | restore-go-mod-cache: 18 | steps: 19 | - restore_cache: 20 | keys: 21 | - v4-dependencies-{{ checksum "go.mod" }} 22 | 23 | save-workspace: 24 | steps: 25 | - persist_to_workspace: 26 | root: . 27 | paths: 28 | - ./ 29 | 30 | restore-workspace: 31 | steps: 32 | - attach_workspace: 33 | at: . 34 | 35 | jobs: 36 | deps: 37 | executor: go-build 38 | steps: 39 | - checkout 40 | - restore-go-mod-cache 41 | # commands 42 | - run: make deps 43 | # persist 44 | - save-go-mod-cache 45 | - save-workspace 46 | 47 | lint: 48 | executor: go-build 49 | steps: 50 | - restore-workspace 51 | - restore-go-mod-cache 52 | # commands 53 | - run: | 54 | curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh \ 55 | | sh -s -- -b $(go env GOPATH)/bin v1.16.0 56 | 57 | - run: make lint 58 | 59 | test: 60 | executor: go-build 61 | steps: 62 | - restore-workspace 63 | - restore-go-mod-cache 64 | # commands 65 | - run: make test 66 | 67 | release: 68 | executor: go-build 69 | steps: 70 | - restore-workspace 71 | - restore-go-mod-cache 72 | - setup_remote_docker 73 | # commands 74 | - run: docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWD 75 | - run: | 76 | curl -s https://api.github.com/repos/pantheon-systems/autotag/releases/latest | \ 77 | grep browser_download | \ 78 | grep -i linux | \ 79 | cut -d '"' -f 4 | \ 80 | xargs curl -o ~/autotag -L \ 81 | && chmod 755 ~/autotag 82 | - run: ~/autotag 83 | - run: git reset --hard 84 | - run: curl -sL https://git.io/goreleaser | bash -s -- --parallelism=4 85 | # goreleaser install script runs goreleaser automatically 86 | 87 | workflows: 88 | version: 2 89 | primary: 90 | jobs: 91 | - deps 92 | - lint: 93 | requires: 94 | - deps 95 | - test: 96 | requires: 97 | - deps 98 | - release: 99 | requires: 100 | - lint 101 | - test 102 | filters: 103 | branches: 104 | only: 105 | - master 106 | - test-release 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-init-sentinel 2 | ================ 3 | 4 | A minimal init system capable of watching files for changes and sending signals for reload, shutdown, etc. 5 | 6 | `go-init-sentinel` is intended as a wrapper for processes that need to be notified when a 7 | configuration file or TLS certificate is updated by an external process. For example, a wrapped 8 | nginx process can be sent a SIGHUP to reload configuration including TLS certificates. 9 | 10 | Example use cases: 11 | 12 | - An app running in Kubernetes with a sidecar that is updating TLS certificates from Vault can use 13 | `go-init-sentinel` to detect updates and signal the app to reload the certificate. 14 | - An external process fetching new certificates and updating a Kubernetes SecretVolume mount. 15 | `go-init-sentinel` can detect updates to the mounted volume and signal the app to reload. 16 | 17 | Suitable for use as pid-1 in a docker container. go-init-sentinel will forward signals to the 18 | child process. 19 | 20 | A simple `stat()`-based polling mechanism is used to detect file changes. The default interval 21 | is 10 seconds and may be customized via the `-interval` flag. `stat()` is used as the change 22 | detection method because experimentation showed that real-time notification interfaces such as 23 | inotify had a variety of edge cases and was dependent on the method in which files were updated. 24 | A simple "stat on interval" approach is simple, reliable, and works in a variety of cases including 25 | with Kubernetes SecretVolume and ConfigMap mounts. 26 | 27 | Install 28 | ------- 29 | 30 | - Binary releases are available for multiple platforms on [GitHub release](https://github.com/joemiller/go-init-sentinel/releases) page. 31 | - Docker images are also available. 32 | 33 | Install using multi-stage Docker build: 34 | 35 | ```dockerfile 36 | FROM joemiller/go-init-sentinel as go-init-sentinel 37 | ... 38 | 39 | COPY --from=go-init-sentinel /go-init-sentinel /go-init-sentinel 40 | ... 41 | ``` 42 | 43 | Usage 44 | ----- 45 | 46 | Typical usage is via Docker `ENTYRPOINT`: 47 | 48 | Watch a certificate for updates, send SIGHUP on change: 49 | 50 | ```dockerfile 51 | ... 52 | ENTRYPOINT ["/go-init-sentinel", "-watch=/certs/stunnel.pem:SIGHUP", "--"] 53 | CMD ["/usr/bin/stunnel", "/config/stunnel.conf"] 54 | ``` 55 | 56 | The `-watch` flag may be specified multiple tiems to watch multiple files. Each file 57 | change may send a different signal. 58 | 59 | ```dockerfile 60 | ... 61 | ENTRYPOINT ["/go-init-sentinel", 62 | "-watch=/etc/nginx/nginx.conf:SIGHUP", 63 | "-watch=/certs/tls.pem:SIGHUP", 64 | "--"] 65 | CMD ["/usr/bin/nginx"] 66 | ``` 67 | 68 | A random amount of time can be added to the check interval by adding the `-splay` flag. 69 | This can be useful if you have many instances (pods) where the reload operation may be slow 70 | and you want to avoid all instances being unavailable at the same time. The splay time added to 71 | the interval will be between `"0-"` seconds. 72 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "strings" 11 | "sync" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/op/go-logging" 16 | "github.com/ramr/go-reaper" 17 | "golang.org/x/sys/unix" 18 | ) 19 | 20 | var ( 21 | log *logging.Logger 22 | 23 | debug = false 24 | version = "development" 25 | 26 | splay = false 27 | interval time.Duration = 10 * time.Second 28 | shutdownTimeout time.Duration = 30 * time.Second 29 | ) 30 | 31 | // watchFlags is a slice of strings defined by 1 or more '-watch' command line args 32 | type watchFlags []string 33 | 34 | func (w *watchFlags) String() string { 35 | return fmt.Sprintf("%s", *w) 36 | } 37 | func (w *watchFlags) Set(value string) error { 38 | *w = append(*w, value) 39 | return nil 40 | } 41 | 42 | func initLogger() *logging.Logger { 43 | l := logging.MustGetLogger("") 44 | be := logging.NewLogBackend(os.Stdout, "", 0) 45 | // Timestamp format is RFC3389 46 | f := logging.MustStringFormatter("[go-init-sentinel] %{time:2006-01-02T15:04:05.999999999Z07:00} - %{level:-8s}: %{message}") 47 | 48 | logging.SetBackend(be) 49 | logging.SetLevel(logging.INFO, "") 50 | logging.SetFormatter(f) 51 | 52 | return l 53 | } 54 | 55 | func main() { 56 | rand.Seed(time.Now().UnixNano()) 57 | 58 | log = initLogger() 59 | 60 | var showVersion bool 61 | var watches watchFlags 62 | 63 | flag.Var(&watches, "watch", "File watch rule, ex: -watch=\"/path/to/file:SIGNAME\". Can be specified multiple times.") 64 | flag.BoolVar(&showVersion, "version", false, "Display go-init-sentinel version") 65 | flag.BoolVar(&debug, "debug", false, "Display debug log messages") 66 | flag.BoolVar(&splay, "splay", false, "Add a random value to the check interval, between 0 - ") 67 | flag.DurationVar(&interval, "interval", interval, "Interval to check files for changes.") 68 | flag.DurationVar(&shutdownTimeout, "stop-timeout", shutdownTimeout, 69 | "Grace period for the child process to shutdown before killing with SIGKILL") 70 | flag.Parse() 71 | 72 | if showVersion { 73 | fmt.Println(version) 74 | os.Exit(0) 75 | } 76 | 77 | if debug { 78 | logging.SetLevel(logging.DEBUG, "") 79 | } 80 | 81 | if splay { 82 | extra := rand.Intn(int(interval.Seconds())) 83 | interval = interval + time.Duration(extra)*time.Second 84 | log.Debugf("splaying enabled, new interval: %s", interval) 85 | } 86 | 87 | if len(watches) == 0 { 88 | log.Fatal("No -watch flags specified") 89 | } 90 | 91 | // TODO: customize help message to show passing a main command 92 | 93 | if flag.NArg() == 0 { 94 | log.Fatal("No main command defined, exiting") 95 | } 96 | mainCommand := flag.Args() 97 | 98 | cmd := exec.Command(mainCommand[0], mainCommand[1:]...) 99 | cmd.Stdin = os.Stdin 100 | cmd.Stdout = os.Stdout 101 | cmd.Stderr = os.Stderr 102 | cmd.SysProcAttr = &syscall.SysProcAttr{} 103 | cmd.SysProcAttr.Setpgid = true 104 | 105 | done := make(chan struct{}) 106 | defer close(done) 107 | 108 | // channels to allow our file watching sentinels to send signals to relay to the child process and signal errors 109 | var sentinelSigChs []<-chan syscall.Signal 110 | var sentinelErrChs []<-chan error 111 | for _, w := range watches { 112 | m := strings.Split(w, ":") 113 | if len(m) != 2 { 114 | log.Fatalf("Invalid watch rule '%s'. Format: '/file/path:SIGNAME'", w) 115 | } 116 | file := m[0] 117 | signal := m[1] 118 | if unix.SignalNum(signal) == 0 { 119 | log.Fatalf("Invalid watch rule '%s'. Signal '%s' is not valid", w, signal) 120 | } 121 | sigCh, errCh := newSignalSentinel(done, file, interval, unix.SignalNum(signal)) 122 | sentinelSigChs = append(sentinelSigChs, sigCh) 123 | sentinelErrChs = append(sentinelErrChs, errCh) 124 | } 125 | // fan-in the channels from the sentinels 126 | sentinelSigCh := mergeSigCh(sentinelSigChs...) 127 | sentinelErrCh := mergeErrCh(sentinelErrChs...) 128 | 129 | // pid-1 has its responsibilities, one of them is being the goto reaper for orphaned children (zombies) 130 | go reaper.Start(reaper.Config{Pid: -1, Options: 0, DisablePid1Check: true}) 131 | 132 | // listen for all signals so that we can forward them to our child 133 | signalCh := make(chan os.Signal, 100) 134 | defer close(signalCh) 135 | signal.Notify(signalCh) 136 | 137 | // start our wrapped command in a goroutine. The exit of the command will be signaled on the exitCh 138 | err := cmd.Start() 139 | if err != nil { 140 | log.Fatalf("unable to start command: %s", err) 141 | } 142 | exitCode := 0 143 | exitCh := make(chan error) 144 | go func() { exitCh <- cmd.Wait() }() 145 | 146 | // main loop 147 | for { 148 | select { 149 | case err := <-sentinelErrCh: 150 | log.Error(err) 151 | close(done) 152 | 153 | signalPID(cmd.Process.Pid, unix.SignalNum("SIGTERM")) 154 | 155 | go func() { 156 | select { 157 | case <-exitCh: 158 | case <-time.After(shutdownTimeout): 159 | log.Info("Timed out waiting for process to exit gracefully on SIGTERM. Sending SIGKILL") 160 | _ = cmd.Process.Kill() 161 | } 162 | }() 163 | 164 | case sig := <-sentinelSigCh: 165 | log.Debugf("sending sentinel signal %s to child process", unix.SignalName(sig)) 166 | signalPID(cmd.Process.Pid, sig) 167 | 168 | case sig := <-signalCh: 169 | // ignore SIGCHLD, those are meant for go-init-sentinel 170 | // forward all other signals to our child 171 | if sig != syscall.SIGCHLD { 172 | log.Debugf("forwarding signal: %s (%d)", unix.SignalName(sig.(syscall.Signal)), sig) 173 | signalPID(cmd.Process.Pid, sig.(syscall.Signal)) 174 | } 175 | 176 | case err := <-exitCh: 177 | // our child has exited. 178 | // if it returned an exit code pass it through to our parent by exiting 179 | if err != nil { 180 | if exiterr, ok := err.(*exec.ExitError); ok { 181 | exitCode = exiterr.Sys().(syscall.WaitStatus).ExitStatus() 182 | log.Debugf("command exited with code: %d", exitCode) 183 | } 184 | } 185 | os.Exit(exitCode) 186 | } 187 | } 188 | } 189 | 190 | func signalPID(pid int, sig syscall.Signal) { 191 | if err := syscall.Kill(pid, sig); err != nil { 192 | log.Warningf("unable to send signal: ", err) 193 | } 194 | } 195 | 196 | // mergeErrCh merges multiple channels of errors. 197 | // Based on https://blog.golang.org/pipelines. 198 | func mergeErrCh(cs ...<-chan error) <-chan error { 199 | var wg sync.WaitGroup 200 | // We must ensure that the output channel has the capacity to hold as many errors 201 | // as there are error channels. This will ensure that it never blocks 202 | out := make(chan error, len(cs)) 203 | 204 | // Start an output goroutine for each input channel in cs. output 205 | // copies values from c to out until c is closed, then calls wg.Done. 206 | output := func(c <-chan error) { 207 | for n := range c { 208 | out <- n 209 | } 210 | wg.Done() 211 | } 212 | wg.Add(len(cs)) 213 | for _, c := range cs { 214 | go output(c) 215 | } 216 | 217 | // Start a goroutine to close out once all the output goroutines are 218 | // done. This must start after the wg.Add call. 219 | go func() { 220 | wg.Wait() 221 | close(out) 222 | }() 223 | return out 224 | } 225 | 226 | func mergeSigCh(cs ...<-chan syscall.Signal) <-chan syscall.Signal { 227 | var wg sync.WaitGroup 228 | // We must ensure that the output channel has the capacity to hold as many errors 229 | // as there are error channels. This will ensure that it never blocks 230 | out := make(chan syscall.Signal, len(cs)) 231 | 232 | // Start an output goroutine for each input channel in cs. output 233 | // copies values from c to out until c is closed, then calls wg.Done. 234 | output := func(c <-chan syscall.Signal) { 235 | for n := range c { 236 | out <- n 237 | } 238 | wg.Done() 239 | } 240 | wg.Add(len(cs)) 241 | for _, c := range cs { 242 | go output(c) 243 | } 244 | 245 | // Start a goroutine to close out once all the output goroutines are 246 | // done. This must start after the wg.Add call. 247 | go func() { 248 | wg.Wait() 249 | close(out) 250 | }() 251 | return out 252 | } 253 | --------------------------------------------------------------------------------