├── .gitignore ├── History.md ├── Makefile ├── Readme.md ├── cmd └── stack │ └── main.go ├── examples ├── nodejs └── nodejs-canonical └── pkg ├── commit-log ├── commit_log.go └── commit_log_test.go ├── logger ├── interactive │ └── interactive.go ├── interface │ └── interface.go ├── plain │ └── plain.go └── tty │ └── tty.go ├── provisioner ├── provisioner.go └── provisioner_test.go └── rewriter ├── fixtures ├── a.sh ├── b.sh └── c.sh ├── rewriter.go └── rewriter_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | provision 2 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visionmedia/stack/2c6f0acb86921a5b36366a5b14a391935a244dfe/History.md -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | test: 3 | @go test ./... 4 | 5 | .PHONY: test -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # Stack 3 | 4 | Minimalist provisioning tool written in Go. 5 | 6 | ![golang provisioning tool stack](https://dl.dropboxusercontent.com/u/6396913/stack/provision.gif) 7 | 8 | ```sh 9 | # Node 10 | curl -L# http://nodejs.org/dist/v0.10.30/node-v0.10.30-darwin-x64.tar.gz | tar -zx --strip 1 -C /usr/local 11 | 12 | # Node version manager 13 | npm install -g n 14 | 15 | # Node releases 16 | n 0.8.28 17 | n 0.10.30 18 | n 0.11.13 19 | ``` 20 | 21 | ## Installation 22 | 23 | Via go-get: 24 | 25 | ``` 26 | $ go get github.com/visionmedia/stack/cmd/stack 27 | ``` 28 | 29 | Via binaries: 30 | 31 | ``` 32 | soonnnnn 33 | ``` 34 | 35 | ## Usage 36 | 37 | ``` 38 | Usage: 39 | stack [--list] [--no-color] [--verbose] 40 | stack -h | --help 41 | stack --version 42 | 43 | Options: 44 | -C, --no-color output with color disabled 45 | -l, --list output commit status 46 | -V, --verbose output command stdio 47 | -h, --help output help information 48 | -v, --version output version 49 | ``` 50 | 51 | ## About 52 | 53 | There are a lot of great provisioning tools out there, but as far as 54 | I know most of them are part of much larger systems, use unfamiliar DSLs, 55 | or rely on the presence of an interpreter for scripting languages such as Ruby or Python. 56 | 57 | I'm not suggesting this tool is better than any existing solution but I really wanted 58 | something that looked and behaved like a regular shell script. Also since it's written in Go it's very simple to curl the binary on to any existing system. 59 | 60 | The choice of using shell commands makes this tool less declarative than 61 | some of the alternatives, however I think it's a good fit for the goal 62 | of being a minimalistic solution. 63 | 64 | ## How it works 65 | 66 | Stack behaves like shell scripts with `set -e`, as it will exit on failure. Unlike 67 | shell scripts a commit log is used in order to prevent re-execution of previous commands. 68 | 69 | The log is held at ~/.provision.log (by default), this file keeps 70 | track of commands which have already completed. Once a command is run 71 | and successfully exits, it is considered complete, at which time the 72 | SHA1 of the command is written to this file. Subsequent runs will see 73 | the SHA and ignore the command. 74 | 75 | The commit log is shared between any number of provision files, this 76 | means the same command run in a different provisioning script will 77 | no-op if it has already been successfully run. 78 | 79 | If a command line is modified it will result in a different hash, 80 | thus it will be re-run. 81 | 82 | This gif illustrates how exiting after the initial "commit" will cause it to be ignored 83 | the second time around: 84 | 85 | ![stack commits](https://dl.dropboxusercontent.com/u/6396913/stack/provision-commits.gif) 86 | 87 | ## Syntax 88 | 89 | The syntax has two flavours, the shell-like syntax, and the canonical version which pkg/provisioner consumes. For example here is the shell version of a small node.js provisioning script: 90 | 91 | ```sh 92 | # Node 93 | curl -L# http://nodejs.org/dist/v0.10.30/node-v0.10.30-darwin-x64.tar.gz | tar -zx --strip 1 -C /usr/local 94 | 95 | # Node version manager 96 | npm install -g n 97 | 98 | # Node releases 99 | n 0.8.28 100 | n 0.10.30 101 | n 0.11.13 102 | ``` 103 | 104 | Here's the same script after it's rewritten to the canonical syntax: 105 | 106 | ```sh 107 | 108 | LOG Node 109 | RUN curl -L# http://nodejs.org/dist/v0.10.30/node-v0.10.30-darwin-x64.tar.gz | tar -zx --strip 1 -C /usr/local 110 | 111 | LOG Node version manager 112 | RUN npm install -g n 113 | 114 | LOG Node releases 115 | RUN n 0.8.28 116 | RUN n 0.10.30 117 | RUN n 0.11.13 118 | ``` 119 | 120 | ## Commands 121 | 122 | Currently only a few commands are supported, however more 123 | may be added in the future to simplify common processes, 124 | provide concurrency, and so on. 125 | 126 | Open an issue if there's something you'd like to see! 127 | 128 | ### RUN 129 | 130 | `RUN ` executes a command through `/bin/sh`, so shell 131 | features such as globbing, brace expansion and pipelines will 132 | work as expected. 133 | 134 | If the `` exits > 0 then commit is a failure and will 135 | not be written to the log. 136 | 137 | Lines without a command are considered to be `RUN` lines. 138 | 139 | ### LOG 140 | 141 | `LOG ` simply outputs a log message to stdio. 142 | 143 | Aliased as `#`. 144 | 145 | ### INCLUDE 146 | 147 | `INCLUDE ` reads the file at ``, rewrites it 148 | and injects it into the location of this command in the 149 | pre-processing step. 150 | 151 | The include `` is relative to the CWD. 152 | 153 | Aliased as `.` and `source`. 154 | 155 | ## Options 156 | 157 | ### --verbose 158 | 159 | By default output is suppressed, however `--verbose` will stream std{err,out}: 160 | 161 | ![stack provisioning verbose](https://dl.dropboxusercontent.com/u/6396913/stack/provision-verbose.gif) 162 | 163 | ## Running tests 164 | 165 | All tests: 166 | 167 | ``` 168 | $ make test 169 | ``` 170 | 171 | Individual tests: 172 | 173 | ``` 174 | $ cd pkg/rewriter 175 | $ go test 176 | ``` 177 | 178 | # License 179 | 180 | MIT 181 | -------------------------------------------------------------------------------- /cmd/stack/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/visionmedia/stack/pkg/logger/interactive" 4 | import "github.com/visionmedia/stack/pkg/logger/plain" 5 | import "github.com/visionmedia/stack/pkg/logger/tty" 6 | import "github.com/visionmedia/stack/pkg/provisioner" 7 | import "github.com/visionmedia/docopt" 8 | import "path/filepath" 9 | import "os/signal" 10 | import "os/user" 11 | import "syscall" 12 | import "fmt" 13 | import "os" 14 | 15 | const Usage = ` 16 | Usage: 17 | stack [--list] [--no-color] [--verbose] 18 | stack -h | --help 19 | stack --version 20 | 21 | Options: 22 | -C, --no-color output with color disabled 23 | -l, --list output commit status 24 | -V, --verbose output command stdio 25 | -h, --help output help information 26 | -v, --version output version 27 | 28 | ` 29 | 30 | func check(err error) { 31 | if err != nil { 32 | panic(err) 33 | } 34 | } 35 | 36 | func main() { 37 | args, err := docopt.Parse(Usage, nil, true, provisioner.Version, false) 38 | check(err) 39 | 40 | u, err := user.Current() 41 | check(err) 42 | 43 | file := args[""].(string) 44 | f, err := os.Open(file) 45 | check(err) 46 | 47 | path := filepath.Join(u.HomeDir, ".provision.log") 48 | p := provisioner.New(f, path) 49 | p.DryRun = args["--list"].(bool) 50 | p.Verbose = args["--verbose"].(bool) 51 | 52 | switch { 53 | case args["--no-color"].(bool): 54 | p.Log = plain_logger.New(os.Stdout) 55 | case p.Verbose: 56 | p.Log = tty_logger.New(os.Stdout) 57 | default: 58 | p.Log = interactive_logger.New(os.Stdout) 59 | } 60 | 61 | ch := make(chan os.Signal, 1) 62 | signal.Notify(ch, syscall.SIGINT) 63 | 64 | hide() 65 | go func() { 66 | <-ch 67 | show() 68 | os.Exit(1) 69 | }() 70 | 71 | p.Run() 72 | show() 73 | } 74 | 75 | func show() { 76 | fmt.Printf("\033[?25h\n") 77 | } 78 | 79 | func hide() { 80 | fmt.Printf("\033[?25l\n") 81 | } 82 | -------------------------------------------------------------------------------- /examples/nodejs: -------------------------------------------------------------------------------- 1 | 2 | # Node 3 | curl -L# http://nodejs.org/dist/v0.10.30/node-v0.10.30-darwin-x64.tar.gz | tar -zx --strip 1 -C /usr/local 4 | 5 | # Node version manager 6 | npm install -g n 7 | 8 | # Node releases 9 | n 0.8.28 10 | n 0.10.30 11 | n 0.11.13 12 | -------------------------------------------------------------------------------- /examples/nodejs-canonical: -------------------------------------------------------------------------------- 1 | 2 | LOG Node 3 | RUN curl -L# http://nodejs.org/dist/v0.10.30/node-v0.10.30-darwin-x64.tar.gz | tar -zx --strip 1 -C /usr/local 4 | 5 | LOG Node version manager 6 | RUN npm install -g n 7 | 8 | LOG Node releases 9 | RUN n 0.8.28 10 | RUN n 0.10.30 11 | RUN n 0.11.13 12 | -------------------------------------------------------------------------------- /pkg/commit-log/commit_log.go: -------------------------------------------------------------------------------- 1 | // 2 | // Commit log which stores sha1 hashes on disk 3 | // so that re-runs of the same command may be 4 | // ignored. 5 | // 6 | package commit_log 7 | 8 | import "path/filepath" 9 | import "strings" 10 | import "bufio" 11 | import "sync" 12 | import "io" 13 | import "os" 14 | 15 | type CommitLog struct { 16 | Path string 17 | commits map[string]bool 18 | file *os.File 19 | sync.Mutex 20 | } 21 | 22 | // New commit log stored at the given `path`. 23 | func New(path string) (*CommitLog, error) { 24 | c := &CommitLog{ 25 | Path: path, 26 | commits: make(map[string]bool), 27 | } 28 | 29 | err := c.Open() 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return c, nil 35 | } 36 | 37 | // Open the commit log and read its contents. 38 | // The directory will be created if it does not exist. 39 | func (c *CommitLog) Open() error { 40 | dir := filepath.Dir(c.Path) 41 | 42 | err := os.MkdirAll(dir, 0644) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | file, err := os.OpenFile(c.Path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0755) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | c.file = file 53 | r := bufio.NewReader(file) 54 | 55 | for { 56 | commit, err := r.ReadString('\n') 57 | 58 | if err == io.EOF { 59 | break 60 | } 61 | 62 | if err != nil { 63 | return err 64 | } 65 | 66 | commit = strings.Trim(commit, "\n") 67 | c.commits[commit] = true 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // Clear the commit log and re-open. 74 | func (c *CommitLog) Clear() error { 75 | err := c.Close() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | err = os.Remove(c.Path) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return c.Open() 86 | } 87 | 88 | // Close the commit log. 89 | func (c *CommitLog) Close() error { 90 | c.Lock() 91 | defer c.Unlock() 92 | c.commits = make(map[string]bool) 93 | return c.file.Close() 94 | } 95 | 96 | // Add `commit` and write to disk. 97 | func (c *CommitLog) Add(commit string) error { 98 | c.Lock() 99 | defer c.Unlock() 100 | 101 | c.commits[commit] = true 102 | 103 | _, err := io.WriteString(c.file, commit+"\n") 104 | return err 105 | } 106 | 107 | // Has checks if `commit` exists. 108 | func (c *CommitLog) Has(commit string) bool { 109 | c.Lock() 110 | defer c.Unlock() 111 | return c.commits[commit] 112 | } 113 | 114 | // Length of commit log. 115 | func (c *CommitLog) Length() int { 116 | c.Lock() 117 | defer c.Unlock() 118 | return len(c.commits) 119 | } 120 | -------------------------------------------------------------------------------- /pkg/commit-log/commit_log_test.go: -------------------------------------------------------------------------------- 1 | package commit_log 2 | 3 | import "github.com/bmizerany/assert" 4 | import "testing" 5 | import "os" 6 | 7 | func check(err error) { 8 | if err != nil { 9 | panic(err) 10 | } 11 | } 12 | 13 | // Test new with no file. 14 | func TestNewEmpty(t *testing.T) { 15 | os.Remove("/tmp/commits.log") 16 | 17 | c, err := New("/tmp/commits.log") 18 | check(err) 19 | 20 | assert.Equal(t, 0, c.Length()) 21 | assert.Equal(t, false, c.Has("foo")) 22 | } 23 | 24 | // Test new with commits. 25 | func TestNewCommits(t *testing.T) { 26 | os.Remove("/tmp/commits.log") 27 | 28 | c, err := New("/tmp/commits.log") 29 | check(err) 30 | 31 | check(c.Add("foo")) 32 | check(c.Add("bar")) 33 | check(c.Add("baz")) 34 | 35 | check(c.Close()) 36 | check(c.Open()) 37 | 38 | assert.Equal(t, 3, c.Length()) 39 | assert.Equal(t, true, c.Has("foo")) 40 | assert.Equal(t, true, c.Has("bar")) 41 | assert.Equal(t, true, c.Has("baz")) 42 | assert.Equal(t, false, c.Has("something")) 43 | } 44 | 45 | // Test clearing the log. 46 | func TestClear(t *testing.T) { 47 | os.Remove("/tmp/commits.log") 48 | 49 | c, err := New("/tmp/commits.log") 50 | check(err) 51 | 52 | check(c.Add("foo")) 53 | check(c.Add("bar")) 54 | check(c.Add("baz")) 55 | 56 | check(c.Clear()) 57 | 58 | assert.Equal(t, 0, c.Length()) 59 | assert.Equal(t, false, c.Has("foo")) 60 | assert.Equal(t, false, c.Has("bar")) 61 | assert.Equal(t, false, c.Has("baz")) 62 | assert.Equal(t, false, c.Has("something")) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/logger/interactive/interactive.go: -------------------------------------------------------------------------------- 1 | // 2 | // Logger implementation for interactive shell sessions. 3 | // 4 | package interactive_logger 5 | 6 | import "github.com/visionmedia/go-spin" 7 | import "time" 8 | import "fmt" 9 | import "io" 10 | import "os" 11 | 12 | type Logger struct { 13 | start time.Time 14 | w io.Writer 15 | q chan bool 16 | } 17 | 18 | // New logger with the given writer. 19 | func New(w io.Writer) *Logger { 20 | return &Logger{ 21 | start: time.Now(), 22 | q: make(chan bool), 23 | w: w, 24 | } 25 | } 26 | 27 | // Log skipped commit. 28 | func (l *Logger) Skip(arg, commit string) { 29 | fmt.Fprintf(l.w, "\033[0m %s %s\033[0m\n", shorten(commit), arg) 30 | } 31 | 32 | // Log running of commit. 33 | func (l *Logger) Start(arg, commit string) { 34 | s := spin.New() 35 | 36 | go func() { 37 | for { 38 | select { 39 | case <-l.q: 40 | return 41 | case <-time.Tick(100 * time.Millisecond): 42 | fmt.Fprintf(l.w, "\r\033[36m %s %s\033[0m %s ", s.Next(), shorten(commit), arg) 43 | s.Next() 44 | } 45 | } 46 | }() 47 | 48 | fmt.Fprintf(l.w, "\033[36m %s\033[0m %s ", shorten(commit), arg) 49 | } 50 | 51 | // Log successful commit. 52 | func (l *Logger) Success(arg, commit string) { 53 | l.q <- true 54 | fmt.Fprintf(l.w, "\r\033[32m %s\033[0m %s\n", shorten(commit), arg) 55 | } 56 | 57 | // Log error. 58 | func (l *Logger) Error(err error, arg, commit string) { 59 | fmt.Fprintf(l.w, "\n\033[31m %s %s: %s\033[0m\n", arg, commit, err) 60 | } 61 | 62 | // Log line. 63 | func (l *Logger) Log(line string) { 64 | fmt.Fprintf(l.w, "\n\033[90m %s\033[0m\n", line) 65 | } 66 | 67 | // Log end of provisioning. 68 | func (l *Logger) End() { 69 | fmt.Fprintf(l.w, "\033[32m completed\033[0m in %s\n", time.Since(l.start)) 70 | } 71 | 72 | // Stdout implementation. 73 | func (l *Logger) Stdout() io.Writer { 74 | return os.Stdout 75 | } 76 | 77 | // Stderr implementation. 78 | func (l *Logger) Stderr() io.Writer { 79 | return os.Stderr 80 | } 81 | 82 | // return shortened hash. 83 | func shorten(commit string) string { 84 | return commit[:8] 85 | } 86 | -------------------------------------------------------------------------------- /pkg/logger/interface/interface.go: -------------------------------------------------------------------------------- 1 | package logger_interface 2 | 3 | import "io" 4 | 5 | type Logger interface { 6 | // Skip a commit. 7 | Skip(arg, commit string) 8 | 9 | // Start a commit. 10 | Start(arg, commit string) 11 | 12 | // Successful commit. 13 | Success(arg, commit string) 14 | 15 | // Error executing a commit. 16 | Error(err error, arg, commit string) 17 | 18 | // Log line (comment). 19 | Log(line string) 20 | 21 | // End of provision. 22 | End() 23 | 24 | // Stdout writer. 25 | Stdout() io.Writer 26 | 27 | // Stderr writer. 28 | Stderr() io.Writer 29 | } 30 | -------------------------------------------------------------------------------- /pkg/logger/plain/plain.go: -------------------------------------------------------------------------------- 1 | // 2 | // Logger implementation for color haters. 3 | // 4 | package plain_logger 5 | 6 | import "time" 7 | import "fmt" 8 | import "io" 9 | import "os" 10 | 11 | type Logger struct { 12 | start time.Time 13 | w io.Writer 14 | } 15 | 16 | // New logger with the given writer. 17 | func New(w io.Writer) *Logger { 18 | return &Logger{ 19 | start: time.Now(), 20 | w: w, 21 | } 22 | } 23 | 24 | // Log skipped commit. 25 | func (l *Logger) Skip(arg, commit string) { 26 | fmt.Fprintf(l.w, " %s %s\n", shorten(commit), arg) 27 | } 28 | 29 | // Log running of commit. 30 | func (l *Logger) Start(arg, commit string) { 31 | fmt.Fprintf(l.w, " %s %s \n\n", shorten(commit), arg) 32 | } 33 | 34 | // Log successful commit. 35 | func (l *Logger) Success(arg, commit string) { 36 | } 37 | 38 | // Log error. 39 | func (l *Logger) Error(err error, arg, commit string) { 40 | fmt.Fprintf(l.w, "\n %s %s: %s\n", arg, commit, err) 41 | } 42 | 43 | // Log line. 44 | func (l *Logger) Log(line string) { 45 | fmt.Fprintf(l.w, "\n %s\n", line) 46 | } 47 | 48 | // Log end of provisioning. 49 | func (l *Logger) End() { 50 | fmt.Fprintf(l.w, " completed in %s\n", time.Since(l.start)) 51 | } 52 | 53 | // Stdout implementation. 54 | func (l *Logger) Stdout() io.Writer { 55 | return os.Stdout 56 | } 57 | 58 | // Stderr implementation. 59 | func (l *Logger) Stderr() io.Writer { 60 | return os.Stderr 61 | } 62 | 63 | // return shortened hash. 64 | func shorten(commit string) string { 65 | return commit[:8] 66 | } 67 | -------------------------------------------------------------------------------- /pkg/logger/tty/tty.go: -------------------------------------------------------------------------------- 1 | // 2 | // Logger implementation for non-interactive shells and/or verbose mode. 3 | // 4 | package tty_logger 5 | 6 | import "time" 7 | import "fmt" 8 | import "io" 9 | import "os" 10 | 11 | type Logger struct { 12 | start time.Time 13 | w io.Writer 14 | } 15 | 16 | // New logger with the given writer. 17 | func New(w io.Writer) *Logger { 18 | return &Logger{ 19 | start: time.Now(), 20 | w: w, 21 | } 22 | } 23 | 24 | // Log skipped commit. 25 | func (l *Logger) Skip(arg, commit string) { 26 | fmt.Fprintf(l.w, "\033[0m %s %s\033[0m\n", shorten(commit), arg) 27 | } 28 | 29 | // Log running of commit. 30 | func (l *Logger) Start(arg, commit string) { 31 | fmt.Fprintf(l.w, "\033[36m %s\033[0m %s \n", shorten(commit), arg) 32 | } 33 | 34 | // Log successful commit. 35 | func (l *Logger) Success(arg, commit string) { 36 | } 37 | 38 | // Log error. 39 | func (l *Logger) Error(err error, arg, commit string) { 40 | fmt.Fprintf(l.w, "\n\033[31m %s %s: %s\033[0m\n", arg, commit, err) 41 | } 42 | 43 | // Log line. 44 | func (l *Logger) Log(line string) { 45 | fmt.Fprintf(l.w, "\n\033[90m %s\033[0m\n", line) 46 | } 47 | 48 | // Log end of provisioning. 49 | func (l *Logger) End() { 50 | fmt.Fprintf(l.w, "\033[32m completed\033[0m in %s\n", time.Since(l.start)) 51 | } 52 | 53 | // Stdout implementation. 54 | func (l *Logger) Stdout() io.Writer { 55 | return os.Stdout 56 | } 57 | 58 | // Stderr implementation. 59 | func (l *Logger) Stderr() io.Writer { 60 | return os.Stderr 61 | } 62 | 63 | // return shortened hash. 64 | func shorten(commit string) string { 65 | return commit[:8] 66 | } 67 | -------------------------------------------------------------------------------- /pkg/provisioner/provisioner.go: -------------------------------------------------------------------------------- 1 | // 2 | // Provisioner library which accepts a shell-like language 3 | // or its canonical form for simple but effective provisioning. 4 | // 5 | package provisioner 6 | 7 | import "github.com/visionmedia/stack/pkg/logger/interface" 8 | import "github.com/visionmedia/stack/pkg/commit-log" 9 | import "github.com/visionmedia/stack/pkg/rewriter" 10 | import "encoding/hex" 11 | import "crypto/sha1" 12 | import "strings" 13 | import "os/exec" 14 | import "bytes" 15 | import "bufio" 16 | import "fmt" 17 | import "os" 18 | import "io" 19 | 20 | const Version = "0.0.1" 21 | 22 | type Provisioner struct { 23 | Log logger_interface.Logger 24 | DryRun bool 25 | Verbose bool 26 | path string 27 | commits *commit_log.CommitLog 28 | r io.Reader 29 | } 30 | 31 | // New provisioner from the given reader. 32 | func New(r io.Reader, path string) *Provisioner { 33 | return &Provisioner{path: path, r: r} 34 | } 35 | 36 | // Run pending commands. 37 | // 38 | // Each command is parsed and checked against 39 | // the set of committed or previously executed 40 | // commands via sha1. 41 | // 42 | func (p *Provisioner) Run() error { 43 | in, err := rewriter.Rewrite(p.r) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | b := bytes.NewBuffer([]byte(in)) 49 | buf := bufio.NewReader(b) 50 | 51 | commits, err := commit_log.New(p.path) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | p.commits = commits 57 | 58 | for { 59 | line, err := buf.ReadString('\n') 60 | 61 | if err == io.EOF { 62 | err = p.process(line) 63 | if err != nil { 64 | return err 65 | } 66 | break 67 | } 68 | 69 | err = p.process(line) 70 | if err != nil { 71 | return err 72 | } 73 | } 74 | 75 | p.Log.End() 76 | 77 | return nil 78 | } 79 | 80 | // process the given `line`. 81 | func (p *Provisioner) process(line string) error { 82 | line = strings.Trim(line, " \r\n") 83 | 84 | if line == "" { 85 | return nil 86 | } 87 | 88 | cmd, arg, err := parse(line) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | hash := sha1.Sum([]byte(line)) 94 | commit := hex.EncodeToString(hash[:]) 95 | 96 | return p.Command(cmd, arg, commit) 97 | } 98 | 99 | // Command runs the given `arg` against `cmd`. 100 | func (p *Provisioner) Command(cmd, arg, commit string) error { 101 | switch strings.ToLower(cmd) { 102 | case "run": 103 | return p.CommandRun(arg, commit) 104 | case "log": 105 | return p.CommandLog(arg, commit) 106 | default: 107 | return fmt.Errorf("invalid command %q", cmd) 108 | } 109 | } 110 | 111 | // CommandRun implements the "RUN" command. 112 | func (p *Provisioner) CommandRun(arg, commit string) error { 113 | if p.commits.Has(commit) { 114 | p.Log.Skip(arg, commit) 115 | return nil 116 | } 117 | 118 | p.Log.Start(arg, commit) 119 | 120 | if p.DryRun { 121 | p.Log.Success(arg, commit) 122 | return nil 123 | } 124 | 125 | c := exec.Command("sh", "-c", arg) 126 | c.Stdin = os.Stdin 127 | 128 | if p.Verbose { 129 | c.Stdout = p.Log.Stdout() 130 | c.Stderr = p.Log.Stderr() 131 | } 132 | 133 | err := c.Run() 134 | 135 | if err == nil { 136 | p.Log.Success(arg, commit) 137 | return p.commits.Add(commit) 138 | } 139 | 140 | p.Log.Error(err, arg, commit) 141 | return nil 142 | } 143 | 144 | // CommandLog implements the "LOG" command. 145 | func (p *Provisioner) CommandLog(arg, commit string) error { 146 | p.Log.Log(arg) 147 | return nil 148 | } 149 | 150 | // parse and arguments from `line`. 151 | func parse(line string) (string, string, error) { 152 | parts := strings.SplitN(line, " ", 2) 153 | 154 | if len(parts) < 2 { 155 | return "", "", fmt.Errorf("invalid command %q", line) 156 | } 157 | 158 | return parts[0], parts[1], nil 159 | } 160 | -------------------------------------------------------------------------------- /pkg/provisioner/provisioner_test.go: -------------------------------------------------------------------------------- 1 | package provisioner 2 | 3 | import "github.com/visionmedia/stack/pkg/logger/tty" 4 | import "github.com/bmizerany/assert" 5 | import "testing" 6 | import "bytes" 7 | import "os" 8 | 9 | func check(err error) { 10 | if err != nil { 11 | panic(err) 12 | } 13 | } 14 | 15 | // Test run with comments and newlines 16 | func TestRun(t *testing.T) { 17 | os.Remove("/tmp/commits.log") 18 | 19 | r := bytes.NewBuffer(nil) 20 | 21 | r.WriteString("echo foo\n") 22 | r.WriteString("echo bar\n") 23 | r.WriteString("\n") 24 | r.WriteString(" \n") 25 | r.WriteString(" \r\n") 26 | r.WriteString(" \r") 27 | r.WriteString("# RUN echo bar\n") 28 | r.WriteString("# something here\n") 29 | r.WriteString("echo baz\n") 30 | 31 | p := New(r, "/tmp/commits.log") 32 | p.Log = tty_logger.New(os.Stdout) 33 | 34 | check(p.Run()) 35 | 36 | assert.Equal(t, 3, p.commits.Length()) 37 | } 38 | 39 | // Test with canonical commands. 40 | func TestRunCanonical(t *testing.T) { 41 | os.Remove("/tmp/commits.log") 42 | 43 | r := bytes.NewBuffer(nil) 44 | 45 | r.WriteString("RUN echo foo\n") 46 | r.WriteString("RUN echo bar\n") 47 | r.WriteString("\n") 48 | r.WriteString(" \n") 49 | r.WriteString(" \r\n") 50 | r.WriteString(" \r") 51 | r.WriteString("RUN echo bar\n") 52 | r.WriteString("LOG something here\n") 53 | r.WriteString("RUN echo baz\n") 54 | 55 | p := New(r, "/tmp/commits.log") 56 | p.Log = tty_logger.New(os.Stdout) 57 | 58 | check(p.Run()) 59 | 60 | assert.Equal(t, 3, p.commits.Length()) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/rewriter/fixtures/a.sh: -------------------------------------------------------------------------------- 1 | echo "from a" 2 | echo "from a again" -------------------------------------------------------------------------------- /pkg/rewriter/fixtures/b.sh: -------------------------------------------------------------------------------- 1 | echo "from b" 2 | include fixtures/c.sh -------------------------------------------------------------------------------- /pkg/rewriter/fixtures/c.sh: -------------------------------------------------------------------------------- 1 | echo "from c" -------------------------------------------------------------------------------- /pkg/rewriter/rewriter.go: -------------------------------------------------------------------------------- 1 | // 2 | // Rewriter accepts an input reader and pre-processes 3 | // the shell-like provisioning language to its 4 | // canonical form. For example command lines become 5 | // prefixed with "RUN ", and comments prefixed with "LOG " 6 | // and so on. 7 | // 8 | package rewriter 9 | 10 | import "strings" 11 | import "bufio" 12 | import "fmt" 13 | import "io" 14 | import "os" 15 | 16 | // Rewrite the given reader. 17 | func Rewrite(r io.Reader) (string, error) { 18 | buf := bufio.NewReader(r) 19 | ret := "" 20 | 21 | for { 22 | line, readError := buf.ReadString('\n') 23 | 24 | str, err := rewrite(line) 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | ret += str 30 | 31 | if readError == io.EOF { 32 | break 33 | } 34 | 35 | if readError != nil { 36 | return "", readError 37 | } 38 | } 39 | 40 | return ret, nil 41 | } 42 | 43 | // Rewrite the given line. 44 | func rewrite(line string) (string, error) { 45 | line = strings.Trim(line, " \r\n") 46 | 47 | switch { 48 | case "" == line: 49 | return "", nil 50 | case isLog(line): 51 | return log(line), nil 52 | case isInclude(line): 53 | return include(line) 54 | case isRun(line): 55 | return run(line), nil 56 | } 57 | 58 | return "", nil 59 | } 60 | 61 | // RUN line. 62 | func run(line string) string { 63 | if hasCommandPrefix(line, "run") { 64 | return "RUN " + strip(line) + "\n" 65 | } else { 66 | return "RUN " + line + "\n" 67 | } 68 | } 69 | 70 | // LOG line. 71 | func log(line string) string { 72 | return "LOG " + strip(line) + "\n" 73 | } 74 | 75 | // Strip the command. 76 | func strip(line string) string { 77 | parts := strings.SplitN(line, " ", 2) 78 | return strings.Trim(parts[1], " ") 79 | } 80 | 81 | // Check if it's a RUN command. 82 | func isRun(line string) bool { 83 | return hasCommandPrefix(line, "run") || true 84 | } 85 | 86 | // Check if it's an INCLUDE command. 87 | func isInclude(line string) bool { 88 | return hasCommandPrefix(line, ".") || hasCommandPrefix(line, "include") || hasCommandPrefix(line, "source") 89 | } 90 | 91 | // Check if it's a LOG command. 92 | func isLog(line string) bool { 93 | return (len(line) > 0 && line[0] == '#') || hasCommandPrefix(line, "log") 94 | } 95 | 96 | // Check for a command prefix. 97 | func hasCommandPrefix(line, cmd string) bool { 98 | return strings.HasPrefix(strings.ToLower(line), cmd+" ") 99 | } 100 | 101 | // Include the file at `path` and rewrite it. 102 | func include(path string) (string, error) { 103 | path = strip(path) 104 | 105 | f, err := os.Open(path) 106 | if err != nil { 107 | return "", fmt.Errorf("failed to include %q: %s", path, err) 108 | } 109 | 110 | return Rewrite(f) 111 | } 112 | -------------------------------------------------------------------------------- /pkg/rewriter/rewriter_test.go: -------------------------------------------------------------------------------- 1 | package rewriter 2 | 3 | import "github.com/bmizerany/assert" 4 | import "testing" 5 | import "bytes" 6 | 7 | func check(err error) { 8 | if err != nil { 9 | panic(err) 10 | } 11 | } 12 | 13 | func TestRun(t *testing.T) { 14 | in := bytes.NewBuffer(nil) 15 | 16 | in.WriteString("echo foo\n") 17 | in.WriteString("echo bar\n") 18 | in.WriteString(" \r\n") 19 | in.WriteString("\n") 20 | in.WriteString("echo baz") 21 | 22 | out, err := Rewrite(in) 23 | check(err) 24 | 25 | exp := `RUN echo foo 26 | RUN echo bar 27 | RUN echo baz 28 | ` 29 | 30 | assert.Equal(t, exp, out) 31 | } 32 | 33 | func TestLog(t *testing.T) { 34 | in := bytes.NewBuffer(nil) 35 | 36 | in.WriteString("# Install node\n") 37 | in.WriteString("LOG Install node\n") 38 | in.WriteString("echo foo\n") 39 | in.WriteString("echo bar\n") 40 | 41 | out, err := Rewrite(in) 42 | check(err) 43 | 44 | exp := `LOG Install node 45 | LOG Install node 46 | RUN echo foo 47 | RUN echo bar 48 | ` 49 | 50 | assert.Equal(t, exp, out) 51 | } 52 | 53 | func TestInclude(t *testing.T) { 54 | in := bytes.NewBuffer(nil) 55 | 56 | in.WriteString(". fixtures/a.sh\n") 57 | in.WriteString("include fixtures/b.sh\n") 58 | 59 | out, err := Rewrite(in) 60 | check(err) 61 | 62 | exp := `RUN echo "from a" 63 | RUN echo "from a again" 64 | RUN echo "from b" 65 | RUN echo "from c" 66 | ` 67 | 68 | assert.Equal(t, exp, out) 69 | } 70 | --------------------------------------------------------------------------------