Skip to content

Commit

Permalink
apply: to check module requirements before run (#358)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
davidcheung authored Apr 8, 2021
1 parent 79f64ac commit 98a272d
Show file tree
Hide file tree
Showing 23 changed files with 287 additions and 73 deletions.
5 changes: 4 additions & 1 deletion cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ var applyCmd = &cobra.Command{
log.Println(err)
rootDir = projectconfig.RootDir
}
apply.Apply(rootDir, applyConfigPath, applyEnvironments)
applyErr := apply.Apply(rootDir, applyConfigPath, applyEnvironments)
if applyErr != nil {
log.Fatal(applyErr)
}
},
}
8 changes: 8 additions & 0 deletions docs/module-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ It also declares the module's dependencies to determine the order of execution
| `author` | string | Author of the module |
| `icon` | string | Path to logo image |
| `parameters` | list(Parameter) | Parameters to prompt users |
| `commands` | Commands | Commands to use instead of makefile defaults |
| `zeroVersion` | string([go-semver])| Zero versions its compatible with |


### Commands
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`.
| Parameters | Type | Default | Description |
|------------|--------|----------------|--------------------------------------------------------------------------|
| `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 |
| `apply` | string | `make` | Command to execute the project provisioning. |
| `summary` | string | `make summary` | Command to summarize to users the module's output and next steps. |
### Template
| Parameters | Type | Description |
|--------------|---------|-----------------------------------------------------------------------|
Expand Down
137 changes: 81 additions & 56 deletions internal/apply/apply.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package apply

import (
"errors"
"fmt"
"path/filepath"

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

"github.com/commitdev/zero/internal/config/moduleconfig"
"github.com/commitdev/zero/internal/config/projectconfig"
"github.com/commitdev/zero/pkg/util/exit"
"github.com/commitdev/zero/pkg/util/flog"
"github.com/manifoldco/promptui"
)

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

flog.Infof(":mag: checking project %s's module requirements.", projectConfig.Name)

errs = modulesWalkCmd("check", rootDir, projectConfig, "check", environments, false, false)
// Check operation walks through all modules and can return multiple errors
if len(errs) > 0 {
msg := ""
for i := 0; i < len(errs); i++ {
msg += "- " + errs[i].Error()
}
return errors.New(fmt.Sprintf("The following Module check(s) failed: \n%s", msg))
}

flog.Infof(":tada: Bootstrapping project %s. Please use the zero-project.yml file to modify the project as needed.", projectConfig.Name)

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

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

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

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

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

func applyAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEnvironments []string) {
environmentArg := fmt.Sprintf("ENVIRONMENT=%s", strings.Join(applyEnvironments, ","))

func modulesWalkCmd(lifecycleName string, dir string, projectConfig *projectconfig.ZeroProjectConfig, operation string, environments []string, bailOnError bool, shouldPipeStderr bool) []error {
var moduleErrors []error
graph := projectConfig.GetDAG()

// Walk the graph of modules and run `make`
root := []dag.Vertex{projectconfig.GraphRootName}
graph.DepthFirstWalk(root, func(v dag.Vertex, depth int) error {
environmentArg := fmt.Sprintf("ENVIRONMENT=%s", strings.Join(environments, ","))
err := graph.DepthFirstWalk(root, func(v dag.Vertex, depth int) error {
// Don't process the root
if depth == 0 {
return nil
Expand Down Expand Up @@ -83,16 +104,64 @@ func applyAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEn
// and we should redownload the module for the user
modConfig, err := module.ParseModuleConfig(modulePath)
if err != nil {
exit.Fatal("Failed to load module config, credentials cannot be injected properly")
exit.Fatal("Failed to load Module: %s", err)
}

envVarTranslationMap := modConfig.GetParamEnvVarTranslationMap()
envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList, envVarTranslationMap)
flog.Debugf("Env injected: %#v", envList)
flog.Infof("Executing apply command for %s...", modConfig.Name)
util.ExecuteCommand(exec.Command("make"), modulePath, envList)

// only print msg for apply, or else it gets a little spammy
if lifecycleName == "apply" {
flog.Infof("Executing %s command for %s...", lifecycleName, modConfig.Name)
}
operationCommand := getModuleOperationCommand(modConfig, operation)
execErr := util.ExecuteCommand(exec.Command(operationCommand[0], operationCommand[1:]...), modulePath, envList, shouldPipeStderr)
if execErr != nil {
formatedErr := errors.New(fmt.Sprintf("Module (%s) %s", modConfig.Name, execErr.Error()))
if bailOnError {
return formatedErr
} else {
moduleErrors = append(moduleErrors, formatedErr)
}
}
return nil
})
if err != nil {
moduleErrors = append(moduleErrors, err)
}

return moduleErrors
}

func getModuleOperationCommand(mod moduleconfig.ModuleConfig, operation string) (operationCommand []string) {
defaultCheck := []string{"make", "check"}
defaultApply := []string{"make"}
defaultSummary := []string{"make", "summary"}

switch operation {
case "check":
if mod.Commands.Check != "" {
operationCommand = []string{"sh", "-c", mod.Commands.Check}
} else {
operationCommand = defaultCheck
}
case "apply":
if mod.Commands.Apply != "" {
operationCommand = []string{"sh", "-c", mod.Commands.Apply}
} else {
operationCommand = defaultApply
}
case "summary":
if mod.Commands.Summary != "" {
operationCommand = []string{"sh", "-c", mod.Commands.Summary}
} else {
operationCommand = defaultSummary
}
default:
panic("Unexpected operation")
}
return operationCommand
}

// promptEnvironments Prompts the user for the environments to apply against and returns a slice of strings representing the environments
Expand Down Expand Up @@ -125,47 +194,3 @@ func validateEnvironments(applyEnvironments []string) {
}
}
}

func summarizeAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEnvironments []string) {
flog.Infof("Your projects and infrastructure have been successfully created. Here are some useful links and commands to get you started:")

graph := projectConfig.GetDAG()

// Walk the graph of modules and run `make summary`
root := []dag.Vertex{projectconfig.GraphRootName}
graph.DepthFirstWalk(root, func(v dag.Vertex, depth int) error {
// Don't process the root
if depth == 0 {
return nil
}

name := v.(string)
mod := projectConfig.Modules[name]
// Add env vars for the makefile
envList := []string{
fmt.Sprintf("ENVIRONMENT=%s", strings.Join(applyEnvironments, ",")),
fmt.Sprintf("REPOSITORY=%s", mod.Files.Repository),
fmt.Sprintf("PROJECT_NAME=%s", projectConfig.Name),
}

modulePath := module.GetSourceDir(mod.Files.Source)
// Passed in `dir` will only be used to find the project path, not the module path,
// unless the module path is relative
if module.IsLocal(mod.Files.Source) && !filepath.IsAbs(modulePath) {
modulePath = filepath.Join(dir, modulePath)
}
flog.Debugf("Loaded module: %s from %s", name, modulePath)

modConfig, err := module.ParseModuleConfig(modulePath)
if err != nil {
exit.Fatal("Failed to load module config, credentials cannot be injected properly")
}
envVarTranslationMap := modConfig.GetParamEnvVarTranslationMap()
envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList, envVarTranslationMap)
flog.Debugf("Env injected: %#v", envList)
util.ExecuteCommand(exec.Command("make", "summary"), modulePath, envList)
return nil
})

