commit 8e02bc33486794b53a1605c8f081d1ee9b700ac2 Author: Max Claus Nunes Date: Sat Jun 16 21:22:21 2018 -0300 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c700a92 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +; top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[*.yml] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d1a0aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# binaries +*.exe +gaper +srv +vendor +coverage.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..dbd0c8f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: go + +go: + # - 1.7.x + # - 1.8.x + # - 1.9.x + - 1.10.x + # - master + +script: + - go version + - make setup + - make lint + - make test + +after_success: + - bash <(curl -s https://codecov.io/bash) + +notifications: + email: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0cb02ae --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to httpfake + +:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: + +There are few ways of contributing to gaper + +* Report an issue. +* Contribute to the code base. + +## Report an issue + +* Before opening the issue make sure there isn't an issue opened for the same problem +* Include the Go and Gaper version you are using +* If it is a bug, please include all info to reproduce the problem + +## Contribute to the code base + +### Pull Request + +* Please discuss the suggested changes on a issue before working on it. Just to make sure the change makes sense before you spending any time on it. + +### Setupping development + +``` +make setup +``` + +### Running gaper in development + +``` +make build && ./gaper --verbose --bin-name srv --build-path ./testdata/server +``` + +### Running lint + +``` +make lint +``` + +### Running tests + +All tests: +``` +make test +``` + +A single test: +``` +go test -run TestSimplePost ./... +``` + diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..a334fdf --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,45 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/fatih/color" + packages = ["."] + revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" + version = "v1.7.0" + +[[projects]] + name = "github.com/mattn/go-colorable" + packages = ["."] + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + name = "github.com/mattn/go-shellwords" + packages = ["."] + revision = "02e3cf038dcea8290e44424da473dd12be796a8a" + version = "v1.0.3" + +[[projects]] + name = "github.com/urfave/cli" + packages = ["."] + revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1" + version = "v1.20.0" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "6c888cc515d3ed83fc103cf1d84468aad274b0a7" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "bd09f9274a4112aeb869a026bcf3ab61443289a245d3d353da6623f3327e7b4e" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..f10c7e9 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,15 @@ +[[constraint]] + name = "github.com/fatih/color" + version = "1.7.0" + +[[constraint]] + name = "github.com/mattn/go-shellwords" + version = "1.0.3" + +[[constraint]] + name = "github.com/urfave/cli" + version = "1.20.0" + +[prune] + go-tests = true + unused-packages = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c8b0e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Max Claus Nunes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e7bc5e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +OS := $(shell uname -s) +TEST_PACKAGES := $(shell go list ./...) +COVER_PACKAGES := $(shell go list ./... | paste -sd "," -) +LINTER := $(shell command -v gometalinter 2> /dev/null) + +.PHONY: setup + +setup: +ifeq ($(OS), Darwin) + brew install dep +else + curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh +endif + dep ensure -vendor-only +ifndef LINTER + @echo "Installing linter" + @go get -u github.com/alecthomas/gometalinter + @gometalinter --install +endif + +build: + @go build . + +## lint: Validate golang code +lint: + @gometalinter \ + --deadline=120s \ + --line-length=120 \ + --enable-all \ + --vendor ./... + +test: + @go test -v -coverpkg $(COVER_PACKAGES) \ + -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES) + +cover: test + @go tool cover -html=coverage.out + +fmt: + @find . -name '*.go' -not -wholename './vendor/*' | \ + while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1dc9b5 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +gaper +===== + +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +[![Build Status](https://travis-ci.org/maxcnunes/gaper.svg?branch=master)](https://travis-ci.org/maxcnunes/gaper) +[![Coverage Status](https://codecov.io/gh/maxcnunes/gaper/branch/master/graph/badge.svg)](https://codecov.io/gh/maxcnunes/gaper) +[![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](http://godoc.org/github.com/maxcnunes/gaper) +[![Go Report Card](https://goreportcard.com/badge/github.com/maxcnunes/gaper)](https://goreportcard.com/report/github.com/maxcnunes/gaper) + +Restarts programs when they crash or a watched file changes. + +**NOT STABLE YET, STILL IN DEVELOPMENT**: Please, check out [this ticket](https://github.com/maxcnunes/gaper/issues/1) to follow its progress. + +## Installation + +``` +go get -u github.com/maxcnunes/gaper +``` + +## Changelog + +See [Releases](https://github.com/maxcnunes/gaper/releases) for detailed history changes. + +## Usage + +``` +NAME: + gaper - Used to restart programs when they crash or a watched file changes + +USAGE: + gaper [global options] command [command options] [arguments...] + +VERSION: + 0.0.0 + +COMMANDS: + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --bin-name value name for the binary built by Gaper for the executed program + --build-path value path to the program source code + --build-args value build arguments passed to the program + --verbose turns on the verbose messages from Gaper + --watch value, -w value a comma-delimited list of folders or files to watch for changes + --ignore value, -i value a comma-delimited list of folders or files to ignore for changes + --poll-interval value, -p value how often in milliseconds to poll watched files for changes (default: 500) + --extensions value, -e value a comma-delimited list of file extensions to watch for changes (default: "go") + ----no-restart-on value, -n value don't automatically restart the executed program if it ends. + If "error", an exit code of 0 will still restart. + If "exit", no restart regardless of exit code. + If "success", no restart only if exit code is 0. + --help, -h show help + --version, -v print the version +``` + +## Contributing + +See the [Contributing guide](/CONTRIBUTING.md) for steps on how to contribute to this project. + +## Reference + +This package was heavily inspired by [gin](https://github.com/codegangsta/gin) and [node-supervisor](https://github.com/petruisfan/node-supervisor). + +Basically, Gaper is a mixing of those projects above. It started from **gin** code base and I rewrote it aiming to get +something similar to **node-supervisor** (but simpler). A big thanks for those projects and for the people behind it! +:clap::clap: + +### How is Gaper different of Gin + +The main difference is that Gaper removes a layer of complexity from Gin which has a proxy running on top of +the executed server. It allows to postpone a build and reload the server when the first call hits it. With Gaper +we don't care about that feature, it just restarts your server whenever a change is made. diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..c87ab6f --- /dev/null +++ b/builder.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Builder ... +type Builder interface { + Build() error + Binary() string + Errors() string +} + +type builder struct { + dir string + binary string + errors string + wd string + buildArgs []string +} + +// NewBuilder ... +func NewBuilder(dir string, bin string, wd string, buildArgs []string) Builder { + if len(bin) == 0 { + bin = "bin" + } + + // does not work on Windows without the ".exe" extension + if runtime.GOOS == OSWindows { + // check if it already has the .exe extension + if !strings.HasSuffix(bin, ".exe") { + bin += ".exe" + } + } + + return &builder{dir: dir, binary: bin, wd: wd, buildArgs: buildArgs} +} + +// Binary ... +func (b *builder) Binary() string { + return b.binary +} + +// Errors ... +func (b *builder) Errors() string { + return b.errors +} + +// Build ... +func (b *builder) Build() error { + logger.Info("Building program") + args := append([]string{"go", "build", "-o", filepath.Join(b.wd, b.binary)}, b.buildArgs...) + logger.Debug("Build command", args) + + command := exec.Command(args[0], args[1:]...) // nolint gas + command.Dir = b.dir + + output, err := command.CombinedOutput() + if err != nil { + return err + } + + if command.ProcessState.Success() { + b.errors = "" + } else { + b.errors = string(output) + } + + if len(b.errors) > 0 { + return fmt.Errorf(b.errors) + } + + return nil +} diff --git a/loggger.go b/loggger.go new file mode 100644 index 0000000..8d92b0d --- /dev/null +++ b/loggger.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "os" + + "github.com/fatih/color" +) + +// Logger .. +type Logger struct { + verbose bool + logDebug *log.Logger + logWarn *log.Logger + logInfo *log.Logger + logError *log.Logger +} + +// NewLogger ... +func NewLogger(prefix string) *Logger { + prefix = "[" + prefix + "] " + return &Logger{ + verbose: false, + logDebug: log.New(os.Stdout, prefix, 0), + logWarn: log.New(os.Stdout, color.YellowString(prefix), 0), + logInfo: log.New(os.Stdout, color.CyanString(prefix), 0), + logError: log.New(os.Stdout, color.RedString(prefix), 0), + } +} + +// Verbose ... +func (l *Logger) Verbose(verbose bool) { + l.verbose = verbose +} + +// Debug ... +func (l *Logger) Debug(v ...interface{}) { + if l.verbose { + l.logDebug.Println(v...) + } +} + +// Warn ... +func (l *Logger) Warn(v ...interface{}) { + l.logWarn.Println(v...) +} + +// Info ... +func (l *Logger) Info(v ...interface{}) { + l.logInfo.Println(v...) +} + +// Error ... +func (l *Logger) Error(v ...interface{}) { + l.logError.Println(v...) +} + +// Errorf ... +func (l *Logger) Errorf(format string, v ...interface{}) { + l.logError.Printf(format, v...) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4592b34 --- /dev/null +++ b/main.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + shellwords "github.com/mattn/go-shellwords" + "github.com/urfave/cli" +) + +var logger = NewLogger("gaper") + +var defaultExtensions = cli.StringSlice{"go"} +var defaultPoolInterval = 500 + +// Config ... +type Config struct { + BinName string + BuildPath string + BuildArgs []string + BuildArgsMerged string + ProgramArgs []string + Verbose bool + WatchItems []string + IgnoreItems []string + PollInterval int + Extensions []string + NoRestartOn string +} + +func main() { + parseArgs := func(c *cli.Context) *Config { + return &Config{ + BinName: c.String("bin-name"), + BuildPath: c.String("build-path"), + BuildArgsMerged: c.String("build-args"), + ProgramArgs: c.Args(), + Verbose: c.Bool("verbose"), + WatchItems: c.StringSlice("watch"), + IgnoreItems: c.StringSlice("ignore"), + PollInterval: c.Int("poll-interval"), + Extensions: c.StringSlice("extensions"), + NoRestartOn: c.String("no-restart-on"), + } + } + + app := cli.NewApp() + app.Name = "gaper" + app.Usage = "Used to restart programs when they crash or a watched file changes" + + app.Action = func(c *cli.Context) { + args := parseArgs(c) + if err := runGaper(args); err != nil { + logger.Error(err) + os.Exit(1) + } + } + + // supported arguments + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "bin-name", + Usage: "name for the binary built by Gaper for the executed program", + }, + cli.StringFlag{ + Name: "build-path", + Usage: "path to the program source code", + }, + cli.StringFlag{ + Name: "build-args", + Usage: "build arguments passed to the program", + }, + cli.BoolFlag{ + Name: "verbose", + Usage: "turns on the verbose messages from Gaper", + }, + cli.StringSliceFlag{ + Name: "watch, w", + Usage: "a comma-delimited list of folders or files to watch for changes", + }, + cli.StringSliceFlag{ + Name: "ignore, i", + Usage: "a comma-delimited list of folders or files to ignore for changes", + }, + cli.IntFlag{ + Name: "poll-interval, p", + Value: defaultPoolInterval, + Usage: "how often in milliseconds to poll watched files for changes", + }, + cli.StringSliceFlag{ + Name: "extensions, e", + Value: &defaultExtensions, + Usage: "a comma-delimited list of file extensions to watch for changes", + }, + cli.StringSliceFlag{ + Name: "--no-restart-on, n", + Usage: "don't automatically restart the supervised program if it ends.\n" + + "\t\tIf \"error\", an exit code of 0 will still restart.\n" + + "\t\tIf \"exit\", no restart regardless of exit code.\n" + + "\t\tIf \"success\", no restart only if exit code is 0.", + }, + } + + if err := app.Run(os.Args); err != nil { + logger.Errorf("Error running gaper: %v", err) + os.Exit(1) + } +} + +// nolint: gocyclo +func runGaper(cfg *Config) error { + var err error + logger.Verbose(cfg.Verbose) + logger.Debug("Starting gaper") + + if len(cfg.BuildArgs) == 0 && len(cfg.BuildArgsMerged) > 0 { + cfg.BuildArgs, err = shellwords.Parse(cfg.BuildArgsMerged) + if err != nil { + return err + } + } + + wd, err := os.Getwd() + if err != nil { + return err + } + + // resolve bin name by current folder name + if cfg.BinName == "" { + cfg.BinName = filepath.Base(wd) + } + + if len(cfg.WatchItems) == 0 { + cfg.WatchItems = append(cfg.WatchItems, cfg.BuildPath) + } + + logger.Debug("Settings: ") + logger.Debug(" | bin: ", cfg.BinName) + logger.Debug(" | build path: ", cfg.BuildPath) + logger.Debug(" | build args: ", cfg.BuildArgs) + logger.Debug(" | verbose: ", cfg.Verbose) + logger.Debug(" | watch: ", cfg.WatchItems) + logger.Debug(" | ignore: ", cfg.IgnoreItems) + logger.Debug(" | poll-interval: ", cfg.PollInterval) + logger.Debug(" | extensions: ", cfg.Extensions) + logger.Debug(" | working directory: ", wd) + + builder := NewBuilder(cfg.BuildPath, cfg.BinName, wd, cfg.BuildArgs) + runner := NewRunner(os.Stdout, filepath.Join(wd, builder.Binary()), cfg.ProgramArgs) + + if err = builder.Build(); err != nil { + return fmt.Errorf("build error: %v", err) + } + + shutdown(runner) + + if _, err = runner.Run(); err != nil { + return fmt.Errorf("run error: %v", err) + } + + watcher := NewWatcher(cfg.WatchItems, cfg.IgnoreItems, cfg.Extensions) + + go watcher.Watch() + for { + select { + case event := <-watcher.Events: + logger.Debug("Detected new changed file: ", event) + if err = runner.Kill(); err != nil { + return fmt.Errorf("kill error: %v", err) + } + if err = builder.Build(); err != nil { + return fmt.Errorf("build error: %v", err) + } + if _, err = runner.Run(); err != nil { + return fmt.Errorf("run error: %v", err) + } + case err := <-watcher.Errors: + return fmt.Errorf("error on watching files: %v", err) + default: + time.Sleep(time.Duration(cfg.PollInterval) * time.Millisecond) + } + } +} + +func shutdown(runner Runner) { + c := make(chan os.Signal, 2) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + s := <-c + logger.Debug("Got signal: ", s) + err := runner.Kill() + if err != nil { + logger.Error("Error killing: ", err) + } + os.Exit(1) + }() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..a7063b3 --- /dev/null +++ b/main_test.go @@ -0,0 +1,10 @@ +package main + +import ( + "fmt" + "testing" +) + +func TestGaper(t *testing.T) { + fmt.Println("Sample test") +} diff --git a/runner.go b/runner.go new file mode 100644 index 0000000..1591bfb --- /dev/null +++ b/runner.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "runtime" + "time" +) + +// OSWindows ... +const OSWindows = "windows" + +// Runner ... +type Runner interface { + Run() (*exec.Cmd, error) + Kill() error +} + +type runner struct { + bin string + args []string + writer io.Writer + command *exec.Cmd + starttime time.Time +} + +// NewRunner ... +func NewRunner(writer io.Writer, bin string, args []string) Runner { + return &runner{ + bin: bin, + args: args, + writer: writer, + starttime: time.Now(), + } +} + +// Run ... +func (r *runner) Run() (*exec.Cmd, error) { + logger.Info("Starting program") + + if r.command == nil || r.Exited() { + if err := r.runBin(); err != nil { + return nil, fmt.Errorf("error running: %v", err) + } + + time.Sleep(250 * time.Millisecond) + return r.command, nil + } + + return r.command, nil +} + +// Kill ... +func (r *runner) Kill() error { + if r.command == nil || r.command.Process == nil { + return nil + } + + done := make(chan error) + go func() { + r.command.Wait() // nolint errcheck + close(done) + }() + + // Trying a "soft" kill first + if runtime.GOOS == OSWindows { + if err := r.command.Process.Kill(); err != nil { + return err + } + } else if err := r.command.Process.Signal(os.Interrupt); err != nil { + return err + } + + // Wait for our process to die before we return or hard kill after 3 sec + select { + case <-time.After(3 * time.Second): + if err := r.command.Process.Kill(); err != nil { + return fmt.Errorf("failed to kill: %v", err) + } + case <-done: + } + + r.command = nil + return nil +} + +// Exited ... +func (r *runner) Exited() bool { + return r.command != nil && r.command.ProcessState != nil && r.command.ProcessState.Exited() +} + +func (r *runner) runBin() error { + r.command = exec.Command(r.bin, r.args...) // nolint gas + stdout, err := r.command.StdoutPipe() + if err != nil { + return err + } + + stderr, err := r.command.StderrPipe() + if err != nil { + return err + } + + err = r.command.Start() + if err != nil { + return err + } + + r.starttime = time.Now() + + // TODO: handle or log errors + go io.Copy(r.writer, stdout) // nolint errcheck + go io.Copy(r.writer, stderr) // nolint errcheck + go r.command.Wait() // nolint errcheck + + return nil +} diff --git a/testdata/server/main.go b/testdata/server/main.go new file mode 100644 index 0000000..a4c49df --- /dev/null +++ b/testdata/server/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "html" + "log" + "net/http" +) + +func main() { + http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) // nolint gas + }) + + log.Println("Starting server") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..33bc646 --- /dev/null +++ b/watcher.go @@ -0,0 +1,73 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "time" +) + +// Watcher ... +type Watcher struct { + WatchItems []string + IgnoreItems []string + AllowedExtensions map[string]bool + Events chan string + Errors chan error +} + +// NewWatcher ... +func NewWatcher(watchItems []string, ignoreItems []string, extensions []string) *Watcher { + allowedExts := make(map[string]bool) + for _, ext := range extensions { + allowedExts["."+ext] = true + } + + return &Watcher{ + Events: make(chan string), + Errors: make(chan error), + WatchItems: watchItems, + IgnoreItems: ignoreItems, + AllowedExtensions: allowedExts, + } +} + +var startTime = time.Now() +var errDetectedChange = errors.New("done") + +// Watch ... +func (w *Watcher) Watch() { // nolint: gocyclo + for { + err := filepath.Walk(w.WatchItems[0], func(path string, info os.FileInfo, err error) error { + if path == ".git" && info.IsDir() { + return filepath.SkipDir + } + + for _, x := range w.IgnoreItems { + if x == path { + return filepath.SkipDir + } + } + + // ignore hidden files + if filepath.Base(path)[0] == '.' { + return nil + } + + ext := filepath.Ext(path) + if _, ok := w.AllowedExtensions[ext]; ok && info.ModTime().After(startTime) { + w.Events <- path + startTime = time.Now() + return errDetectedChange + } + + return nil + }) + + if err != nil && err != errDetectedChange { + w.Errors <- err + } + + time.Sleep(500 * time.Millisecond) + } +}