From e2c4f092abc1f386ee69bbb59f5eb3feb9e69bdc Mon Sep 17 00:00:00 2001 From: Xavi Soler Date: Thu, 19 Jan 2017 17:19:16 +0100 Subject: [PATCH 01/13] Create dog and dog/run packages --- .gitignore | 1 + .travis.yml | 12 +- DOGFILE_SPEC.md | 28 ++-- Dogfile.yml | 24 ++-- README.md | 55 +++---- chain.go | 163 +++++++++++++++++++++ cli.go => cmd/dog/cli.go | 41 +----- cmd/dog/main.go | 97 +++++++++++++ examples/hello/hello.go | 49 +++++++ execute/executor.go | 115 --------------- execute/runner.go | 198 -------------------------- img/dog_logo.png | Bin 0 -> 54837 bytes init.go | 25 ++++ main.go | 66 --------- parse.go | 260 ++++++++++++++++++++++++++++++++++ parser/parser.go | 163 --------------------- run/cmd.go | 73 ++++++++++ run/run.go | 70 +++++++++ scripts/test-dogfiles.sh | 2 +- task.go | 53 +++++++ testdata/.env | 1 - testdata/Dogfile-bash.json | 8 +- testdata/Dogfile-bash.yml | 4 +- testdata/Dogfile-env.yml | 6 - testdata/Dogfile-hooks.yml | 11 +- testdata/Dogfile-longrun.yml | 2 +- testdata/Dogfile-mustfail.yml | 4 +- testdata/Dogfile-perl.yml | 4 + testdata/Dogfile-python.yml | 4 +- testdata/Dogfile-ruby.yml | 4 +- testdata/Dogfile-sh.yml | 6 +- types/event.go | 45 ------ types/task.go | 17 --- 33 files changed, 873 insertions(+), 738 deletions(-) create mode 100644 chain.go rename cli.go => cmd/dog/cli.go (75%) create mode 100644 cmd/dog/main.go create mode 100644 examples/hello/hello.go delete mode 100644 execute/executor.go delete mode 100644 execute/runner.go create mode 100644 img/dog_logo.png create mode 100644 init.go delete mode 100644 main.go create mode 100644 parse.go delete mode 100644 parser/parser.go create mode 100644 run/cmd.go create mode 100644 run/run.go create mode 100644 task.go delete mode 100644 testdata/.env delete mode 100644 testdata/Dogfile-env.yml create mode 100644 testdata/Dogfile-perl.yml delete mode 100644 types/event.go delete mode 100644 types/task.go diff --git a/.gitignore b/.gitignore index b33b889..5c0338c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ +dog-*/ *tar.gz diff --git a/.travis.yml b/.travis.yml index 2ea2b6d..7a9b54a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,8 @@ go: install: - go get -t ./... - go get github.com/golang/lint/golint + - go get honnef.co/go/staticcheck/cmd/staticcheck + - go get github.com/kisielk/errcheck script: - diff <(echo -n) <(gofmt -s -d .) @@ -15,11 +17,5 @@ script: after_script: - golint ./... - -notifications: - webhooks: - urls: - - https://webhooks.gitter.im/e/87d757f61554eae3d779 - on_success: change - on_failure: always - on_start: never + - staticcheck ./... + - errcheck ./... diff --git a/DOGFILE_SPEC.md b/DOGFILE_SPEC.md index c81cdc1..d0fa791 100644 --- a/DOGFILE_SPEC.md +++ b/DOGFILE_SPEC.md @@ -11,11 +11,11 @@ Dogfiles are [YAML](http://yaml.org/) files that describe the execution of autom ```yml - task: hello description: Say Hello - run: echo hello + code: echo hello - task: bye description: Say Good Bye - run: echo bye + code: echo bye ``` Multiple Dogfiles in the same directory are processed together as a single entity. Although the name `Dogfile.yml` is recommended, any file with a name that starts with `Dogfile` and follows this specification is a valid Dogfile. @@ -40,18 +40,18 @@ Description of the task. Tasks that avoid this directive are not shown in the ta description: This task does some cool stuff ``` -### run +### code The code that will be executed. ```yml - run: echo 'hello' + code: echo 'hello' ``` Multiline scripts are supported. ```yml - run: | + code: | echo "This is the Dogfile in your current directory:" for dogfile in `ls -1 Dogfile*`; do @@ -59,20 +59,20 @@ Multiline scripts are supported. done ``` -### exec +### runner -When this directive is not defined, the default executor is `sh`. Additional executors are supported if they are present in the system. The following example uses the Ruby executor to print 'Hello World'. +When this directive is not defined, the default runner is `sh`. Additional runners are supported if they are present in the system. The following example uses the Ruby runner to print 'Hello World'. ```yml task: hello-ruby description: Hello World from Ruby - exec: ruby - run: | + runner: ruby + code: | hello = "Hello World" puts hello ``` -The following list of executors are known to work: +The following list of runners are supported: - sh - bash @@ -179,7 +179,7 @@ Additional parameters can be provided to the task that will be executed. All par - name: age regex: ^\d+$ - run: echo "Hello, I'm in the city of $1, planet $2. I am a $3 and I'm $4 years old" + code: echo "Hello, I'm in the city of $1, planet $2. I am a $3 and I'm $4 years old" ``` The *regex* option and the *choices* option are mutually exclusive. @@ -190,13 +190,13 @@ Registers store the STDOUT of executed tasks as environment variables so other t ```yml task: get-dog-version - run: dog --version | awk '{print $3}' + code: dog --version | awk '{print $3}' register: DOG_VERSION task: print-dog-version description: Print Dog version pre: get-dog-version - run: echo "I am running Dog $DOG_VERSION" + code: echo "I am running Dog $DOG_VERSION" ``` Dogfiles don't have global variables, use registers instead. @@ -210,7 +210,7 @@ Tools using Dogfiles and having special requirements can define their own direct description: Clear the cache x_path: /task/clear-cache x_tls_required: true - run: ./scripts/cache-clear.sh + code: ./scripts/cache-clear.sh ``` (*) Not implemented yet diff --git a/Dogfile.yml b/Dogfile.yml index e58b997..594f39f 100644 --- a/Dogfile.yml +++ b/Dogfile.yml @@ -1,45 +1,47 @@ - task: clean description: Clean compiled binaries - run: rm -rf dist + code: rm -rf dist dog-* - task: build description: Build dog binary for current platform env: - OUTPUT_PATH=dist/current - - REV=v0.3.0 - run: | + - REV=v0.4.0 + code: | go build \ -ldflags "-X main.Release=$REV -w" \ -o "${OUTPUT_PATH}/dog" \ - . + cmd/dog/*.go - task: install-build-deps description: Installs required dependencies for building dog - run: go get -u github.com/mitchellh/gox + code: go get -u github.com/mitchellh/gox - task: build-all description: Build dog binary for all platforms env: - XC_ARCH=386 amd64 - XC_OS=linux darwin freebsd openbsd solaris - - REV=v0.3.0 + - REV=v0.4.0 pre: - install-build-deps - clean - run: | + code: | gox \ -os="${XC_OS}" \ -arch="${XC_ARCH}" \ -ldflags "-X main.Release=$REV -w" \ -output "dist/{{.OS}}_{{.Arch}}/dog" \ - . + ./cmd/dog/ - task: dist description: Put all dist binaries in a compressed file - env: REV=v0.3.0 + env: REV=v0.4.0 pre: build-all - run: tar zcvf dog-${REV}.tar.gz dist/ + code: | + mv -v dist dog-${REV} + tar zcvf dog-${REV}.tar.gz dog-${REV} - task: run-test-dogfiles description: Run all Tasks in testdata Dogfiles - run: ./scripts/test-dogfiles.sh + code: ./scripts/test-dogfiles.sh diff --git a/README.md b/README.md index ed1e150..f6810d9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,8 @@ # Dog [![Build Status](https://travis-ci.org/dogtools/dog.svg?branch=master)](https://travis-ci.org/dogtools/dog) -[![Join the chat](https://badges.gitter.im/dogtools/dog.svg)](https://gitter.im/dogtools/dog) -Dog is a command line application that executes automated tasks. It works in a similar way as GNU Make but it is a more generic task runner, not a build tool. Dog's default script syntax is `sh` but most interpreted languages like BASH, Python, Ruby or Perl can also be used. - -## Installing Dog - -If you are on macOS you can install Dog using brew: - -``` -brew tap dogtools/dog -brew install dog -``` - -If you have your golang environment set up, you can use: - -``` -go get github.com/dogtools/dog -``` +Dog is a command line application that executes automated tasks. It works in a similar way as GNU Make but it is a more generic task runner, not a build tool. Dog's default script syntax is `sh` but most interpreted languages like BASH, Python or Ruby can also be used. ## Using Dog @@ -30,7 +14,7 @@ Execute a task dog taskname -Execute a task, printing elapsed time and status code +Execute a task, printing elapsed time and exit status dog -i taskname @@ -41,28 +25,33 @@ Dogfile is a specification that uses YAML to describe the tasks related to a pro - Read Dog's own [Dogfile.yml][1] - Read the [Dogfile Spec][2] +## Installing Dog + +If you are using macOS you can install Dog using brew: + + brew tap dogtools/dog + brew install dog + +If you have your golang environment set up, you can use: + + go get -u github.com/dogtools/dog + ## Other tools -Tools that use Dogfiles are called *dogtools*. Dog is the first dogtool but there are other things that can implemented in the future: web and desktop UIs, chat bot interfaces, plugins for text editors and IDEs, tools to export Dogfiles to other formats, HTTP API interfaces, even implementations of the cli in other languages! To simplify the process of creating dogtools we are implementing parts of Dog as Go packages so they can be used in other projects (see [parser][3], [types][4] and [execute][5]). Let us know if you have any uncovered need on any of these packages. +Tools that use the Dogfile Specification are called *dogtools*. Dog is the first dogtool but there are other things that can be implemented in the future: web and desktop UIs, chat bot interfaces, plugins for text editors and IDEs, tools to export Dogfiles to other formats, HTTP API interfaces, even implementations of the cli in other languages! -## Contributing +The root directory of this repository contains the dog package that can be used to create dogtools in Go. -If you want to help, take a look at: + import "github.com/dogtools/dog" -- Open [bugs][6] -- Lacking features for [v0.4.0][7] -- Our [Code of Conduct][8] +Check the `examples/` directory to see how it works. -In case you are not interested in improving Dog but on building your own tool on top of the Dogfile Spec, please help us discussing it: +## Contributing -- Dogfile Spec [open discussion][9] +If you want to help, take a look at the open [bugs][3], the list of all [issues][4] and our [Code of Conduct][5]. [1]: https://github.com/dogtools/dog/blob/master/Dogfile.yml [2]: https://github.com/dogtools/dog/blob/master/DOGFILE_SPEC.md -[3]: https://github.com/dogtools/dog/tree/master/parser -[4]: https://github.com/dogtools/dog/tree/master/types -[5]: https://github.com/dogtools/dog/tree/master/execute -[6]: https://github.com/dogtools/dog/issues?q=is%3Aissue+is%3Aopen+label%3Abug -[7]: https://github.com/dogtools/dog/milestone/4 -[8]: https://github.com/dogtools/dog/blob/master/CODE_OF_CONDUCT.md -[9]: https://github.com/dogtools/dog/issues?q=is%3Aissue+is%3Aopen+label%3A%22dogfile+spec%22 +[3]: https://github.com/dogtools/dog/issues?q=is%3Aissue+is%3Aopen+label%3Abug +[4]: https://github.com/dogtools/dog/issues +[5]: https://github.com/dogtools/dog/blob/master/CODE_OF_CONDUCT.md diff --git a/chain.go b/chain.go new file mode 100644 index 0000000..f418cde --- /dev/null +++ b/chain.go @@ -0,0 +1,163 @@ +package dog + +import ( + "errors" + "fmt" + "os/exec" + "syscall" + "time" + + "github.com/dogtools/dog/run" +) + +// ErrCycleInTaskChain means that there is a loop in the path of tasks execution. +var ErrCycleInTaskChain = errors.New("TaskChain includes a cycle of tasks") + +// TaskChain contains one or more tasks to be executed in order. +type TaskChain struct { + Tasks []*Task +} + +// Generate creates the TaskChain for a specific task. +func (taskChain *TaskChain) Generate(d Dogfile, task string) error { + + // Cycle detection + for i := 0; i < len(taskChain.Tasks); i++ { + if taskChain.Tasks[i].Name == task { + if len(taskChain.Tasks[i].Pre) > 0 || len(taskChain.Tasks[i].Post) > 0 { + return ErrCycleInTaskChain + } + } + } + + t := d.Tasks[task] + + // Iterate over pre-tasks + if err := addToChain(taskChain, d, t.Pre); err != nil { + return err + } + + // Add current task to chain + taskChain.Tasks = append(taskChain.Tasks, t) + + // Iterate over post-tasks + if err := addToChain(taskChain, d, t.Post); err != nil { + return err + } + return nil +} + +// addToChain iterates over a list of pre or post tasks and adds them to the task chain. +func addToChain(taskChain *TaskChain, d Dogfile, tasks []string) error { + for _, name := range tasks { + + t, found := d.Tasks[name] + if !found { + return errors.New("Task " + name + " does not exist") + } + + if err := taskChain.Generate(d, t.Name); err != nil { + return err + } + } + return nil +} + +// Run handles the execution of all tasks in the TaskChain. +func (taskChain *TaskChain) Run() error { + var startTime time.Time + + for _, t := range taskChain.Tasks { + var err error + var runner run.Runner + exitStatus := 0 + + switch t.Runner { + case "sh": + runner, err = run.NewShRunner(t.Code, t.Workdir, t.Env) + case "bash": + runner, err = run.NewBashRunner(t.Code, t.Workdir, t.Env) + case "python": + runner, err = run.NewPythonRunner(t.Code, t.Workdir, t.Env) + case "ruby": + runner, err = run.NewRubyRunner(t.Code, t.Workdir, t.Env) + case "perl": + runner, err = run.NewPerlRunner(t.Code, t.Workdir, t.Env) + default: + if t.Runner == "" { + return fmt.Errorf("Runner not specified") + } + return fmt.Errorf("%s is not a supported runner", t.Runner) + } + if err != nil { + return err + } + + stdoutScanner, stderrScanner, err := run.GetOutputScanners(runner) + if err != nil { + return err + } + + go func() { + for stdoutScanner.Scan() { + fmt.Println(stdoutScanner.Text()) + } + }() + + go func() { + for stderrScanner.Scan() { + fmt.Println(stderrScanner.Text()) + } + }() + + startTime = time.Now() + err = runner.Start() + if err != nil { + return err + } + + err = runner.Wait() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); !ok { + exitStatus = 1 // For unknown error exit codes set it to 1 + } else { + exitStatus = waitStatus.ExitStatus() + } + } + if ProvideExtraInfo { + fmt.Printf("-- %s (%s) failed with exit status %d\n", + t.Name, formatDuration(time.Since(startTime)), exitStatus) + } + return err + } + + if ProvideExtraInfo { + fmt.Printf("-- %s (%s) finished with exit status %d\n", + t.Name, formatDuration(time.Since(startTime)), exitStatus) + } + + } + return nil +} + +// formatDuration is a time formatter. +func formatDuration(d time.Duration) (s string) { + timeMsg := "" + + if d.Hours() > 1.0 { + timeMsg += fmt.Sprintf("%1.0fh", d.Hours()) + } + + if d.Minutes() > 1.0 { + timeMsg += fmt.Sprintf("%1.0fm", d.Minutes()) + } + + if d.Seconds() > 1.0 { + timeMsg += fmt.Sprintf("%1.0fs", d.Seconds()) + } else { + timeMsg += fmt.Sprintf("%1.3fs", d.Seconds()) + } + + return timeMsg +} diff --git a/cli.go b/cmd/dog/cli.go similarity index 75% rename from cli.go rename to cmd/dog/cli.go index df0cccd..e6895a4 100644 --- a/cli.go +++ b/cmd/dog/cli.go @@ -1,12 +1,6 @@ package main -import ( - "fmt" - "sort" - "strings" - - "github.com/dogtools/dog/types" -) +import "fmt" type userArgs struct { help bool @@ -36,10 +30,8 @@ func printHelp() { dog [--help] [--version] Dog is a command line application that executes tasks. - Options: - - -i, --info Print execution info (duration, statuscode) after task execution + -i, --info Print execution info (duration, exit status) after task execution -w, --workdir Specify the working directory -d, --directory Specify the dogfiles' directory -h, --help Print usage information and help @@ -52,29 +44,6 @@ Need help? --> dog --help More info --> https://github.com/dogtools/dog`) } -func printTasks(tm types.TaskMap) { - - maxCharSize := 0 - for taskName, task := range tm { - if task.Description != "" && len(taskName) > maxCharSize { - maxCharSize = len(taskName) - } - } - - var tasks []string - for taskName, task := range tm { - if task.Description != "" { - tasks = append(tasks, taskName) - } - } - sort.Strings(tasks) - - for _, taskName := range tasks { - spaces := strings.Repeat(" ", maxCharSize-len(taskName)+2) - fmt.Printf("%s%s%s\n", taskName, spaces, tm[taskName].Description) - } -} - func parseArgs(args []string) (a userArgs, err error) { // default values @@ -102,18 +71,16 @@ func parseArgs(args []string) (a userArgs, err error) { if i == 0 && len(args) == 1 && a.taskName == "" { a.help = true return a, nil - } else { - return a, fmt.Errorf("Error: %s doesn't accept additional parameters", arg) } + return a, fmt.Errorf("Error: %s doesn't accept additional parameters", arg) } if arg == "--version" || arg == "-v" { if i == 0 && len(args) == 1 && a.taskName == "" { a.version = true return a, nil - } else { - return a, fmt.Errorf("Error: %s doesn't accept additional parameters", arg) } + return a, fmt.Errorf("Error: %s doesn't accept additional parameters", arg) } if arg == "--info" || arg == "-i" { diff --git a/cmd/dog/main.go b/cmd/dog/main.go new file mode 100644 index 0000000..356b98d --- /dev/null +++ b/cmd/dog/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/dogtools/dog" +) + +const version = "v0.4.0" + +func main() { + // parse cli arguments + a, err := parseArgs(os.Args[1:]) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if a.help { + printHelp() + os.Exit(0) + } + + if a.version { + printVersion() + os.Exit(0) + } + + // parse dogfile + var d dog.Dogfile + if err = d.ParseFromDisk(a.directory); err != nil { + printNoValidDogfile() + os.Exit(1) + } + dog.DeprecationWarnings(os.Stderr) + + if a.taskName != "" { + if a.info { + dog.ProvideExtraInfo = true + } + + if d.Tasks[a.taskName] != nil { + if a.workdir != "" { + d.Tasks[a.taskName].Workdir = a.workdir + } + if d.Tasks[a.taskName].Workdir == "" { + d.Tasks[a.taskName].Workdir = a.directory + } + } else { + fmt.Println("Unknown task name:", a.taskName) + os.Exit(1) + } + + // generate task chain + var tc dog.TaskChain + if err = tc.Generate(d, a.taskName); err != nil { + fmt.Println(err) + os.Exit(1) + } + + // run task chain + err = tc.Run() + if err != nil { + os.Exit(2) + } + + } else { + printTasks(d) + os.Exit(0) + } +} + +// print tasks with description +func printTasks(d dog.Dogfile) { + maxCharSize := 0 + for taskName, task := range d.Tasks { + if task.Description != "" && len(taskName) > maxCharSize { + maxCharSize = len(taskName) + } + } + + var tasks []string + for taskName, task := range d.Tasks { + if task.Description != "" { + tasks = append(tasks, taskName) + } + } + sort.Strings(tasks) + + for _, taskName := range tasks { + spaces := strings.Repeat(" ", maxCharSize-len(taskName)+2) + fmt.Printf("%s%s%s\n", taskName, spaces, d.Tasks[taskName].Description) + } +} diff --git a/examples/hello/hello.go b/examples/hello/hello.go new file mode 100644 index 0000000..679fdfe --- /dev/null +++ b/examples/hello/hello.go @@ -0,0 +1,49 @@ +package main + +// This example shows how to define a Dogfile, parse it, generate the task +// chain and finally run it. + +import ( + "fmt" + "os" + + "github.com/dogtools/dog" +) + +func main() { + + // Define two tasks in the Dogfile format using YAML + dogfile := ` +- task: hello-dog + description: Say Hello + post: hello-world + code: echo "Hello Dog!" + +- task: hello-world + description: Say Hello Again + code: echo "Hello World!" +` + + // Parse Dogfile + var d dog.Dogfile + err := d.Parse([]byte(dogfile)) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Generate task chain that starts with 'hello-dog' but include both tasks + var tc dog.TaskChain + err = tc.Generate(d, "hello-dog") + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Run task chain + err = tc.Run() + if err != nil { + fmt.Println(err) + os.Exit(2) + } +} diff --git a/execute/executor.go b/execute/executor.go deleted file mode 100644 index e9373c5..0000000 --- a/execute/executor.go +++ /dev/null @@ -1,115 +0,0 @@ -package execute - -import ( - "bufio" - "io/ioutil" - "os" - "os/exec" - "syscall" - - "github.com/dogtools/dog/types" -) - -func writeTempFile(dir, prefix string, data string) (*os.File, error) { - f, err := ioutil.TempFile(dir, prefix) - if err != nil { - return f, err - } - - _, err = f.WriteString(data) - return f, err -} - -// Executor implements standard shell executor. -type Executor struct { - cmd string -} - -// NewExecutor returns a default executor with a cmd. -func NewExecutor(cmd string) *Executor { - return &Executor{ - cmd, - } -} - -// Exec executes the created tmp script and writes the output to the writer. -func (ex *Executor) Exec(t *types.Task, eventsChan chan *types.Event) error { - f, err := writeTempFile("", "dog", t.Run) - if err != nil { - return err - } - - defer func() { - if err := os.Remove(f.Name()); err != nil { - eventsChan <- types.NewOutputEvent(t.Name, []byte(err.Error())) - } - }() - - binary, err := exec.LookPath(ex.cmd) - if err != nil { - return err - } - - if t.Workdir != "" { - if err := os.Chdir(t.Workdir); err != nil { - return err - } - } - - cmd := exec.Command(binary, f.Name()) - - if err := gatherCmdOutput(t.Name, cmd, eventsChan); err != nil { - return err - } - - cmd.Stdin = os.Stdin - if err := cmd.Start(); err != nil { - return nil - } - - eventsChan <- types.NewStartEvent(t.Name) - - statusCode := 0 - if err := cmd.Wait(); err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); !ok { - // For unknown error status codes set it to 1 - statusCode = 1 - } else { - statusCode = waitStatus.ExitStatus() - } - } - } - - eventsChan <- types.NewEndEvent(t.Name, statusCode) - - return nil -} - -func gatherCmdOutput(taskName string, cmd *exec.Cmd, eventsChan chan *types.Event) error { - stdoutReader, err := cmd.StdoutPipe() - if err != nil { - return err - } - - stderrReader, err := cmd.StderrPipe() - if err != nil { - return err - } - - stdoutScanner := bufio.NewScanner(stdoutReader) - stderrScanner := bufio.NewScanner(stderrReader) - go func() { - for stdoutScanner.Scan() { - eventsChan <- types.NewOutputEvent(taskName, stdoutScanner.Bytes()) - } - }() - - go func() { - for stderrScanner.Scan() { - eventsChan <- types.NewOutputEvent(taskName, stderrScanner.Bytes()) - } - }() - - return nil -} diff --git a/execute/runner.go b/execute/runner.go deleted file mode 100644 index af8d007..0000000 --- a/execute/runner.go +++ /dev/null @@ -1,198 +0,0 @@ -package execute - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/dogtools/dog/types" -) - -type runner struct { - taskHierarchy map[string][]*types.Task - eventsChan chan *types.Event - printFooter bool -} - -func isCyclic(chain []*types.Task) bool { - maxLen := len(chain) / 2 - for i := 2; i < maxLen; i++ { - a := chain[:i] - b := chain[i : 2*i] - for x, c := range a { - if c != b[x] { - return false - } - } - return true - } - return false -} - -func generateChainFor(t *types.Task, tm types.TaskMap, chain []*types.Task) ([]*types.Task, error) { - var err error - if isCyclic(chain) { - return nil, errors.New("Task " + t.Name + " has a hook cycle") - } - - for _, preName := range t.Pre { - pre, found := tm[preName] - if !found { - return nil, errors.New( - "Task " + preName + " does not exist", - ) - } - - for _, prePre := range pre.Pre { - if prePre == t.Name { - return nil, errors.New("Task " + preName + " has a hook cycle") - } - } - - chain, err = generateChainFor(pre, tm, chain) - if err != nil { - return nil, err - } - } - - chain = append(chain, t) - - for _, postName := range t.Post { - post, found := tm[postName] - if !found { - return nil, errors.New( - "Task " + postName + " does not exist", - ) - } - chain, err = generateChainFor(post, tm, chain) - if err != nil { - return nil, err - } - } - - return chain, nil -} - -func buildHierarchy(tm types.TaskMap) (map[string][]*types.Task, error) { - th := make(map[string][]*types.Task, len(tm)) - - for n, t := range tm { - chain, err := generateChainFor(t, tm, []*types.Task{}) - if err != nil { - return nil, err - } - th[n] = chain - } - - return th, nil -} - -func formatDuration(d time.Duration) (s string) { - timeMsg := "" - - if d.Hours() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fh", d.Hours()) - } - - if d.Minutes() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fm", d.Minutes()) - } - - if d.Seconds() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fs", d.Seconds()) - } else { - timeMsg += fmt.Sprintf("%1.3fs", d.Seconds()) - } - - return timeMsg -} - -// NewRunner creates a new runner that contains a list of all execution paths. -func NewRunner(tm types.TaskMap, printFooter bool) (*runner, error) { - th, err := buildHierarchy(tm) - if err != nil { - return nil, err - } - - return &runner{ - taskHierarchy: th, - eventsChan: make(chan *types.Event, 2048), - printFooter: printFooter, - }, nil -} - -// Run executes the execution path for a given task. -func (r *runner) Run(taskName string) { - tasks, found := r.taskHierarchy[taskName] - if !found { - fmt.Println("Task " + taskName + " does not exist") - os.Exit(1) - } - executors := map[string]*Executor{} - go func() { - for _, t := range tasks { - var e *Executor - if t.Executor == "" { - e = NewExecutor("sh") - } else { - e, found = executors[t.Executor] - if !found { - e = NewExecutor(t.Executor) - executors[t.Executor] = e - } - } - - modifiedEnvvars := map[string]bool{} - - for _, e := range t.Env { - pair := strings.SplitN(e, "=", 2) - if len(pair) != 2 { - fmt.Println("Error: env var invalid for task", t.Name) - os.Exit(1) - } - - if os.Getenv(pair[0]) == "" { - os.Setenv(pair[0], pair[1]) - modifiedEnvvars[pair[0]] = true - } - } - - if err := e.Exec(t, r.eventsChan); err != nil { - fmt.Println(err) - os.Exit(1) - } - - for k := range modifiedEnvvars { - os.Setenv(k, "") - } - } - }() - r.waitFor((tasks[len(tasks)-1]).Name) -} - -func (r *runner) waitFor(taskName string) { - var startTime time.Time - - for { - select { - case event := <-r.eventsChan: - switch event.Type { - case types.StartEvent: - startTime = event.Time - case types.OutputEvent: - fmt.Println(string(event.Body)) - case types.EndEvent: - if r.printFooter { - fmt.Printf("-- %s took %s and finished with status code %d\n", - event.Task, formatDuration(time.Since(startTime)), event.ExitCode) - } - - if event.ExitCode != 0 || event.Task == taskName { - os.Exit(event.ExitCode) - } - } - } - } -} diff --git a/img/dog_logo.png b/img/dog_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e710efeaeb53724215a3b642c67023cc8c874927 GIT binary patch literal 54837 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 VjQP$OH&Xxr002ovPDHLkV1jx5cisR1 literal 0 HcmV?d00001 diff --git a/init.go b/init.go new file mode 100644 index 0000000..ec36609 --- /dev/null +++ b/init.go @@ -0,0 +1,25 @@ +package dog + +import "runtime" + +// DefaultRunner defines the runner to use in case the task does not specify it. +// +// The value is automatically assigned based on the operating system when the +// package initializes. +var DefaultRunner string + +// ProvideExtraInfo specifies if dog needs to provide execution info (duration, +// exit status) after task execution. +var ProvideExtraInfo bool + +// deprecation warning flags +var deprecationWarningRun bool +var deprecationWarningExec bool + +func init() { + if runtime.GOOS == "windows" { + DefaultRunner = "cmd" // not implemented yet + } else { + DefaultRunner = "sh" + } +} diff --git a/main.go b/main.go deleted file mode 100644 index aa01d25..0000000 --- a/main.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/dogtools/dog/execute" - "github.com/dogtools/dog/parser" - "github.com/joho/godotenv" -) - -const version = "v0.3.0" - -func main() { - // if .env file exists (in same dir as Dogfile), load values into env - if _, err := os.Stat(`./.env`); !os.IsNotExist(err) { - err = godotenv.Load() - if err != nil { - fmt.Println("Error loading .env file") - os.Exit(1) - } - } - - a, err := parseArgs(os.Args[1:]) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - if a.help { - printHelp() - os.Exit(0) - } - - if a.version { - printVersion() - os.Exit(0) - } - - tm, err := parser.LoadDogFile(a.directory) - if err != nil { - printNoValidDogfile() - os.Exit(1) - } - - if a.taskName != "" { - if tm[a.taskName] != nil { - if a.workdir != "" { - tm[a.taskName].Workdir = a.workdir - } - if tm[a.taskName].Workdir == "" { - tm[a.taskName].Workdir = a.directory - } - } - - runner, err := execute.NewRunner(tm, a.info) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - runner.Run(a.taskName) - } else { - printTasks(tm) - os.Exit(0) - } -} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..2d8824d --- /dev/null +++ b/parse.go @@ -0,0 +1,260 @@ +package dog + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "path/filepath" + "regexp" + + "github.com/ghodss/yaml" +) + +// ErrMalformedStringArray means that a task have a value of +// pre, post or env that can't be parsed as an array of strings. +var ErrMalformedStringArray = errors.New("Malformed strings array") + +// ErrNoDogfile means that the application is unable to find +// a Dogfile in the specified directory. +var ErrNoDogfile = errors.New("No dogfile found") + +// Dogfile contains tasks defined in the Dogfile format. +type Dogfile struct { + + // Tasks is used to map task objects by their name. + Tasks map[string]*Task + + // Path is an optional field that stores the directory + // where the Dogfile is found. + Path string + + // Files is an optional field that stores the full path + // of each Dogfile used to define the Dogfile object. + Files []string +} + +// TaskYAML represents a task written in the Dogfile format. +type taskYAML struct { + Name string `json:"task"` + Description string `json:"description,omitempty"` + + Code string `json:"code"` + Run string `json:"run"` // backwards compatibility for 'code' + + Runner string `json:"runner,omitempty"` + Exec string `json:"exec,omitempty"` // backwards compatibility for 'runner' + + Pre interface{} `json:"pre,omitempty"` + Post interface{} `json:"post,omitempty"` + Env interface{} `json:"env,omitempty"` + Workdir string `json:"workdir,omitempty"` +} + +// Parse accepts a slice of bytes and parses it folloing the Dogfile Spec. +func (d *Dogfile) Parse(p []byte) error { + var tasks []*taskYAML + + err := yaml.Unmarshal(p, &tasks) + if err != nil { + return err + } + + for _, parsedTask := range tasks { + if _, ok := d.Tasks[parsedTask.Name]; ok { + return fmt.Errorf("Duplicated task name %s", parsedTask.Name) + } else if !validTaskName(parsedTask.Name) { + return fmt.Errorf("Invalid name for task %s", parsedTask.Name) + } else { + task := &Task{ + Name: parsedTask.Name, + Description: parsedTask.Description, + Code: parsedTask.Code, + Runner: parsedTask.Runner, + Workdir: parsedTask.Workdir, + } + + // convert pre-tasks, post-tasks and environment variables + // into []string + if task.Pre, err = parseStringSlice(parsedTask.Pre); err != nil { + return err + } + if task.Post, err = parseStringSlice(parsedTask.Post); err != nil { + return err + } + if task.Env, err = parseStringSlice(parsedTask.Env); err != nil { + return err + } + + // backwards compatibility support for 'run' and 'exec', now called + // 'code' and 'runner' respectively. + if parsedTask.Code == "" && parsedTask.Run != "" { + deprecationWarningRun = true + task.Code = parsedTask.Run + } + if parsedTask.Runner == "" && parsedTask.Exec != "" { + deprecationWarningExec = true + task.Runner = parsedTask.Exec + } + + // set default runner if not specified + if task.Runner == "" { + task.Runner = DefaultRunner + } + + if d.Tasks == nil { + d.Tasks = make(map[string]*Task) + } + d.Tasks[task.Name] = task + } + } + + return nil +} + +// DeprecationWarnings writes deprecation warnings if they have been found on +// parse time. +// +// Call it with os.Stderr as a parameter to print warnings to STDERR. +func DeprecationWarnings(w io.Writer) { + if deprecationWarningRun { + fmt.Fprintln(w, + "dog: 'run' directive will be deprecated in v0.6.0, use 'code' instead.") + } + if deprecationWarningExec { + fmt.Fprintln(w, + "dog: 'exec' directive will be deprecated in v0.6.0, use 'runner' instead.") + } +} + +// parseStringSlice takes an interface from a pre, post or env field +// and returns a slice of strings representing the found values. +func parseStringSlice(str interface{}) ([]string, error) { + switch h := str.(type) { + case string: + return []string{h}, nil + case []interface{}: + s := make([]string, len(h)) + for i, hook := range h { + sHook, ok := hook.(string) + if !ok { + return nil, ErrMalformedStringArray + } + s[i] = sHook + } + return s, nil + case nil: + return []string{}, nil + default: + return nil, ErrMalformedStringArray + } +} + +// ParseFromDisk finds a Dogfile in disk and parses it. +func (d *Dogfile) ParseFromDisk(dir string) error { + if dir == "" { + dir = "." + } + + dir, err := filepath.Abs(dir) + if err != nil { + return err + } + d.Path = dir + + files, err := FindDogfiles(dir) + if err != nil { + return err + } + if len(files) == 0 { + return ErrNoDogfile + } + d.Files = files + + for _, file := range d.Files { + var fileData []byte + fileData, err = ioutil.ReadFile(file) + if err != nil { + return err + } + + if err = d.Parse(fileData); err != nil { + return err + } + } + + return nil +} + +// Validate checks that all tasks in a Dogfile are valid. +func (d *Dogfile) Validate() error { + for _, t := range d.Tasks { + if err := t.Validate(); err != nil { + return err + } + } + return nil +} + +// FindDogfiles finds Dogfiles in disk for a given path. +// +// It traverses directories until it finds one containing Dogfiles. +// If such a directory is found, the function returns the full path +// for each valid Dogfile in that directory. +func FindDogfiles(p string) ([]string, error) { + var dogfilePaths []string + + currentPath, err := filepath.Abs(p) + if err != nil { + return nil, err + } + + for { + var files []os.FileInfo + files, err = ioutil.ReadDir(currentPath) + if err != nil { + return nil, err + } + + for _, file := range files { + if validDogfileName(file.Name()) { + dogfilePath := path.Join(currentPath, file.Name()) + dogfilePaths = append(dogfilePaths, dogfilePath) + } + } + + if len(dogfilePaths) > 0 { + return dogfilePaths, nil + } + + nextPath := path.Dir(currentPath) + if nextPath == currentPath { + return dogfilePaths, nil + } + currentPath = nextPath + } +} + +// validDogfileName checks if a Dogfile name is valid as defined +// by the Dogfile Spec. +func validDogfileName(name string) bool { + var match bool + match, err := regexp.MatchString("^(Dogfile|🐕)", name) + if err != nil { + return false + } + return match +} + +// validTaskName checks if a task name is valid as defined +// by the Dogfile Spec. +func validTaskName(name string) bool { + var match bool + match, err := regexp.MatchString("^[a-z0-9-]+$", name) + if err != nil { + return false + } + return match +} diff --git a/parser/parser.go b/parser/parser.go deleted file mode 100644 index 0dc406f..0000000 --- a/parser/parser.go +++ /dev/null @@ -1,163 +0,0 @@ -package parser - -import ( - "errors" - "fmt" - "io/ioutil" - "os" - "path" - "path/filepath" - "regexp" - - "github.com/dogtools/dog/types" - "github.com/ghodss/yaml" -) - -var ErrMalformedStringArray = errors.New("Malformed strings array") -var ErrNoDogfile = errors.New("No dogfile found") - -type task struct { - Name string `json:"task"` - Description string `json:"description,omitempty"` - Time bool `json:"time,omitempty"` - Run string `json:"run"` - Executor string `json:"exec,omitempty"` - Pre interface{} `json:"pre,omitempty"` - Post interface{} `json:"post,omitempty"` - Env interface{} `json:"env,omitempty"` - Workdir string `json:"workdir,omitempty"` -} - -func parseStringSlice(str interface{}) ([]string, error) { - switch h := str.(type) { - case string: - return []string{h}, nil - case []interface{}: - s := make([]string, len(h)) - for i, hook := range h { - sHook, ok := hook.(string) - if !ok { - return nil, ErrMalformedStringArray - } - s[i] = sHook - } - return s, nil - case nil: - return []string{}, nil - default: - return nil, ErrMalformedStringArray - } -} - -// ParseDogfile takes a byte slice and process it to return a TaskMap. -func ParseDogfile(d []byte, tm types.TaskMap) (err error) { - const validTaskName = "^[a-z0-9-]+$" - var tasksToParse []*task - - err = yaml.Unmarshal(d, &tasksToParse) - if err != nil { - return - } - - for _, t := range tasksToParse { - if _, ok := tm[t.Name]; ok { - return fmt.Errorf("Duplicated task name %s", t.Name) - } else if matches, _ := regexp.MatchString(validTaskName, t.Name); !matches { - return fmt.Errorf("Invalid name for task %s", t.Name) - } else { - task := &types.Task{ - Name: t.Name, - Description: t.Description, - Time: t.Time, - Run: t.Run, - Executor: t.Executor, - Workdir: t.Workdir, - } - if task.Pre, err = parseStringSlice(t.Pre); err != nil { - return - } - if task.Post, err = parseStringSlice(t.Post); err != nil { - return - } - if task.Env, err = parseStringSlice(t.Env); err != nil { - return - } - tm[t.Name] = task - } - } - - return -} - -// FindDogFiles finds Dogfiles in disk, traversing directories up from the -// given path until it finds a directory containing Dogfiles, and returns -// their paths. -func FindDogFiles(startPath string) (dogfilePaths []string, err error) { - const validDogfileName = "^(Dogfile|🐕)" - currentPath, err := filepath.Abs(startPath) - if err != nil { - return - } - - for { - var files []os.FileInfo - files, err = ioutil.ReadDir(currentPath) - if err != nil { - return - } - - for _, file := range files { - var match bool - match, err = regexp.MatchString(validDogfileName, file.Name()) - if err != nil { - return - } - - if match { - dogfilePath := path.Join(currentPath, file.Name()) - dogfilePaths = append(dogfilePaths, dogfilePath) - } - } - - if len(dogfilePaths) > 0 { - return - } - - nextPath := path.Dir(currentPath) - if nextPath == currentPath { - return - } - currentPath = nextPath - } -} - -// LoadDogFile finds a Dogfile in disk, parses YAML and returns a map. -func LoadDogFile(directory string) (tm types.TaskMap, err error) { - if directory == "" { - directory = "." - } - - tm = make(types.TaskMap) - files, err := FindDogFiles(directory) - if err != nil { - return - } - if len(files) == 0 { - err = ErrNoDogfile - return - } - - for _, file := range files { - var fileData []byte - fileData, err = ioutil.ReadFile(file) - if err != nil { - return - } - - if err = ParseDogfile(fileData, tm); err != nil { - return - } - } - - return -} diff --git a/run/cmd.go b/run/cmd.go new file mode 100644 index 0000000..9d3df67 --- /dev/null +++ b/run/cmd.go @@ -0,0 +1,73 @@ +package run + +import ( + "errors" + "io/ioutil" + "os" + "os/exec" +) + +// runCmd embeds and extends exec.Cmd. +type runCmd struct { + exec.Cmd + tmpFile *os.File +} + +// Wait waits until the command finishes running and provides exit information. +// +// This method overrites the Wait method that comes from the embedded exec.Cmd +// type, adding the removal of the temporary file. +func (c *runCmd) Wait() error { + defer func() { + _ = os.Remove(c.tmpFile.Name()) + }() + + err := c.Cmd.Wait() + if err != nil { + return err + } + return nil +} + +// writeTempFile copies the code in a temporary file that will get passed as an +// argument to the runner (as in `sh `). +func (c *runCmd) writeTempFile(data string) error { + f, err := ioutil.TempFile("", "dog") + if err != nil { + return err + } + _, err = f.WriteString(data) + if err != nil { + return err + } + c.tmpFile = f + return nil +} + +// newCmdRunner creates a cmd type runner of the choosen executor. +func newCmdRunner(runner string, code string, workdir string, env []string) (Runner, error) { + if code == "" { + return nil, errors.New("No code specified to run") + } + + cmd := runCmd{} + + path, err := exec.LookPath(runner) + if err != nil { + return nil, err + } + cmd.Path = path + + err = cmd.writeTempFile(code) + if err != nil { + return nil, err + } + + cmd.Stdin = os.Stdin + cmd.Dir = workdir + cmd.Args = append(cmd.Args, runner, cmd.tmpFile.Name()) + cmd.Env = append(cmd.Env, os.Environ()...) + cmd.Env = append(cmd.Env, env...) + + return &cmd, nil +} diff --git a/run/run.go b/run/run.go new file mode 100644 index 0000000..76418bc --- /dev/null +++ b/run/run.go @@ -0,0 +1,70 @@ +package run + +import ( + "bufio" + "io" +) + +// Runner just runs anything. +type Runner interface { + // StdoutPipe returns a pipe that will be connected to the runner's + // standard output when the command starts. + StdoutPipe() (io.ReadCloser, error) + + // StderrPipe returns a pipe that will be connected to the runner's + // standard error when the command starts. + StderrPipe() (io.ReadCloser, error) + + // Start starts the runner but does not wait for it to complete. + Start() error + + // Wait waits for the runner to exit. It must have been started by Start. + // + // The returned error is nil if the runner has no problems copying + // stdin, stdout, and stderr, and exits with a zero exit status. + Wait() error +} + +// NewShRunner creates a system standard shell script runner. +func NewShRunner(code string, workdir string, env []string) (Runner, error) { + return newCmdRunner("sh", code, workdir, env) +} + +// NewBashRunner creates a Bash runner. +func NewBashRunner(code string, workdir string, env []string) (Runner, error) { + return newCmdRunner("bash", code, workdir, env) +} + +// NewPythonRunner creates a Python runner. +func NewPythonRunner(code string, workdir string, env []string) (Runner, error) { + return newCmdRunner("python", code, workdir, env) +} + +// NewRubyRunner creates a Ruby runner. +func NewRubyRunner(code string, workdir string, env []string) (Runner, error) { + return newCmdRunner("ruby", code, workdir, env) +} + +// NewPerlRunner creates a Perl runner. +func NewPerlRunner(code string, workdir string, env []string) (Runner, error) { + return newCmdRunner("perl", code, workdir, env) +} + +// GetOutputScanners is a helper method that returns two bufio.Scanner objects, +// one for stdout and one for stderr. +func GetOutputScanners(r Runner) (stdoutScanner bufio.Scanner, stderrScanner bufio.Scanner, err error) { + stdoutReader, err := r.StdoutPipe() + if err != nil { + return bufio.Scanner{}, bufio.Scanner{}, err + } + + stderrReader, err := r.StderrPipe() + if err != nil { + return bufio.Scanner{}, bufio.Scanner{}, err + } + + stdoutScanner = *bufio.NewScanner(stdoutReader) + stderrScanner = *bufio.NewScanner(stderrReader) + + return stdoutScanner, stderrScanner, nil +} diff --git a/scripts/test-dogfiles.sh b/scripts/test-dogfiles.sh index 3c23098..e9ce261 100755 --- a/scripts/test-dogfiles.sh +++ b/scripts/test-dogfiles.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash set -e cd testdata for task in $(dog | awk '{print $1}'); do diff --git a/task.go b/task.go new file mode 100644 index 0000000..d826208 --- /dev/null +++ b/task.go @@ -0,0 +1,53 @@ +package dog + +import "fmt" + +// Task represents a task described in the Dogfile format. +type Task struct { + // Name of the task. + Name string + + // Description of the task. + Description string + + // The code that will be executed. + Code string + + // Defaults to operating system main shell. + Runner string + + // Pre-hooks execute other tasks before starting the current one. + Pre []string + + // Post-hooks are analog to pre-hooks but they are executed after + // current task finishes its execution. + Post []string + + // Default values for environment variables can be provided in the Dogfile. + // They can be modified at execution time. + Env []string + + // Sets the working directory for the task. Relative paths are + // considered relative to the location of the Dogfile. + Workdir string +} + +// Validate runs a series of validations against a task. +// +// It checks if it has a non standard name and also if +// the resulting task chain have undesired cycles. +func (t *Task) Validate() error { + + if !validTaskName(t.Name) { + return fmt.Errorf("Invalid name for task %s", t.Name) + } + + var d Dogfile + d.Tasks[t.Name] = t + + var tc TaskChain + if err := tc.Generate(d, t.Name); err != nil { + return err + } + return nil +} diff --git a/testdata/.env b/testdata/.env deleted file mode 100644 index b75a4c9..0000000 --- a/testdata/.env +++ /dev/null @@ -1 +0,0 @@ -S3_BUCKET=sdfghjkjhgfdfghjksdfghjklkjhgfghsjghjsdgfhjbhsjkdfhjksgdfhjk \ No newline at end of file diff --git a/testdata/Dogfile-bash.json b/testdata/Dogfile-bash.json index 974b965..d93ed26 100644 --- a/testdata/Dogfile-bash.json +++ b/testdata/Dogfile-bash.json @@ -1,8 +1,8 @@ [ { "task": "bash-conditional-echo-json", - "description": "Conditional and echo from BASH", - "exec": "bash", - "run": "if [[ 1 == 1 ]]; then\n echo \"Yo Dawg ! Now parsing JSON too !!!\"\nfi\n" + "description": "Conditional and echo from BASH in JSON Dogfile", + "runner": "bash", + "code": "if [[ 1 == 1 ]]; then\n echo \"Yo Dawg ! Now parsing JSON too !!!\"\nfi\n" } -] \ No newline at end of file +] diff --git a/testdata/Dogfile-bash.yml b/testdata/Dogfile-bash.yml index eeb5759..4c27254 100644 --- a/testdata/Dogfile-bash.yml +++ b/testdata/Dogfile-bash.yml @@ -1,7 +1,7 @@ - task: bash-conditional-echo description: Conditional and echo from BASH - exec: bash - run: | + runner: bash + code: | if [[ 1 == 1 ]]; then echo "Hello Dog from BASH!" fi diff --git a/testdata/Dogfile-env.yml b/testdata/Dogfile-env.yml deleted file mode 100644 index b441c67..0000000 --- a/testdata/Dogfile-env.yml +++ /dev/null @@ -1,6 +0,0 @@ -- task: bash-env-file-echo - description: Conditional and echo from BASH - exec: bash - run: | - echo "Hello env values !" - echo $S3_BUCKET diff --git a/testdata/Dogfile-hooks.yml b/testdata/Dogfile-hooks.yml index 6eda68c..705c2e1 100644 --- a/testdata/Dogfile-hooks.yml +++ b/testdata/Dogfile-hooks.yml @@ -1,18 +1,15 @@ - task: pre-task - description: Pre Task - run: echo "I am a prehook" + code: echo "I am a prehook" - task: post-task - description: Post Task post: post-task-2 - run: echo "I am a post hook" + code: echo "I am a post hook" - task: post-task-2 - description: Post Task 2 - run: echo "I am another post hook" + code: echo "I am another post hook" - task: task-with-hooks description: Run a task with pre and post hooks pre: pre-task post: post-task - run: echo "I am the main task" + code: echo "I am the main task" diff --git a/testdata/Dogfile-longrun.yml b/testdata/Dogfile-longrun.yml index f443185..81a3b9f 100644 --- a/testdata/Dogfile-longrun.yml +++ b/testdata/Dogfile-longrun.yml @@ -1,6 +1,6 @@ - task: long-run description: A task that takes 5 seconds - run: | + code: | for i in $(seq 1 5); do sleep 1 echo "> $i/5" diff --git a/testdata/Dogfile-mustfail.yml b/testdata/Dogfile-mustfail.yml index 22a6b9d..569024e 100644 --- a/testdata/Dogfile-mustfail.yml +++ b/testdata/Dogfile-mustfail.yml @@ -1,4 +1,4 @@ - task: must-fail description: A task that fails - exec: ruby - run: putss "I fail" + runner: ruby + code: putss "I fail" diff --git a/testdata/Dogfile-perl.yml b/testdata/Dogfile-perl.yml new file mode 100644 index 0000000..d7d9fc1 --- /dev/null +++ b/testdata/Dogfile-perl.yml @@ -0,0 +1,4 @@ +- task: perl-print + description: Perl print says hello + runner: perl + code: print "Hello from Perl!\n"; diff --git a/testdata/Dogfile-python.yml b/testdata/Dogfile-python.yml index 10e97a6..386c493 100644 --- a/testdata/Dogfile-python.yml +++ b/testdata/Dogfile-python.yml @@ -1,4 +1,4 @@ - task: python-print description: Python print says hello - exec: python - run: print("Hello Dog from Python!") + runner: python + code: print("Hello Dog from Python!") diff --git a/testdata/Dogfile-ruby.yml b/testdata/Dogfile-ruby.yml index cb4f550..847c867 100644 --- a/testdata/Dogfile-ruby.yml +++ b/testdata/Dogfile-ruby.yml @@ -1,4 +1,4 @@ - task: ruby-puts description: Ruby puts says hello - exec: ruby - run: puts "Hello Dog from Ruby!" + runner: ruby + code: puts "Hello Dog from Ruby!" diff --git a/testdata/Dogfile-sh.yml b/testdata/Dogfile-sh.yml index ce262e9..c5303e3 100644 --- a/testdata/Dogfile-sh.yml +++ b/testdata/Dogfile-sh.yml @@ -1,6 +1,6 @@ - task: sh-blank-lines description: Output with blank lines - run: | + code: | for i in $(seq 1 5); do echo echo $i @@ -8,11 +8,11 @@ - task: sh-echo description: Shell says hello - run: echo "Hello Dog from sh!" + code: echo "Hello Dog from sh!" - task: sh-count-animals description: Count animals - run: | + code: | mammals="dog cat dolphin dog cat human dog dog cat" reptiles="crocodile lizard snake" birds="owl eagle owl" diff --git a/types/event.go b/types/event.go deleted file mode 100644 index 7618b13..0000000 --- a/types/event.go +++ /dev/null @@ -1,45 +0,0 @@ -package types - -import "time" - -type EventType int - -const ( - StartEvent = EventType(iota + 1) - OutputEvent - EndEvent -) - -type Event struct { - Type EventType - Task string - Time time.Time - Body []byte - ExitCode int -} - -func NewStartEvent(taskName string) *Event { - return &Event{ - Type: StartEvent, - Task: taskName, - Time: time.Now(), - } -} - -func NewOutputEvent(taskName string, body []byte) *Event { - return &Event{ - Type: OutputEvent, - Task: taskName, - Time: time.Now(), - Body: body, - } -} - -func NewEndEvent(taskName string, exitCode int) *Event { - return &Event{ - Type: EndEvent, - Task: taskName, - Time: time.Now(), - ExitCode: exitCode, - } -} diff --git a/types/task.go b/types/task.go deleted file mode 100644 index 90f6c9d..0000000 --- a/types/task.go +++ /dev/null @@ -1,17 +0,0 @@ -package types - -// Task is a representation of a dogfile task -type Task struct { - Name string - Description string - Time bool - Run string - Executor string - Pre []string - Post []string - Env []string - Workdir string -} - -// TaskMap is a map in which the key is a task name and the value is a Task object -type TaskMap map[string]*Task From b338de0dbcb8e211fab422a5433a4ae96a2d7147 Mon Sep 17 00:00:00 2001 From: xavi Date: Thu, 19 Jan 2017 23:23:21 +0100 Subject: [PATCH 02/13] Add example exposing tasks through HTTP --- examples/http-api/http-api.go | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 examples/http-api/http-api.go diff --git a/examples/http-api/http-api.go b/examples/http-api/http-api.go new file mode 100644 index 0000000..d151d10 --- /dev/null +++ b/examples/http-api/http-api.go @@ -0,0 +1,51 @@ +package main + +// This example shows an application exposing the execution of Dogfile tasks +// through an HTTP endpoint. + +import ( + "fmt" + "net/http" + "os" + + "github.com/dogtools/dog" +) + +// Dogfile object +var d dog.Dogfile + +func main() { + + // Parse Dogfile from current path + err := d.ParseFromDisk(".") + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Launch the HTTP server + http.HandleFunc("/", handler) + http.ListenAndServe(":8080", nil) +} + +func handler(w http.ResponseWriter, r *http.Request) { + + // Get task name from path + taskName := r.URL.Path[1:] + + // Generate task chain for the task named as the URL path + var tc dog.TaskChain + err := tc.Generate(d, taskName) + if err != nil { + fmt.Fprintf(w, "task chain generation failed: %s\n", err) + os.Exit(1) + } + + // Run task chain, HTTP client receives info about how task finished + err = tc.Run() + if err != nil { + fmt.Fprintf(w, "%s failed: %s\n", taskName, err) + os.Exit(2) + } + fmt.Fprintf(w, "%s finished\n", taskName) +} From bd0ec8da5c2a3b42c3c496446f6fc020cf9770cf Mon Sep 17 00:00:00 2001 From: Ivan Daniluk Date: Fri, 20 Jan 2017 22:29:53 +0100 Subject: [PATCH 03/13] CombinedOutput returns an io.Reader --- chain.go | 16 ++++------------ run/run.go | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/chain.go b/chain.go index f418cde..ea4bc17 100644 --- a/chain.go +++ b/chain.go @@ -3,6 +3,8 @@ package dog import ( "errors" "fmt" + "io" + "os" "os/exec" "syscall" "time" @@ -93,22 +95,12 @@ func (taskChain *TaskChain) Run() error { return err } - stdoutScanner, stderrScanner, err := run.GetOutputScanners(runner) + runOutput, err := run.CombinedOutput(runner) if err != nil { return err } - go func() { - for stdoutScanner.Scan() { - fmt.Println(stdoutScanner.Text()) - } - }() - - go func() { - for stderrScanner.Scan() { - fmt.Println(stderrScanner.Text()) - } - }() + go io.Copy(os.Stdout, runOutput) startTime = time.Now() err = runner.Start() diff --git a/run/run.go b/run/run.go index 76418bc..b6361f0 100644 --- a/run/run.go +++ b/run/run.go @@ -50,21 +50,21 @@ func NewPerlRunner(code string, workdir string, env []string) (Runner, error) { return newCmdRunner("perl", code, workdir, env) } -// GetOutputScanners is a helper method that returns two bufio.Scanner objects, -// one for stdout and one for stderr. -func GetOutputScanners(r Runner) (stdoutScanner bufio.Scanner, stderrScanner bufio.Scanner, err error) { - stdoutReader, err := r.StdoutPipe() +// CombinedOutput is a helper method that returns combined (stdout and stderr) +// output from the runner. +func CombinedOutput(r Runner) (io.Reader, error) { + stdout, err := r.StdoutPipe() if err != nil { - return bufio.Scanner{}, bufio.Scanner{}, err + return nil, err } - stderrReader, err := r.StderrPipe() + stderr, err := r.StderrPipe() if err != nil { - return bufio.Scanner{}, bufio.Scanner{}, err + return nil, err } - stdoutScanner = *bufio.NewScanner(stdoutReader) - stderrScanner = *bufio.NewScanner(stderrReader) + r1 := bufio.NewReader(stdout) + r2 := bufio.NewReader(stderr) - return stdoutScanner, stderrScanner, nil + return io.MultiReader(r1, r2), nil } From e4a3f59bbed37f2e1c185d084b860086457558aa Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 12:52:40 +0100 Subject: [PATCH 04/13] Format error using fmt.Errorf --- chain.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chain.go b/chain.go index ea4bc17..aefa05c 100644 --- a/chain.go +++ b/chain.go @@ -55,7 +55,7 @@ func addToChain(taskChain *TaskChain, d Dogfile, tasks []string) error { t, found := d.Tasks[name] if !found { - return errors.New("Task " + name + " does not exist") + return fmt.Errorf("Task %q does not exist", name) } if err := taskChain.Generate(d, t.Name); err != nil { @@ -87,7 +87,7 @@ func (taskChain *TaskChain) Run() error { runner, err = run.NewPerlRunner(t.Code, t.Workdir, t.Env) default: if t.Runner == "" { - return fmt.Errorf("Runner not specified") + return errors.New("Runner not specified") } return fmt.Errorf("%s is not a supported runner", t.Runner) } From 36b19768b5fd19a6552a87067df249e3857b2947 Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 12:56:32 +0100 Subject: [PATCH 05/13] Add error handling to ListenAndServe --- examples/http-api/http-api.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/http-api/http-api.go b/examples/http-api/http-api.go index d151d10..15b0bdc 100644 --- a/examples/http-api/http-api.go +++ b/examples/http-api/http-api.go @@ -25,7 +25,11 @@ func main() { // Launch the HTTP server http.HandleFunc("/", handler) - http.ListenAndServe(":8080", nil) + err = http.ListenAndServe(":8080", nil) + if err != nil { + fmt.Println(err) + os.Exit(1) + } } func handler(w http.ResponseWriter, r *http.Request) { From efcfb595af8269d8ec50bedaa5be595c504712a4 Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 12:57:40 +0100 Subject: [PATCH 06/13] Typo --- parse.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parse.go b/parse.go index 2d8824d..3544831 100644 --- a/parse.go +++ b/parse.go @@ -53,7 +53,7 @@ type taskYAML struct { Workdir string `json:"workdir,omitempty"` } -// Parse accepts a slice of bytes and parses it folloing the Dogfile Spec. +// Parse accepts a slice of bytes and parses it following the Dogfile Spec. func (d *Dogfile) Parse(p []byte) error { var tasks []*taskYAML From ead3340dbb50e4798e1538aa62b5ed0da8faf236 Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 13:01:04 +0100 Subject: [PATCH 07/13] Remove unnecessary string concatenation --- chain.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/chain.go b/chain.go index aefa05c..8706784 100644 --- a/chain.go +++ b/chain.go @@ -134,21 +134,20 @@ func (taskChain *TaskChain) Run() error { } // formatDuration is a time formatter. -func formatDuration(d time.Duration) (s string) { - timeMsg := "" +func formatDuration(d time.Duration) (timeMsg string) { if d.Hours() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fh", d.Hours()) + timeMsg = fmt.Sprintf("%1.0fh", d.Hours()) } if d.Minutes() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fm", d.Minutes()) + timeMsg = fmt.Sprintf("%1.0fm", d.Minutes()) } if d.Seconds() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fs", d.Seconds()) + timeMsg = fmt.Sprintf("%1.0fs", d.Seconds()) } else { - timeMsg += fmt.Sprintf("%1.3fs", d.Seconds()) + timeMsg = fmt.Sprintf("%1.3fs", d.Seconds()) } return timeMsg From 553a6ae2041f985b24946ba17301eac65c6dc512 Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 13:10:09 +0100 Subject: [PATCH 08/13] Revert "Remove unnecessary string concatenation" This reverts commit ead3340dbb50e4798e1538aa62b5ed0da8faf236. --- chain.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/chain.go b/chain.go index 8706784..aefa05c 100644 --- a/chain.go +++ b/chain.go @@ -134,20 +134,21 @@ func (taskChain *TaskChain) Run() error { } // formatDuration is a time formatter. -func formatDuration(d time.Duration) (timeMsg string) { +func formatDuration(d time.Duration) (s string) { + timeMsg := "" if d.Hours() > 1.0 { - timeMsg = fmt.Sprintf("%1.0fh", d.Hours()) + timeMsg += fmt.Sprintf("%1.0fh", d.Hours()) } if d.Minutes() > 1.0 { - timeMsg = fmt.Sprintf("%1.0fm", d.Minutes()) + timeMsg += fmt.Sprintf("%1.0fm", d.Minutes()) } if d.Seconds() > 1.0 { - timeMsg = fmt.Sprintf("%1.0fs", d.Seconds()) + timeMsg += fmt.Sprintf("%1.0fs", d.Seconds()) } else { - timeMsg = fmt.Sprintf("%1.3fs", d.Seconds()) + timeMsg += fmt.Sprintf("%1.3fs", d.Seconds()) } return timeMsg From e06469c89c9163ae6e3558371e06ffc23942d0dd Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 14:06:04 +0100 Subject: [PATCH 09/13] Fix unnecessary concatenation, improve code comment. --- chain.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/chain.go b/chain.go index aefa05c..1dfd222 100644 --- a/chain.go +++ b/chain.go @@ -133,12 +133,13 @@ func (taskChain *TaskChain) Run() error { return nil } -// formatDuration is a time formatter. +// formatDuration returns a string representing a time duration in the format +// {x}h{y}m{z}s, for example 3m25s. func formatDuration(d time.Duration) (s string) { timeMsg := "" if d.Hours() > 1.0 { - timeMsg += fmt.Sprintf("%1.0fh", d.Hours()) + timeMsg = fmt.Sprintf("%1.0fh", d.Hours()) } if d.Minutes() > 1.0 { From e1d53627319256f5f97353f68e8391dab54ed5a5 Mon Sep 17 00:00:00 2001 From: xavi Date: Sun, 22 Jan 2017 19:17:55 +0100 Subject: [PATCH 10/13] Add fail if task does not exist at package level --- chain.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/chain.go b/chain.go index 1dfd222..769777f 100644 --- a/chain.go +++ b/chain.go @@ -23,6 +23,11 @@ type TaskChain struct { // Generate creates the TaskChain for a specific task. func (taskChain *TaskChain) Generate(d Dogfile, task string) error { + t, found := d.Tasks[task] + if !found { + return fmt.Errorf("Task %q does not exist", task) + } + // Cycle detection for i := 0; i < len(taskChain.Tasks); i++ { if taskChain.Tasks[i].Name == task { @@ -32,8 +37,6 @@ func (taskChain *TaskChain) Generate(d Dogfile, task string) error { } } - t := d.Tasks[task] - // Iterate over pre-tasks if err := addToChain(taskChain, d, t.Pre); err != nil { return err From 4446f4c5d1257553e040d046d74b80339aca2fd3 Mon Sep 17 00:00:00 2001 From: xavi Date: Mon, 23 Jan 2017 20:45:32 +0100 Subject: [PATCH 11/13] No need to declare timeMsg variable --- chain.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/chain.go b/chain.go index 769777f..ef9314c 100644 --- a/chain.go +++ b/chain.go @@ -138,8 +138,7 @@ func (taskChain *TaskChain) Run() error { // formatDuration returns a string representing a time duration in the format // {x}h{y}m{z}s, for example 3m25s. -func formatDuration(d time.Duration) (s string) { - timeMsg := "" +func formatDuration(d time.Duration) (timeMsg string) { if d.Hours() > 1.0 { timeMsg = fmt.Sprintf("%1.0fh", d.Hours()) From 5cb24e334376a71eb3a5f8d0b9196aebda466c78 Mon Sep 17 00:00:00 2001 From: xavi Date: Mon, 30 Jan 2017 22:08:05 +0100 Subject: [PATCH 12/13] Run accepts two io.Writers to collect stdout and stderr separately --- chain.go | 8 ++++---- cmd/dog/main.go | 2 +- run/run.go | 15 ++++++--------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/chain.go b/chain.go index ef9314c..b758b03 100644 --- a/chain.go +++ b/chain.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "io" - "os" "os/exec" "syscall" "time" @@ -69,7 +68,7 @@ func addToChain(taskChain *TaskChain, d Dogfile, tasks []string) error { } // Run handles the execution of all tasks in the TaskChain. -func (taskChain *TaskChain) Run() error { +func (taskChain *TaskChain) Run(stdout, stderr io.Writer) error { var startTime time.Time for _, t := range taskChain.Tasks { @@ -98,12 +97,13 @@ func (taskChain *TaskChain) Run() error { return err } - runOutput, err := run.CombinedOutput(runner) + runOut, runErr, err := run.GetOutputs(runner) if err != nil { return err } - go io.Copy(os.Stdout, runOutput) + go io.Copy(stdout, runOut) + go io.Copy(stderr, runErr) startTime = time.Now() err = runner.Start() diff --git a/cmd/dog/main.go b/cmd/dog/main.go index 356b98d..73487a3 100644 --- a/cmd/dog/main.go +++ b/cmd/dog/main.go @@ -62,7 +62,7 @@ func main() { } // run task chain - err = tc.Run() + err = tc.Run(os.Stdout, os.Stderr) if err != nil { os.Exit(2) } diff --git a/run/run.go b/run/run.go index b6361f0..69251e8 100644 --- a/run/run.go +++ b/run/run.go @@ -50,21 +50,18 @@ func NewPerlRunner(code string, workdir string, env []string) (Runner, error) { return newCmdRunner("perl", code, workdir, env) } -// CombinedOutput is a helper method that returns combined (stdout and stderr) -// output from the runner. -func CombinedOutput(r Runner) (io.Reader, error) { +// GetOutputs is a helper method that returns both stdout and stderr outputs +// from the runner. +func GetOutputs(r Runner) (io.Reader, io.Reader, error) { stdout, err := r.StdoutPipe() if err != nil { - return nil, err + return nil, nil, err } stderr, err := r.StderrPipe() if err != nil { - return nil, err + return nil, nil, err } - r1 := bufio.NewReader(stdout) - r2 := bufio.NewReader(stderr) - - return io.MultiReader(r1, r2), nil + return bufio.NewReader(stdout), bufio.NewReader(stderr), nil } From 812f202c709cfec71eca3893347a8dc984362b90 Mon Sep 17 00:00:00 2001 From: xavi Date: Mon, 30 Jan 2017 22:12:18 +0100 Subject: [PATCH 13/13] Update examples, now passing stdout and stderr to Run() --- examples/hello/hello.go | 2 +- examples/http-api/http-api.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/hello/hello.go b/examples/hello/hello.go index 679fdfe..a6f7fba 100644 --- a/examples/hello/hello.go +++ b/examples/hello/hello.go @@ -41,7 +41,7 @@ func main() { } // Run task chain - err = tc.Run() + err = tc.Run(os.Stdout, os.Stderr) if err != nil { fmt.Println(err) os.Exit(2) diff --git a/examples/http-api/http-api.go b/examples/http-api/http-api.go index 15b0bdc..fa105de 100644 --- a/examples/http-api/http-api.go +++ b/examples/http-api/http-api.go @@ -46,7 +46,7 @@ func handler(w http.ResponseWriter, r *http.Request) { } // Run task chain, HTTP client receives info about how task finished - err = tc.Run() + err = tc.Run(os.Stdout, os.Stderr) if err != nil { fmt.Fprintf(w, "%s failed: %s\n", taskName, err) os.Exit(2)