Parse config and build execution plan.

This commit is contained in:
Luke Kysow
2018-06-05 17:23:58 +01:00
parent 75a55dd696
commit 7dfab213e8
4 changed files with 420 additions and 1 deletions

View File

@@ -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
}

View File

@@ -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 foundcontinuing 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 setcontinuing 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,
},
}
}

View File

@@ -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
}

View File

@@ -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)
}
}