mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-08-06 09:28:34 +00:00
Parse config and build execution plan.
This commit is contained in:
188
server/events/repoconfig/config.go
Normal file
188
server/events/repoconfig/config.go
Normal 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
|
||||
}
|
||||
144
server/events/repoconfig/execution_planner.go
Normal file
144
server/events/repoconfig/execution_planner.go
Normal 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 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
87
server/events/repoconfig/repoconfig.go
Normal file
87
server/events/repoconfig/repoconfig.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user