From ecd770dcaf3ab852cdb8bfc2d1d33f0d9cbe7807 Mon Sep 17 00:00:00 2001 From: gwoo Date: Tue, 31 Dec 2013 11:02:48 -0800 Subject: [PATCH] Initial Commit. TODO: log rotation --- LICENSE | 19 +++ README.md | 37 ++++++ config.go | 49 ++++++++ config_test.go | 38 ++++++ goforever.go | 125 ++++++++++++++++++++ goforever.toml | 21 ++++ http.go | 156 +++++++++++++++++++++++++ http_test.go | 59 ++++++++++ process.go | 299 ++++++++++++++++++++++++++++++++++++++++++++++++ process_test.go | 56 +++++++++ 10 files changed, 859 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.go create mode 100644 config_test.go create mode 100644 goforever.go create mode 100644 goforever.toml create mode 100644 http.go create mode 100644 http_test.go create mode 100644 process.go create mode 100644 process_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9b7df0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo) + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e59aef --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +## Goforever + +Config based process manager. Goforever could be used in place of supervisor, upstart, launchctl, etc. +Goforever will start an http server on the specified port. + + Usage of ./goforever: + -conf="goforever.toml": Path to config file. + -d=false: Daemonize goforever. Must be first flag + -password="test": Password for basic auth. + -port=8080: Port for the server. + -username="demo": Username for basic auth. + +## CLI + list List processes. + show Show a process. + start Start a process. + stop Stop a process. + restart Restart a process. + + +## HTTP API + +Return a list of managed processes + + GET host:port/ + +Start the process + + POST host:port/:name + +Restart the process + + PUT host:port/:name + +Stop the process + + DELETE host:port/:name diff --git a/config.go b/config.go new file mode 100644 index 0000000..26a85e7 --- /dev/null +++ b/config.go @@ -0,0 +1,49 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Username string + Password string + Processes []*Process `toml:"process"` +} + +func (c Config) Keys() []string { + keys := []string{} + for _, p := range c.Processes { + keys = append(keys, p.Name) + } + return keys +} + +func (c Config) Get(key string) *Process { + for _, p := range c.Processes { + if p.Name == key { + return p + } + } + return nil +} + +func LoadConfig(file string) (*Config, error) { + if string(file[0]) != "/" { + wd, err := os.Getwd() + if err != nil { + return nil, err + } + file = filepath.Join(wd, file) + } + var c *Config + if _, err := toml.DecodeFile(file, &c); err != nil { + return nil, err + } + return c, nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..17c311b --- /dev/null +++ b/config_test.go @@ -0,0 +1,38 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "testing" +) + +func TestNewConfig(t *testing.T) { + r, err := LoadConfig("goforever.toml") + + if err != nil { + t.Errorf("Error creating config %s.", err) + return + } + if r == nil { + t.Errorf("Expected %#v. Result %#v\n", r, nil) + } +} + +func TestConfigGet(t *testing.T) { + c, _ := LoadConfig("goforever.toml") + ex := "example/example.pid" + r := string(c.Get("example").Pidfile) + if ex != r { + t.Errorf("Expected %#v. Result %#v\n", ex, r) + } +} + +func TestConfigKeys(t *testing.T) { + c, _ := LoadConfig("goforever.toml") + ex := []string{"example", "example-panic"} + r := c.Keys() + if len(ex) != len(r) { + t.Errorf("Expected %#v. Result %#v\n", ex, r) + } +} diff --git a/goforever.go b/goforever.go new file mode 100644 index 0000000..034f647 --- /dev/null +++ b/goforever.go @@ -0,0 +1,125 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/gwoo/greq" +) + +var d = flag.Bool("d", false, "Daemonize goforever. Must be first flag") +var conf = flag.String("conf", "goforever.toml", "Path to config file.") +var port = flag.Int("port", 8080, "Port for the server.") +var username = flag.String("username", "demo", "Username for basic auth.") +var password = flag.String("password", "test", "Password for basic auth.") +var server string +var config *Config + +var Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + usage := ` +Process subcommands + list List processes. + show Show a process. + start Start a process. + stop Stop a process. + restart Restart a process. +` + fmt.Fprintln(os.Stderr, usage) +} + +func init() { + setConfig() + setHost() + daemon = &Process{ + Name: "goforever", + Args: []string{"./goforever"}, + Command: "goforever", + Pidfile: "goforever.pid", + Logfile: "goforever.debug.log", + Errfile: "goforever.errors.log", + Respawn: 1, + } + flag.Usage = Usage +} + +func main() { + flag.Parse() + daemon.Name = "goforever" + if *d == true { + daemon.Args = append(daemon.Args, os.Args[2:]...) + daemon.start(daemon.Name) + return + } + if len(flag.Args()) > 0 { + fmt.Printf("%s\n", Cli()) + return + } + if len(flag.Args()) == 0 { + RunDaemon() + HttpServer() + return + } +} + +func Cli() string { + sub := flag.Arg(0) + name := flag.Arg(1) + var o []byte + + if sub == "list" { + o, _ = greq.Get("/") + } + if name != "" { + switch sub { + case "show": + o, _ = greq.Get("/" + name) + case "start": + o, _ = greq.Post("/"+name, nil) + case "stop": + o, _ = greq.Delete("/" + name) + case "restart": + o, _ = greq.Put("/"+name, nil) + } + } + return string(o) +} + +func RunDaemon() { + fmt.Printf("Running %s.\n", daemon.Name) + daemon.children = make(map[string]*Process, 0) + for _, name := range config.Keys() { + daemon.children[name] = config.Get(name) + } + daemon.run() +} + +func setConfig() { + c, err := LoadConfig(*conf) + if err != nil { + log.Fatalf("Config error: %s", err) + return + } + config = c + + if config.Username != "" { + username = &config.Username + } + if config.Password != "" { + password = &config.Password + } +} + +func setHost() { + scheme := "https" + if isHttps() == false { + scheme = "http" + } + greq.Host = fmt.Sprintf("%s://%s:%s@0.0:%d", scheme, *username, *password, *port) +} diff --git a/goforever.toml b/goforever.toml new file mode 100644 index 0000000..fde0f41 --- /dev/null +++ b/goforever.toml @@ -0,0 +1,21 @@ +username = "go" +password = "forever" + + +[[process]] +name = "example-panic" +command = "./example/example-panic" +pidfile = "example/example-panic.pid" +logfile = "example/logs/example-panic.debug.log" +errfile = "example/logs/example-panic.errors.log" +respawn = 1 +ping = "30s" + +[[process]] +name = "example" +command = "./example/example" +pidfile = "example/example.pid" +logfile = "example/logs/example.debug.log" +errfile = "example/logs/example.errors.log" +respawn = 1 + diff --git a/http.go b/http.go new file mode 100644 index 0000000..d0d29cd --- /dev/null +++ b/http.go @@ -0,0 +1,156 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" +) + +var daemon *Process + +func HttpServer() { + http.HandleFunc("/favicon.ico", http.NotFound) + http.HandleFunc("/", AuthHandler(Handler)) + fmt.Printf("goforever serving port %d\n", *port) + + if isHttps() == false { + http.ListenAndServe(fmt.Sprintf(":%d", *port), nil) + return + } + log.Printf("SSL enabled.\n") + http.ListenAndServeTLS(fmt.Sprintf(":%d", *port), "cert.pem", "key.pem", nil) +} + +func isHttps() bool { + _, cerr := os.Open("cert.pem") + _, kerr := os.Open("key.pem") + + if os.IsNotExist(cerr) || os.IsNotExist(kerr) { + return false + } + return true +} + +func Handler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "DELETE": + DeleteHandler(w, r) + return + case "POST": + PostHandler(w, r) + return + case "PUT": + PutHandler(w, r) + return + case "GET": + GetHandler(w, r) + return + } +} + +func GetHandler(w http.ResponseWriter, r *http.Request) { + var output []byte + var err error + switch r.URL.Path[1:] { + case "": + output, err = json.Marshal(daemon.children.keys()) + default: + output, err = json.Marshal(daemon.children.get(r.URL.Path[1:])) + } + if err != nil { + log.Printf("Get Error: %#v", err) + return + } + fmt.Fprintf(w, "%s", output) +} + +func PostHandler(w http.ResponseWriter, r *http.Request) { + name := r.URL.Path[1:] + p := daemon.children.get(name) + if p == nil { + fmt.Fprintf(w, "%s does not exist.", name) + return + } + cp, _ := p.find() + if cp != nil { + fmt.Fprintf(w, "%s already running.", name) + return + } + ch := RunProcess(name, p) + fmt.Fprintf(w, "%s", <-ch) +} + +func PutHandler(w http.ResponseWriter, r *http.Request) { + name := r.URL.Path[1:] + p := daemon.children.get(name) + if p == nil { + fmt.Fprintf(w, "%s does not exist.", name) + return + } + p.find() + ch := p.restart() + fmt.Fprintf(w, "%s", <-ch) +} + +func DeleteHandler(w http.ResponseWriter, r *http.Request) { + name := r.URL.Path[1:] + p := daemon.children.get(name) + if p == nil { + fmt.Fprintf(w, "%s does not exist.", name) + return + } + p.find() + p.stop() + fmt.Fprintf(w, "%s stopped.", name) +} + +func AuthHandler(fn func(http.ResponseWriter, *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + url := r.URL + for k, v := range r.Header { + fmt.Printf(" %s = %s\n", k, v[0]) + } + auth, ok := r.Header["Authorization"] + if !ok { + log.Printf("Unauthorized access to %s", url) + w.Header().Add("WWW-Authenticate", "basic realm=\"host\"") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, "Not Authorized.") + return + } + encoded := strings.Split(auth[0], " ") + if len(encoded) != 2 || encoded[0] != "Basic" { + log.Printf("Strange Authorization %q", auth) + w.WriteHeader(http.StatusBadRequest) + return + } + decoded, err := base64.StdEncoding.DecodeString(encoded[1]) + if err != nil { + log.Printf("Cannot decode %q: %s", auth, err) + w.WriteHeader(http.StatusBadRequest) + return + } + parts := strings.Split(string(decoded), ":") + if len(parts) != 2 { + log.Printf("Unknown format for credentials %q", decoded) + w.WriteHeader(http.StatusBadRequest) + return + } + if parts[0] == *username && parts[1] == *password { + fn(w, r) + return + } + log.Printf("Unauthorized access to %s", url) + w.Header().Add("WWW-Authenticate", "basic realm=\"host\"") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, "Not Authorized.") + return + } +} diff --git a/http_test.go b/http_test.go new file mode 100644 index 0000000..fb954fb --- /dev/null +++ b/http_test.go @@ -0,0 +1,59 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gwoo/greq" +) + +func TestListHandler(t *testing.T) { + daemon.children = children{ + "test": &Process{Name: "test"}, + } + body, _ := newTestResponse("GET", "/", nil) + ex := fmt.Sprintf("%s", string([]byte(`["test"]`))) + r := fmt.Sprintf("%s", string(body)) + if ex != r { + t.Errorf("\nExpected = %v\nResult = %v\n", ex, r) + } +} + +func TestShowHandler(t *testing.T) { + daemon.children = children{ + "test": &Process{Name: "test"}, + } + body, _ := newTestResponse("GET", "/test", nil) + e := []byte(`{"Name":"test","Command":"","Args":null,"Pidfile":"","Logfile":"","Errfile":"","Path":"","Respawn":0,"Ping":"","Pid":0,"Status":""}`) + ex := fmt.Sprintf("%s", e) + r := fmt.Sprintf("%s", body) + if ex != r { + t.Errorf("\nExpected = %v\nResult = %v\n", ex, r) + } +} + +func TestPostHandler(t *testing.T) { + daemon.children = children{ + "test": &Process{Name: "test", Command: "/bin/echo", Args: []string{"woohoo"}}, + } + body, _ := newTestResponse("POST", "/test", nil) + e := []byte(`{"Name":"test","Command":"/bin/echo","Args":["woohoo"],"Pidfile":"","Logfile":"","Errfile":"","Path":"","Respawn":0,"Ping":"","Pid":0,"Status":"stopped"}`) + ex := fmt.Sprintf("%s", e) + r := fmt.Sprintf("%s", body) + if ex != r { + t.Errorf("\nExpected = %v\nResult = %v\n", ex, r) + } +} + +func newTestResponse(method string, path string, body io.Reader) ([]byte, *http.Response) { + ts := httptest.NewServer(http.HandlerFunc(Handler)) + defer ts.Close() + url := ts.URL + path + return greq.Request(method, url, body) +} diff --git a/process.go b/process.go new file mode 100644 index 0000000..7e05514 --- /dev/null +++ b/process.go @@ -0,0 +1,299 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "strconv" + "time" +) + +var ping = "1m" + +//Run the process +func RunProcess(name string, p *Process) chan *Process { + ch := make(chan *Process) + go func() { + p.start(name) + p.ping(ping, func(time time.Duration, p *Process) { + if p.Pid > 0 { + p.respawns = 0 + fmt.Printf("%s refreshed after %s.\n", p.Name, time) + p.Status = "running" + } + }) + go p.watch() + ch <- p + }() + return ch +} + +type Process struct { + Name string + Command string + Args []string + Pidfile Pidfile + Logfile string + Errfile string + Path string + Respawn int + Ping string + Pid int + Status string + x *os.Process + respawns int + children children +} + +func (p *Process) String() string { + js, err := json.Marshal(p) + if err != nil { + log.Print(err) + return "" + } + return string(js) +} + +//Find a process by name +func (p *Process) find() (*os.Process, error) { + if p.Pidfile == "" { + return nil, errors.New("Pidfile is empty.") + } + if pid := p.Pidfile.read(); pid > 0 { + process, err := os.FindProcess(pid) + if err != nil { + return nil, err + } + p.x = process + p.Pid = process.Pid + fmt.Printf("%s is %#v\n", p.Name, process.Pid) + p.Status = "running" + return process, nil + } + return nil, errors.New(fmt.Sprintf("Could not find process %s.", p.Name)) +} + +//Start the process +func (p *Process) start(name string) { + p.Name = name + wd, _ := os.Getwd() + proc := &os.ProcAttr{ + Dir: wd, + Env: os.Environ(), + Files: []*os.File{ + os.Stdin, + NewLog(p.Logfile), + NewLog(p.Errfile), + }, + } + process, err := os.StartProcess(p.Command, p.Args, proc) + if err != nil { + log.Fatalf("%s failed. %s", p.Name, err) + return + } + err = p.Pidfile.write(process.Pid) + if err != nil { + log.Printf("%s pidfile error: %s", p.Name, err) + return + } + p.x = process + p.Pid = process.Pid + fmt.Printf("%s is %#v\n", p.Name, process.Pid) + p.Status = "started" +} + +//Stop the process +func (p *Process) stop() { + if p.x != nil { + // p.x.Kill() this seems to cause trouble + cmd := exec.Command("kill", fmt.Sprintf("%d", p.x.Pid)) + _, err := cmd.CombinedOutput() + if err != nil { + log.Println(err) + } + p.children.stop("all") + } + p.release("stopped") + fmt.Printf("%s stopped.\n", p.Name) + +} + +//Release process and remove pidfile +func (p *Process) release(status string) { + if p.x != nil { + p.x.Release() + } + p.Pid = 0 + p.Pidfile.delete() + p.Status = status +} + +//Restart the process +func (p *Process) restart() chan *Process { + p.stop() + fmt.Fprintf(os.Stderr, "%s restarted.\n", p.Name) + return RunProcess(p.Name, p) +} + +//Run callback on the process after given duration. +func (p *Process) ping(duration string, f func(t time.Duration, p *Process)) { + if p.Ping != "" { + duration = p.Ping + } + t, err := time.ParseDuration(duration) + if err != nil { + t, _ = time.ParseDuration(ping) + } + go func() { + select { + case <-time.After(t): + f(t, p) + } + }() +} + +//Watch the process +func (p *Process) watch() { + if p.x == nil { + p.release("stopped") + return + } + status := make(chan *os.ProcessState) + died := make(chan error) + go func() { + state, err := p.x.Wait() + if err != nil { + died <- err + return + } + status <- state + }() + select { + case s := <-status: + if p.Status == "stopped" { + return + } + fmt.Fprintf(os.Stderr, "%s %s\n", p.Name, s) + fmt.Fprintf(os.Stderr, "%s success = %#v\n", p.Name, s.Success()) + fmt.Fprintf(os.Stderr, "%s exited = %#v\n", p.Name, s.Exited()) + p.respawns++ + if p.respawns > p.Respawn { + p.release("exited") + log.Printf("%s respawn limit reached.\n", p.Name) + return + } + fmt.Fprintf(os.Stderr, "%s respawns = %#v\n", p.Name, p.respawns) + p.restart() + p.Status = "restarted" + case err := <-died: + p.release("killed") + log.Printf("%d %s killed = %#v", p.x.Pid, p.Name, err) + } +} + +//Run child processes +func (p *Process) run() { + for name, p := range p.children { + RunProcess(name, p) + } +} + +//Child processes. +type children map[string]*Process + +//Stringify +func (c children) String() string { + js, err := json.Marshal(c) + if err != nil { + log.Print(err) + return "" + } + return string(js) +} + +//Get child processes names. +func (c children) keys() []string { + keys := []string{} + for k, _ := range c { + keys = append(keys, k) + } + return keys +} + +//Get child process. +func (c children) get(key string) *Process { + if v, ok := c[key]; ok { + return v + } + return nil +} + +func (c children) stop(name string) { + if name == "all" { + for name, p := range c { + p.stop() + delete(c, name) + } + return + } + p := c.get(name) + p.stop() + delete(c, name) +} + +type Pidfile string + +//Read the pidfile. +func (f *Pidfile) read() int { + data, err := ioutil.ReadFile(string(*f)) + if err != nil { + return 0 + } + pid, err := strconv.ParseInt(string(data), 0, 32) + if err != nil { + return 0 + } + return int(pid) +} + +//Write the pidfile. +func (f *Pidfile) write(data int) error { + err := ioutil.WriteFile(string(*f), []byte(strconv.Itoa(data)), 0660) + if err != nil { + return err + } + return nil +} + +//Delete the pidfile +func (f *Pidfile) delete() bool { + _, err := os.Stat(string(*f)) + if err != nil { + return true + } + err = os.Remove(string(*f)) + if err == nil { + return true + } + return false +} + +//Create a new file for logging +func NewLog(path string) *os.File { + if path == "" { + return nil + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0660) + if err != nil { + log.Fatalf("%s", err) + return nil + } + return file +} diff --git a/process_test.go b/process_test.go new file mode 100644 index 0000000..8c72814 --- /dev/null +++ b/process_test.go @@ -0,0 +1,56 @@ +// goforever - processes management +// Copyright (c) 2013 Garrett Woodworth (https://github.com/gwoo). + +package main + +import ( + "testing" +) + +func TestPidfile(t *testing.T) { + c := &Config{"", "", + []*Process{&Process{ + Name: "test", + Pidfile: "test.pid", + }}, + } + p := c.Get("test") + err := p.Pidfile.write(100) + if err != nil { + t.Errorf("Error: %s.", err) + return + } + ex := 100 + r := p.Pidfile.read() + if ex != r { + t.Errorf("Expected %#v. Result %#v\n", ex, r) + } + + s := p.Pidfile.delete() + if s != true { + t.Error("Failed to remove pidfile.") + return + } +} + +func TestProcessStart(t *testing.T) { + c := &Config{ + "", "", + []*Process{&Process{ + Name: "example", + Command: "example/example", + Pidfile: "example/example.pid", + Logfile: "example/logs/example.debug.log", + Errfile: "example/logs/example.errors.log", + Respawn: 3, + }}, + } + p := c.Get("example") + p.start("example") + ex := 0 + r := p.x.Pid + if ex >= r { + t.Errorf("Expected %#v < %#v\n", ex, r) + } + p.stop() +}