Skip to content

Commit 98a272d

Browse files
authored
apply: to check module requirements before run (#358)
* apply: to check module requirements before run modules must now provide a `make check` in their root makefile make check must exit 0 on all modules before proceeding to apply this allows checks for module specific binaries or check tokens permissions * combine the module walk methods and return err * custom makefile error logic * allow overriding commands for apply and check * add module commands to readme * fix module error printing incorrect message
1 parent 79f64ac commit 98a272d

File tree

23 files changed

+287
-73
lines changed

23 files changed

+287
-73
lines changed

cmd/apply.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ var applyCmd = &cobra.Command{
2929
log.Println(err)
3030
rootDir = projectconfig.RootDir
3131
}
32-
apply.Apply(rootDir, applyConfigPath, applyEnvironments)
32+
applyErr := apply.Apply(rootDir, applyConfigPath, applyEnvironments)
33+
if applyErr != nil {
34+
log.Fatal(applyErr)
35+
}
3336
},
3437
}

docs/module-definition.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@ It also declares the module's dependencies to determine the order of execution
1010
| `author` | string | Author of the module |
1111
| `icon` | string | Path to logo image |
1212
| `parameters` | list(Parameter) | Parameters to prompt users |
13+
| `commands` | Commands | Commands to use instead of makefile defaults |
1314
| `zeroVersion` | string([go-semver])| Zero versions its compatible with |
1415

1516

17+
### Commands
18+
Commands are the lifecycle of `zero apply`, it will run all module's `check phase`, then once satisfied run in sequence `apply phase` then if successful run `summary phase`.
19+
| Parameters | Type | Default | Description |
20+
|------------|--------|----------------|--------------------------------------------------------------------------|
21+
| `check` | string | `make check` | Command to check module requirements. check is satisfied if exit code is 0 eg: `sh check-token.sh`, `zero apply` will check all modules before executing |
22+
| `apply` | string | `make` | Command to execute the project provisioning. |
23+
| `summary` | string | `make summary` | Command to summarize to users the module's output and next steps. |
1624
### Template
1725
| Parameters | Type | Description |
1826
|--------------|---------|-----------------------------------------------------------------------|

internal/apply/apply.go

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package apply
22

