Skip to content

Commit

Permalink
Merge pull request #362 from asteris-llc/feature/conditionals
Browse files Browse the repository at this point in the history
Feature/conditionals
  • Loading branch information
BrianHicks authored Oct 14, 2016
2 parents 3bb1b54 + ec4e794 commit c0ece90
Show file tree
Hide file tree
Showing 32 changed files with 2,175 additions and 60 deletions.
87 changes: 87 additions & 0 deletions docs/content/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,93 @@ will walk over them as nodes.
{{< figure src="/images/getting-started/hello-you.png"
caption="Our graph, but with our original module as a dependent module." >}}

## Conditional Evaluation

Converge supports the ability to conditionally execute a set of actions
depending on the value of expressions that are evaluated at runtime. These
`switch` expressions will allow you to write a single converge file that will
execute differently depending on information such as:

- `param`s passed in by the user
- information gathered calls to `platform`
- the status of another resource with `lookup`

To understand how this works, let's consider the following example: You wish to
create a file, `greeting.txt`. You want that file to contain a greeting in the
users preferred language. Here we have an example of a converge script that
will allow the user to specify that they would prefer their greeting in spanish
by passing in a param.

`helloLanguages.hcl`:

```hcl
param "lang" {
default = ""
}
switch "test-switch" {
case "eq `spanish` `{{param `lang`}}`" "spanish" {
file.content "foo-file" {
destination = "greeting.txt"
content = "hola\n"
}
}
default {
file.content "foo-file" {
destination = "greeting.txt"
content = "hello\n"
}
}
}
```

Here we define a *conditional* clause using the keyword `switch`, which contains
several *branches*, defined with they keyword `case`. Each *branch* contains
one or more *child* resources that define what should happen when the *branch*
is executed.

*Branch evaluation* refers to the process of evaluating a *predicate* to
determine whether a branch may be run, and if so looking at the other branches
to determine whether the current branch has priority. Branches are evaluated
top-to-bottom and the first branch that is true will be the one that is
executed. The special branch `default` is one whose predicate will always
evaluate to `true`.

{{< note title="Fall-Through" >}}
If you're familiar with `switch` statments in other languages you should keep in
mind that converge branches do not support fall-through. You do not need to
specify `break` to end a `case` statement, and there is no supported way of
evaluating multiple branches in a single `switch` statement. The first
(top-to-bottom) `case` with a true `predicate` is the one that is evalauted.
{{< /note >}}

*predicate*s are evaluated like other templates in converge, and may reference
`param`s, and perform `lookup`s on other values in the system. A *predicate*
may not reference any of it's *child* resourceses. The value of the predicate
is it's truth value: The strings `t` and `true` (case insensitive) are `true`
values and will cause the *branch* to be evaluated. The strings `f` and `false`
(case insensitive) will cause the *branch* to remain unevaluated. Any other
value is an error.

### Reference: Rules of Conditionals

- `switch` statements must have a name
- `case` statements must have a name and a predicate
- `case` statements may not be named *case*, *switch*, or *default*
- `default` statements must not have a name or a predicate
- predicates must evaluate to one of: *t*, *true*, *f*, *false*
- branches may not contain `module` references
- predicates may reference `param`s and `lookup` resources outside of the
- switch statement
- child nodes may refrence `param`s and `lookup` resources outside of the
- switch statement or within the same branch
- no resource may reference anything that is part of a branch that it does not
- belong to
- root and module level resources may not reference fields inside of a switch
- statement
- only the first (top-to-bottom) true branch of a switch will be evaluated

## What's Next?

A great next step is to try and make something simple with Converge! Try
Expand Down
51 changes: 50 additions & 1 deletion graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ func (g *Graph) Get(id string) (*node.Node, bool) {
if !ok {
return nil, ok
}

return raw.(*node.Node), true
}

Expand All @@ -104,6 +103,43 @@ func (g *Graph) GetParent(id string) (*node.Node, bool) {
return g.Get(parentID)
}

// GetParentID is a combination of getting the parent the getting the ID.
func (g *Graph) GetParentID(id string) (string, bool) {
node, ok := g.GetParent(id)
if !ok {
return "", false
}
return node.ID, true
}

// AreSiblings returns true if both nodes share a parent edge, or both nodes are
// at the root of the graph (and have no parent edge). It returns false if both
// IDs are the same.
func (g *Graph) AreSiblings(fst, snd string) bool {
fstParent, fstFound := g.GetParentID(fst)
sndParent, sndFound := g.GetParentID(snd)
return (fstParent == sndParent) && (fstFound == sndFound) && (fst != snd)
}

// IsNibling checks to see if second is the child of a sibling of the first.
func (g *Graph) IsNibling(fst, snd string) bool {
sndID, sndHasParent := g.GetParentID(snd)
if !sndHasParent {
return false
}
if fst == sndID {
return false
}
if g.AreSiblings(fst, snd) {
return true
}

if !sndHasParent {
return false
}
return g.IsNibling(fst, sndID)
}

