Initial commit

This commit is contained in:
Max Claus Nunes 2018-06-16 21:22:21 -03:00
commit 8e02bc3348
16 changed files with 850 additions and 0 deletions

20
.editorconfig Normal file
View File

@ -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

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# binaries
*.exe
gaper
srv
vendor
coverage.out

20
.travis.yml Normal file
View File

@ -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

51
CONTRIBUTING.md Normal file
View File

@ -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 ./...
```

45
Gopkg.lock generated Normal file
View File

@ -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

15
Gopkg.toml Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

41
Makefile Normal file
View File

@ -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

72
README.md Normal file
View File

@ -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.

78
builder.go Normal file
View File

@ -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
}

61
loggger.go Normal file
View File

@ -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...)
}

201
main.go Normal file
View File

@ -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)
}()
}

10
main_test.go Normal file
View File

@ -0,0 +1,10 @@
package main
import (
"fmt"
"testing"
)
func TestGaper(t *testing.T) {
fmt.Println("Sample test")
}

119
runner.go Normal file
View File

@ -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
}

17
testdata/server/main.go vendored Normal file
View File

@ -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))
}

73
watcher.go Normal file
View File

@ -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)
}
}