33
import (
4+
"errors"
45
"fmt"
56
"path/filepath"
67

@@ -13,13 +14,15 @@ import (
1314
"github.com/commitdev/zero/internal/util"
1415
"github.com/hashicorp/terraform/dag"
1516

17+
"github.com/commitdev/zero/internal/config/moduleconfig"
1618
"github.com/commitdev/zero/internal/config/projectconfig"
1719
"github.com/commitdev/zero/pkg/util/exit"
1820
"github.com/commitdev/zero/pkg/util/flog"
1921
"github.com/manifoldco/promptui"
2022
)
2123

22-
func Apply(rootDir string, configPath string, environments []string) {
24+
func Apply(rootDir string, configPath string, environments []string) error {
25+
var errs []error
2326
if strings.Trim(configPath, " ") == "" {
2427
exit.Fatal("config path cannot be empty!")
2528
}
@@ -33,6 +36,18 @@ Only a single environment may be suitable for an initial test, but for a real sy
3336
environments = promptEnvironments()
3437
}
3538

39+
flog.Infof(":mag: checking project %s's module requirements.", projectConfig.Name)
40+
41+
errs = modulesWalkCmd("check", rootDir, projectConfig, "check", environments, false, false)
42+
// Check operation walks through all modules and can return multiple errors
43+
if len(errs) > 0 {
44+
msg := ""
45+
for i := 0; i < len(errs); i++ {
46+
msg += "- " + errs[i].Error()
47+
}
48+
return errors.New(fmt.Sprintf("The following Module check(s) failed: \n%s", msg))
49+
}
50+
3651
flog.Infof(":tada: Bootstrapping project %s. Please use the zero-project.yml file to modify the project as needed.", projectConfig.Name)
3752

3853
flog.Infof("Cloud provider: %s", "AWS") // will this come from the config?
@@ -41,21 +56,27 @@ Only a single environment may be suitable for an initial test, but for a real sy
4156

4257
flog.Infof("Infrastructure executor: %s", "Terraform")
4358

44-
applyAll(rootDir, *projectConfig, environments)
59+
errs = modulesWalkCmd("apply", rootDir, projectConfig, "apply", environments, true, true)
60+
if len(errs) > 0 {
61+
return errors.New(fmt.Sprintf("Module Apply failed: %s", errs[0]))
62+
}
4563

4664
flog.Infof(":check_mark_button: Done.")
4765

48-
summarizeAll(rootDir, *projectConfig, environments)
66+
flog.Infof("Your projects and infrastructure have been successfully created. Here are some useful links and commands to get you started:")
67+
errs = modulesWalkCmd("summary", rootDir, projectConfig, "summary", environments, true, true)
68+
if len(errs) > 0 {
69+
return errors.New(fmt.Sprintf("Module summary failed: %s", errs[0]))
70+
}
71+
return nil
4972
}
5073

51-
func applyAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEnvironments []string) {
52-
environmentArg := fmt.Sprintf("ENVIRONMENT=%s", strings.Join(applyEnvironments, ","))
53-
74+
func modulesWalkCmd(lifecycleName string, dir string, projectConfig *projectconfig.ZeroProjectConfig, operation string, environments []string, bailOnError bool, shouldPipeStderr bool) []error {
75+
var moduleErrors []error
5476
graph := projectConfig.GetDAG()
55-
56-
// Walk the graph of modules and run `make`
5777
root := []dag.Vertex{projectconfig.GraphRootName}
58-
graph.DepthFirstWalk(root, func(v dag.Vertex, depth int) error {
78+
environmentArg := fmt.Sprintf("ENVIRONMENT=%s", strings.Join(environments, ","))
79+
err := graph.DepthFirstWalk(root, func(v dag.Vertex, depth int) error {
5980
// Don't process the root
6081
if depth == 0 {
6182
return nil
@@ -83,16 +104,64 @@ func applyAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEn
83104
// and we should redownload the module for the user
84105
modConfig, err := module.ParseModuleConfig(modulePath)
85106
if err != nil {
86-
exit.Fatal("Failed to load module config, credentials cannot be injected properly")
107+
exit.Fatal("Failed to load Module: %s", err)
87108
}
88109

89110
envVarTranslationMap := modConfig.GetParamEnvVarTranslationMap()
90111
envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList, envVarTranslationMap)
91112
flog.Debugf("Env injected: %#v", envList)
92-
flog.Infof("Executing apply command for %s...", modConfig.Name)
93-
util.ExecuteCommand(exec.Command("make"), modulePath, envList)
113+
114+
// only print msg for apply, or else it gets a little spammy
115+
if lifecycleName == "apply" {
116+
flog.Infof("Executing %s command for %s...", lifecycleName, modConfig.Name)
117+
}
118+
operationCommand := getModuleOperationCommand(modConfig, operation)
119+
execErr := util.ExecuteCommand(exec.Command(operationCommand[0], operationCommand[1:]...), modulePath, envList, shouldPipeStderr)
120+
if execErr != nil {
121+
formatedErr := errors.New(fmt.Sprintf("Module (%s) %s", modConfig.Name, execErr.Error()))
122+
if bailOnError {
123+
return formatedErr
124+
} else {
125+
moduleErrors = append(moduleErrors, formatedErr)
126+
}
127+
}
94128
return nil
95129
})
130+
if err != nil {
131+
moduleErrors = append(moduleErrors, err)
132+
}
133+
134+
return moduleErrors
135+
}
136+
137+
func getModuleOperationCommand(mod moduleconfig.ModuleConfig, operation string) (operationCommand []string) {
138+
defaultCheck := []string{"make", "check"}
139+
defaultApply := []string{"make"}
140+
defaultSummary := []string{"make", "summary"}
141+
142+
switch operation {
143+
case "check":
144+
if mod.Commands.Check != "" {
145+
operationCommand = []string{"sh", "-c", mod.Commands.Check}
146+
} else {
147+
operationCommand = defaultCheck
148+
}
149+
case "apply":
150+
if mod.Commands.Apply != "" {
151+
operationCommand = []string{"sh", "-c", mod.Commands.Apply}
152+
} else {
153+
operationCommand = defaultApply
154+
}
155+
case "summary":
156+
if mod.Commands.Summary != "" {
157+
operationCommand = []string{"sh", "-c", mod.Commands.Summary}
158+
} else {
159+
operationCommand = defaultSummary
160+
}
161+
default:
162+
panic("Unexpected operation")
163+
}
164+
return operationCommand
96165
}
97166

98167
// promptEnvironments Prompts the user for the environments to apply against and returns a slice of strings representing the environments
@@ -125,47 +194,3 @@ func validateEnvironments(applyEnvironments []string) {
125194
}
126195
}
127196
}
128-
129-
func summarizeAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEnvironments []string) {
130-
flog.Infof("Your projects and infrastructure have been successfully created. Here are some useful links and commands to get you started:")
131-
132-
graph := projectConfig.GetDAG()
133-
134-
// Walk the graph of modules and run `make summary`
135-
root := []dag.Vertex{projectconfig.GraphRootName}
136-
graph.DepthFirstWalk(root, func(v dag.Vertex, depth int) error {
137-
// Don't process the root
138-
if depth == 0 {
139-
return nil
140-
}
141-
142-
name := v.(string)
143-
mod := projectConfig.Modules[name]
144-
// Add env vars for the makefile
145-
envList := []string{
146-
fmt.Sprintf("ENVIRONMENT=%s", strings.Join(applyEnvironments, ",")),
147-
fmt.Sprintf("REPOSITORY=%s", mod.Files.Repository),
148-
fmt.Sprintf("PROJECT_NAME=%s", projectConfig.Name),
149-
}
150-
151-
modulePath := module.GetSourceDir(mod.Files.Source)
152-
// Passed in `dir` will only be used to find the project path, not the module path,
153-
// unless the module path is relative
154-
if module.IsLocal(mod.Files.Source) && !filepath.IsAbs(modulePath) {
155-
modulePath = filepath.Join(dir, modulePath)
156-
}
157-
flog.Debugf("Loaded module: %s from %s", name, modulePath)
158-
159-
modConfig, err := module.ParseModuleConfig(modulePath)
160-
if err != nil {
161-
exit.Fatal("Failed to load module config, credentials cannot be injected properly")
162-
}
163-
envVarTranslationMap := modConfig.GetParamEnvVarTranslationMap()
164-
envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList, envVarTranslationMap)
165-
flog.Debugf("Env injected: %#v", envList)
166-
util.ExecuteCommand(exec.Command("make", "summary"), modulePath, envList)
167-
return nil
168-
})
169-
170-
flog.Infof("Happy coding! :smile:")
171-
}

