diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2fd91fe --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Stovepipe Studios, Inc. + +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/README.md b/README.md new file mode 100644 index 0000000..c1f8f97 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +tempredis +========= + +`tempredis` is a Go package that makes it easy to start and stop temporary +`redis-server` processes. + +[API documentation](http://godoc.org/github.com/stvp/tempredis) diff --git a/config.go b/config.go new file mode 100644 index 0000000..44404c3 --- /dev/null +++ b/config.go @@ -0,0 +1,8 @@ +package tempredis + +// Config is a key-value map of Redis config settings. +type Config map[string]string + +func (c Config) Socket() string { + return c["unixsocket"] +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..5a86efe --- /dev/null +++ b/example_test.go @@ -0,0 +1,21 @@ +package tempredis + +import ( + "github.com/garyburd/redigo/redis" +) + +func ExampleUsage() { + server, err := Start(Config{"databases": "8"}) + if err != nil { + panic(err) + } + defer server.Term() + + conn, err := redis.Dial("unix", server.Socket()) + defer conn.Close() + if err != nil { + panic(err) + } + + conn.Do("SET", "foo", "bar") +} diff --git a/tempredis.go b/tempredis.go new file mode 100644 index 0000000..d954e3a --- /dev/null +++ b/tempredis.go @@ -0,0 +1,155 @@ +package tempredis + +import ( + "bufio" + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "strings" + "syscall" +) + +const ( + // ready is the string redis-server prints to stdout after starting + // successfully. + ready = "The server is now ready to accept connections" +) + +// Server encapsulates the configuration, starting, and stopping of a single +// redis-server process that is reachable via a local Unix socket. +type Server struct { + dir string + config Config + cmd *exec.Cmd + stdout io.Reader + stdoutBuf bytes.Buffer + stderr io.Reader +} + +// Start initiates a new redis-server process configured with the given +// configuration. redis-server will listen on a temporary local Unix socket. An +// error is returned if redis-server is unable to successfully start for any +// reason. +func Start(config Config) (server *Server, err error) { + if config == nil { + config = Config{} + } + + dir, err := ioutil.TempDir(os.TempDir(), "tempredis") + if err != nil { + return nil, err + } + + if _, ok := config["unixsocket"]; !ok { + config["unixsocket"] = fmt.Sprintf("%s/%s", dir, "redis.sock") + } + if _, ok := config["port"]; !ok { + config["port"] = "0" + } + + server = &Server{ + dir: dir, + config: config, + } + err = server.start() + if err != nil { + return server, err + } + + // Block until Redis is ready to accept connections. + err = server.waitFor(ready) + + return server, err +} + +func (s *Server) start() (err error) { + if s.cmd != nil { + return fmt.Errorf("redis-server has already been started") + } + + s.cmd = exec.Command("redis-server", "-") + + stdin, _ := s.cmd.StdinPipe() + s.stdout, _ = s.cmd.StdoutPipe() + s.stderr, _ = s.cmd.StderrPipe() + + err = s.cmd.Start() + if err == nil { + err = writeConfig(s.config, stdin) + } + + return err +} + +func writeConfig(config Config, w io.WriteCloser) (err error) { + for key, value := range config { + if value == "" { + value = "\"\"" + } + _, err = fmt.Fprintf(w, "%s %s\n", key, value) + if err != nil { + return err + } + } + return w.Close() +} + +// waitFor blocks until redis-server prints the given string to stdout. +func (s *Server) waitFor(search string) (err error) { + var line string + + scanner := bufio.NewScanner(s.stdout) + for scanner.Scan() { + line = scanner.Text() + fmt.Fprintf(&s.stdoutBuf, "%s\n", line) + if strings.Contains(line, search) { + return nil + } + } + err = scanner.Err() + if err == nil { + err = io.EOF + } + return err +} + +// Socket returns the full path to the local redis-server Unix socket. +func (s *Server) Socket() string { + return s.config.Socket() +} + +// Stdout blocks until redis-server returns and then returns the full stdout +// output. +func (s *Server) Stdout() string { + io.Copy(&s.stdoutBuf, s.stdout) + return s.stdoutBuf.String() +} + +// Stderr blocks until redis-server returns and then returns the full stdout +// output. +func (s *Server) Stderr() string { + bytes, _ := ioutil.ReadAll(s.stderr) + return string(bytes) +} + +// Term gracefully shuts down redis-server. It returns an error if redis-server +// fails to terminate. +func (s *Server) Term() (err error) { + return s.signalAndCleanup(syscall.SIGTERM) +} + +// Kill forcefully shuts down redis-server. It returns an error if redis-server +// fails to die. +func (s *Server) Kill() (err error) { + return s.signalAndCleanup(syscall.SIGKILL) +} + +func (s *Server) signalAndCleanup(sig syscall.Signal) error { + s.cmd.Process.Signal(sig) + _, err := s.cmd.Process.Wait() + os.RemoveAll(s.dir) + return err +} diff --git a/tempredis_test.go b/tempredis_test.go new file mode 100644 index 0000000..129eef1 --- /dev/null +++ b/tempredis_test.go @@ -0,0 +1,63 @@ +package tempredis + +import ( + "testing" + + "github.com/garyburd/redigo/redis" +) + +func TestServer(t *testing.T) { + server, err := Start(Config{"databases": "3"}) + if err != nil { + t.Fatal(err) + } + defer server.Kill() + + r, err := redis.Dial("unix", server.Socket()) + if err != nil { + t.Fatal(err) + } + defer r.Close() + + databases, err := redis.Strings(r.Do("CONFIG", "GET", "databases")) + if err != nil { + t.Fatal(err) + } + if databases[1] != "3" { + t.Fatalf("databases config should be 3, but got %s", databases) + } + + if err := server.Term(); err != nil { + t.Fatal(err) + } + if err := server.Term(); err == nil { + t.Fatal("stopping an already stopped server should fail") + } +} + +func TestStartWithDefaultConfig(t *testing.T) { + server, err := Start(nil) + if err != nil { + t.Fatal(err) + } + defer server.Kill() + + r, err := redis.Dial("unix", server.Socket()) + if err != nil { + t.Fatal(err) + } + defer r.Close() + + _, err = r.Do("PING") + if err != nil { + t.Fatal(err) + } +} + +func TestStartFail(t *testing.T) { + server, err := Start(Config{"oops": "borked"}) + if err == nil { + t.Fatal("expected error, got nil") + } + defer server.Kill() +}