Merged in efritz/convey/env-loop (pull request #21)
Fix infinite env mapping loop
Fixes #124
--- a/REFERENCE.md Thu Sep 21 15:37:20 2017 -0500
+++ b/REFERENCE.md Tue Sep 26 01:21:54 2017 +0000
@@ -116,6 +116,34 @@
+An extend task overrides the environment of another task. +| Name | Required | Default | Description | +| ----------- | -------- | ------- | ----------- | +| task | Yes | | The name of the task to extend | +| environment | | | The override environment | + image: gliderlabs/alpine:edge + - MESSAGE="Hello, World" A build task will build a docker image.
--- a/docker/build.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/build.go Tue Sep 26 01:21:54 2017 +0000
@@ -60,7 +60,12 @@
// export the files to it
for _, src := range b.Files {
- src, dest := tasks.ParseFilePath(base, environment.Mapper(src, fullEnv))
+ src, err := environment.Mapper(src, fullEnv) + src, dest := tasks.ParseFilePath(base, src) cleanDest := filepath.Clean(filepath.Join(tmpDir, dest))
if err = exportFile(name, st.Workspace.Name(), src, cleanDest, st); err != nil {
@@ -80,12 +85,22 @@
+ tag, err := environment.Mapper(b.Tag, fullEnv) + labels, err := environment.SliceMapper(b.Labels, fullEnv) params = map[string]interface{}{
"dockerfile": filepath.Join(tmpDir, filepath.Base(b.Dockerfile)),
- "tag": environment.Mapper(b.Tag, fullEnv),
- "Labels": environment.SliceMapper(b.Labels, fullEnv),
"Arguments": b.Arguments,
--- a/docker/export.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/export.go Tue Sep 26 01:21:54 2017 +0000
@@ -87,7 +87,10 @@
fullEnv := environment.Merge(env, st.Environment)
for _, file := range e.Files {
- file = environment.Mapper(file, fullEnv)
+ file, err := environment.Mapper(file, fullEnv) if err := checkFilePattern(file); err != nil {
@@ -96,7 +99,10 @@
dockerWorkspace := st.Workspace.(*Workspace)
if strings.ContainsRune(file, '*') {
- mountPoint := environment.Mapper(dockerWorkspace.mountPoint, fullEnv)
+ mountPoint, err := environment.Mapper(dockerWorkspace.mountPoint, fullEnv) if err := exportGlob(name, st.Workspace.Name(), mountPoint, file, st); err != nil {
--- a/docker/import.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/import.go Tue Sep 26 01:21:54 2017 +0000
@@ -36,7 +36,12 @@
fullEnv := environment.Merge(env, st.Environment)
for _, file := range i.Files {
- src, dest := tasks.ParseFilePath("", environment.Mapper(file, fullEnv))
+ file, err := environment.Mapper(file, fullEnv) + src, dest := tasks.ParseFilePath("", file) params := map[string]interface{}{
@@ -44,8 +49,7 @@
"workspaceID": st.Workspace.Name(),
- err := Docker(name, importTemplate, params, st)
+ if err := Docker(name, importTemplate, params, st); err != nil { --- a/docker/pull.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/pull.go Tue Sep 26 01:21:54 2017 +0000
@@ -37,8 +37,13 @@
fullEnv := environment.Merge(env, st.Environment)
for _, image := range p.Images {
+ image, err := environment.Mapper(image, fullEnv) params := map[string]interface{}{
- "image": environment.Mapper(image, fullEnv),
if err := Docker(name, pullTemplate, params, st); err != nil {
--- a/docker/push.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/push.go Tue Sep 26 01:21:54 2017 +0000
@@ -37,8 +37,13 @@
fullEnv := environment.Merge(env, st.Environment)
for _, image := range p.Images {
+ image, err := environment.Mapper(image, fullEnv) params := map[string]interface{}{
- "image": environment.Mapper(image, fullEnv),
if err := Docker(name, pushTemplate, params, st); err != nil {
--- a/docker/remove.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/remove.go Tue Sep 26 01:21:54 2017 +0000
@@ -37,8 +37,13 @@
fullEnv := environment.Merge(env, st.Environment)
for _, image := range r.Images {
+ image, err := environment.Mapper(image, fullEnv) params := map[string]interface{}{
- "image": environment.Mapper(image, fullEnv),
if err := Docker(name, removeTemplate, params, st); err != nil {
--- a/docker/run.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/run.go Tue Sep 26 01:21:54 2017 +0000
@@ -120,7 +120,12 @@
// iterate the script and run the environment variable exapansion on each line
for idx, item := range r.Script {
- r.Script[idx] = environment.Mapper(item, fullEnv)
+ item, err := environment.Mapper(item, fullEnv) // write the script to the file
@@ -145,7 +150,12 @@
// now expand the environment
for idx, v := range fullEnv {
- fullEnv[idx] = environment.Mapper(v, fullEnv)
+ v, err := environment.Mapper(v, fullEnv) // assign a default workspace location
@@ -155,9 +165,12 @@
// initialize some variables
entryPoint := r.EntryPoint
- commandArg := environment.Mapper(r.Command, fullEnv)
+ commandArg, err := environment.Mapper(r.Command, fullEnv) // if we're using a script defined in the yaml, create it and override
@@ -177,6 +190,34 @@
dockerWorkspace := st.Workspace.(*Workspace)
+ image, err := environment.Mapper(r.Image, fullEnv) + labels, err := environment.SliceMapper(r.Labels, fullEnv) + workdir, err := environment.Mapper(r.WorkDir, fullEnv) + workspacePath, err := environment.Mapper(dockerWorkspace.mountPoint, fullEnv) + workspaceMount, err := environment.Mapper(workSpace, fullEnv) // build the dict for the template
params := map[string]interface{}{
@@ -186,17 +227,17 @@
"EntryPoint": entryPoint,
"HealthCheck": r.HealthCheck,
- "Image": environment.Mapper(r.Image, fullEnv),
- "Labels": environment.SliceMapper(r.Labels, fullEnv),
"Network": st.Network.Name(),
"ScriptFile": scriptFile,
"SSHAgent": st.EnableSSHAgent,
"SSHAuthSock": os.Getenv("SSH_AUTH_SOCK"),
- "WorkDir": environment.Mapper(r.WorkDir, fullEnv),
- "WorkspacePath": environment.Mapper(dockerWorkspace.mountPoint, fullEnv),
- "WorkspaceMount": environment.Mapper(workSpace, fullEnv),
+ "WorkspacePath": workspacePath, + "WorkspaceMount": workspaceMount, --- a/docker/tag.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/tag.go Tue Sep 26 01:21:54 2017 +0000
@@ -38,9 +38,19 @@
fullEnv := environment.Merge(env, st.Environment)
for _, destination := range t.Destinations {
+ source, err := environment.Mapper(t.Source, fullEnv) + destination, err := environment.Mapper(destination, fullEnv) params := map[string]interface{}{
- "source": environment.Mapper(t.Source, fullEnv),
- "destination": environment.Mapper(destination, fullEnv),
+ "destination": destination, if err := Docker(name, tagTemplate, params, st); err != nil {
--- a/docker/workspace_test.go Thu Sep 21 15:37:20 2017 -0500
+++ b/docker/workspace_test.go Tue Sep 26 01:21:54 2017 +0000
@@ -17,22 +17,8 @@
- "github.com/aphistic/sweet"
- . "github.com/onsi/gomega"
- "bitbucket.org/rw_grim/convey/workspace"
+import "bitbucket.org/rw_grim/convey/workspace" type workspaceSuite struct{}
-func (s *workspaceSuite) TestImplementsWorkspace(t sweet.T) {
- var ws workspace.Workspace
- fmt.Printf("%#v %v", ws, ws)
- Expect(ws).To(Not(BeNil()))
+var _ workspace.Workspace = &Workspace{} --- a/environment/mapper.go Thu Sep 21 15:37:20 2017 -0500
+++ b/environment/mapper.go Tue Sep 26 01:21:54 2017 +0000
@@ -18,6 +18,7 @@
@@ -43,28 +44,38 @@
// Mapper will replace ${TEMPLATE} patterns in the string with the KEY=VAL pairs
// in the given environment. This function will replace patterns recursively, so
// if VAL has the form ${TEMPLATE}, it will be replaced again.
-func Mapper(str string, env []string) string {
+func Mapper(str string, env []string) (string, error) { next := os.Expand(last, mapper.Map)
+ prev := map[string]struct{}{} - // TODO - add a max-depth or a list to detect replacement loops
+ if _, ok := prev[next]; ok { + return "", fmt.Errorf("infinite environment mapping loop while expanding '%s'", next) next = os.Expand(last, mapper.Map)
+ prev[last] = struct{}{}
// SliceMapper calls Mapper for each item in a slice and returns a new slice.
-func SliceMapper(slice []string, env []string) []string {
+func SliceMapper(slice []string, env []string) ([]string, error) { for _, template := range slice {
- mapped = append(mapped, Mapper(template, env))
+ expanded, err := Mapper(template, env) + mapped = append(mapped, expanded)
--- a/environment/mapper_test.go Thu Sep 21 15:37:20 2017 -0500
+++ b/environment/mapper_test.go Tue Sep 26 01:21:54 2017 +0000
@@ -25,36 +25,57 @@
func (e *environmentSuite) TestMapperNoOSWithValue(t sweet.T) {
- Expect(Mapper("${FOO}", []string{"FOO=bar"})).To(Equal("bar"))
+ result, err := Mapper("${FOO}", []string{"FOO=bar"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("bar")) func (e *environmentSuite) TestMapperNoOSWithoutValue(t sweet.T) {
- Expect(Mapper("${FOO}", []string{"FOO="})).To(Equal(""))
+ result, err := Mapper("${FOO}", []string{"FOO="}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("")) func (e *environmentSuite) TestMultipleEquals(t sweet.T) {
- Expect(Mapper("${FOO}", []string{"FOO=bar=baz"})).To(Equal("bar=baz"))
+ result, err := Mapper("${FOO}", []string{"FOO=bar=baz"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("bar=baz")) func (e *environmentSuite) TestMapperOSWithValue(t sweet.T) {
- Expect(Mapper("${FOO}", []string{"FOO"})).To(Equal("bar"))
+ result, err := Mapper("${FOO}", []string{"FOO"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("bar")) func (e *environmentSuite) TestMapperOSWithoutValue(t sweet.T) {
- Expect(Mapper("${FOO}", []string{"FOO"})).To(Equal(""))
+ result, err := Mapper("${FOO}", []string{"FOO"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("")) func (e *environmentSuite) TestPartialMatches(t sweet.T) {
- Expect(Mapper("${FOO}", []string{"FOOBAR"})).To(Equal("$FOO"))
- Expect(Mapper("${BARBAZ}", []string{"BAR"})).To(Equal("$BARBAZ"))
+ result, err := Mapper("${FOO}", []string{"FOOBAR"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("$FOO")) + result, err = Mapper("${BARBAZ}", []string{"BAR"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("$BARBAZ")) func (e *environmentSuite) TestRecursiveMatch(t sweet.T) {
- Expect(Mapper("1${FOO}a", []string{"FOO=2${BAR}b", "BAR=3${BAZ}c", "BAZ=-ohhai!-"})).To(Equal("123-ohhai!-cba"))
+ result, err := Mapper("1${FOO}a", []string{"FOO=2${BAR}b", "BAR=3${BAZ}c", "BAZ=-ohhai!-"}) + Expect(err).To(BeNil()) + Expect(result).To(Equal("123-ohhai!-cba")) -// TODO - infinite expansion
+func (e *environmentSuite) TestInfiniteExpansion(t sweet.T) { + _, err := Mapper("${FOO}", []string{"FOO=$BAR", "BAR=$FOO"}) + Expect(err).To(MatchError("infinite environment mapping loop while expanding '$BAR'")) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/examples/extend.yml Tue Sep 26 01:21:54 2017 +0000
@@ -0,0 +1,27 @@
+# This plan includes a task that extends another task with +# a different, higher precedence environment. Running this +# plan should print foo, bar, then baz. + image: gliderlabs/alpine:edge + command: echo "${MESSAGE}" + - tasks: [foo, bar, baz] --- a/intrinsic/clean.go Thu Sep 21 15:37:20 2017 -0500
+++ b/intrinsic/clean.go Tue Sep 26 01:21:54 2017 +0000
@@ -36,7 +36,10 @@
func sanitizeFile(base, file string, env []string) (string, error) {
- filename := environment.Mapper(file, env)
+ filename, err := environment.Mapper(file, env) pathname, err := filepath.Abs(filename)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/intrinsic/extend.go Tue Sep 26 01:21:54 2017 +0000
@@ -0,0 +1,84 @@
+ * Copyright 2016-2017 Gary Kramlich <grim@reaperworld.com> + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + "github.com/aphistic/gomol" + "github.com/mohae/deepcopy" + "bitbucket.org/rw_grim/convey/environment" + "bitbucket.org/rw_grim/convey/state" + "bitbucket.org/rw_grim/convey/tasks" + "bitbucket.org/rw_grim/convey/yaml" + Task string `yaml:"task"` + Environment yaml.StringOrSlice `yaml:"environment"` + // A copy of the extended task. Must be public so that deepcopy + // can serialize it properly in case an extended task is extended + // itself (when private the innerTask is nil and causes a bad + // data access exception). +func (e *Extend) Execute(name string, logger *gomol.LogAdapter, env []string, st *state.State) error { + // We want the extending environment to take precedence over the extended + // task environment (if it exists). In order to get the correct precedence, + // we stash it onto the state's environment. Once we're done executing the + // inner task we'll restore it to the previous values. Notice that we merge + // in what appears at first as backwards - we don't want to override command + // line arguments, so we let state keep a higher precedence. + stashed := st.Environment + st.Environment = environment.Merge(e.Environment, st.Environment) + err := e.InnerTask.Execute(name, logger, env, st) + st.Environment = stashed +func (e *Extend) New() tasks.Task { +func (e *Extend) Valid() error { +func (e *Extend) Dependencies() []string { + return []string{e.Task} +func (e *Extend) Resolve(taskMap map[string]tasks.Task) error { + task, ok := taskMap[e.Task] + // Should never happen due to dependency order + return fmt.Errorf("Extending undeclared task '%s'", e.Task) + // Some tasks may set their own fields, e.g. when mapping things + // to the correct runtime environment. In order to make sure that + // the inner task doesn't cache something from another run, we + // do a clone of the task here so we get our own copy to muck with. + e.InnerTask = deepcopy.Copy(task).(tasks.Task) --- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/intrinsic/extend_test.go Tue Sep 26 01:21:54 2017 +0000
@@ -0,0 +1,147 @@
+ * Copyright 2016-2017 Gary Kramlich <grim@reaperworld.com> + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + "bitbucket.org/rw_grim/convey/environment" + "bitbucket.org/rw_grim/convey/state" + "bitbucket.org/rw_grim/convey/tasks" + "github.com/aphistic/gomol" + "github.com/aphistic/sweet" + . "github.com/onsi/gomega" +func (s *intrinsicSuite) TestExtendExecute(t sweet.T) { + st = &state.State{Environment: []string{"bar=bonk", "quux=honk"}} + task.executeFn = func(name string, logger *gomol.LogAdapter, env []string, st *state.State) error { + fullEnv = environment.Merge(environment.Merge(env, []string{"x=1", "foo=quux"}), st.Environment) + Environment: []string{"foo=bar", "bar=baz"}, + Expect(c.Execute("task", nil, nil, st)).To(BeNil()) + Expect(fullEnv).To(HaveLen(4)) + Expect(fullEnv).To(ConsistOf("x=1", "foo=bar", "bar=bonk", "quux=honk")) + Expect(st.Environment).To(HaveLen(2)) + Expect(st.Environment).To(ConsistOf("bar=bonk", "quux=honk")) +func (s *intrinsicSuite) TestDependencies(t sweet.T) { + c := &Extend{Task: "test-task"} + Expect(c.Dependencies()).To(Equal([]string{"test-task"})) +func (s *intrinsicSuite) TestResolve(t sweet.T) { + task.Y = []string{"foo", "bar"} + c := &Extend{Task: "a"} + err := c.Resolve(map[string]tasks.Task{ + Expect(err).To(BeNil()) + // Must clone inner task + Expect(c.InnerTask).NotTo(BeNil()) + Expect(c.InnerTask).NotTo(BeIdenticalTo(task)) + Expect(c.InnerTask.(*mockTask).X).To(Equal(3)) + Expect(c.InnerTask.(*mockTask).Y).To(Equal([]string{"foo", "bar"})) + Expect(c.InnerTask.(*mockTask).Z.A).To(Equal("wynken")) + Expect(c.InnerTask.(*mockTask).Z.B).To(Equal("blynken")) + Expect(c.InnerTask.(*mockTask).Z.C).To(Equal("nod")) +func (s *intrinsicSuite) TestResolveChain(t sweet.T) { + task.Y = []string{"foo", "bar"} + c1 := &Extend{Task: "a"} + c2 := &Extend{Task: "b"} + taskMapping := map[string]tasks.Task{ + Expect(c1.Resolve(taskMapping)).To(BeNil()) + Expect(c2.Resolve(taskMapping)).To(BeNil()) + // Must clone inner task + Expect(c2.InnerTask).NotTo(BeNil()) + Expect(c2.InnerTask).NotTo(BeIdenticalTo(c1)) + Expect(c2.InnerTask.(*Extend).InnerTask).NotTo(BeNil()) + Expect(c2.InnerTask.(*Extend).InnerTask.(*mockTask).X).To(Equal(3)) + Expect(c2.InnerTask.(*Extend).InnerTask.(*mockTask).Y).To(Equal([]string{"foo", "bar"})) + executeFn func(string, *gomol.LogAdapter, []string, *state.State) error +func newMockTask() *mockTask { + executeFn: func(string, *gomol.LogAdapter, []string, *state.State) error { +func (t *mockTask) Execute(name string, logger *gomol.LogAdapter, env []string, st *state.State) error { + return t.executeFn(name, logger, env, st) +func (t *mockTask) New() tasks.Task { +func (t *mockTask) Valid() error { --- a/intrinsic/intrinsic.go Thu Sep 21 15:37:20 2017 -0500
+++ b/intrinsic/intrinsic.go Tue Sep 26 01:21:54 2017 +0000
@@ -23,6 +23,7 @@
Tasks = map[string]tasks.Task{
--- a/loaders/convey/convey.go Thu Sep 21 15:37:20 2017 -0500
+++ b/loaders/convey/convey.go Tue Sep 26 01:21:54 2017 +0000
@@ -19,8 +19,11 @@
"github.com/go-yaml/yaml"
+ toposort "github.com/philopon/go-toposort" cConfig "bitbucket.org/rw_grim/convey/config"
"bitbucket.org/rw_grim/convey/environment"
@@ -142,6 +145,16 @@
baseConfig.SSHIdentities = cfg.Options.SSHIdentities
+ // Now that we have all of the tasks defined up to this point, any task + // that needs a reference to another task via the ResolvableTask interface + // is passed the entire map. We need to make sure that tasks are resolved + // in the correct order. This is a problem, for example, if the extend + // intrinsic clones a task that is not fully resolved. + if err := c.resolve(baseConfig); err != nil { // Return the base config at this point, but maybe possibly return an
// error that we're missing something in order to proceed. This gets
// kind of weird: if we're calling Load recursively through LoadFile,
@@ -160,6 +173,75 @@
+func (c *Loader) resolve(baseConfig *cConfig.Config) error { + // Extract resolvable tasks from config tasks + resolvables := map[string]tasks.ResolvableTask{} + for name, task := range baseConfig.Tasks { + if rc, ok := task.(tasks.ResolvableTask); ok { + graph := toposort.NewGraph(len(resolvables)) + // Add all nodes to graph before edges, as it won't count + // the in/out degree correctly if we add an edge before one + // of the terminating nodes. + for name, rc := range resolvables { + for _, dependency := range rc.Dependencies() { + if _, ok := baseConfig.Tasks[name]; !ok { + return fmt.Errorf("Unknown task dependency '%s'", dependency) + graph.AddNode(dependency) + for name, rc := range resolvables { + for _, dependency := range rc.Dependencies() { + graph.AddEdge(dependency, name) + // Sort and resolve all tasks + order, ok := graph.Toposort() + for _, name := range order { + delete(resolvables, name) + remaining := []string{} + for k := range resolvables { + remaining = append(remaining, k) + sort.Strings(remaining) + return fmt.Errorf("The following tasks are part of a dependency cycle: %s", strings.Join(remaining, ", ")) + for _, name := range order { + rc, ok := resolvables[name] + if err := rc.Resolve(baseConfig.Tasks); err != nil { + return fmt.Errorf("%s: %s", name, err.Error()) func (c *Loader) loadFile(path string) (*cConfig.Config, error) {
return cConfig.LoadFile(path, c)
--- a/loaders/convey/convey_test.go Thu Sep 21 15:37:20 2017 -0500
+++ b/loaders/convey/convey_test.go Tue Sep 26 01:21:54 2017 +0000
@@ -37,6 +37,7 @@
s.RegisterPlugin(junit.NewPlugin())
s.AddSuite(&conveySuite{})
+ s.AddSuite(&extendSuite{}) s.AddSuite(&extendsSuite{})
s.AddSuite(&defaultPlanSuite{})
s.AddSuite(&environmentSuite{})
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/loaders/convey/extend_test.go Tue Sep 26 01:21:54 2017 +0000
@@ -0,0 +1,182 @@
+ * Copyright 2016-2017 Gary Kramlich <grim@reaperworld.com> + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + "github.com/aphistic/sweet" + . "github.com/onsi/gomega" + cConfig "bitbucket.org/rw_grim/convey/config" + "bitbucket.org/rw_grim/convey/intrinsic" +// This test suite tests +type extendSuite struct{} +func (d *extendSuite) TestSimple(t sweet.T) { + cfg, err := loader.Load(".", ".", []byte(data)) + Expect(err).To(BeNil()) + Expect(cfg.Tasks).To(HaveLen(2)) + Expect(cfg.Tasks).To(HaveKey("a")) + Expect(cfg.Tasks).To(HaveKey("b")) + Expect(cfg.Tasks["b"].(*intrinsic.Extend).InnerTask).NotTo(BeIdenticalTo(cfg.Tasks["a"])) + Expect(cfg.Tasks["b"].(*intrinsic.Extend).InnerTask).To(BeEquivalentTo(cfg.Tasks["a"])) +func (d *extendSuite) TestExtendChain(t sweet.T) { + cfg, err := loader.Load(".", ".", []byte(data)) + Expect(err).To(BeNil()) + Expect(cfg.Tasks).To(HaveLen(3)) + Expect(cfg.Tasks).To(HaveKey("a")) + Expect(cfg.Tasks).To(HaveKey("b")) + Expect(cfg.Tasks).To(HaveKey("c")) + Expect(cfg.Tasks["c"].(*intrinsic.Extend).InnerTask).NotTo(BeIdenticalTo(cfg.Tasks["b"])) + Expect(cfg.Tasks["c"].(*intrinsic.Extend).InnerTask).To(BeEquivalentTo(cfg.Tasks["b"])) + Expect(cfg.Tasks["b"].(*intrinsic.Extend).InnerTask).NotTo(BeIdenticalTo(cfg.Tasks["a"])) + Expect(cfg.Tasks["b"].(*intrinsic.Extend).InnerTask).To(BeEquivalentTo(cfg.Tasks["a"])) + Expect(cfg.Tasks["c"].(*intrinsic.Extend).InnerTask.(*intrinsic.Extend).InnerTask).NotTo(BeIdenticalTo(cfg.Tasks["a"])) + Expect(cfg.Tasks["c"].(*intrinsic.Extend).InnerTask.(*intrinsic.Extend).InnerTask).To(BeEquivalentTo(cfg.Tasks["a"])) +func (d *extendSuite) TestCycle(t sweet.T) { + _, err := loader.Load(".", ".", []byte(data)) + Expect(err).To(MatchError("The following tasks are part of a dependency cycle: a, b")) +func (d *extendSuite) TestMissingTask(t sweet.T) { + _, err := loader.Load(".", ".", []byte(data)) + Expect(err).To(MatchError("b: Extending undeclared task 'a'")) +func (d *extendSuite) TestExtendExtends(t sweet.T) { + fileLoader: func(name string, c *Loader) (*cConfig.Config, error) { + return c.Load(".", name, []byte(baseData)) + cfg, err := loader.Load(".", ".", []byte(extendedData)) + Expect(err).To(BeNil()) + Expect(cfg.Tasks).To(HaveLen(2)) + Expect(cfg.Tasks).To(HaveKey("foo")) + Expect(cfg.Tasks).To(HaveKey("bar")) + Expect(cfg.Tasks).To(HaveKey("foo")) + Expect(cfg.Tasks).To(HaveKey("bar")) + Expect(cfg.Tasks["bar"].(*intrinsic.Extend).InnerTask).NotTo(BeIdenticalTo(cfg.Tasks["foo"])) + Expect(cfg.Tasks["bar"].(*intrinsic.Extend).InnerTask).To(BeEquivalentTo(cfg.Tasks["foo"])) --- a/loaders/convey/tasks.go Thu Sep 21 15:37:20 2017 -0500
+++ b/loaders/convey/tasks.go Tue Sep 26 01:21:54 2017 +0000
@@ -36,79 +36,78 @@
func loadTasks(path string, raw map[string]yaml.MapSlice) (map[string]tasks.Task, error) {
realTasks := map[string]tasks.Task{}
for name, task := range raw {
- // figure out the engine and type for the task
- for _, item := range task {
- switch item.Key.(string) {
- rawEngine = item.Value.(string)
- rawType = item.Value.(string)
- var engineMap cConfig.TaskMap
- // if the engine wasn't specified search based on the task type
- // if the task type wasn't specified use the default engine
- engineMap = cConfig.TasksMap[cConfig.DefaultEngine]
- eMap, err := findTask(rawType)
- // now see if we have the task for the given engine
- eMap, found := cConfig.TasksMap[rawEngine]
- return nil, fmt.Errorf("unknown engine '%s'", rawEngine)
- // if we don't have a rawType use the default for the engine
- rawType = engineMap.Default
- taskType, found := engineMap.Tasks[rawType]
- return nil, fmt.Errorf("unknown task type '%s'", rawType)
- // now marshal the data again
- rawTask, err := yaml.Marshal(task)
+ realTask, err := loadTask(name, task) - // unmarshal it into the correct struct
- realTask := taskType.New()
- err = yaml.Unmarshal(rawTask, realTask)
- return nil, fmt.Errorf("%s: %s", name, err.Error())
realTasks[name] = realTask
+func loadTask(name string, task yaml.MapSlice) (tasks.Task, error) { + // figure out the engine and type for the task + for _, item := range task { + switch item.Key.(string) { + rawEngine = item.Value.(string) + rawType = item.Value.(string) + var engineMap cConfig.TaskMap + // if the engine wasn't specified search based on the task type + // if the task type wasn't specified use the default engine + engineMap = cConfig.TasksMap[cConfig.DefaultEngine] + eMap, err := findTask(rawType) + // now see if we have the task for the given engine + eMap, found := cConfig.TasksMap[rawEngine] + return nil, fmt.Errorf("unknown engine '%s'", rawEngine) + // if we don't have a rawType use the default for the engine + rawType = engineMap.Default + taskType, found := engineMap.Tasks[rawType] + return nil, fmt.Errorf("unknown task type '%s'", rawType) + realTask, err := tasks.CloneTask(task, taskType) + return nil, fmt.Errorf("%s: %s", name, err.Error()) --- a/tasks/tasks.go Thu Sep 21 15:37:20 2017 -0500
+++ b/tasks/tasks.go Tue Sep 26 01:21:54 2017 +0000
@@ -28,3 +28,13 @@
+type ResolvableTask interface { + // Resolve is called by the loader once the current set of tasks + // by name have been loaded from the config file. + Resolve(tasks map[string]Task) error + // Dependencies will return a list of tasks which must be resolved + // before resolving this task. + Dependencies() []string --- a/tasks/util.go Thu Sep 21 15:37:20 2017 -0500
+++ b/tasks/util.go Tue Sep 26 01:21:54 2017 +0000
@@ -20,8 +20,27 @@
+ "github.com/go-yaml/yaml" +// CloneTask creates a task of the given type from the given payload. It +// does this by creating a fresh instance of a task of the target type, +// then marshalling/unmarshalling the payload to that type. +func CloneTask(task interface{}, taskType Task) (Task, error) { + rawTask, err := yaml.Marshal(task) + realTask := taskType.New() + if err = yaml.Unmarshal(rawTask, realTask); err != nil { // ParseFilePath will split a string on the first : and return two pieces.
// If there is no colon, then either "." (the current working directory)
// will be returned as the destination if the source was an item in the