Files
atlantis/server/events/project_command_builder.go
John Julien 1fcdaba719 Added new --repo-config option and deprecated --allow-repo-config
This enables atlantis.yaml in all repos, but by default restricts
certain sensitive keys from being used.

The keys apply_requirements, workflow, and workflows can only be
specified in an atlantis.yaml file if explicitly allowed by a
server side repo config.

The repo config file provides the ability to specify a default set of
workflows, and default values for apply_requirements and workflow to use
use on a per repo basis.  It also supports applying to a collection of
repos by using regex to match a repo name.

If more than one repo name matches, the values from last repo matched
are used.

This deprecates the --allow-repo-config option
2019-03-27 14:47:48 -05:00

437 lines
16 KiB
Go

package events
import (
"fmt"
"github.com/runatlantis/atlantis/server/events/yaml/raw"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"strings"
"github.com/hashicorp/go-version"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/yaml"
"github.com/runatlantis/atlantis/server/logging"
)
const (
// DefaultRepoRelDir is the default directory we run commands in, relative
// to the root of the repo.
DefaultRepoRelDir = "."
// DefaultWorkspace is the default Terraform workspace we run commands in.
// This is also Terraform's default workspace.
DefaultWorkspace = "default"
)
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_command_builder.go ProjectCommandBuilder
// ProjectCommandBuilder builds commands that run on individual projects.
type ProjectCommandBuilder interface {
// BuildAutoplanCommands builds project commands that will run plan on
// the projects determined to be modified.
BuildAutoplanCommands(ctx *CommandContext) ([]models.ProjectCommandContext, error)
// BuildPlanCommands builds project plan commands for this comment. If the
// comment doesn't specify one project then there may be multiple commands
// to be run.
BuildPlanCommands(ctx *CommandContext, commentCommand *CommentCommand) ([]models.ProjectCommandContext, error)
// BuildApplyCommands builds project apply commands for this comment. If the
// comment doesn't specify one project then there may be multiple commands
// to be run.
BuildApplyCommands(ctx *CommandContext, commentCommand *CommentCommand) ([]models.ProjectCommandContext, error)
}
// DefaultProjectCommandBuilder implements ProjectCommandBuilder.
// This class combines the data from the comment and any repo config file or
// Atlantis server config and then generates a set of contexts.
type DefaultProjectCommandBuilder struct {
ParserValidator *yaml.ParserValidator
ProjectFinder ProjectFinder
VCSClient vcs.Client
WorkingDir WorkingDir
WorkingDirLocker WorkingDirLocker
AllowRepoConfig bool
AllowRepoConfigFlag string
RepoConfig raw.RepoConfig
PendingPlanFinder *DefaultPendingPlanFinder
CommentBuilder CommentBuilder
}
// TFCommandRunner runs Terraform commands.
type TFCommandRunner interface {
// RunCommandWithVersion runs a Terraform command using the version v.
RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error)
}
// BuildAutoplanCommands builds project commands that will run plan on
// the projects determined to be modified.
func (p *DefaultProjectCommandBuilder) BuildAutoplanCommands(ctx *CommandContext) ([]models.ProjectCommandContext, error) {
cmds, err := p.buildPlanAllCommands(ctx, nil, false)
if err != nil {
return nil, err
}
// Filter out projects where autoplanning is specifically disabled.
var autoplanEnabled []models.ProjectCommandContext
for _, cmd := range cmds {
if cmd.ProjectConfig != nil && !cmd.ProjectConfig.Autoplan.Enabled {
ctx.Log.Debug("ignoring project at dir %q, workspace: %q because autoplan is disabled", cmd.RepoRelDir, cmd.Workspace)
continue
}
autoplanEnabled = append(autoplanEnabled, cmd)
}
return autoplanEnabled, nil
}
func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, commentFlags []string, verbose bool) ([]models.ProjectCommandContext, error) {
// Need to lock the workspace we're about to clone to.
workspace := DefaultWorkspace
unlockFn, err := p.WorkingDirLocker.TryLock(ctx.BaseRepo.FullName, ctx.Pull.Num, workspace)
if err != nil {
ctx.Log.Warn("workspace was locked")
return nil, err
}
ctx.Log.Debug("got workspace lock")
defer unlockFn()
repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, workspace)
if err != nil {
return nil, err
}
// Parse config file if it exists.
var config valid.Config
hasConfigFile, err := p.ParserValidator.HasConfigFile(repoDir)
if err != nil {
return nil, errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir)
}
if hasConfigFile {
config, err = p.ParserValidator.ReadConfig(repoDir, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
if err != nil {
return nil, err
}
ctx.Log.Info("successfully parsed %s file", yaml.AtlantisYAMLFilename)
} else {
ctx.Log.Info("found no %s file", yaml.AtlantisYAMLFilename)
}
// We'll need the list of modified files.
modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull)
if err != nil {
return nil, err
}
ctx.Log.Debug("%d files were modified in this pull request", len(modifiedFiles))
// Prepare the project contexts so the ProjectCommandRunner can execute.
var projCtxs []models.ProjectCommandContext
// If there is no config file, then we try to plan for each project that
// was modified in the pull request.
if !hasConfigFile {
modifiedProjects := p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, repoDir)
ctx.Log.Info("automatically determined that there were %d projects modified in this pull request: %s", len(modifiedProjects), modifiedProjects)
for _, mp := range modifiedProjects {
var globalConfig valid.Config
var projectConfig *valid.Project
// If there is a server side repo config that matches, then the project should be planned using
// a config that honors those values. Creating a single project config with no settings other than
// dir and merging with the server side repo yaml achieves this
version := 2
config := raw.Config{
Version: &version,
Projects: []raw.Project{{Dir: &mp.Path}},
}
config, err = p.ParserValidator.ValidateOverridesAndMergeConfig(config, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
if err != nil {
return nil, err
}
globalConfig = config.ToValid()
projectConfig = &globalConfig.Projects[0]
projCtxs = append(projCtxs, models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
RepoRelDir: mp.Path,
ProjectConfig: projectConfig,
GlobalConfig: &globalConfig,
CommentArgs: commentFlags,
Workspace: DefaultWorkspace,
Verbose: verbose,
RePlanCmd: p.CommentBuilder.BuildPlanComment(mp.Path, DefaultWorkspace, "", commentFlags),
ApplyCmd: p.CommentBuilder.BuildApplyComment(mp.Path, DefaultWorkspace, ""),
PullMergeable: ctx.PullMergeable,
})
}
} else {
// Otherwise, we use the projects that match the WhenModified fields
// in the config file.
matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, config, repoDir)
if err != nil {
return nil, err
}
ctx.Log.Info("%d projects are to be planned based on their when_modified config", len(matchingProjects))
// Use for i instead of range because need to get the pointer to the
// project config.
for i := 0; i < len(matchingProjects); i++ {
mp := matchingProjects[i]
projCtxs = append(projCtxs, models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
CommentArgs: commentFlags,
Workspace: mp.Workspace,
RepoRelDir: mp.Dir,
ProjectConfig: &mp,
GlobalConfig: &config,
Verbose: verbose,
RePlanCmd: p.CommentBuilder.BuildPlanComment(mp.Dir, mp.Workspace, mp.GetName(), commentFlags),
ApplyCmd: p.CommentBuilder.BuildApplyComment(mp.Dir, mp.Workspace, mp.GetName()),
PullMergeable: ctx.PullMergeable,
})
}
}
return projCtxs, nil
}
func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *CommandContext, cmd *CommentCommand) (models.ProjectCommandContext, error) {
workspace := DefaultWorkspace
if cmd.Workspace != "" {
workspace = cmd.Workspace
}
var pcc models.ProjectCommandContext
ctx.Log.Debug("building plan command")
unlockFn, err := p.WorkingDirLocker.TryLock(ctx.BaseRepo.FullName, ctx.Pull.Num, workspace)
if err != nil {
return pcc, err
}
defer unlockFn()
ctx.Log.Debug("cloning repository")
repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, workspace)
if err != nil {
return pcc, err
}
repoRelDir := DefaultRepoRelDir
if cmd.RepoRelDir != "" {
repoRelDir = cmd.RepoRelDir
}
return p.buildProjectCommandCtx(ctx, cmd.ProjectName, cmd.Flags, repoDir, repoRelDir, workspace)
}
// BuildPlanCommands builds project plan commands for this comment. If the
// comment doesn't specify one project then there may be multiple commands
// to be run.
func (p *DefaultProjectCommandBuilder) BuildPlanCommands(ctx *CommandContext, cmd *CommentCommand) ([]models.ProjectCommandContext, error) {
if !cmd.IsForSpecificProject() {
return p.buildPlanAllCommands(ctx, cmd.Flags, cmd.Verbose)
}
pcc, err := p.buildProjectPlanCommand(ctx, cmd)
if err != nil {
return nil, err
}
return []models.ProjectCommandContext{pcc}, nil
}
func (p *DefaultProjectCommandBuilder) buildApplyAllCommands(ctx *CommandContext, commentCmd *CommentCommand) ([]models.ProjectCommandContext, error) {
// lock all dirs in this pull request
unlockFn, err := p.WorkingDirLocker.TryLockPull(ctx.BaseRepo.FullName, ctx.Pull.Num)
if err != nil {
return nil, err
}
defer unlockFn()
pullDir, err := p.WorkingDir.GetPullDir(ctx.BaseRepo, ctx.Pull)
if err != nil {
return nil, err
}
plans, err := p.PendingPlanFinder.Find(pullDir)
if err != nil {
return nil, err
}
var cmds []models.ProjectCommandContext
for _, plan := range plans {
cmd, err := p.buildProjectCommandCtx(ctx, commentCmd.ProjectName, commentCmd.Flags, plan.RepoDir, plan.RepoRelDir, plan.Workspace)
if err != nil {
return nil, errors.Wrapf(err, "building command for dir %q", plan.RepoRelDir)
}
cmds = append(cmds, cmd)
}
return cmds, nil
}
// BuildApplyCommands builds project apply commands for this comment. If the
// comment doesn't specify one project then there may be multiple commands
// to be run.
func (p *DefaultProjectCommandBuilder) BuildApplyCommands(ctx *CommandContext, cmd *CommentCommand) ([]models.ProjectCommandContext, error) {
if !cmd.IsForSpecificProject() {
return p.buildApplyAllCommands(ctx, cmd)
}
pac, err := p.buildProjectApplyCommand(ctx, cmd)
if err != nil {
return nil, err
}
return []models.ProjectCommandContext{pac}, nil
}
func (p *DefaultProjectCommandBuilder) buildProjectApplyCommand(ctx *CommandContext, cmd *CommentCommand) (models.ProjectCommandContext, error) {
workspace := DefaultWorkspace
if cmd.Workspace != "" {
workspace = cmd.Workspace
}
var projCtx models.ProjectCommandContext
unlockFn, err := p.WorkingDirLocker.TryLock(ctx.BaseRepo.FullName, ctx.Pull.Num, workspace)
if err != nil {
return projCtx, err
}
defer unlockFn()
repoDir, err := p.WorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, workspace)
if err != nil {
return projCtx, err
}
repoRelDir := DefaultRepoRelDir
if cmd.RepoRelDir != "" {
repoRelDir = cmd.RepoRelDir
}
return p.buildProjectCommandCtx(ctx, cmd.ProjectName, cmd.Flags, repoDir, repoRelDir, workspace)
}
func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContext, projectName string, commentFlags []string, repoDir string, repoRelDir string, workspace string) (models.ProjectCommandContext, error) {
projCfg, globalCfg, err := p.getCfg(ctx, projectName, repoRelDir, workspace, repoDir)
if err != nil {
return models.ProjectCommandContext{}, err
}
// Override any dir/workspace defined on the comment with what was
// defined in config. This shouldn't matter since we don't allow comments
// with both project name and dir/workspace.
if projCfg != nil {
repoRelDir = projCfg.Dir
workspace = projCfg.Workspace
}
if err := p.validateWorkspaceAllowed(globalCfg, repoRelDir, workspace); err != nil {
return models.ProjectCommandContext{}, err
}
return models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
CommentArgs: commentFlags,
Workspace: workspace,
RepoRelDir: repoRelDir,
ProjectConfig: projCfg,
GlobalConfig: globalCfg,
RePlanCmd: p.CommentBuilder.BuildPlanComment(repoRelDir, workspace, projectName, commentFlags),
ApplyCmd: p.CommentBuilder.BuildApplyComment(repoRelDir, workspace, projectName),
PullMergeable: ctx.PullMergeable,
}, nil
}
// This function is used to get the project config file when apply is being run
func (p *DefaultProjectCommandBuilder) getCfg(ctx *CommandContext, projectName string, dir string, workspace string, repoDir string) (projectCfg *valid.Project, globalCfg *valid.Config, err error) {
hasConfigFile, err := p.ParserValidator.HasConfigFile(repoDir)
if err != nil {
err = errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir)
return
}
if !hasConfigFile && projectName != "" {
err = fmt.Errorf("cannot specify a project name unless an %s file exists to configure projects", yaml.AtlantisYAMLFilename)
return
}
var globalCfgStruct valid.Config
// If we have a config file, read it and let any repo restricted config be merged and validated
if hasConfigFile {
globalCfgStruct, err = p.ParserValidator.ReadConfig(repoDir, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
} else {
// If no atlantis.yaml file exists, we generate a skeleton config and merge all of the server side repo config
// settings into. If no server side repo config was provided, a default one was generated at server start
version := 2
rawConfig := raw.Config{
Version: &version,
Projects: []raw.Project{
{
Dir: &dir,
Workspace: &workspace,
},
},
}
rawConfig, err = p.ParserValidator.ValidateOverridesAndMergeConfig(rawConfig, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
globalCfgStruct = rawConfig.ToValid()
}
if err != nil {
return
}
globalCfg = &globalCfgStruct
// If they've specified a project by name we look it up. Otherwise we
// use the dir and workspace.
if projectName != "" {
projectCfg = globalCfg.FindProjectByName(projectName)
if projectCfg == nil {
err = fmt.Errorf("no project with name %q is defined in %s", projectName, yaml.AtlantisYAMLFilename)
return
}
return
}
projCfgs := globalCfg.FindProjectsByDirWorkspace(dir, workspace)
if len(projCfgs) == 0 {
return
}
if len(projCfgs) > 1 {
err = fmt.Errorf("must specify project name: more than one project defined in %s matched dir: %q workspace: %q", yaml.AtlantisYAMLFilename, dir, workspace)
return
}
projectCfg = &projCfgs[0]
return
}
// validateWorkspaceAllowed returns an error if there are projects configured
// in globalCfg for repoRelDir and none of those projects use workspace.
func (p *DefaultProjectCommandBuilder) validateWorkspaceAllowed(globalCfg *valid.Config, repoRelDir string, workspace string) error {
if globalCfg == nil {
return nil
}
projects := globalCfg.FindProjectsByDir(repoRelDir)
// If that directory doesn't have any projects configured then we don't
// enforce workspace names.
if len(projects) == 0 {
return nil
}
var configuredSpaces []string
for _, p := range projects {
if p.Workspace == workspace {
return nil
}
configuredSpaces = append(configuredSpaces, p.Workspace)
}
return fmt.Errorf(
"running commands in workspace %q is not allowed because this"+
" directory is only configured for the following workspaces: %s",
workspace,
strings.Join(configuredSpaces, ", "),
)
}