flog.Infof("Happy coding! :smile:")
}
42 changes: 32 additions & 10 deletions internal/apply/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,13 @@ import (
)

func TestApply(t *testing.T) {
dir := "../../tests/test_data/apply/"
applyConfigPath := constants.ZeroProjectYml
applyEnvironments := []string{"staging", "production"}

tmpDir := filepath.Join(os.TempDir(), "apply")

err := os.RemoveAll(tmpDir)
assert.NoError(t, err)

err = shutil.CopyTree(dir, tmpDir, nil)
assert.NoError(t, err)
var tmpDir string

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

Expand All @@ -37,6 +30,13 @@ func TestApply(t *testing.T) {
content, err = ioutil.ReadFile(filepath.Join(tmpDir, "project2/project.out"))
assert.NoError(t, err)
assert.Equal(t, "baz: qux\n", string(content))

})

t.Run("Modules runs command overides", func(t *testing.T) {
content, err := ioutil.ReadFile(filepath.Join(tmpDir, "project2/check.out"))
assert.NoError(t, err)
assert.Equal(t, "custom check\n", string(content))
})

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

t.Run("Modules with failing checks should return error", func(t *testing.T) {
tmpDir = setupTmpDir(t, "../../tests/test_data/apply-failing/")

err := apply.Apply(tmpDir, applyConfigPath, applyEnvironments)
assert.Regexp(t, "^The following Module check\\(s\\) failed:", err.Error())
assert.Regexp(t, "Module \\(project1\\)", err.Error())
assert.Regexp(t, "Module \\(project2\\)", err.Error())
assert.Regexp(t, "Module \\(project3\\)", err.Error())
})

}

func setupTmpDir(t *testing.T, exampleDirPath string) string {
var err error
tmpDir := filepath.Join(os.TempDir(), "apply")

err = os.RemoveAll(tmpDir)
assert.NoError(t, err)

err = shutil.CopyTree(exampleDirPath, tmpDir, nil)
assert.NoError(t, err)
return tmpDir
}
9 changes: 8 additions & 1 deletion internal/config/moduleconfig/module_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,21 @@ type ModuleConfig struct {
Name string
Description string
Author string
DependsOn []string `yaml:"dependsOn,omitempty"`
Commands ModuleCommands `yaml:"commands,omitempty"`
DependsOn []string `yaml:"dependsOn,omitempty"`
TemplateConfig `yaml:"template"`
RequiredCredentials []string `yaml:"requiredCredentials"`
ZeroVersion VersionConstraints `yaml:"zeroVersion,omitempty"`
Parameters []Parameter
Conditions []Condition `yaml:"conditions,omitempty"`
}

type ModuleCommands struct {
Apply string `yaml:"apply,omitempty"`
Check string `yaml:"check,omitempty"`
Summary string `yaml:"summary,omitempty"`
}

func checkVersionAgainstConstrains(vc VersionConstraints, versionString string) bool {
v, err := goVerson.NewVersion(versionString)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions internal/module/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ func TestParseModuleConfig(t *testing.T) {
assert.Equal(t, []string{"<%", "%>"}, mod.TemplateConfig.Delimiters)
})

t.Run("Parsing commands", func(t *testing.T) {
checkCommand := mod.Commands.Check
assert.Equal(t, "ls", checkCommand)
})

t.Run("Parsing zero version constraints", func(t *testing.T) {
moduleConstraints := mod.ZeroVersion.Constraints.String()
assert.Equal(t, ">= 3.0.0, < 4.0.0", moduleConstraints)
Expand Down
Loading

0 comments on commit 98a272d

Please sign in to comment.