Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

apply: to check module requirements before run #358

Merged
merged 12 commits into from
Apr 8, 2021
Merged
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"`
bmonkman marked this conversation as resolved.
Show resolved Hide resolved
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