diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 27d1f94de..35ad2577e 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -47,27 +47,37 @@ won't work for multiple accounts since Atlantis wouldn't know which environment Terraform with. ### Assume Role Session Names -Atlantis injects the Terraform variable `atlantis_user` and sets it to the GitHub username of -the user that is running the Atlantis command. This can be used to dynamically name the assume role -session which would allow you to view the GitHub username associated with the AWS API calls -being made during a `plan` or `apply` in CloudWatch. - -To take advantage of this feature, use Terraform's [built-in support](https://www.terraform.io/docs/providers/aws/#assume-role) for assume role -and use the `atlantis_user` terraform variable - -```hcl -provider "aws" { - assume_role { - role_arn = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" - session_name = "${var.atlantis_user}" - } -} +Atlantis injects 3 Terraform variables that can be used to dynamically name the assume role +session: +```bash +# Set to the VCS username of who is running the plan command, ex. lkysow variable "atlantis_user" { default = "atlantis_user" } + +# Set to the full name of the repo the pull request is in, ex. runatlantis/atlantis +variable "atlantis_repo" { + default = "atlantis_repo" +} + +# Set to the pull request number, ex. 200 +variable "atlantis_pull_num" { + default = "atlantis_pull_num" +} + +# Can be used within the assume_role block for session_name. +provider "aws" { + assume_role { + role_arn = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" + session_name = "${var.atlantis_user}:${var.atlantis_repo}:${var.atlantis_pull_num}" + } +} ``` +Setting `session_name` allows you to trace where API calls made through Atlantis came from in +CloudWatch. + If you're also using the [S3 Backend](https://www.terraform.io/docs/backends/types/s3.html) make sure to add the `role_arn` option: @@ -85,8 +95,10 @@ terraform { } ``` +::: warning Terraform doesn't support interpolations in backend config so you will not be able to use `session_name = "${var.atlantis_user}"`. However, the backend assumed role is only used for state-related API actions. Any other API actions will be performed using the assumed role specified in the `aws` provider and will have the session named as the GitHub user. +::: diff --git a/server/events/runtime/plan_step_runner.go b/server/events/runtime/plan_step_runner.go index c2c96e831..5ee3e10d6 100644 --- a/server/events/runtime/plan_step_runner.go +++ b/server/events/runtime/plan_step_runner.go @@ -10,9 +10,18 @@ import ( "github.com/runatlantis/atlantis/server/events/models" ) -// atlantisUserTFVar is the name of the variable we execute terraform -// with, containing the vcs username of who is running the command +// atlantisUserTFVar is the name of the tf variable we execute terraform +// with set to the vcs username of who caused the plan command to run. const atlantisUserTFVar = "atlantis_user" + +// atlantisRepoTFVar is the name of the tf variable we execute terraform +// with set to the full name of the repository this pull request is from, ex. +// "runatlantis/atlantis", "repo/gitlab/subgroup". +const atlantisRepoTFVar = "atlantis_repo" + +// atlantisPullNumTFVar is the name of the tf variable we execute terraform +// with set to the number of the pull request. +const atlantisPullNumTFVar = "atlantis_pull_num" const defaultWorkspace = "default" type PlanStepRunner struct { @@ -32,20 +41,8 @@ func (p *PlanStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []strin return "", err } - planFile := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectConfig)) - userVar := fmt.Sprintf("%s=%s", atlantisUserTFVar, ctx.User.Username) - tfPlanCmd := append(append([]string{"plan", "-input=false", "-refresh", "-no-color", "-out", planFile, "-var", userVar}, extraArgs...), ctx.CommentArgs...) - - // Check if env/{workspace}.tfvars exist and include it. This is a use-case - // from Hootsuite where Atlantis was first created so we're keeping this as - // an homage and a favor so they don't need to refactor all their repos. - // It's also a nice way to structure your repos to reduce duplication. - optionalEnvFile := filepath.Join(path, "env", ctx.Workspace+".tfvars") - if _, err := os.Stat(optionalEnvFile); err == nil { - tfPlanCmd = append(tfPlanCmd, "-var-file", optionalEnvFile) - } - - return p.TerraformExecutor.RunCommandWithVersion(ctx.Log, filepath.Join(path), tfPlanCmd, tfVersion, ctx.Workspace) + planCmd := p.buildPlanCmd(ctx, extraArgs, path) + return p.TerraformExecutor.RunCommandWithVersion(ctx.Log, filepath.Clean(path), planCmd, tfVersion, ctx.Workspace) } // switchWorkspace changes the terraform workspace if necessary and will create @@ -97,3 +94,53 @@ func (p *PlanStepRunner) switchWorkspace(ctx models.ProjectCommandContext, path } return nil } + +func (p *PlanStepRunner) buildPlanCmd(ctx models.ProjectCommandContext, extraArgs []string, path string) []string { + tfVars := p.tfVars(ctx.User.Username, ctx.BaseRepo.FullName, ctx.Pull.Num) + planFile := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectConfig)) + + // Check if env/{workspace}.tfvars exist and include it. This is a use-case + // from Hootsuite where Atlantis was first created so we're keeping this as + // an homage and a favor so they don't need to refactor all their repos. + // It's also a nice way to structure your repos to reduce duplication. + var envFileArgs []string + envFile := filepath.Join(path, "env", ctx.Workspace+".tfvars") + if _, err := os.Stat(envFile); err == nil { + envFileArgs = []string{"-var-file", envFile} + } + + argList := [][]string{ + {"plan", "-input=false", "-refresh", "-no-color", "-out", planFile}, + tfVars, + extraArgs, + ctx.CommentArgs, + envFileArgs, + } + + return p.flatten(argList) +} + +// tfVars returns a list of "-var", "key=value" pairs that identify who and which +// repo this command is running for. This can be used for naming the +// session name in AWS which will identify in CloudTrail the source of +// Atlantis API calls. +func (p *PlanStepRunner) tfVars(username string, baseRepoFullName string, pullNum int) []string { + // NOTE: not using maps and looping here because we need to keep the + // ordering for testing purposes. + return []string{ + "-var", + fmt.Sprintf("%s=%s", atlantisUserTFVar, username), + "-var", + fmt.Sprintf("%s=%s", atlantisRepoTFVar, baseRepoFullName), + "-var", + fmt.Sprintf("%s=%d", atlantisPullNumTFVar, pullNum), + } +} + +func (p *PlanStepRunner) flatten(slices [][]string) []string { + var flattened []string + for _, v := range slices { + flattened = append(flattened, v...) + } + return flattened +} diff --git a/server/events/runtime/plan_step_runner_test.go b/server/events/runtime/plan_step_runner_test.go index 9dd0692c6..356c8a3c3 100644 --- a/server/events/runtime/plan_step_runner_test.go +++ b/server/events/runtime/plan_step_runner_test.go @@ -40,15 +40,55 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { Workspace: workspace, RepoRelDir: ".", User: models.User{Username: "username"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + }, }, []string{"extra", "args"}, "/path") Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", []string{"plan", "-input=false", "-refresh", "-no-color", "-out", "/path/default.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"}, tfVersion, workspace) + terraform.VerifyWasCalledOnce().RunCommandWithVersion( + logger, + "/path", + []string{"plan", + "-input=false", + "-refresh", + "-no-color", + "-out", + "/path/default.tfplan", + "-var", + "atlantis_user=username", + "-var", + "atlantis_repo=owner/repo", + "-var", + "atlantis_pull_num=2", + "extra", + "args", + "comment", + "args"}, + tfVersion, + workspace) // Verify that no env or workspace commands were run - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, "/path", []string{"env", "select", "-no-color", "workspace"}, tfVersion, workspace) - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, "/path", []string{"workspace", "select", "-no-color", "workspace"}, tfVersion, workspace) + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, + "/path", + []string{"env", + "select", + "-no-color", + "workspace"}, + tfVersion, + workspace) + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, + "/path", + []string{"workspace", + "select", + "-no-color", + "workspace"}, + tfVersion, + workspace) } func TestRun_ErrWorkspaceIn08(t *testing.T) { @@ -121,13 +161,45 @@ func TestRun_SwitchesWorkspace(t *testing.T) { RepoRelDir: ".", User: models.User{Username: "username"}, CommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + }, }, []string{"extra", "args"}, "/path") Ok(t, err) Equals(t, "output", output) // Verify that env select was called as well as plan. - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", []string{c.expWorkspaceCmd, "select", "-no-color", "workspace"}, tfVersion, "workspace") - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", []string{"plan", "-input=false", "-refresh", "-no-color", "-out", "/path/workspace.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"}, tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, + "/path", + []string{c.expWorkspaceCmd, + "select", + "-no-color", + "workspace"}, + tfVersion, + "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, + "/path", + []string{"plan", + "-input=false", + "-refresh", + "-no-color", + "-out", + "/path/workspace.tfplan", + "-var", + "atlantis_user=username", + "-var", + "atlantis_repo=owner/repo", + "-var", + "atlantis_pull_num=2", + "extra", + "args", + "comment", + "args"}, + tfVersion, + "workspace") }) } } @@ -175,7 +247,22 @@ func TestRun_CreatesWorkspace(t *testing.T) { expWorkspaceArgs := []string{c.expWorkspaceCommand, "select", "-no-color", "workspace"} When(terraform.RunCommandWithVersion(logger, "/path", expWorkspaceArgs, tfVersion, "workspace")).ThenReturn("", errors.New("workspace does not exist")) - expPlanArgs := []string{"plan", "-input=false", "-refresh", "-no-color", "-out", "/path/workspace.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"} + expPlanArgs := []string{"plan", + "-input=false", + "-refresh", + "-no-color", + "-out", + "/path/workspace.tfplan", + "-var", + "atlantis_user=username", + "-var", + "atlantis_repo=owner/repo", + "-var", + "atlantis_pull_num=2", + "extra", + "args", + "comment", + "args"} When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "workspace")).ThenReturn("output", nil) output, err := s.Run(models.ProjectCommandContext{ @@ -184,6 +271,12 @@ func TestRun_CreatesWorkspace(t *testing.T) { RepoRelDir: ".", User: models.User{Username: "username"}, CommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + }, }, []string{"extra", "args"}, "/path") Ok(t, err) @@ -208,7 +301,22 @@ func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { } When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, tfVersion, "workspace")).ThenReturn("workspace\n", nil) - expPlanArgs := []string{"plan", "-input=false", "-refresh", "-no-color", "-out", "/path/workspace.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"} + expPlanArgs := []string{"plan", + "-input=false", + "-refresh", + "-no-color", + "-out", + "/path/workspace.tfplan", + "-var", + "atlantis_user=username", + "-var", + "atlantis_repo=owner/repo", + "-var", + "atlantis_pull_num=2", + "extra", + "args", + "comment", + "args"} When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "workspace")).ThenReturn("output", nil) output, err := s.Run(models.ProjectCommandContext{ @@ -217,6 +325,12 @@ func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { RepoRelDir: ".", User: models.User{Username: "username"}, CommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + }, }, []string{"extra", "args"}, "/path") Ok(t, err) @@ -249,7 +363,25 @@ func TestRun_AddsEnvVarFile(t *testing.T) { DefaultTFVersion: tfVersion, } - expPlanArgs := []string{"plan", "-input=false", "-refresh", "-no-color", "-out", filepath.Join(tmpDir, "workspace.tfplan"), "-var", "atlantis_user=username", "extra", "args", "comment", "args", "-var-file", envVarsFile} + expPlanArgs := []string{"plan", + "-input=false", + "-refresh", + "-no-color", + "-out", + filepath.Join(tmpDir, "workspace.tfplan"), + "-var", + "atlantis_user=username", + "-var", + "atlantis_repo=owner/repo", + "-var", + "atlantis_pull_num=2", + "extra", + "args", + "comment", + "args", + "-var-file", + envVarsFile, + } When(terraform.RunCommandWithVersion(logger, tmpDir, expPlanArgs, tfVersion, "workspace")).ThenReturn("output", nil) output, err := s.Run(models.ProjectCommandContext{ @@ -258,6 +390,12 @@ func TestRun_AddsEnvVarFile(t *testing.T) { RepoRelDir: ".", User: models.User{Username: "username"}, CommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + }, }, []string{"extra", "args"}, tmpDir) Ok(t, err) @@ -280,7 +418,23 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { } When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, tfVersion, "workspace")).ThenReturn("workspace\n", nil) - expPlanArgs := []string{"plan", "-input=false", "-refresh", "-no-color", "-out", "/path/projectname-default.tfplan", "-var", "atlantis_user=username", "extra", "args", "comment", "args"} + expPlanArgs := []string{"plan", + "-input=false", + "-refresh", + "-no-color", + "-out", + "/path/projectname-default.tfplan", + "-var", + "atlantis_user=username", + "-var", + "atlantis_repo=owner/repo", + "-var", + "atlantis_pull_num=2", + "extra", + "args", + "comment", + "args", + } When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, tfVersion, "default")).ThenReturn("output", nil) projectName := "projectname" @@ -293,6 +447,12 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { ProjectConfig: &valid.Project{ Name: &projectName, }, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + }, }, []string{"extra", "args"}, "/path") Ok(t, err) Equals(t, "output", output)