internal/apply/apply_test.go

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,13 @@ import (
1313
)
1414

1515
func TestApply(t *testing.T) {
16-
dir := "../../tests/test_data/apply/"
1716
applyConfigPath := constants.ZeroProjectYml
1817
applyEnvironments := []string{"staging", "production"}
19-
20-
tmpDir := filepath.Join(os.TempDir(), "apply")
21-
22-
err := os.RemoveAll(tmpDir)
23-
assert.NoError(t, err)
24-
25-
err = shutil.CopyTree(dir, tmpDir, nil)
26-
assert.NoError(t, err)
18+
var tmpDir string
2719

2820
t.Run("Should run apply and execute make on each folder module", func(t *testing.T) {
29-
apply.Apply(tmpDir, applyConfigPath, applyEnvironments)
21+
tmpDir = setupTmpDir(t, "../../tests/test_data/apply/")
22+
err := apply.Apply(tmpDir, applyConfigPath, applyEnvironments)
3023
assert.FileExists(t, filepath.Join(tmpDir, "project1/project.out"))
3124
assert.FileExists(t, filepath.Join(tmpDir, "project2/project.out"))
3225

@@ -37,6 +30,13 @@ func TestApply(t *testing.T) {
3730
content, err = ioutil.ReadFile(filepath.Join(tmpDir, "project2/project.out"))
3831
assert.NoError(t, err)
3932
assert.Equal(t, "baz: qux\n", string(content))
33+
34+
})
35+
36+
t.Run("Modules runs command overides", func(t *testing.T) {
37+
content, err := ioutil.ReadFile(filepath.Join(tmpDir, "project2/check.out"))
38+
assert.NoError(t, err)
39+
assert.Equal(t, "custom check\n", string(content))
4040
})
4141

4242
t.Run("Zero apply honors the envVarName overwrite from module definition", func(t *testing.T) {
@@ -45,4 +45,26 @@ func TestApply(t *testing.T) {
4545
assert.Equal(t, "envVarName of viaEnvVarName: baz\n", string(content))
4646
})
4747

48+
t.Run("Modules with failing checks should return error", func(t *testing.T) {
49+
tmpDir = setupTmpDir(t, "../../tests/test_data/apply-failing/")
50+
51+
err := apply.Apply(tmpDir, applyConfigPath, applyEnvironments)
52+
assert.Regexp(t, "^The following Module check\\(s\\) failed:", err.Error())
53+
assert.Regexp(t, "Module \\(project1\\)", err.Error())
54+
assert.Regexp(t, "Module \\(project2\\)", err.Error())
55+
assert.Regexp(t, "Module \\(project3\\)", err.Error())
56+
})
57+
58+
}
59+
60+
func setupTmpDir(t *testing.T, exampleDirPath string) string {
61+
var err error
62+
tmpDir := filepath.Join(os.TempDir(), "apply")
63+
64+
err = os.RemoveAll(tmpDir)
65+
assert.NoError(t, err)
66+
67+
err = shutil.CopyTree(exampleDirPath, tmpDir, nil)
68+
assert.NoError(t, err)
69+
return tmpDir
4870
}

internal/config/moduleconfig/module_config.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,21 @@ type ModuleConfig struct {
2222
Name string
2323
Description string
2424
Author string
25-
DependsOn []string `yaml:"dependsOn,omitempty"`
25+
Commands ModuleCommands `yaml:"commands,omitempty"`
26+
DependsOn []string `yaml:"dependsOn,omitempty"`
2627
TemplateConfig `yaml:"template"`
2728
RequiredCredentials []string `yaml:"requiredCredentials"`
2829
ZeroVersion VersionConstraints `yaml:"zeroVersion,omitempty"`
2930
Parameters []Parameter
3031
Conditions []Condition `yaml:"conditions,omitempty"`
3132
}
3233

34+
type ModuleCommands struct {
35+
Apply string `yaml:"apply,omitempty"`
36+
Check string `yaml:"check,omitempty"`
37+
Summary string `yaml:"summary,omitempty"`
38+
}
39+
3340
func checkVersionAgainstConstrains(vc VersionConstraints, versionString string) bool {
3441
v, err := goVerson.NewVersion(versionString)
3542
if err != nil {

internal/module/module_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ func TestParseModuleConfig(t *testing.T) {
8989
assert.Equal(t, []string{"<%", "%>"}, mod.TemplateConfig.Delimiters)
9090
})
9191

92+
t.Run("Parsing commands", func(t *testing.T) {
93+
checkCommand := mod.Commands.Check
94+
assert.Equal(t, "ls", checkCommand)
95+
})
96+
9297
t.Run("Parsing zero version constraints", func(t *testing.T) {
9398
moduleConstraints := mod.ZeroVersion.Constraints.String()
9499
assert.Equal(t, ">= 3.0.0, < 4.0.0", moduleConstraints)

0 commit comments

Comments
 (0)