Skip to content

Commit

Permalink
Adds basic workflow graphing
Browse files Browse the repository at this point in the history
fixes #190
  • Loading branch information
ivotron committed Dec 25, 2017
1 parent fdacc9f commit 8ea68fe
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 95 deletions.
4 changes: 4 additions & 0 deletions docs/ci/demo
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ git init
popper init
popper init mypipeline
popper ci travis
cat .travis.yml
popper ci circleci
cat .circleci/config.yml
popper workflow mypipeline
cd pipelines/mypipeline
popper check
1 change: 1 addition & 0 deletions popper/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ ignored.`,
if len(args) != 0 {
log.Fatalln("This command doesn't take arguments.")
}
initPopperFolder()
runCheck()
},
}
Expand Down
185 changes: 185 additions & 0 deletions popper/graph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package main

import (
"bytes"
"fmt"
"hash/fnv"
"io/ioutil"
"log"
"strings"

"github.com/spf13/cobra"
"mvdan.cc/sh/syntax"
)

func hashIt(s string) uint32 {
h := fnv.New32a()
h.Write([]byte(s))
return h.Sum32()
}

func getDotGraphForStage(pipelinePath, stageFile, previousStageFile string) (dot string, err error) {
content, err := ioutil.ReadFile(pipelinePath + "/" + stageFile)
if err != nil {
return
}
f, err := syntax.NewParser(syntax.KeepComments).Parse(bytes.NewReader(content), "")
if err != nil {
return
}

dot = ""

stage := strings.Replace(stageFile, ".sh", "", -1)
stage = strings.Replace(stage, "-", "", -1)

posIfClause := uint(0)
endIfClause := uint(0)
ifClauseNodeId := uint(0)
nodesInGraph := make(map[uint32]bool)

// walk the walk
syntax.Walk(f, func(node syntax.Node) bool {
label := ""
foundWfComment := false
switch x := node.(type) {
case *syntax.Stmt:
if len(x.Comments) == 0 {
return true
}
for _, c := range x.Comments {

// ignore statements without [wf]
if !strings.Contains(c.Text, "[wf]") {
continue
}
foundWfComment = true

// get the label of the node
label = strings.Replace(c.Text, " [wf] ", "", 1)
label = strings.Replace(label, "[wf]", "", 1)
label = strings.Replace(label, " [wf]", "", 1)
label = strings.Replace(label, "[wf] ", "", 1)
}

if !foundWfComment {
return true
}

// first comment in a script goes in the root of that stage
if _, present := nodesInGraph[hashIt(stage)]; !present {
dot += fmt.Sprintf(" %s [shape=record,label=\"{%s|%s}\"];\n", stage, stageFile, label)

if len(previousStageFile) > 0 {
previous := strings.Replace(previousStageFile, ".sh", "", -1)
previous = strings.Replace(previous, "-", "", -1)
dot += fmt.Sprintf(" %s -> %s;\n", previous, stage)
}
nodesInGraph[hashIt(stage)] = true
return true
}

// add any clause-specific elements to label

switch y := x.Cmd.(type) {
case *syntax.CallExpr:

cmdValue := ""
for _, w := range y.Args {
for _, wp := range w.Parts {
switch x := wp.(type) {
case *syntax.Lit:
cmdValue = x.Value
default:
cmdValue = "unknownCmd"
}
break
}
break
}
label = cmdValue + ":" + label
case *syntax.ForClause:
label = "loop: " + label
case *syntax.WhileClause:
label = "loop: " + label
case *syntax.CaseClause:
label = "case: " + label
}

dot += fmt.Sprintf(" s%d [shape=record,label=\"%s\"];\n", x.Pos().Offset(), label)

// add edges
if x.Pos().Offset() > posIfClause && x.End().Offset() < endIfClause {

if _, present := nodesInGraph[uint32(posIfClause)]; !present {
// add the ifClause node
dot += fmt.Sprintf(" s%d [label=\"condition\"];\n", ifClauseNodeId)
// add a link from stage to it
dot += fmt.Sprintf(" %s -> s%d;\n", stage, ifClauseNodeId)
nodesInGraph[uint32(posIfClause)] = false
}

// within an if statement, create edge from if clause node
dot += fmt.Sprintf(" s%d -> s%d;\n", ifClauseNodeId, x.Pos().Offset())
} else {
// create edge from stage node
dot += fmt.Sprintf(" %s -> s%d;\n", stage, x.Pos().Offset())
}
case *syntax.IfClause:

// get position of current if clause
posIfClause = x.Pos().Offset()
endIfClause = x.End().Offset()
ifClauseNodeId = x.Pos().Offset()

// TODO: support nested if clauses, e.g. by using a stack
}
return true
})
return
}

func getDotGraph(pipelinePath string, stages []string) (dot string, err error) {
dot = "digraph pipeline {\n"
previousStage := ""

for _, stage := range stages {

if subdot, err := getDotGraphForStage(pipelinePath, stage, previousStage); err != nil {
return "", err
} else {
dot += subdot
}

previousStage = stage
}

dot += "}"

return
}

var graphCmd = &cobra.Command{
Use: "workflow [pipeline]",
Short: "Obtain a call graph of a pipeline in .dot format.",
Long: "",
Run: func(cmd *cobra.Command, args []string) {

if len(args) > 1 {
log.Fatalln("This command takes one argument at most.")
}

pipelinePath, stages := getPipelineStages(args)

dot, err := getDotGraph(pipelinePath, stages)
if err != nil {
log.Fatalln(err)
}

fmt.Println(dot)
},
}

func init() {
RootCmd.AddCommand(graphCmd)
}
21 changes: 12 additions & 9 deletions popper/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import (
var envFlagValue string

var setupSh = []byte(`#!/bin/bash
# Any setup required by the pipeline goes here. Things like installing
# packages, allocating resources or deploying software on remote
# infrastructure can be implemented here.
# [wf] any setup required by the pipeline.
# Things like installing packages, allocating resources
# or deploying software on remote infrastructure can be implemented here.
set -e
# add commands here:
Expand All @@ -24,7 +24,8 @@ exit 0
`)

var runSh = []byte(`#!/bin/bash
# This file should contain the series of steps that are required to execute
# [wf] series of steps required to execute the pipeline.
# This file should contain the series of steps that are required to execute
# the pipeline. Any non-zero exit code will be interpreted as a failure
# by the 'popper check' command.
set -e
Expand All @@ -35,6 +36,7 @@ exit 0
`)

var postRunSh = []byte(`#!/bin/bash
# [wf] run post-processing tasks
# Any post-run tasks should be included here. For example, post-processing
# of output data, or updating a dataset with results of execution. Any
# non-zero exit code will be interpreted as a failure by the 'popper check'
Expand All @@ -47,6 +49,7 @@ exit 0
`)

var validateSh = []byte(`#!/bin/bash
# [wf] validate the output of pipeline.
# The point of entry to the validation of results produced by the pipeline.
# Any non-zero exit code will be interpreted as a failure by the 'popper check'
# command. Additionally, the command should print "true" or "false" for each
Expand All @@ -59,6 +62,7 @@ exit 0
`)

var teardownSh = []byte(`#!/bin/bash
# [wf] cleanup tasks.
# Put all your cleanup tasks here.
set -e
exit 0
Expand Down Expand Up @@ -124,10 +128,9 @@ name is 'paper', then a 'paper' folder is created. Otherwise, an pipeline named
if len(args) > 1 {
log.Fatalln("This command takes one argument at most.")
}
if !sh.Test("dir", popperFolder) {
if err := sh.Command("git", "clone", "https://github.com/systemslab/popper", popperFolder).Run(); err != nil {
log.Fatalln(err)
}
err := initPopperFolder()
if err != nil {
log.Fatalln(err)
}
if !sh.Test("dir", ".git") {
log.Fatalln("Can't find .git folder. Are you on the root folder of project?")
Expand All @@ -136,7 +139,7 @@ name is 'paper', then a 'paper' folder is created. Otherwise, an pipeline named
if sh.Test("file", ".popper.yml") {
log.Fatalln("File .popper.yml already exists")
}
err := ioutil.WriteFile(".popper.yml", []byte(""), 0644)
err = ioutil.WriteFile(".popper.yml", []byte(""), 0644)
if err != nil {
log.Fatalln(err)
}
Expand Down
86 changes: 0 additions & 86 deletions popper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,9 @@ package main

import (
"fmt"
"log"
"os"
"path"
"strings"

sh "github.com/codeskyblue/go-sh"
"github.com/spf13/cobra"
"github.com/theherk/viper"
"github.com/casimir/xdg-go"
)

var showVersion bool
Expand All @@ -35,86 +29,6 @@ func main() {
}
}

var popperFolder = xdg.CacheHome() + "/popper"

var popperRepoUrl = "https://github.com/systemslab/popper"

func ensurePipelineFolder() {
if sh.Test("file", ".popper.yml") {
log.Fatalln("File .popper.yml already exists")
}
if !sh.Test("dir", "../../pipelines") {
log.Fatalln("Not inside an pipeline folder, 'cd' into one first.")
}
}

func getPipelinePath() (dir string, err error) {
ensurePipelineFolder()
dir, err = os.Getwd()
if err != nil {
return
}
return
}

func getRepoInfo() (user, repo string, err error) {
remoteURL, err := sh.Command(
"git", "config", "--get", "remote.origin.url").Output()
if err != nil {
return
}
urlAndUser, repo := path.Split(string(remoteURL))

// get the user or org name
user = path.Base(strings.Replace(urlAndUser, ":", "/", -1))

// trim and remove .git extension, if present
repo = strings.TrimSuffix(strings.TrimSpace(repo), ".git")

return
}

func getProjectPath() (projectPath string, err error) {
if sh.Test("dir", "pipelines") {
projectPath, err = os.Getwd()
} else if sh.Test("dir", "../../pipelines") {
expPath, err := os.Getwd()
if err == nil {
projectPath = expPath + "/../../"
}
} else {
// TODO: create an error
log.Fatalln("Cannot identify project folder.")
}
return
}

func getPipelineName() (expName string, err error) {
dir, err := getPipelinePath()
expName = path.Base(dir)
return
}

func ensureRootFolder() {
if !sh.Test("dir", "pipelines") {
log.Fatalln("Can't find pipelines/ folder in current directory, 'cd' into project root folder first.")
}
}

func readPopperConfig() (err error) {
projectPath, err := getProjectPath()
if err != nil {
return
}
viper.AddConfigPath(projectPath)
viper.SetConfigName(".popper")
viper.SetConfigType("yaml")
if err = viper.ReadInConfig(); err != nil {
log.Fatalln(err)
}
return
}

func init() {
RootCmd.Flags().BoolVarP(
&showVersion, "version", "v", false,
Expand Down

0 comments on commit 8ea68fe

Please sign in to comment.