From 7dfab213e8d84a1984da7ff75abf26fe55fc71d2 Mon Sep 17 00:00:00 2001 From: Luke Kysow Date: Tue, 5 Jun 2018 17:23:58 +0100 Subject: [PATCH] Parse config and build execution plan. --- server/events/repoconfig/config.go | 188 ++++++++++++++++++ server/events/repoconfig/execution_planner.go | 144 ++++++++++++++ server/events/repoconfig/repoconfig.go | 87 ++++++++ testing/assertions.go | 2 +- 4 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 server/events/repoconfig/config.go create mode 100644 server/events/repoconfig/execution_planner.go create mode 100644 server/events/repoconfig/repoconfig.go diff --git a/server/events/repoconfig/config.go b/server/events/repoconfig/config.go new file mode 100644 index 000000000..48a0a9e38 --- /dev/null +++ b/server/events/repoconfig/config.go @@ -0,0 +1,188 @@ +package repoconfig + +import "fmt" + +type RepoConfig struct { + Version int `yaml:"version"` + Projects []Project `yaml:"projects"` + Workflows map[string]Workflow `yaml:"workflows"` +} + +type Project struct { + Dir string `yaml:"dir"` + Workspace string `yaml:"workspace"` + Workflow string `yaml:"workflow"` + TerraformVersion string `yaml:"terraform_version"` + AutoPlan *AutoPlan `yaml:"auto_plan,omitempty"` + ApplyRequirements []string `yaml:"apply_requirements"` +} + +func (p *Project) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Use a type alias so unmarshal doesn't get into an infinite loop. + type alias Project + // Set up defaults. + defaults := alias{ + Workspace: defaultWorkspace, + AutoPlan: &AutoPlan{ + Enabled: true, + WhenModified: []string{"**/*.tf"}, + }, + } + if err := unmarshal(&defaults); err != nil { + return err + } + *p = Project(defaults) + return nil +} + +type AutoPlan struct { + WhenModified []string `yaml:"when_modified"` + Enabled bool `yaml:"enabled"` // defaults to true +} + +func (a *AutoPlan) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Use a type alias so unmarshal doesn't get into an infinite loop. + type alias AutoPlan + // Set up defaults. + defaults := alias{ + // If not specified, we assume it's enabled. + Enabled: true, + } + if err := unmarshal(&defaults); err != nil { + return err + } + *a = AutoPlan(defaults) + return nil +} + +type Workflow struct { + Apply *Stage `yaml:"apply"` // defaults to regular apply steps + Plan *Stage `yaml:"plan"` // defaults to regular plan steps +} + +func (p *Workflow) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Use a type alias so unmarshal doesn't get into an infinite loop. + type alias Workflow + var tmp alias + if err := unmarshal(&tmp); err != nil { + return err + } + *p = Workflow(tmp) + + // If plan or apply keys aren't specified we use the default workflow. + if p.Apply == nil { + p.Apply = &Stage{ + []StepConfig{ + { + StepType: "apply", + }, + }, + } + } + + if p.Plan == nil { + p.Plan = &Stage{ + []StepConfig{ + { + StepType: "init", + }, + { + StepType: "plan", + }, + }, + } + } + + return nil +} + +type Stage struct { + Steps []StepConfig `yaml:"steps"` // can either be a built in step like 'plan' or a custom step like 'run: echo hi' +} + +type StepConfig struct { + StepType string + ExtraArgs []string +} + +func (s *StepConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // First try to unmarshal as a single string, ex. + // steps: + // - init + // - plan + var singleString string + err := unmarshal(&singleString) + if err == nil { + if singleString != "init" && singleString != "plan" && singleString != "apply" { + return fmt.Errorf("unsupported step type: %q", singleString) + } + s.StepType = singleString + return nil + } + + // Next, try to unmarshal as a built-in command with extra_args set, ex. + // steps: + // - init: + /// extra_args: ["arg1"] + // + // We need to create a struct for each step so go-yaml knows to call into + // our routine based on the key (ex. init, plan, etc). + // We use a map[string]interface{} as the value so we can manually + // validate key names and return better errors. This is instead of: + // Init struct{ + // ExtraArgs []string `yaml:"extra_args"` + // } `yaml:"init"` + + validateBuiltIn := func(stepType string, args map[string]interface{}) error { + s.StepType = stepType + for k, v := range args { + if k != "extra_args" { + return fmt.Errorf("unsupported key %q for step %s – the only supported key is extra_args", k, stepType) + } + + // parse as []string + val, ok := v.([]interface{}) + if !ok { + return fmt.Errorf("expected array of strings as value of extra_args, not %q", v) + } + var finalVals []string + for _, i := range val { + finalVals = append(finalVals, fmt.Sprintf("%s", i)) + } + s.ExtraArgs = finalVals + } + return nil + } + var initStep struct { + Init map[string]interface{} `yaml:"init"` + } + if err = unmarshal(&initStep); err == nil { + return validateBuiltIn("init", initStep.Init) + } + + var planStep struct { + Plan map[string]interface{} `yaml:"plan"` + } + if err = unmarshal(&planStep); err == nil { + return validateBuiltIn("plan", planStep.Plan) + } + + var applyStep struct { + Apply map[string]interface{} `yaml:"apply"` + } + if err = unmarshal(&applyStep); err == nil { + return validateBuiltIn("apply", applyStep.Apply) + } + + // todo: run step + // Try to unmarshal as a custom run step, ex. + // steps: + // - run: my command + //var runStep struct { + // Run string `yaml:"run"` + //} + //if err = unmarshal(&runStep); err == nil { + // + //} + return err +} diff --git a/server/events/repoconfig/execution_planner.go b/server/events/repoconfig/execution_planner.go new file mode 100644 index 000000000..02cba0496 --- /dev/null +++ b/server/events/repoconfig/execution_planner.go @@ -0,0 +1,144 @@ +package repoconfig + +import ( + "fmt" + "path/filepath" + + "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/logging" +) + +type ExecutionPlanner struct { + TerraformExecutor TerraformExec + DefaultTFVersion *version.Version + ConfigReader *Reader +} + +type TerraformExec interface { + RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error) +} + +func (s *ExecutionPlanner) BuildPlanStage(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) (*PlanStage, error) { + defaults := s.defaultPlanSteps(log, repoDir, workspace, relProjectPath, extraCommentArgs, username) + steps, err := s.buildStage(PlanStageName, log, repoDir, workspace, relProjectPath, extraCommentArgs, username, defaults) + if err != nil { + return nil, err + } + return &PlanStage{ + Steps: steps, + }, nil +} + +func (s *ExecutionPlanner) buildStage(stageName string, log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string, defaults []Step) ([]Step, error) { + config, err := s.ConfigReader.ReadConfig(repoDir) + if err != nil { + return nil, err + } + + // If there's no config file, use defaults. + if config == nil { + log.Info("no %s file found––continuing with defaults", AtlantisYAMLFilename) + return defaults, nil + } + + // Get this project's configuration. + for _, p := range config.Projects { + if p.Dir == relProjectPath && p.Workspace == workspace { + workflowName := p.Workflow + + // If they didn't specify a workflow, use the default. + if workflowName == "" { + log.Info("no %s workflow set––continuing with defaults", AtlantisYAMLFilename) + return defaults, nil + } + + // If they did specify a workflow, find it. + workflow, exists := config.Workflows[workflowName] + if !exists { + return nil, fmt.Errorf("no workflow with key %q defined", workflowName) + } + + // We have a workflow defined, so now we need to build it. + meta := s.buildMeta(log, repoDir, workspace, relProjectPath, extraCommentArgs, username) + var steps []Step + var stepsConfig []StepConfig + if stageName == PlanStageName { + stepsConfig = workflow.Plan.Steps + } else { + stepsConfig = workflow.Apply.Steps + } + for _, stepConfig := range stepsConfig { + var step Step + switch stepConfig.StepType { + case "init": + step = &InitStep{ + Meta: meta, + ExtraArgs: stepConfig.ExtraArgs, + } + case "plan": + step = &PlanStep{ + Meta: meta, + ExtraArgs: stepConfig.ExtraArgs, + } + case "apply": + step = &ApplyStep{ + Meta: meta, + ExtraArgs: stepConfig.ExtraArgs, + } + } + // todo: custom step + steps = append(steps, step) + } + return steps, nil + } + } + return nil, fmt.Errorf("no project with dir %q and workspace %q defined", relProjectPath, workspace) +} + +func (s *ExecutionPlanner) BuildApplyStage(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) (*ApplyStage, error) { + defaults := s.defaultApplySteps(log, repoDir, workspace, relProjectPath, extraCommentArgs, username) + steps, err := s.buildStage(ApplyStageName, log, repoDir, workspace, relProjectPath, extraCommentArgs, username, defaults) + if err != nil { + return nil, err + } + return &ApplyStage{ + Steps: steps, + }, nil +} + +func (s *ExecutionPlanner) buildMeta(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) StepMeta { + return StepMeta{ + Log: log, + Workspace: workspace, + AbsolutePath: filepath.Join(repoDir, relProjectPath), + DirRelativeToRepoRoot: relProjectPath, + // If there's no config then we should use the default tf version. + TerraformVersion: s.DefaultTFVersion, + TerraformExecutor: s.TerraformExecutor, + ExtraCommentArgs: extraCommentArgs, + Username: username, + } +} + +func (s *ExecutionPlanner) defaultPlanSteps(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) []Step { + meta := s.buildMeta(log, repoDir, workspace, relProjectPath, extraCommentArgs, username) + return []Step{ + &InitStep{ + ExtraArgs: nil, + Meta: meta, + }, + &PlanStep{ + ExtraArgs: nil, + Meta: meta, + }, + } +} +func (s *ExecutionPlanner) defaultApplySteps(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) []Step { + meta := s.buildMeta(log, repoDir, workspace, relProjectPath, extraCommentArgs, username) + return []Step{ + &ApplyStep{ + ExtraArgs: nil, + Meta: meta, + }, + } +} diff --git a/server/events/repoconfig/repoconfig.go b/server/events/repoconfig/repoconfig.go new file mode 100644 index 000000000..6d71d7de0 --- /dev/null +++ b/server/events/repoconfig/repoconfig.go @@ -0,0 +1,87 @@ +package repoconfig + +import ( + "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/logging" +) + +type ApplyRequirement interface { + // IsMet returns true if the requirement is met and false if not. + // If it returns false, it also returns a string describing why not. + IsMet() (bool, string) +} + +type PlanStage struct { + Steps []Step +} + +type ApplyStage struct { + Steps []Step + ApplyRequirements []ApplyRequirement +} + +func (p PlanStage) Run() (string, error) { + var outputs string + for _, step := range p.Steps { + out, err := step.Run() + if err != nil { + return outputs, err + } + if out != "" { + // Outputs are separated by newlines. + outputs += "\n" + out + } + } + return outputs, nil +} + +func (a ApplyStage) Run() (string, error) { + var outputs string + for _, step := range a.Steps { + out, err := step.Run() + if err != nil { + return outputs, err + } + if out != "" { + // Outputs are separated by newlines. + outputs += "\n" + out + } + } + return outputs, nil +} + +type Step interface { + // Run runs the step. It returns any output that needs to be commented back + // onto the pull request and error. + Run() (string, error) +} + +// StepMeta is the data that is available to all steps. +type StepMeta struct { + Log *logging.SimpleLogger + Workspace string + // AbsolutePath is the path to this project on disk. It's not necessarily + // the repository root since a project can be in a subdir of the root. + AbsolutePath string + // DirRelativeToRepoRoot is the directory for this project relative + // to the repo root. + DirRelativeToRepoRoot string + TerraformVersion *version.Version + TerraformExecutor TerraformExec + // ExtraCommentArgs are the arguments that may have been appended to the comment. + // For example 'atlantis plan -- -target=resource'. They are already quoted + // further up the call tree to mitigate security issues. + ExtraCommentArgs []string + // VCS username of who caused this step to be executed. For example the + // commenter, or who pushed a new commit. + Username string +} + +// MustConstraint returns a constraint. It panics on error. +func MustConstraint(constraint string) version.Constraints { + c, err := version.NewConstraint(constraint) + if err != nil { + panic(err) + } + return c +} diff --git a/testing/assertions.go b/testing/assertions.go index efb2ce9b1..5b6ad147b 100644 --- a/testing/assertions.go +++ b/testing/assertions.go @@ -73,7 +73,7 @@ func ErrContains(tb testing.TB, substr string, act error) { tb.Fatalf("exp err to contain %q but err was nil", substr) } if !strings.Contains(act.Error(), substr) { - tb.Fatalf("exp err %q to contain $q", act.Error(), substr) + tb.Fatalf("exp err %q to contain %q", act.Error(), substr) } }