// ConnectParent connects a parent node to a child node
func (g *Graph) ConnectParent(from, to string) {
g.innerLock.Lock()
Expand All @@ -112,6 +148,19 @@ func (g *Graph) ConnectParent(from, to string) {
g.inner.Connect(NewParentEdge(from, to))
}

// Children returns a list of ids whose parent id is set to the specified node
func (g *Graph) Children(id string) (out []string) {
downEdges := g.DownEdges(id)
g.innerLock.RLock()
defer g.innerLock.RUnlock()
for _, edge := range downEdges {
if _, ok := edge.(*ParentEdge); ok {
out = append(out, edge.Target().(string))
}
}
return
}

// Connect two vertices together by ID
func (g *Graph) Connect(from, to string) {
g.innerLock.Lock()
Expand Down
95 changes: 95 additions & 0 deletions graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"context"
"errors"
"math/rand"
"sort"

"strconv"
"sync"
Expand Down Expand Up @@ -138,6 +139,51 @@ func TestDescendents(t *testing.T) {
assert.Equal(t, []string{"one/two"}, g.Descendents("one"))
}

// TestChildren tests to ensure the correct behavior when getting children
func TestChildren(t *testing.T) {
t.Parallel()
defer logging.HideLogs(t)()

g := graph.New()
g.Add(node.New("root", nil))
g.Add(node.New("child1", nil))
g.Add(node.New("child2", nil))

g.Add(node.New("child1.1", nil))
g.Add(node.New("child1.2", nil))
g.Add(node.New("child1.3", nil))

g.Add(node.New("child.1.1.1", nil))

g.Add(node.New("child2.1", nil))
g.Add(node.New("child2.2", nil))
g.Add(node.New("child2.3", nil))

g.ConnectParent("root", "child1")
g.ConnectParent("root", "child2")

g.ConnectParent("child1", "child1.1")
g.ConnectParent("child1", "child1.2")
g.ConnectParent("child1", "child1.3")

g.ConnectParent("child2", "child2.1")
g.ConnectParent("child2", "child2.2")
g.ConnectParent("child2", "child2.3")

g.ConnectParent("child1.1", "child.1.1.1")

g.Connect("child1", "child2.1")
g.Connect("child1", "child2.2")
g.Connect("child1", "child2.3")

children := g.Children("child1")

expected := []string{"child1.1", "child1.2", "child1.3"}
sort.Strings(expected)
sort.Strings(children)
assert.Equal(t, expected, children)
}

func TestWalkOrder(t *testing.T) {
// the walk order should start with leaves and head towards the root
defer logging.HideLogs(t)()
Expand Down Expand Up @@ -428,6 +474,55 @@ func TestRootFirstTransform(t *testing.T) {
assert.Equal(t, 2, meta.Value().(int))
}

// TestIsNibling tests various scenarios where we want to know if a node is a
// nibling of the source node.
func TestIsNibling(t *testing.T) {
t.Parallel()

g := graph.New()
g.Add(node.New("a", struct{}{}))
g.Add(node.New("a/b", struct{}{}))
g.ConnectParent("a", "a/b")
g.Add(node.New("a/b/c", struct{}{}))
g.ConnectParent("a/b", "a/b/c")
g.Add(node.New("a/b/c/d", struct{}{}))
g.ConnectParent("a/b/c", "a/b/c/d")
g.Add(node.New("a/c", struct{}{}))
g.ConnectParent("a", "a/c")
g.Add(node.New("a/c/d", struct{}{}))
g.ConnectParent("a/c", "a/c/d")
g.Add(node.New("a/c/d/e", struct{}{}))
g.ConnectParent("a/c/d", "a/c/d/e")
g.Add(node.New("x", struct{}{}))
g.Add(node.New("x/c", struct{}{}))
g.ConnectParent("x", "x/c")

t.Run("are siblings", func(t *testing.T) {
assert.True(t, g.IsNibling("a/b", "a/c"))
})
t.Run("is direct nibling", func(t *testing.T) {
assert.True(t, g.IsNibling("a/b", "a/c/d"))
})
t.Run("is nibling child of nibling", func(t *testing.T) {
assert.True(t, g.IsNibling("a/b", "a/c/d/e"))
})
t.Run("child", func(t *testing.T) {
assert.False(t, g.IsNibling("a/b", "a/b/c"))
})
t.Run("grandchild", func(t *testing.T) {
assert.False(t, g.IsNibling("a/b", "a/b/c/d"))
})
t.Run("unrelated", func(t *testing.T) {
assert.False(t, g.IsNibling("a/b", "x/c"))
})
t.Run("cousins", func(t *testing.T) {
assert.False(t, g.IsNibling("a/b/c", "a/x"))
})
t.Run("parent", func(t *testing.T) {
assert.False(t, g.IsNibling("a/b/c", "a/b"))
})
}

func idsInOrderOfExecution(g *graph.Graph) ([]string, error) {
lock := new(sync.Mutex)
out := []string{}
Expand Down
Loading

0 comments on commit c0ece90

Please sign in to comment.