From 004074a29f4cf18e16cbdb93cf98dc06b4d80a3c Mon Sep 17 00:00:00 2001 From: Aayush Gupta <43479002+Aayyush@users.noreply.github.com> Date: Thu, 30 Dec 2021 06:52:52 -0800 Subject: [PATCH] feat: streaming terraform logs in real-time (#1937) --- go.mod | 2 +- runatlantis.io/docs/custom-workflows.md | 18 +- .../events/events_controller_e2e_test.go | 14 +- .../automerge/exp-output-apply-dir1.txt | 1 + .../automerge/exp-output-apply-dir1.txt.act | 10 - .../automerge/exp-output-apply-dir2.txt | 1 + .../automerge/exp-output-apply-dir2.txt.act | 10 - .../automerge/exp-output-autoplan.txt | 2 + .../automerge/exp-output-autoplan.txt.act | 67 ---- .../exp-output-apply-production.txt.act | 14 - .../exp-output-apply-staging.txt.act | 14 - .../modules-yaml/exp-output-autoplan.txt.act | 73 ---- .../exp-output-apply-production.txt.act | 14 - .../modules/exp-output-apply-staging.txt.act | 14 - .../exp-output-autoplan-only-staging.txt.act | 37 -- .../exp-output-plan-production.txt.act | 37 -- .../modules/exp-output-plan-staging.txt.act | 37 -- ...exp-output-apply-default-workspace.txt.act | 14 - ...exp-output-apply-staging-workspace.txt.act | 14 - .../exp-output-autoplan.txt.act | 79 ----- .../simple-yaml/exp-output-apply-all.txt.act | 43 --- .../exp-output-apply-default.txt.act | 15 - .../exp-output-apply-staging.txt.act | 22 -- .../simple-yaml/exp-output-autoplan.txt.act | 79 ----- .../simple/exp-output-apply-var-all.txt.act | 50 --- ...output-apply-var-default-workspace.txt.act | 22 -- ...exp-output-apply-var-new-workspace.txt.act | 22 -- .../simple/exp-output-apply-var.txt.act | 22 -- .../simple/exp-output-apply.txt.act | 22 -- ...output-atlantis-plan-new-workspace.txt.act | 48 --- ...utput-atlantis-plan-var-overridden.txt.act | 48 --- .../simple/exp-output-atlantis-plan.txt.act | 48 --- .../simple/exp-output-autoplan.txt.act | 48 --- .../exp-output-apply-default.txt.act | 15 - .../exp-output-apply-staging.txt.act | 15 - .../exp-output-plan-default.txt.act | 38 -- .../exp-output-plan-staging.txt.act | 38 -- .../exp-output-apply-default.txt.act | 15 - .../exp-output-apply-staging.txt.act | 15 - .../tfvars-yaml/exp-output-autoplan.txt.act | 77 ----- .../exp-output-apply-all-production.txt.act | 34 -- .../exp-output-apply-all-staging.txt.act | 34 -- .../exp-output-autoplan-production.txt.act | 73 ---- .../exp-output-autoplan-staging.txt.act | 73 ---- server/controllers/jobs_controller.go | 144 ++++++++ server/controllers/templates/web_templates.go | 162 +++++++++ server/controllers/websocket/mux.go | 63 ++++ server/controllers/websocket/writer.go | 68 ++++ server/core/runtime/apply_step_runner.go | 15 +- server/core/runtime/apply_step_runner_test.go | 78 +++-- server/core/runtime/init_step_runner.go | 4 +- server/core/runtime/init_step_runner_test.go | 117 ++++--- server/core/runtime/plan_step_runner.go | 14 +- server/core/runtime/plan_step_runner_test.go | 257 +++++++------- server/core/runtime/runtime.go | 4 +- server/core/runtime/show_step_runner.go | 4 +- server/core/runtime/show_step_runner_test.go | 11 +- server/core/runtime/version_step_runner.go | 2 +- .../core/runtime/version_step_runner_test.go | 2 +- .../terraform/mocks/mock_terraform_client.go | 25 +- server/core/terraform/terraform_client.go | 93 +++-- .../terraform_client_internal_test.go | 165 ++++++++- .../core/terraform/terraform_client_test.go | 75 +++- server/events/command_runner_test.go | 1 - server/events/commit_status_updater.go | 25 +- .../mocks/mock_log_stream_url_generator.go | 109 ++++++ server/events/models/fixtures/fixtures.go | 15 +- server/events/models/models.go | 26 ++ server/events/project_command_runner.go | 52 +++ server/events/project_command_runner_test.go | 159 ++++++++- server/events/pull_closed_executor.go | 37 +- server/events/pull_closed_executor_test.go | 127 ++++++- server/events/terraform/ansi/strip.go | 13 + .../handlers/mocks/matchers/chan_of_string.go | 31 ++ server/handlers/mocks/matchers/http_header.go | 33 ++ .../mocks/matchers/http_responsewriter.go | 33 ++ .../matchers/map_of_chan_of_string_to_bool.go | 31 ++ .../mocks/matchers/models_commandname.go | 33 ++ .../mocks/matchers/models_commitstatus.go | 33 ++ .../matchers/models_projectcommandcontext.go | 33 ++ .../mocks/matchers/ptr_to_http_request.go | 33 ++ .../handlers/mocks/matchers/slice_of_byte.go | 31 ++ .../mocks/matchers/slice_of_string.go | 31 ++ .../mock_project_command_output_handler.go | 325 ++++++++++++++++++ .../mocks/mock_project_job_url_generator.go | 109 ++++++ .../mocks/mock_project_status_updater.go | 117 +++++++ .../handlers/mocks/mock_resource_cleaner.go | 97 ++++++ .../project_command_output_handler.go | 232 +++++++++++++ .../project_command_output_handler_test.go | 216 ++++++++++++ server/handlers/websocket_handler.go | 61 ++++ server/middleware.go | 44 +-- server/router.go | 22 ++ server/router_test.go | 55 +++ server/server.go | 119 +++++-- 94 files changed, 3197 insertions(+), 1777 deletions(-) delete mode 100644 server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-production.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-autoplan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-production.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules/exp-output-autoplan-only-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-production.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-default-workspace.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-staging-workspace.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-autoplan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-all.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-default.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-autoplan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-all.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-default-workspace.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-new-workspace.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-apply.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-new-workspace.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-var-overridden.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/simple/exp-output-autoplan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-default.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-default.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-default.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-autoplan.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-production.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-staging.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-production.txt.act delete mode 100644 server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-staging.txt.act create mode 100644 server/controllers/jobs_controller.go create mode 100644 server/controllers/websocket/mux.go create mode 100644 server/controllers/websocket/writer.go create mode 100644 server/events/mocks/mock_log_stream_url_generator.go create mode 100644 server/events/terraform/ansi/strip.go create mode 100644 server/handlers/mocks/matchers/chan_of_string.go create mode 100644 server/handlers/mocks/matchers/http_header.go create mode 100644 server/handlers/mocks/matchers/http_responsewriter.go create mode 100644 server/handlers/mocks/matchers/map_of_chan_of_string_to_bool.go create mode 100644 server/handlers/mocks/matchers/models_commandname.go create mode 100644 server/handlers/mocks/matchers/models_commitstatus.go create mode 100644 server/handlers/mocks/matchers/models_projectcommandcontext.go create mode 100644 server/handlers/mocks/matchers/ptr_to_http_request.go create mode 100644 server/handlers/mocks/matchers/slice_of_byte.go create mode 100644 server/handlers/mocks/matchers/slice_of_string.go create mode 100644 server/handlers/mocks/mock_project_command_output_handler.go create mode 100644 server/handlers/mocks/mock_project_job_url_generator.go create mode 100644 server/handlers/mocks/mock_project_status_updater.go create mode 100644 server/handlers/mocks/mock_resource_cleaner.go create mode 100644 server/handlers/project_command_output_handler.go create mode 100644 server/handlers/project_command_output_handler_test.go create mode 100644 server/handlers/websocket_handler.go diff --git a/go.mod b/go.mod index fb39557e2d..c67cda8439 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/googleapis/gax-go/v2 v2.1.1 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/mux v1.8.0 - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.4.2 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-getter v1.5.10 github.com/hashicorp/go-retryablehttp v0.6.8 // indirect diff --git a/runatlantis.io/docs/custom-workflows.md b/runatlantis.io/docs/custom-workflows.md index a8e34bcb5a..2253e221e2 100644 --- a/runatlantis.io/docs/custom-workflows.md +++ b/runatlantis.io/docs/custom-workflows.md @@ -128,19 +128,19 @@ workflows: myworkflow: plan: steps: - - run: terraform init -input=false -no-color + - run: terraform init -input=false # If you're using workspaces you need to select the workspace using the # $WORKSPACE environment variable. - - run: terraform workspace select -no-color $WORKSPACE + - run: terraform workspace select $WORKSPACE # You MUST output the plan using -out $PLANFILE because Atlantis expects # plans to be in a specific location. - - run: terraform plan -input=false -refresh -no-color -out $PLANFILE + - run: terraform plan -input=false -refresh -out $PLANFILE apply: steps: # Again, you must use the $PLANFILE environment variable. - - run: terraform apply -no-color $PLANFILE + - run: terraform apply $PLANFILE ``` ### Terragrunt @@ -176,14 +176,14 @@ workflows: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - - run: terragrunt plan -no-color -out=$PLANFILE - - run: terragrunt show -no-color -json $PLANFILE > $SHOWFILE + - run: terragrunt plan -out=$PLANFILE + - run: terragrunt show -json $PLANFILE > $SHOWFILE apply: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - - run: terragrunt apply -no-color $PLANFILE + - run: terragrunt apply $PLANFILE ``` If using the repo's `atlantis.yaml` file you would use the following config: @@ -201,13 +201,13 @@ workflows: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - - run: terragrunt plan -no-color -out $PLANFILE + - run: terragrunt plan -out $PLANFILE apply: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - - run: terragrunt apply -no-color $PLANFILE + - run: terragrunt apply $PLANFILE ``` **NOTE:** If using the repo's `atlantis.yaml` file, you will need to specify each directory that is a Terragrunt project. diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index 8c125bdd77..3496d86642 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -34,6 +34,7 @@ import ( "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/events/yaml" "github.com/runatlantis/atlantis/server/events/yaml/valid" + handlermocks "github.com/runatlantis/atlantis/server/handlers/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) @@ -816,6 +817,7 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl e2eStatusUpdater := &events.DefaultCommitStatusUpdater{Client: e2eVCSClient, TitleBuilder: vcs.StatusTitleBuilder{TitlePrefix: "atlantis"}} e2eGithubGetter := mocks.NewMockGithubPullGetter() e2eGitlabGetter := mocks.NewMockGitlabMergeRequestGetter() + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() // Real dependencies. logger := logging.NewNoopLogger(t) @@ -830,7 +832,7 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl GithubUser: "github-user", GitlabUser: "gitlab-user", } - terraformClient, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", &NoopTFDownloader{}, false) + terraformClient, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", &NoopTFDownloader{}, false, projectCmdOutputHandler) Ok(t, err) boltdb, err := db.New(dataDir) Ok(t, err) @@ -1057,10 +1059,12 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl TestingMode: true, CommandRunner: commandRunner, PullCleaner: &events.PullClosedExecutor{ - Locker: lockingClient, - VCSClient: e2eVCSClient, - WorkingDir: workingDir, - DB: boltdb, + Locker: lockingClient, + VCSClient: e2eVCSClient, + WorkingDir: workingDir, + DB: boltdb, + PullClosedTemplate: &events.PullClosedEventTemplate{}, + LogStreamResourceCleaner: projectCmdOutputHandler, }, Logger: logger, Parser: eventParser, diff --git a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt index 19bf762f1b..8c358550ec 100644 --- a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt +++ b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt @@ -6,5 +6,6 @@ null_resource.automerge[0]: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. + ``` diff --git a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt.act b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt.act deleted file mode 100644 index 19bf762f1b..0000000000 --- a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir1.txt.act +++ /dev/null @@ -1,10 +0,0 @@ -Ran Apply for dir: `dir1` workspace: `default` - -```diff -null_resource.automerge[0]: Creating... -null_resource.automerge[0]: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt index f486966159..c4f1a9ec09 100644 --- a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt +++ b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt @@ -6,5 +6,6 @@ null_resource.automerge[0]: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. + ``` diff --git a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt.act b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt.act deleted file mode 100644 index f486966159..0000000000 --- a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-apply-dir2.txt.act +++ /dev/null @@ -1,10 +0,0 @@ -Ran Apply for dir: `dir2` workspace: `default` - -```diff -null_resource.automerge[0]: Creating... -null_resource.automerge[0]: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt index 8aa62370f3..5a232aa80f 100644 --- a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt +++ b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt @@ -21,6 +21,7 @@ Terraform will perform the following actions: Plan: 1 to add, 0 to change, 0 to destroy. + ``` * :arrow_forward: To **apply** this plan, comment: @@ -50,6 +51,7 @@ Terraform will perform the following actions: Plan: 1 to add, 0 to change, 0 to destroy. + ``` * :arrow_forward: To **apply** this plan, comment: diff --git a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt.act b/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt.act deleted file mode 100644 index 8aa62370f3..0000000000 --- a/server/controllers/events/testfixtures/test-repos/automerge/exp-output-autoplan.txt.act +++ /dev/null @@ -1,67 +0,0 @@ -Ran Plan for 2 projects: - -1. dir: `dir1` workspace: `default` -1. dir: `dir2` workspace: `default` - -### 1. dir: `dir1` workspace: `default` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.automerge[0] will be created -+ resource "null_resource" "automerge" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d dir1` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d dir1` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. dir: `dir2` workspace: `default` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.automerge[0] will be created -+ resource "null_resource" "automerge" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d dir2` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d dir2` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-production.txt.act b/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-production.txt.act deleted file mode 100644 index 4885579d1e..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-production.txt.act +++ /dev/null @@ -1,14 +0,0 @@ -Ran Apply for dir: `production` workspace: `default` - -```diff -module.null.null_resource.this: Creating... -module.null.null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "production" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-staging.txt.act b/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-staging.txt.act deleted file mode 100644 index 44d7f37145..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-apply-staging.txt.act +++ /dev/null @@ -1,14 +0,0 @@ -Ran Apply for dir: `staging` workspace: `default` - -```diff -module.null.null_resource.this: Creating... -module.null.null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "staging" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-autoplan.txt.act b/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-autoplan.txt.act deleted file mode 100644 index 6b1c2e2433..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules-yaml/exp-output-autoplan.txt.act +++ /dev/null @@ -1,73 +0,0 @@ -Ran Plan for 2 projects: - -1. dir: `staging` workspace: `default` -1. dir: `production` workspace: `default` - -### 1. dir: `staging` workspace: `default` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # module.null.null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. dir: `production` workspace: `default` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # module.null.null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "production" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d production` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d production` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-production.txt.act b/server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-production.txt.act deleted file mode 100644 index 4885579d1e..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-production.txt.act +++ /dev/null @@ -1,14 +0,0 @@ -Ran Apply for dir: `production` workspace: `default` - -```diff -module.null.null_resource.this: Creating... -module.null.null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "production" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-staging.txt.act b/server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-staging.txt.act deleted file mode 100644 index 44d7f37145..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules/exp-output-apply-staging.txt.act +++ /dev/null @@ -1,14 +0,0 @@ -Ran Apply for dir: `staging` workspace: `default` - -```diff -module.null.null_resource.this: Creating... -module.null.null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "staging" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/modules/exp-output-autoplan-only-staging.txt.act b/server/controllers/events/testfixtures/test-repos/modules/exp-output-autoplan-only-staging.txt.act deleted file mode 100644 index 50f8aca13c..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules/exp-output-autoplan-only-staging.txt.act +++ /dev/null @@ -1,37 +0,0 @@ -Ran Plan for dir: `staging` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # module.null.null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-production.txt.act b/server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-production.txt.act deleted file mode 100644 index e238d50a49..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-production.txt.act +++ /dev/null @@ -1,37 +0,0 @@ -Ran Plan for dir: `production` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # module.null.null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "production" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d production` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d production` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-staging.txt.act b/server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-staging.txt.act deleted file mode 100644 index 50f8aca13c..0000000000 --- a/server/controllers/events/testfixtures/test-repos/modules/exp-output-plan-staging.txt.act +++ /dev/null @@ -1,37 +0,0 @@ -Ran Plan for dir: `staging` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # module.null.null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-default-workspace.txt.act b/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-default-workspace.txt.act deleted file mode 100644 index 336a849553..0000000000 --- a/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-default-workspace.txt.act +++ /dev/null @@ -1,14 +0,0 @@ -Ran Apply for dir: `.` workspace: `default` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -workspace = "default" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-staging-workspace.txt.act b/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-staging-workspace.txt.act deleted file mode 100644 index b36f8209c8..0000000000 --- a/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-apply-staging-workspace.txt.act +++ /dev/null @@ -1,14 +0,0 @@ -Ran Apply for dir: `.` workspace: `staging` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -workspace = "staging" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-autoplan.txt.act b/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-autoplan.txt.act deleted file mode 100644 index 25eddec36e..0000000000 --- a/server/controllers/events/testfixtures/test-repos/server-side-cfg/exp-output-autoplan.txt.act +++ /dev/null @@ -1,79 +0,0 @@ -Ran Plan for 2 projects: - -1. dir: `.` workspace: `default` -1. dir: `.` workspace: `staging` - -### 1. dir: `.` workspace: `default` -
Show Output - -```diff -preinit custom - - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ workspace = "default" - -postplan custom - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d .` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d .` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. dir: `.` workspace: `staging` -
Show Output - -```diff -preinit staging - - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ workspace = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -w staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -w staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-all.txt.act b/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-all.txt.act deleted file mode 100644 index 04b926ff5e..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-all.txt.act +++ /dev/null @@ -1,43 +0,0 @@ -Ran Apply for 2 projects: - -1. dir: `.` workspace: `default` -1. dir: `.` workspace: `staging` - -### 1. dir: `.` workspace: `default` -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "fromconfig" -workspace = "default" - -``` - ---- -### 2. dir: `.` workspace: `staging` -
Show Output - -```diff -preapply - -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "fromfile" -workspace = "staging" - -postapply - -``` -
- ---- - diff --git a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-default.txt.act b/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-default.txt.act deleted file mode 100644 index 5e3d22778b..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-default.txt.act +++ /dev/null @@ -1,15 +0,0 @@ -Ran Apply for dir: `.` workspace: `default` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "fromconfig" -workspace = "default" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-staging.txt.act b/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-staging.txt.act deleted file mode 100644 index 88f2698f0b..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-apply-staging.txt.act +++ /dev/null @@ -1,22 +0,0 @@ -Ran Apply for dir: `.` workspace: `staging` - -
Show Output - -```diff -preapply - -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "fromfile" -workspace = "staging" - -postapply - -``` -
- diff --git a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-autoplan.txt.act b/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-autoplan.txt.act deleted file mode 100644 index 5145516ef5..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple-yaml/exp-output-autoplan.txt.act +++ /dev/null @@ -1,79 +0,0 @@ -Ran Plan for 2 projects: - -1. dir: `.` workspace: `default` -1. dir: `.` workspace: `staging` - -### 1. dir: `.` workspace: `default` -
Show Output - -```diff -preinit - - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "fromconfig" -+ workspace = "default" - -postplan - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d .` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d .` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. dir: `.` workspace: `staging` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "fromfile" -+ workspace = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -w staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -w staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-all.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-all.txt.act deleted file mode 100644 index 11f65032da..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-all.txt.act +++ /dev/null @@ -1,50 +0,0 @@ -Ran Apply for 2 projects: - -1. dir: `.` workspace: `default` -1. dir: `.` workspace: `new_workspace` - -### 1. dir: `.` workspace: `default` -
Show Output - -```diff -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - -Outputs: - -var = "default_workspace" -workspace = "default" - -``` -
- ---- -### 2. dir: `.` workspace: `new_workspace` -
Show Output - -```diff -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - -Outputs: - -var = "new_workspace" -workspace = "new_workspace" - -``` -
- ---- - diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-default-workspace.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-default-workspace.txt.act deleted file mode 100644 index cfa21dde33..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-default-workspace.txt.act +++ /dev/null @@ -1,22 +0,0 @@ -Ran Apply for dir: `.` workspace: `default` - -
Show Output - -```diff -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - -Outputs: - -var = "default_workspace" -workspace = "default" - -``` -
- diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-new-workspace.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-new-workspace.txt.act deleted file mode 100644 index 8c1a0bac5e..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var-new-workspace.txt.act +++ /dev/null @@ -1,22 +0,0 @@ -Ran Apply for dir: `.` workspace: `new_workspace` - -
Show Output - -```diff -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - -Outputs: - -var = "new_workspace" -workspace = "new_workspace" - -``` -
- diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var.txt.act deleted file mode 100644 index 59aff5f18c..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply-var.txt.act +++ /dev/null @@ -1,22 +0,0 @@ -Ran Apply for dir: `.` workspace: `default` - -
Show Output - -```diff -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - -Outputs: - -var = "overridden" -workspace = "default" - -``` -
- diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply.txt.act deleted file mode 100644 index 98ffd366e1..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-apply.txt.act +++ /dev/null @@ -1,22 +0,0 @@ -Ran Apply for dir: `.` workspace: `default` - -
Show Output - -```diff -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 3 added, 0 changed, 0 destroyed. - -Outputs: - -var = "default" -workspace = "default" - -``` -
- diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-new-workspace.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-new-workspace.txt.act deleted file mode 100644 index b725eb1bf6..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-new-workspace.txt.act +++ /dev/null @@ -1,48 +0,0 @@ -Ran Plan for dir: `.` workspace: `new_workspace` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - - # null_resource.simple2 will be created -+ resource "null_resource" "simple2" { - + id = (known after apply) - } - - # null_resource.simple3 will be created -+ resource "null_resource" "simple3" { - + id = (known after apply) - } - -Plan: 3 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "new_workspace" -+ workspace = "new_workspace" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -w new_workspace` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -w new_workspace -- -var var=new_workspace` -
-Plan: 3 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-var-overridden.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-var-overridden.txt.act deleted file mode 100644 index bc608ceb14..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan-var-overridden.txt.act +++ /dev/null @@ -1,48 +0,0 @@ -Ran Plan for dir: `.` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - - # null_resource.simple2 will be created -+ resource "null_resource" "simple2" { - + id = (known after apply) - } - - # null_resource.simple3 will be created -+ resource "null_resource" "simple3" { - + id = (known after apply) - } - -Plan: 3 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "overridden" -+ workspace = "default" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d .` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d . -- -var var=overridden` -
-Plan: 3 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan.txt.act deleted file mode 100644 index c56cd47e14..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-atlantis-plan.txt.act +++ /dev/null @@ -1,48 +0,0 @@ -Ran Plan for dir: `.` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - - # null_resource.simple2 will be created -+ resource "null_resource" "simple2" { - + id = (known after apply) - } - - # null_resource.simple3 will be created -+ resource "null_resource" "simple3" { - + id = (known after apply) - } - -Plan: 3 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "default_workspace" -+ workspace = "default" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d .` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d . -- -var var=default_workspace` -
-Plan: 3 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-autoplan.txt.act b/server/controllers/events/testfixtures/test-repos/simple/exp-output-autoplan.txt.act deleted file mode 100644 index b301024b0c..0000000000 --- a/server/controllers/events/testfixtures/test-repos/simple/exp-output-autoplan.txt.act +++ /dev/null @@ -1,48 +0,0 @@ -Ran Plan for dir: `.` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - - # null_resource.simple2 will be created -+ resource "null_resource" "simple2" { - + id = (known after apply) - } - - # null_resource.simple3 will be created -+ resource "null_resource" "simple3" { - + id = (known after apply) - } - -Plan: 3 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "default" -+ workspace = "default" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d .` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d .` -
-Plan: 3 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-default.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-default.txt.act deleted file mode 100644 index ccc0bfe017..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-default.txt.act +++ /dev/null @@ -1,15 +0,0 @@ -Ran Apply for project: `default` dir: `.` workspace: `default` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "default" -workspace = "default" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-staging.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-staging.txt.act deleted file mode 100644 index 6d217cc7fd..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-staging.txt.act +++ /dev/null @@ -1,15 +0,0 @@ -Ran Apply for project: `staging` dir: `.` workspace: `default` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "staging" -workspace = "default" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-default.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-default.txt.act deleted file mode 100644 index c97767f650..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-default.txt.act +++ /dev/null @@ -1,38 +0,0 @@ -Ran Plan for project: `default` dir: `.` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "default" -+ workspace = "default" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -p default` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -p default` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-staging.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-staging.txt.act deleted file mode 100644 index 1c367d94bc..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-staging.txt.act +++ /dev/null @@ -1,38 +0,0 @@ -Ran Plan for project: `staging` dir: `.` workspace: `default` - -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "staging" -+ workspace = "default" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -p staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -p staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-default.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-default.txt.act deleted file mode 100644 index ccc0bfe017..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-default.txt.act +++ /dev/null @@ -1,15 +0,0 @@ -Ran Apply for project: `default` dir: `.` workspace: `default` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "default" -workspace = "default" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-staging.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-staging.txt.act deleted file mode 100644 index 6d217cc7fd..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-apply-staging.txt.act +++ /dev/null @@ -1,15 +0,0 @@ -Ran Apply for project: `staging` dir: `.` workspace: `default` - -```diff -null_resource.simple: -null_resource.simple: - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -var = "staging" -workspace = "default" - -``` - diff --git a/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-autoplan.txt.act b/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-autoplan.txt.act deleted file mode 100644 index 73619713ad..0000000000 --- a/server/controllers/events/testfixtures/test-repos/tfvars-yaml/exp-output-autoplan.txt.act +++ /dev/null @@ -1,77 +0,0 @@ -Ran Plan for 2 projects: - -1. project: `default` dir: `.` workspace: `default` -1. project: `staging` dir: `.` workspace: `default` - -### 1. project: `default` dir: `.` workspace: `default` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "default" -+ workspace = "default" - -workspace=default - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -p default` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -p default` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. project: `staging` dir: `.` workspace: `default` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.simple[0] will be created -+ resource "null_resource" "simple" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ var = "staging" -+ workspace = "default" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -p staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -p staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-production.txt.act b/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-production.txt.act deleted file mode 100644 index b82518ed6b..0000000000 --- a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-production.txt.act +++ /dev/null @@ -1,34 +0,0 @@ -Ran Apply for 2 projects: - -1. dir: `production` workspace: `production` -1. dir: `staging` workspace: `staging` - -### 1. dir: `production` workspace: `production` -```diff -null_resource.this: Creating... -null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -workspace = "production" - -``` - ---- -### 2. dir: `staging` workspace: `staging` -```diff -null_resource.this: Creating... -null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -workspace = "staging" - -``` - ---- - diff --git a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-staging.txt.act b/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-staging.txt.act deleted file mode 100644 index b82518ed6b..0000000000 --- a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-apply-all-staging.txt.act +++ /dev/null @@ -1,34 +0,0 @@ -Ran Apply for 2 projects: - -1. dir: `production` workspace: `production` -1. dir: `staging` workspace: `staging` - -### 1. dir: `production` workspace: `production` -```diff -null_resource.this: Creating... -null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -workspace = "production" - -``` - ---- -### 2. dir: `staging` workspace: `staging` -```diff -null_resource.this: Creating... -null_resource.this: Creation complete after *s [id=*******************] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed. - -Outputs: - -workspace = "staging" - -``` - ---- - diff --git a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-production.txt.act b/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-production.txt.act deleted file mode 100644 index a8f4b695a0..0000000000 --- a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-production.txt.act +++ /dev/null @@ -1,73 +0,0 @@ -Ran Plan for 2 projects: - -1. dir: `production` workspace: `production` -1. dir: `staging` workspace: `staging` - -### 1. dir: `production` workspace: `production` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ workspace = "production" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d production -w production` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d production -w production` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. dir: `staging` workspace: `staging` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ workspace = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d staging -w staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d staging -w staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-staging.txt.act b/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-staging.txt.act deleted file mode 100644 index a8f4b695a0..0000000000 --- a/server/controllers/events/testfixtures/test-repos/workspace-parallel-yaml/exp-output-autoplan-staging.txt.act +++ /dev/null @@ -1,73 +0,0 @@ -Ran Plan for 2 projects: - -1. dir: `production` workspace: `production` -1. dir: `staging` workspace: `staging` - -### 1. dir: `production` workspace: `production` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ workspace = "production" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d production -w production` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d production -w production` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -### 2. dir: `staging` workspace: `staging` -
Show Output - -```diff - -Terraform used the selected providers to generate the following execution -plan. Resource actions are indicated with the following symbols: -+ create - -Terraform will perform the following actions: - - # null_resource.this will be created -+ resource "null_resource" "this" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Changes to Outputs: -+ workspace = "staging" - -``` - -* :arrow_forward: To **apply** this plan, comment: - * `atlantis apply -d staging -w staging` -* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) -* :repeat: To **plan** this project again, comment: - * `atlantis plan -d staging -w staging` -
-Plan: 1 to add, 0 to change, 0 to destroy. - ---- -* :fast_forward: To **apply** all unapplied plans from this pull request, comment: - * `atlantis apply` -* :put_litter_in_its_place: To delete all plans and locks for the PR, comment: - * `atlantis unlock` diff --git a/server/controllers/jobs_controller.go b/server/controllers/jobs_controller.go new file mode 100644 index 0000000000..872176c66d --- /dev/null +++ b/server/controllers/jobs_controller.go @@ -0,0 +1,144 @@ +package controllers + +import ( + "fmt" + "net/http" + "net/url" + + "strconv" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/controllers/templates" + "github.com/runatlantis/atlantis/server/controllers/websocket" + "github.com/runatlantis/atlantis/server/core/db" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" +) + +type JobsController struct { + AtlantisVersion string + AtlantisURL *url.URL + Logger logging.SimpleLogging + ProjectJobsTemplate templates.TemplateWriter + ProjectJobsErrorTemplate templates.TemplateWriter + Db *db.BoltDB + WsMux *websocket.Multiplexor +} + +type ProjectInfoKeyGenerator struct{} + +func (g ProjectInfoKeyGenerator) Generate(r *http.Request) (string, error) { + projectInfo, err := newProjectInfo(r) + + if err != nil { + return "", errors.Wrap(err, "creating project info") + } + + return projectInfo.String(), nil +} + +type pullInfo struct { + org string + repo string + pull int +} + +func (p *pullInfo) String() string { + return fmt.Sprintf("%s/%s/%d", p.org, p.repo, p.pull) +} + +type projectInfo struct { + projectName string + workspace string + pullInfo +} + +func (p *projectInfo) String() string { + return fmt.Sprintf("%s/%s/%d/%s/%s", p.org, p.repo, p.pull, p.projectName, p.workspace) +} + +func newPullInfo(r *http.Request) (*pullInfo, error) { + org, ok := mux.Vars(r)["org"] + if !ok { + return nil, fmt.Errorf("Internal error: no org in route") + } + repo, ok := mux.Vars(r)["repo"] + if !ok { + return nil, fmt.Errorf("Internal error: no repo in route") + } + pull, ok := mux.Vars(r)["pull"] + if !ok { + return nil, fmt.Errorf("Internal error: no pull in route") + } + pullNum, err := strconv.Atoi(pull) + if err != nil { + return nil, err + } + + return &pullInfo{ + org: org, + repo: repo, + pull: pullNum, + }, nil +} + +// Gets the PR information from the HTTP request params +func newProjectInfo(r *http.Request) (*projectInfo, error) { + pullInfo, err := newPullInfo(r) + if err != nil { + return nil, err + } + + project, ok := mux.Vars(r)["project"] + if !ok { + return nil, fmt.Errorf("Internal error: no project in route") + } + + workspace, ok := mux.Vars(r)["workspace"] + if !ok { + return nil, fmt.Errorf("Internal error: no workspace in route") + } + + return &projectInfo{ + pullInfo: *pullInfo, + projectName: project, + workspace: workspace, + }, nil +} + +func (j *JobsController) GetProjectJobs(w http.ResponseWriter, r *http.Request) { + projectInfo, err := newProjectInfo(r) + if err != nil { + j.respond(w, logging.Error, http.StatusInternalServerError, err.Error()) + return + } + + viewData := templates.ProjectJobData{ + AtlantisVersion: j.AtlantisVersion, + ProjectPath: projectInfo.String(), + CleanedBasePath: j.AtlantisURL.Path, + ClearMsg: models.LogStreamingClearMsg, + } + + err = j.ProjectJobsTemplate.Execute(w, viewData) + if err != nil { + j.Logger.Err(err.Error()) + } +} + +func (j *JobsController) GetProjectJobsWS(w http.ResponseWriter, r *http.Request) { + err := j.WsMux.Handle(w, r) + + if err != nil { + j.respond(w, logging.Error, http.StatusInternalServerError, err.Error()) + return + } +} + +func (j *JobsController) respond(w http.ResponseWriter, lvl logging.LogLevel, responseCode int, format string, args ...interface{}) { + response := fmt.Sprintf(format, args...) + j.Logger.Log(lvl, response) + w.WriteHeader(responseCode) + fmt.Fprintln(w, response) +} diff --git a/server/controllers/templates/web_templates.go b/server/controllers/templates/web_templates.go index 4c120d4558..db1cd5cd96 100644 --- a/server/controllers/templates/web_templates.go +++ b/server/controllers/templates/web_templates.go @@ -352,6 +352,168 @@ v{{ .AtlantisVersion }} `)) +// ProjectJobData holds the data needed to stream the current PR information +type ProjectJobData struct { + AtlantisVersion string + ProjectPath string + CleanedBasePath string + ClearMsg string +} + +var ProjectJobsTemplate = template.Must(template.New("blank.html.tmpl").Parse(` + + + + + atlantis + + + + + + + + + + + +
+ +

atlantis

+

+
+
+
+
+
+
+ + + + + + + + + + + +`)) + +type ProjectJobsError struct { + AtlantisVersion string + ProjectPath string + CleanedBasePath string +} + +var ProjectJobsErrorTemplate = template.Must(template.New("blank.html.tmpl").Parse(` + + + + + atlantis + + + + + + + + + + + +
+
+ +

atlantis

+

+
+
+
+
+
+
+
+ + + + + + + + + + +`)) + // GithubSetupData holds the data for rendering the github app setup page type GithubSetupData struct { Target string diff --git a/server/controllers/websocket/mux.go b/server/controllers/websocket/mux.go new file mode 100644 index 0000000000..ccfbdf99f9 --- /dev/null +++ b/server/controllers/websocket/mux.go @@ -0,0 +1,63 @@ +package websocket + +import ( + "net/http" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/logging" +) + +// PartitionKeyGenerator generates partition keys for the multiplexor +type PartitionKeyGenerator interface { + Generate(r *http.Request) (string, error) +} + +// PartitionRegistry is the registry holding each partition +// and is responsible for registering/deregistering new buffers +type PartitionRegistry interface { + Register(key string, buffer chan string) + Deregister(key string, buffer chan string) +} + +// Multiplexor is responsible for handling the data transfer between the storage layer +// and the registry. Note this is still a WIP as right now the registry is assumed to handle +// everything. +type Multiplexor struct { + writer *Writer + keyGenerator PartitionKeyGenerator + registry PartitionRegistry +} + +func NewMultiplexor(log logging.SimpleLogging, keyGenerator PartitionKeyGenerator, registry PartitionRegistry) *Multiplexor { + upgrader := websocket.Upgrader{} + upgrader.CheckOrigin = func(r *http.Request) bool { return true } + return &Multiplexor{ + writer: &Writer{ + upgrader: upgrader, + log: log, + }, + keyGenerator: keyGenerator, + registry: registry, + } +} + +// Handle should be called for a given websocket request. It blocks +// while writing to the websocket until the buffer is closed. +func (m *Multiplexor) Handle(w http.ResponseWriter, r *http.Request) error { + key, err := m.keyGenerator.Generate(r) + + if err != nil { + return errors.Wrapf(err, "generating partition key") + } + + // Buffer size set to 1000 to ensure messages get queued. + // TODO: make buffer size configurable + buffer := make(chan string, 1000) + + // spinning up a goroutine for this since we are attempting to block on the read side. + go m.registry.Register(key, buffer) + defer m.registry.Deregister(key, buffer) + + return errors.Wrapf(m.writer.Write(w, r, buffer), "writing to ws %s", key) +} diff --git a/server/controllers/websocket/writer.go b/server/controllers/websocket/writer.go new file mode 100644 index 0000000000..1e19e50376 --- /dev/null +++ b/server/controllers/websocket/writer.go @@ -0,0 +1,68 @@ +package websocket + +import ( + "net/http" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/logging" +) + +func NewWriter(log logging.SimpleLogging) *Writer { + upgrader := websocket.Upgrader{} + upgrader.CheckOrigin = func(r *http.Request) bool { return true } + return &Writer{ + upgrader: upgrader, + log: log, + } +} + +type Writer struct { + upgrader websocket.Upgrader + log logging.SimpleLogging +} + +func (w *Writer) Write(rw http.ResponseWriter, r *http.Request, input chan string) error { + conn, err := w.upgrader.Upgrade(rw, r, nil) + + if err != nil { + return errors.Wrap(err, "upgrading websocket connection") + } + + conn.SetCloseHandler(func(code int, text string) error { + // Close the channnel after websocket connection closed. + // Will gracefully exit the ProjectCommandOutputHandler.Register() call and cleanup. + // is it good practice to close at the receiver? Probably not, we should figure out a better + // way to handle this case + close(input) + return nil + }) + + // Add a reader goroutine to listen for socket.close() events. + go w.setReadHandler(conn) + + // block on reading our input channel + for msg := range input { + if err := conn.WriteMessage(websocket.BinaryMessage, []byte("\r"+msg+"\n")); err != nil { + w.log.Warn("Failed to write ws message: %s", err) + return err + } + } + + return nil +} + +func (w *Writer) setReadHandler(c *websocket.Conn) { + for { + _, _, err := c.ReadMessage() + if err != nil { + // CloseGoingAway (1001) when a browser tab is closed. + // Expected behaviour since we have a CloseHandler(), log warning if not a CloseGoingAway + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { + w.log.Warn("Failed to read WS message: %s", err) + } + return + } + } + +} diff --git a/server/core/runtime/apply_step_runner.go b/server/core/runtime/apply_step_runner.go index 0f9d5458d8..9fe422da15 100644 --- a/server/core/runtime/apply_step_runner.go +++ b/server/core/runtime/apply_step_runner.go @@ -22,11 +22,6 @@ type ApplyStepRunner struct { } func (a *ApplyStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []string, path string, envs map[string]string) (string, error) { - tfVersion := a.DefaultTFVersion - if ctx.TerraformVersion != nil { - tfVersion = ctx.TerraformVersion - } - if a.hasTargetFlag(ctx, extraArgs) { return "", errors.New("cannot run apply with -target because we are applying an already generated plan. Instead, run -target with atlantis plan") } @@ -45,16 +40,16 @@ func (a *ApplyStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []stri // TODO: Leverage PlanTypeStepRunnerDelegate here if IsRemotePlan(contents) { - args := append(append([]string{"apply", "-input=false", "-no-color"}, extraArgs...), ctx.EscapedCommentArgs...) - out, err = a.runRemoteApply(ctx, args, path, planPath, tfVersion, envs) + args := append(append([]string{"apply", "-input=false"}, extraArgs...), ctx.EscapedCommentArgs...) + out, err = a.runRemoteApply(ctx, args, path, planPath, ctx.TerraformVersion, envs) if err == nil { out = a.cleanRemoteApplyOutput(out) } } else { // NOTE: we need to quote the plan path because Bitbucket Server can // have spaces in its repo owner names which is part of the path. - args := append(append(append([]string{"apply", "-input=false", "-no-color"}, extraArgs...), ctx.EscapedCommentArgs...), fmt.Sprintf("%q", planPath)) - out, err = a.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, args, envs, tfVersion, ctx.Workspace) + args := append(append(append([]string{"apply", "-input=false"}, extraArgs...), ctx.EscapedCommentArgs...), fmt.Sprintf("%q", planPath)) + out, err = a.TerraformExecutor.RunCommandWithVersion(ctx, path, args, envs, ctx.TerraformVersion, ctx.Workspace) } // If the apply was successful, delete the plan. @@ -137,7 +132,7 @@ func (a *ApplyStepRunner) runRemoteApply( // Start the async command execution. ctx.Log.Debug("starting async tf remote operation") - inCh, outCh := a.AsyncTFExec.RunCommandAsync(ctx.Log, filepath.Clean(path), applyArgs, envs, tfVersion, ctx.Workspace) + inCh, outCh := a.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), applyArgs, envs, tfVersion, ctx.Workspace) var lines []string nextLineIsRunURL := false var runURL string diff --git a/server/core/runtime/apply_step_runner_test.go b/server/core/runtime/apply_step_runner_test.go index 7010f79d6a..f8df7187ee 100644 --- a/server/core/runtime/apply_step_runner_test.go +++ b/server/core/runtime/apply_step_runner_test.go @@ -1,8 +1,8 @@ package runtime_test import ( - "errors" "fmt" + "io/ioutil" "os" "path/filepath" "strings" @@ -11,6 +11,7 @@ import ( version "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock" + "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" @@ -19,7 +20,7 @@ import ( "github.com/runatlantis/atlantis/server/events/mocks/matchers" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" - logging_matchers "github.com/runatlantis/atlantis/server/logging/mocks/matchers" + . "github.com/runatlantis/atlantis/testing" ) @@ -51,7 +52,14 @@ func TestRun_Success(t *testing.T) { tmpDir, cleanup := TempDir(t) defer cleanup() planPath := filepath.Join(tmpDir, "workspace.tfplan") - err := os.WriteFile(planPath, nil, 0600) + err := ioutil.WriteFile(planPath, nil, 0600) + logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "workspace", + RepoRelDir: ".", + EscapedCommentArgs: []string{"comment", "args"}, + } Ok(t, err) RegisterMockTestingT(t) @@ -59,19 +67,13 @@ func TestRun_Success(t *testing.T) { o := runtime.ApplyStepRunner{ TerraformExecutor: terraform, } - logger := logging.NewNoopLogger(t) - When(terraform.RunCommandWithVersion(matchers.AnyPtrToLoggingSimpleLogger(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := o.Run(models.ProjectCommandContext{ - Log: logger, - Workspace: "workspace", - RepoRelDir: ".", - EscapedCommentArgs: []string{"comment", "args"}, - }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, []string{"apply", "-input=false", "-no-color", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), nil, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), nil, "workspace") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -81,7 +83,16 @@ func TestRun_AppliesCorrectProjectPlan(t *testing.T) { tmpDir, cleanup := TempDir(t) defer cleanup() planPath := filepath.Join(tmpDir, "projectname-default.tfplan") - err := os.WriteFile(planPath, nil, 0600) + err := ioutil.WriteFile(planPath, nil, 0600) + + logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + ProjectName: "projectname", + EscapedCommentArgs: []string{"comment", "args"}, + } Ok(t, err) RegisterMockTestingT(t) @@ -89,20 +100,13 @@ func TestRun_AppliesCorrectProjectPlan(t *testing.T) { o := runtime.ApplyStepRunner{ TerraformExecutor: terraform, } - logger := logging.NewNoopLogger(t) - When(terraform.RunCommandWithVersion(matchers.AnyPtrToLoggingSimpleLogger(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := o.Run(models.ProjectCommandContext{ - Log: logger, - Workspace: "default", - RepoRelDir: ".", - ProjectName: "projectname", - EscapedCommentArgs: []string{"comment", "args"}, - }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, []string{"apply", "-input=false", "-no-color", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), nil, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), nil, "default") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -114,26 +118,28 @@ func TestRun_UsesConfiguredTFVersion(t *testing.T) { err := os.WriteFile(planPath, nil, 0600) Ok(t, err) - RegisterMockTestingT(t) - terraform := mocks.NewMockClient() - o := runtime.ApplyStepRunner{ - TerraformExecutor: terraform, - } logger := logging.NewNoopLogger(t) tfVersion, _ := version.NewVersion("0.11.0") - - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). - ThenReturn("output", nil) - output, err := o.Run(models.ProjectCommandContext{ + ctx := models.ProjectCommandContext{ Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, TerraformVersion: tfVersion, Log: logger, - }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + } + + RegisterMockTestingT(t) + terraform := mocks.NewMockClient() + o := runtime.ApplyStepRunner{ + TerraformExecutor: terraform, + } + + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + ThenReturn("output", nil) + output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, []string{"apply", "-input=false", "-no-color", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, []string{"apply", "-input=false", "extra", "args", "comment", "args", fmt.Sprintf("%q", planPath)}, map[string]string(nil), tfVersion, "workspace") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } @@ -267,7 +273,7 @@ null_resource.dir2[1]: Destruction complete after 0s Apply complete! Resources: 0 added, 0 changed, 1 destroyed. `, output) - Equals(t, []string{"apply", "-input=false", "-no-color", "extra", "args", "comment", "args"}, tfExec.CalledArgs) + Equals(t, []string{"apply", "-input=false", "extra", "args", "comment", "args"}, tfExec.CalledArgs) _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") @@ -364,7 +370,7 @@ type remoteApplyMock struct { } // RunCommandAsync fakes out running terraform async. -func (r *remoteApplyMock) RunCommandAsync(log logging.SimpleLogging, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan terraform.Line) { +func (r *remoteApplyMock) RunCommandAsync(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan terraform.Line) { r.CalledArgs = args in := make(chan string) diff --git a/server/core/runtime/init_step_runner.go b/server/core/runtime/init_step_runner.go index c1f5438e66..55092b2451 100644 --- a/server/core/runtime/init_step_runner.go +++ b/server/core/runtime/init_step_runner.go @@ -49,8 +49,6 @@ func (i *InitStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []strin terraformInitArgs = []string{} } - terraformInitArgs = append(terraformInitArgs, "-no-color") - if MustConstraint("< 0.14.0").Check(tfVersion) || !common.FileExists(terraformLockfilePath) { terraformInitArgs = append(terraformInitArgs, "-upgrade") } @@ -59,7 +57,7 @@ func (i *InitStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []strin terraformInitCmd := append(terraformInitVerb, finalArgs...) - out, err := i.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, terraformInitCmd, envs, tfVersion, ctx.Workspace) + out, err := i.TerraformExecutor.RunCommandWithVersion(ctx, path, terraformInitCmd, envs, tfVersion, ctx.Workspace) // Only include the init output if there was an error. Otherwise it's // unnecessary and lengthens the comment. if err != nil { diff --git a/server/core/runtime/init_step_runner_test.go b/server/core/runtime/init_step_runner_test.go index 9c839e0e4f..a47c5da094 100644 --- a/server/core/runtime/init_step_runner_test.go +++ b/server/core/runtime/init_step_runner_test.go @@ -14,9 +14,9 @@ import ( "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/core/terraform/mocks" matchers2 "github.com/runatlantis/atlantis/server/core/terraform/mocks/matchers" + "github.com/runatlantis/atlantis/server/events/mocks/matchers" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" - logging_matchers "github.com/runatlantis/atlantis/server/logging/mocks/matchers" . "github.com/runatlantis/atlantis/testing" ) @@ -49,30 +49,31 @@ func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { terraform := mocks.NewMockClient() logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Workspace: "workspace", + RepoRelDir: ".", + Log: logger, + } tfVersion, _ := version.NewVersion(c.version) iso := runtime.InitStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := iso.Run(models.ProjectCommandContext{ - Workspace: "workspace", - RepoRelDir: ".", - Log: logger, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + output, err := iso.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) // If using init then we specify -input=false but not for get. - expArgs := []string{c.expCmd, "-input=false", "-no-color", "-upgrade", "extra", "args"} + expArgs := []string{c.expCmd, "-input=false", "-upgrade", "extra", "args"} if c.expCmd == "get" { - expArgs = []string{c.expCmd, "-no-color", "-upgrade", "extra", "args"} + expArgs = []string{c.expCmd, "-upgrade", "extra", "args"} } - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expArgs, map[string]string(nil), tfVersion, "workspace") }) } } @@ -82,7 +83,7 @@ func TestRun_ShowInitOutputOnError(t *testing.T) { RegisterMockTestingT(t) tfClient := mocks.NewMockClient() logger := logging.NewNoopLogger(t) - When(tfClient.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(tfClient.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", errors.New("error")) tfVersion, _ := version.NewVersion("0.11.0") @@ -112,30 +113,31 @@ func TestRun_InitOmitsUpgradeFlagIfLockFileTracked(t *testing.T) { runCmd(t, repoDir, "git", "add", ".terraform.lock.hcl") runCmd(t, repoDir, "git", "commit", "-m", "add .terraform.lock.hcl") + logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Workspace: "workspace", + RepoRelDir: ".", + Log: logger, + } + RegisterMockTestingT(t) terraform := mocks.NewMockClient() - logger := logging.NewNoopLogger(t) - tfVersion, _ := version.NewVersion("0.14.0") iso := runtime.InitStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := iso.Run(models.ProjectCommandContext{ - Workspace: "workspace", - RepoRelDir: ".", - Log: logger, - }, []string{"extra", "args"}, repoDir, map[string]string(nil)) + output, err := iso.Run(ctx, []string{"extra", "args"}, repoDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) - expectedArgs := []string{"init", "-input=false", "-no-color", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, repoDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + expectedArgs := []string{"init", "-input=false", "extra", "args"} + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, repoDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") } func TestRun_InitKeepsUpgradeFlagIfLockFileNotPresent(t *testing.T) { @@ -144,28 +146,28 @@ func TestRun_InitKeepsUpgradeFlagIfLockFileNotPresent(t *testing.T) { RegisterMockTestingT(t) terraform := mocks.NewMockClient() - logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Workspace: "workspace", + RepoRelDir: ".", + Log: logger, + } tfVersion, _ := version.NewVersion("0.14.0") iso := runtime.InitStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := iso.Run(models.ProjectCommandContext{ - Workspace: "workspace", - RepoRelDir: ".", - Log: logger, - }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + output, err := iso.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) - expectedArgs := []string{"init", "-input=false", "-no-color", "-upgrade", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") } func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing.T) { @@ -179,26 +181,27 @@ func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing terraform := mocks.NewMockClient() logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Workspace: "workspace", + RepoRelDir: ".", + Log: logger, + } tfVersion, _ := version.NewVersion("0.13.0") iso := runtime.InitStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := iso.Run(models.ProjectCommandContext{ - Workspace: "workspace", - RepoRelDir: ".", - Log: logger, - }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + output, err := iso.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) - expectedArgs := []string{"init", "-input=false", "-no-color", "-upgrade", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") } func TestRun_InitExtraArgsDeDupe(t *testing.T) { @@ -211,32 +214,32 @@ func TestRun_InitExtraArgsDeDupe(t *testing.T) { { "No extra args", []string{}, - []string{"init", "-input=false", "-no-color", "-upgrade"}, + []string{"init", "-input=false", "-upgrade"}, }, { "Override -upgrade", []string{"-upgrade=false"}, - []string{"init", "-input=false", "-no-color", "-upgrade=false"}, + []string{"init", "-input=false", "-upgrade=false"}, }, { "Override -input", []string{"-input=true"}, - []string{"init", "-input=true", "-no-color", "-upgrade"}, + []string{"init", "-input=true", "-upgrade"}, }, { "Override -input and -upgrade", []string{"-input=true", "-upgrade=false"}, - []string{"init", "-input=true", "-no-color", "-upgrade=false"}, + []string{"init", "-input=true", "-upgrade=false"}, }, { "Non duplicate extra args", []string{"extra", "args"}, - []string{"init", "-input=false", "-no-color", "-upgrade", "extra", "args"}, + []string{"init", "-input=false", "-upgrade", "extra", "args"}, }, { "Override upgrade with extra args", []string{"extra", "args", "-upgrade=false"}, - []string{"init", "-input=false", "-no-color", "-upgrade=false", "extra", "args"}, + []string{"init", "-input=false", "-upgrade=false", "extra", "args"}, }, } @@ -245,25 +248,26 @@ func TestRun_InitExtraArgsDeDupe(t *testing.T) { terraform := mocks.NewMockClient() logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Workspace: "workspace", + RepoRelDir: ".", + Log: logger, + } tfVersion, _ := version.NewVersion("0.10.0") iso := runtime.InitStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := iso.Run(models.ProjectCommandContext{ - Workspace: "workspace", - RepoRelDir: ".", - Log: logger, - }, c.extraArgs, "/path", map[string]string(nil)) + output, err := iso.Run(ctx, c.extraArgs, "/path", map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", c.expectedArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", c.expectedArgs, map[string]string(nil), tfVersion, "workspace") }) } } @@ -287,20 +291,21 @@ func TestRun_InitDeletesLockFileIfPresentAndNotTracked(t *testing.T) { TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) - output, err := iso.Run(models.ProjectCommandContext{ + ctx := models.ProjectCommandContext{ Workspace: "workspace", RepoRelDir: ".", Log: logger, - }, []string{"extra", "args"}, repoDir, map[string]string(nil)) + } + output, err := iso.Run(ctx, []string{"extra", "args"}, repoDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) - expectedArgs := []string{"init", "-input=false", "-no-color", "-upgrade", "extra", "args"} - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, repoDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") + expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, repoDir, expectedArgs, map[string]string(nil), tfVersion, "workspace") } func runCmd(t *testing.T, dir string, name string, args ...string) string { diff --git a/server/core/runtime/plan_step_runner.go b/server/core/runtime/plan_step_runner.go index 8ac365003c..f7dba9402f 100644 --- a/server/core/runtime/plan_step_runner.go +++ b/server/core/runtime/plan_step_runner.go @@ -45,7 +45,7 @@ func (p *PlanStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []strin planFile := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) planCmd := p.buildPlanCmd(ctx, extraArgs, path, tfVersion, planFile) - output, err := p.TerraformExecutor.RunCommandWithVersion(ctx.Log, filepath.Clean(path), planCmd, envs, tfVersion, ctx.Workspace) + output, err := p.TerraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), planCmd, envs, tfVersion, ctx.Workspace) if p.isRemoteOpsErr(output, err) { ctx.Log.Debug("detected that this project is using TFE remote ops") return p.remotePlan(ctx, extraArgs, path, tfVersion, planFile, envs) @@ -69,7 +69,7 @@ func (p *PlanStepRunner) isRemoteOpsErr(output string, err error) bool { // operations. func (p *PlanStepRunner) remotePlan(ctx models.ProjectCommandContext, extraArgs []string, path string, tfVersion *version.Version, planFile string, envs map[string]string) (string, error) { argList := [][]string{ - {"plan", "-input=false", "-refresh", "-no-color"}, + {"plan", "-input=false", "-refresh"}, extraArgs, ctx.EscapedCommentArgs, } @@ -124,7 +124,7 @@ func (p *PlanStepRunner) switchWorkspace(ctx models.ProjectCommandContext, path // already in the right workspace then no need to switch. This will save us // about ten seconds. This command is only available in > 0.10. if !runningZeroPointNine { - workspaceShowOutput, err := p.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, []string{workspaceCmd, "show"}, envs, tfVersion, ctx.Workspace) + workspaceShowOutput, err := p.TerraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "show"}, envs, tfVersion, ctx.Workspace) if err != nil { return err } @@ -139,11 +139,11 @@ func (p *PlanStepRunner) switchWorkspace(ctx models.ProjectCommandContext, path // To do this we can either select and catch the error or use list and then // look for the workspace. Both commands take the same amount of time so // that's why we're running select here. - _, err := p.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, []string{workspaceCmd, "select", "-no-color", ctx.Workspace}, envs, tfVersion, ctx.Workspace) + _, err := p.TerraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "select", ctx.Workspace}, envs, tfVersion, ctx.Workspace) if err != nil { // If terraform workspace select fails we run terraform workspace // new to create a new workspace automatically. - out, err := p.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, []string{workspaceCmd, "new", "-no-color", ctx.Workspace}, envs, tfVersion, ctx.Workspace) + out, err := p.TerraformExecutor.RunCommandWithVersion(ctx, path, []string{workspaceCmd, "new", ctx.Workspace}, envs, tfVersion, ctx.Workspace) if err != nil { return fmt.Errorf("%s: %s", err, out) } @@ -167,7 +167,7 @@ func (p *PlanStepRunner) buildPlanCmd(ctx models.ProjectCommandContext, extraArg argList := [][]string{ // NOTE: we need to quote the plan filename because Bitbucket Server can // have spaces in its repo owner names. - {"plan", "-input=false", "-refresh", "-no-color", "-out", fmt.Sprintf("%q", planFile)}, + {"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", planFile)}, tfVars, extraArgs, ctx.EscapedCommentArgs, @@ -252,7 +252,7 @@ func (p *PlanStepRunner) runRemotePlan( // Start the async command execution. ctx.Log.Debug("starting async tf remote operation") - _, outCh := p.AsyncTFExec.RunCommandAsync(ctx.Log, filepath.Clean(path), cmdArgs, envs, tfVersion, ctx.Workspace) + _, outCh := p.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), cmdArgs, envs, tfVersion, ctx.Workspace) var lines []string nextLineIsRunURL := false var runURL string diff --git a/server/core/runtime/plan_step_runner_test.go b/server/core/runtime/plan_step_runner_test.go index f4a19fbb44..1f609fc023 100644 --- a/server/core/runtime/plan_step_runner_test.go +++ b/server/core/runtime/plan_step_runner_test.go @@ -19,7 +19,7 @@ import ( "github.com/runatlantis/atlantis/server/events/mocks/matchers" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" - logging_matchers "github.com/runatlantis/atlantis/server/logging/mocks/matchers" + . "github.com/runatlantis/atlantis/testing" ) @@ -29,16 +29,10 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { terraform := mocks.NewMockClient() tfVersion, _ := version.NewVersion("0.8") - logger := logging.NewNoopLogger(t) - workspace := "default" - s := runtime.PlanStepRunner{ - DefaultTFVersion: tfVersion, - TerraformExecutor: terraform, - } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). - ThenReturn("output", nil) - output, err := s.Run(models.ProjectCommandContext{ + workspace := "default" + logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ Log: logger, EscapedCommentArgs: []string{"comment", "args"}, Workspace: workspace, @@ -52,17 +46,24 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { Owner: "owner", Name: "repo", }, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + } + s := runtime.PlanStepRunner{ + DefaultTFVersion: tfVersion, + TerraformExecutor: terraform, + } + + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + ThenReturn("output", nil) + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) terraform.VerifyWasCalledOnce().RunCommandWithVersion( - logger, + ctx, "/path", []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", "\"/path/default.tfplan\"", "-var", @@ -84,20 +85,18 @@ func TestRun_NoWorkspaceIn08(t *testing.T) { workspace) // Verify that no env or workspace commands were run - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"env", "select", - "-no-color", "workspace"}, map[string]string(nil), tfVersion, workspace) - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"workspace", "select", - "-no-color", "workspace"}, map[string]string(nil), tfVersion, @@ -118,7 +117,7 @@ func TestRun_ErrWorkspaceIn08(t *testing.T) { DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). ThenReturn("output", nil) _, err := s.Run(models.ProjectCommandContext{ Log: logger, @@ -160,15 +159,7 @@ func TestRun_SwitchesWorkspace(t *testing.T) { tfVersion, _ := version.NewVersion(c.tfVersion) logger := logging.NewNoopLogger(t) - - s := runtime.PlanStepRunner{ - TerraformExecutor: terraform, - DefaultTFVersion: tfVersion, - } - - When(terraform.RunCommandWithVersion(logging_matchers.AnyLoggingSimpleLogging(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). - ThenReturn("output", nil) - output, err := s.Run(models.ProjectCommandContext{ + ctx := models.ProjectCommandContext{ Log: logger, Workspace: "workspace", RepoRelDir: ".", @@ -182,26 +173,32 @@ func TestRun_SwitchesWorkspace(t *testing.T) { Owner: "owner", Name: "repo", }, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + } + s := runtime.PlanStepRunner{ + TerraformExecutor: terraform, + DefaultTFVersion: tfVersion, + } + + When(terraform.RunCommandWithVersion(matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), matchers2.AnyPtrToGoVersionVersion(), AnyString())). + ThenReturn("output", nil) + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) // Verify that env select was called as well as plan. - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", []string{c.expWorkspaceCmd, "select", - "-no-color", "workspace"}, map[string]string(nil), tfVersion, "workspace") - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", "\"/path/workspace.tfplan\"", "-var", @@ -256,6 +253,21 @@ func TestRun_CreatesWorkspace(t *testing.T) { terraform := mocks.NewMockClient() tfVersion, _ := version.NewVersion(c.tfVersion) logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "workspace", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } s := runtime.PlanStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, @@ -263,15 +275,14 @@ func TestRun_CreatesWorkspace(t *testing.T) { // Ensure that we actually try to switch workspaces by making the // output of `workspace show` to be a different name. - When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("diffworkspace\n", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("diffworkspace\n", nil) - expWorkspaceArgs := []string{c.expWorkspaceCommand, "select", "-no-color", "workspace"} - When(terraform.RunCommandWithVersion(logger, "/path", expWorkspaceArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("", errors.New("workspace does not exist")) + expWorkspaceArgs := []string{c.expWorkspaceCommand, "select", "workspace"} + When(terraform.RunCommandWithVersion(ctx, "/path", expWorkspaceArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("", errors.New("workspace does not exist")) expPlanArgs := []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", "\"/path/workspace.tfplan\"", "-var", @@ -288,29 +299,15 @@ func TestRun_CreatesWorkspace(t *testing.T) { "args", "comment", "args"} - When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) - output, err := s.Run(models.ProjectCommandContext{ - Log: logger, - Workspace: "workspace", - RepoRelDir: ".", - User: models.User{Username: "username"}, - EscapedCommentArgs: []string{"comment", "args"}, - Pull: models.PullRequest{ - Num: 2, - }, - BaseRepo: models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - }, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) // Verify that env select was called as well as plan. - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expWorkspaceArgs, map[string]string(nil), tfVersion, "workspace") - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expWorkspaceArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace") }) } } @@ -322,16 +319,30 @@ func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { terraform := mocks.NewMockClient() tfVersion, _ := version.NewVersion("0.10.0") logger := logging.NewNoopLogger(t) + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "workspace", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } s := runtime.PlanStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("workspace\n", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("workspace\n", nil) expPlanArgs := []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", "\"/path/workspace.tfplan\"", "-var", @@ -348,30 +359,16 @@ func TestRun_NoWorkspaceSwitchIfNotNecessary(t *testing.T) { "args", "comment", "args"} - When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) - output, err := s.Run(models.ProjectCommandContext{ - Log: logger, - Workspace: "workspace", - RepoRelDir: ".", - User: models.User{Username: "username"}, - EscapedCommentArgs: []string{"comment", "args"}, - Pull: models.PullRequest{ - Num: 2, - }, - BaseRepo: models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - }, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "workspace") // Verify that workspace select was never called. - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, "/path", []string{"workspace", "select", "-no-color", "workspace"}, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"workspace", "select", "workspace"}, map[string]string(nil), tfVersion, "workspace") } func TestRun_AddsEnvVarFile(t *testing.T) { @@ -399,7 +396,6 @@ func TestRun_AddsEnvVarFile(t *testing.T) { expPlanArgs := []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", fmt.Sprintf("%q", filepath.Join(tmpDir, "workspace.tfplan")), "-var", @@ -419,9 +415,7 @@ func TestRun_AddsEnvVarFile(t *testing.T) { "-var-file", envVarsFile, } - When(terraform.RunCommandWithVersion(logger, tmpDir, expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) - - output, err := s.Run(models.ProjectCommandContext{ + ctx := models.ProjectCommandContext{ Log: logger, Workspace: "workspace", RepoRelDir: ".", @@ -435,12 +429,15 @@ func TestRun_AddsEnvVarFile(t *testing.T) { Owner: "owner", Name: "repo", }, - }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) + } + When(terraform.RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfVersion, "workspace")).ThenReturn("output", nil) + + output, err := s.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) Ok(t, err) // Verify that env select was never called since we're in version >= 0.10 - terraform.VerifyWasCalled(Never()).RunCommandWithVersion(logger, tmpDir, []string{"env", "select", "-no-color", "workspace"}, map[string]string(nil), tfVersion, "workspace") - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, expPlanArgs, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, tmpDir, []string{"env", "select", "workspace"}, map[string]string(nil), tfVersion, "workspace") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfVersion, "workspace") Equals(t, "output", output) } @@ -455,12 +452,27 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - When(terraform.RunCommandWithVersion(logger, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("workspace\n", nil) + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } + When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfVersion, "workspace")).ThenReturn("workspace\n", nil) expPlanArgs := []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", "\"/path/projectname-default.tfplan\"", "-var", @@ -478,24 +490,9 @@ func TestRun_UsesDiffPathForProject(t *testing.T) { "comment", "args", } - When(terraform.RunCommandWithVersion(logger, "/path", expPlanArgs, map[string]string(nil), tfVersion, "default")).ThenReturn("output", nil) + When(terraform.RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "default")).ThenReturn("output", nil) - output, err := s.Run(models.ProjectCommandContext{ - Log: logger, - Workspace: "default", - RepoRelDir: ".", - User: models.User{Username: "username"}, - EscapedCommentArgs: []string{"comment", "args"}, - ProjectName: "projectname", - Pull: models.PullRequest{ - Num: 2, - }, - BaseRepo: models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - }, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) } @@ -536,7 +533,7 @@ Terraform will perform the following actions: DefaultTFVersion: tfVersion, } When(terraform.RunCommandWithVersion( - matchers.AnyPtrToLoggingSimpleLogger(), + matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), @@ -590,7 +587,7 @@ func TestRun_OutputOnErr(t *testing.T) { expOutput := "expected output" expErrMsg := "error!" When(terraform.RunCommandWithVersion( - matchers.AnyPtrToLoggingSimpleLogger(), + matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), @@ -623,7 +620,6 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { "plan", "-input=false", "-refresh", - "-no-color", "-out", fmt.Sprintf("%q", "/path/default.tfplan"), "extra", @@ -650,7 +646,7 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { t.Run(c.name, func(t *testing.T) { terraform := mocks.NewMockClient() When(terraform.RunCommandWithVersion( - matchers.AnyPtrToLoggingSimpleLogger(), + matchers.AnyModelsProjectCommandContext(), AnyString(), AnyStringSlice(), matchers2.AnyMapOfStringToString(), @@ -662,8 +658,7 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { TerraformExecutor: terraform, DefaultTFVersion: tfVersion, } - - output, err := s.Run(models.ProjectCommandContext{ + ctx := models.ProjectCommandContext{ Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, @@ -676,11 +671,13 @@ func TestRun_NoOptionalVarsIn012(t *testing.T) { Owner: "owner", Name: "repo", }, - }, []string{"extra", "args"}, "/path", map[string]string(nil)) + } + + output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(nil, "/path", expPlanArgs, map[string]string(nil), tfVersion, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfVersion, "default") }) } @@ -706,13 +703,28 @@ locally at this time. t.Run(name, func(t *testing.T) { logger := logging.NewNoopLogger(t) - + // Now that mocking is set up, we're ready to run the plan. + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } RegisterMockTestingT(t) terraform := mocks.NewMockClient() - asyncTf := &remotePlanMock{} tfVersion, _ := version.NewVersion("0.11.12") updater := mocks2.NewMockCommitStatusUpdater() + asyncTf := &remotePlanMock{} s := runtime.PlanStepRunner{ TerraformExecutor: terraform, DefaultTFVersion: tfVersion, @@ -724,7 +736,7 @@ locally at this time. // First, terraform workspace gets run. When(terraform.RunCommandWithVersion( - logger, + ctx, absProjectPath, []string{"workspace", "show"}, map[string]string(nil), @@ -735,7 +747,6 @@ locally at this time. expPlanArgs := []string{"plan", "-input=false", "-refresh", - "-no-color", "-out", fmt.Sprintf("%q", filepath.Join(absProjectPath, "default.tfplan")), "-var", @@ -757,25 +768,9 @@ locally at this time. planErr := errors.New("exit status 1: err") planOutput := "\n" + remoteOpsErr asyncTf.LinesToSend = remotePlanOutput - When(terraform.RunCommandWithVersion(logger, absProjectPath, expPlanArgs, map[string]string(nil), tfVersion, "default")). + When(terraform.RunCommandWithVersion(ctx, absProjectPath, expPlanArgs, map[string]string(nil), tfVersion, "default")). ThenReturn(planOutput, planErr) - // Now that mocking is set up, we're ready to run the plan. - ctx := models.ProjectCommandContext{ - Log: logger, - Workspace: "default", - RepoRelDir: ".", - User: models.User{Username: "username"}, - EscapedCommentArgs: []string{"comment", "args"}, - Pull: models.PullRequest{ - Num: 2, - }, - BaseRepo: models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - }, - } output, err := s.Run(ctx, []string{"extra", "args"}, absProjectPath, map[string]string(nil)) Ok(t, err) Equals(t, ` @@ -790,7 +785,7 @@ Terraform will perform the following actions: Plan: 0 to add, 0 to change, 1 to destroy.`, output) - expRemotePlanArgs := []string{"plan", "-input=false", "-refresh", "-no-color", "extra", "args", "comment", "args"} + expRemotePlanArgs := []string{"plan", "-input=false", "-refresh", "extra", "args", "comment", "args"} Equals(t, expRemotePlanArgs, asyncTf.CalledArgs) // Verify that the fake plan file we write has the correct contents. @@ -889,7 +884,7 @@ type remotePlanMock struct { CalledArgs []string } -func (r *remotePlanMock) RunCommandAsync(log logging.SimpleLogging, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan terraform.Line) { +func (r *remotePlanMock) RunCommandAsync(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan terraform.Line) { r.CalledArgs = args in := make(chan string) out := make(chan terraform.Line) diff --git a/server/core/runtime/runtime.go b/server/core/runtime/runtime.go index f3b130c689..7e35e07623 100644 --- a/server/core/runtime/runtime.go +++ b/server/core/runtime/runtime.go @@ -25,7 +25,7 @@ const ( // TerraformExec brings the interface from TerraformClient into this package // without causing circular imports. type TerraformExec interface { - RunCommandWithVersion(log logging.SimpleLogging, path string, args []string, envs map[string]string, v *version.Version, workspace string) (string, error) + RunCommandWithVersion(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (string, error) EnsureVersion(log logging.SimpleLogging, v *version.Version) error } @@ -40,7 +40,7 @@ type AsyncTFExec interface { // Callers can use the input channel to pass stdin input to the command. // If any error is passed on the out channel, there will be no // further output (so callers are free to exit). - RunCommandAsync(log logging.SimpleLogging, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan terraform.Line) + RunCommandAsync(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *version.Version, workspace string) (chan<- string, <-chan terraform.Line) } // StatusUpdater brings the interface from CommitStatusUpdater into this package diff --git a/server/core/runtime/show_step_runner.go b/server/core/runtime/show_step_runner.go index a8f0e398ed..46b9b3aae4 100644 --- a/server/core/runtime/show_step_runner.go +++ b/server/core/runtime/show_step_runner.go @@ -39,9 +39,9 @@ func (p *ShowStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []strin showResultFile := filepath.Join(path, ctx.GetShowResultFileName()) output, err := p.TerraformExecutor.RunCommandWithVersion( - ctx.Log, + ctx, path, - []string{"show", "-no-color", "-json", filepath.Clean(planFile)}, + []string{"show", "-json", filepath.Clean(planFile)}, envs, tfVersion, ctx.Workspace, diff --git a/server/core/runtime/show_step_runner_test.go b/server/core/runtime/show_step_runner_test.go index 264d768ccb..3ff1a5de06 100644 --- a/server/core/runtime/show_step_runner_test.go +++ b/server/core/runtime/show_step_runner_test.go @@ -2,6 +2,7 @@ package runtime import ( "errors" + "fmt" "os" "path/filepath" "testing" @@ -38,7 +39,7 @@ func TestShowStepRunnner(t *testing.T) { t.Run("success", func(t *testing.T) { When(mockExecutor.RunCommandWithVersion( - logger, path, []string{"show", "-no-color", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfVersion, context.Workspace, + context, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfVersion, context.Workspace, )).ThenReturn("success", nil) r, err := subject.Run(context, []string{}, path, envs) @@ -48,8 +49,8 @@ func TestShowStepRunnner(t *testing.T) { actual, _ := os.ReadFile(resultPath) actualStr := string(actual) - Assert(t, actualStr == "success", "got expected result") - Assert(t, r == "success", "returned expected result") + Assert(t, actualStr == "success", fmt.Sprintf("expected '%s' to be success", actualStr)) + Assert(t, r == "success", fmt.Sprintf("expected '%s' to be success", r)) }) @@ -65,7 +66,7 @@ func TestShowStepRunnner(t *testing.T) { } When(mockExecutor.RunCommandWithVersion( - logger, path, []string{"show", "-no-color", "-json", filepath.Join(path, "test-default.tfplan")}, envs, v, context.Workspace, + contextWithVersionOverride, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, v, context.Workspace, )).ThenReturn("success", nil) r, err := subject.Run(contextWithVersionOverride, []string{}, path, envs) @@ -82,7 +83,7 @@ func TestShowStepRunnner(t *testing.T) { t.Run("failure running command", func(t *testing.T) { When(mockExecutor.RunCommandWithVersion( - logger, path, []string{"show", "-no-color", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfVersion, context.Workspace, + context, path, []string{"show", "-json", filepath.Join(path, "test-default.tfplan")}, envs, tfVersion, context.Workspace, )).ThenReturn("success", errors.New("error")) _, err := subject.Run(context, []string{}, path, envs) diff --git a/server/core/runtime/version_step_runner.go b/server/core/runtime/version_step_runner.go index d20ea919db..a7369af7c2 100644 --- a/server/core/runtime/version_step_runner.go +++ b/server/core/runtime/version_step_runner.go @@ -21,5 +21,5 @@ func (v *VersionStepRunner) Run(ctx models.ProjectCommandContext, extraArgs []st } versionCmd := []string{"version"} - return v.TerraformExecutor.RunCommandWithVersion(ctx.Log, filepath.Clean(path), versionCmd, envs, tfVersion, ctx.Workspace) + return v.TerraformExecutor.RunCommandWithVersion(ctx, filepath.Clean(path), versionCmd, envs, tfVersion, ctx.Workspace) } diff --git a/server/core/runtime/version_step_runner_test.go b/server/core/runtime/version_step_runner_test.go index 6279b4d037..797e4ba6c0 100644 --- a/server/core/runtime/version_step_runner_test.go +++ b/server/core/runtime/version_step_runner_test.go @@ -44,7 +44,7 @@ func TestRunVersionStep(t *testing.T) { t.Run("ensure runs", func(t *testing.T) { _, err := s.Run(context, []string{}, tmpDir, map[string]string(nil)) - terraform.VerifyWasCalledOnce().RunCommandWithVersion(logger, tmpDir, []string{"version"}, map[string]string(nil), tfVersion, "default") + terraform.VerifyWasCalledOnce().RunCommandWithVersion(context, tmpDir, []string{"version"}, map[string]string(nil), tfVersion, "default") Ok(t, err) }) } diff --git a/server/core/terraform/mocks/mock_terraform_client.go b/server/core/terraform/mocks/mock_terraform_client.go index 05565e2080..95157c3685 100644 --- a/server/core/terraform/mocks/mock_terraform_client.go +++ b/server/core/terraform/mocks/mock_terraform_client.go @@ -4,11 +4,14 @@ package mocks import ( + "reflect" + "time" + go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/core/terraform" + "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" - "reflect" - "time" ) type MockClient struct { @@ -26,11 +29,11 @@ func NewMockClient(options ...pegomock.Option) *MockClient { func (mock *MockClient) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockClient) FailHandler() pegomock.FailHandler { return mock.fail } -func (mock *MockClient) RunCommandWithVersion(log logging.SimpleLogging, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) (string, error) { +func (mock *MockClient) RunCommandWithVersion(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } - params := []pegomock.Param{log, path, args, envs, v, workspace} + params := []pegomock.Param{ctx, path, args, envs, v, workspace} result := pegomock.GetGenericMockFrom(mock).Invoke("RunCommandWithVersion", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 error @@ -45,6 +48,16 @@ func (mock *MockClient) RunCommandWithVersion(log logging.SimpleLogging, path st return ret0, ret1 } +func (mock *MockClient) RunCommandAsync(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) (chan<- string, <-chan terraform.Line) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockClient().") + } + outCh := make(chan terraform.Line) + inCh := make(chan string) + + return inCh, outCh +} + func (mock *MockClient) EnsureVersion(log logging.SimpleLogging, v *go_version.Version) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") @@ -97,8 +110,8 @@ type VerifierMockClient struct { timeout time.Duration } -func (verifier *VerifierMockClient) RunCommandWithVersion(log logging.SimpleLogging, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) *MockClient_RunCommandWithVersion_OngoingVerification { - params := []pegomock.Param{log, path, args, envs, v, workspace} +func (verifier *VerifierMockClient) RunCommandWithVersion(ctx models.ProjectCommandContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) *MockClient_RunCommandWithVersion_OngoingVerification { + params := []pegomock.Param{ctx, path, args, envs, v, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunCommandWithVersion", params, verifier.timeout) return &MockClient_RunCommandWithVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } diff --git a/server/core/terraform/terraform_client.go b/server/core/terraform/terraform_client.go index 39defed68c..c40b245166 100644 --- a/server/core/terraform/terraform_client.go +++ b/server/core/terraform/terraform_client.go @@ -30,9 +30,18 @@ import ( "github.com/hashicorp/go-version" "github.com/mitchellh/go-homedir" "github.com/pkg/errors" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/terraform/ansi" + "github.com/runatlantis/atlantis/server/handlers" "github.com/runatlantis/atlantis/server/logging" ) +var LogStreamingValidCmds = [...]string{"init", "plan", "apply"} + +// Setting the buffer size to 10mb +const BufioScannerBufferSize = 10 * 1024 * 1024 + //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_terraform_client.go Client type Client interface { @@ -69,6 +78,8 @@ type DefaultClient struct { // usePluginCache determines whether or not to set the TF_PLUGIN_CACHE_DIR env var usePluginCache bool + + projectCmdOutputHandler handlers.ProjectCommandOutputHandler } //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_downloader.go Downloader @@ -100,6 +111,7 @@ func NewClientWithDefaultVersion( tfDownloader Downloader, usePluginCache bool, fetchAsync bool, + projectCmdOutputHandler handlers.ProjectCommandOutputHandler, ) (*DefaultClient, error) { var finalDefaultVersion *version.Version var localVersion *version.Version @@ -157,7 +169,6 @@ func NewClientWithDefaultVersion( return nil, err } } - return &DefaultClient{ defaultVersion: finalDefaultVersion, terraformPluginCacheDir: cacheDir, @@ -167,6 +178,7 @@ func NewClientWithDefaultVersion( versionsLock: &versionsLock, versions: versions, usePluginCache: usePluginCache, + projectCmdOutputHandler: projectCmdOutputHandler, }, nil } @@ -181,7 +193,9 @@ func NewTestClient( defaultVersionFlagName string, tfDownloadURL string, tfDownloader Downloader, - usePluginCache bool) (*DefaultClient, error) { + usePluginCache bool, + projectCmdOutputHandler handlers.ProjectCommandOutputHandler, +) (*DefaultClient, error) { return NewClientWithDefaultVersion( log, binDir, @@ -194,6 +208,7 @@ func NewTestClient( tfDownloader, usePluginCache, false, + projectCmdOutputHandler, ) } @@ -215,7 +230,9 @@ func NewClient( defaultVersionFlagName string, tfDownloadURL string, tfDownloader Downloader, - usePluginCache bool) (*DefaultClient, error) { + usePluginCache bool, + projectCmdOutputHandler handlers.ProjectCommandOutputHandler, +) (*DefaultClient, error) { return NewClientWithDefaultVersion( log, binDir, @@ -228,6 +245,7 @@ func NewClient( tfDownloader, usePluginCache, true, + projectCmdOutputHandler, ) } @@ -260,8 +278,25 @@ func (c *DefaultClient) EnsureVersion(log logging.SimpleLogging, v *version.Vers } // See Client.RunCommandWithVersion. -func (c *DefaultClient) RunCommandWithVersion(log logging.SimpleLogging, path string, args []string, customEnvVars map[string]string, v *version.Version, workspace string) (string, error) { - tfCmd, cmd, err := c.prepCmd(log, v, workspace, path, args) +func (c *DefaultClient) RunCommandWithVersion(ctx models.ProjectCommandContext, path string, args []string, customEnvVars map[string]string, v *version.Version, workspace string) (string, error) { + if isAsyncEligibleCommand(args[0]) { + _, outCh := c.RunCommandAsync(ctx, path, args, customEnvVars, v, workspace) + var lines []string + var err error + for line := range outCh { + if line.Err != nil { + err = line.Err + break + } + lines = append(lines, line.Line) + } + output := strings.Join(lines, "\n") + + // sanitize output by stripping out any ansi characters. + output = ansi.Strip(output) + return fmt.Sprintf("%s\n", output), err + } + tfCmd, cmd, err := c.prepCmd(ctx.Log, v, workspace, path, args) if err != nil { return "", err } @@ -273,11 +308,12 @@ func (c *DefaultClient) RunCommandWithVersion(log logging.SimpleLogging, path st out, err := cmd.CombinedOutput() if err != nil { err = errors.Wrapf(err, "running %q in %q", tfCmd, path) - log.Err(err.Error()) - return string(out), err + ctx.Log.Err(err.Error()) + return ansi.Strip(string(out)), err } - log.Info("successfully ran %q in %q", tfCmd, path) - return string(out), nil + ctx.Log.Info("successfully ran %q in %q", tfCmd, path) + + return ansi.Strip(string(out)), nil } // prepCmd builds a ready to execute command based on the version of terraform @@ -340,7 +376,7 @@ type Line struct { // Callers can use the input channel to pass stdin input to the command. // If any error is passed on the out channel, there will be no // further output (so callers are free to exit). -func (c *DefaultClient) RunCommandAsync(log logging.SimpleLogging, path string, args []string, customEnvVars map[string]string, v *version.Version, workspace string) (chan<- string, <-chan Line) { +func (c *DefaultClient) RunCommandAsync(ctx models.ProjectCommandContext, path string, args []string, customEnvVars map[string]string, v *version.Version, workspace string) (chan<- string, <-chan Line) { outCh := make(chan Line) inCh := make(chan string) @@ -354,9 +390,9 @@ func (c *DefaultClient) RunCommandAsync(log logging.SimpleLogging, path string, close(inCh) }() - tfCmd, cmd, err := c.prepCmd(log, v, workspace, path, args) + tfCmd, cmd, err := c.prepCmd(ctx.Log, v, workspace, path, args) if err != nil { - log.Err(err.Error()) + ctx.Log.Err(err.Error()) outCh <- Line{Err: err} return } @@ -369,11 +405,11 @@ func (c *DefaultClient) RunCommandAsync(log logging.SimpleLogging, path string, } cmd.Env = envVars - log.Debug("starting %q in %q", tfCmd, path) + ctx.Log.Debug("starting %q in %q", tfCmd, path) err = cmd.Start() if err != nil { err = errors.Wrapf(err, "running %q in %q", tfCmd, path) - log.Err(err.Error()) + ctx.Log.Err(err.Error()) outCh <- Line{Err: err} return } @@ -382,10 +418,10 @@ func (c *DefaultClient) RunCommandAsync(log logging.SimpleLogging, path string, // This function will exit when inCh is closed which we do in our defer. go func() { for line := range inCh { - log.Debug("writing %q to remote command's stdin", line) + ctx.Log.Debug("writing %q to remote command's stdin", line) _, err := io.WriteString(stdin, line) if err != nil { - log.Err(errors.Wrapf(err, "writing %q to process", line).Error()) + ctx.Log.Err(errors.Wrapf(err, "writing %q to process", line).Error()) } } }() @@ -393,19 +429,25 @@ func (c *DefaultClient) RunCommandAsync(log logging.SimpleLogging, path string, // Use a waitgroup to block until our stdout/err copying is complete. wg := new(sync.WaitGroup) wg.Add(2) - // Asynchronously copy from stdout/err to outCh. go func() { s := bufio.NewScanner(stdout) + buf := []byte{} + s.Buffer(buf, BufioScannerBufferSize) + for s.Scan() { - outCh <- Line{Line: s.Text()} + message := s.Text() + outCh <- Line{Line: message} + c.projectCmdOutputHandler.Send(ctx, message) } wg.Done() }() go func() { s := bufio.NewScanner(stderr) for s.Scan() { - outCh <- Line{Line: s.Text()} + message := s.Text() + outCh <- Line{Line: message} + c.projectCmdOutputHandler.Send(ctx, message) } wg.Done() }() @@ -420,10 +462,10 @@ func (c *DefaultClient) RunCommandAsync(log logging.SimpleLogging, path string, // We're done now. Send an error if there was one. if err != nil { err = errors.Wrapf(err, "running %q in %q", tfCmd, path) - log.Err(err.Error()) + ctx.Log.Err(err.Error()) outCh <- Line{Err: err} } else { - log.Info("successfully ran %q in %q", tfCmd, path) + ctx.Log.Info("successfully ran %q in %q", tfCmd, path) } }() @@ -508,6 +550,15 @@ func generateRCFile(tfeToken string, tfeHostname string, home string) error { return nil } +func isAsyncEligibleCommand(cmd string) bool { + for _, validCmd := range LogStreamingValidCmds { + if validCmd == cmd { + return true + } + } + return false +} + func getVersion(tfBinary string) (*version.Version, error) { versionOutBytes, err := exec.Command(tfBinary, "version").Output() // #nosec versionOutput := string(versionOutBytes) diff --git a/server/core/terraform/terraform_client_internal_test.go b/server/core/terraform/terraform_client_internal_test.go index 036f08766a..903724d1f7 100644 --- a/server/core/terraform/terraform_client_internal_test.go +++ b/server/core/terraform/terraform_client_internal_test.go @@ -8,6 +8,8 @@ import ( "testing" version "github.com/hashicorp/go-version" + "github.com/runatlantis/atlantis/server/events/models" + handlermocks "github.com/runatlantis/atlantis/server/handlers/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) @@ -88,12 +90,32 @@ func TestDefaultClient_RunCommandWithVersion_EnvVars(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", usePluginCache: true, + projectCmdOutputHandler: projectCmdOutputHandler, } args := []string{ @@ -103,8 +125,8 @@ func TestDefaultClient_RunCommandWithVersion_EnvVars(t *testing.T) { "ATLANTIS_TERRAFORM_VERSION=$ATLANTIS_TERRAFORM_VERSION", "DIR=$DIR", } - log := logging.NewNoopLogger(t) - out, err := client.RunCommandWithVersion(log, tmp, args, map[string]string{}, nil, "workspace") + customEnvVars := map[string]string{} + out, err := client.RunCommandWithVersion(ctx, tmp, args, customEnvVars, nil, "workspace") Ok(t, err) exp := fmt.Sprintf("TF_IN_AUTOMATION=true TF_PLUGIN_CACHE_DIR=%s WORKSPACE=workspace ATLANTIS_TERRAFORM_VERSION=0.11.11 DIR=%s\n", tmp, tmp) Equals(t, exp, out) @@ -115,11 +137,31 @@ func TestDefaultClient_RunCommandWithVersion_Error(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", + projectCmdOutputHandler: projectCmdOutputHandler, } args := []string{ @@ -128,8 +170,7 @@ func TestDefaultClient_RunCommandWithVersion_Error(t *testing.T) { "exit", "1", } - log := logging.NewNoopLogger(t) - out, err := client.RunCommandWithVersion(log, tmp, args, map[string]string{}, nil, "workspace") + out, err := client.RunCommandWithVersion(ctx, tmp, args, map[string]string{}, nil, "workspace") ErrEquals(t, fmt.Sprintf(`running "echo dying && exit 1" in %q: exit status 1`, tmp), err) // Test that we still get our output. Equals(t, "dying\n", out) @@ -139,12 +180,32 @@ func TestDefaultClient_RunCommandAsync_Success(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", usePluginCache: true, + projectCmdOutputHandler: projectCmdOutputHandler, } args := []string{ @@ -154,8 +215,7 @@ func TestDefaultClient_RunCommandAsync_Success(t *testing.T) { "ATLANTIS_TERRAFORM_VERSION=$ATLANTIS_TERRAFORM_VERSION", "DIR=$DIR", } - log := logging.NewNoopLogger(t) - _, outCh := client.RunCommandAsync(log, tmp, args, map[string]string{}, nil, "workspace") + _, outCh := client.RunCommandAsync(ctx, tmp, args, map[string]string{}, nil, "workspace") out, err := waitCh(outCh) Ok(t, err) @@ -167,11 +227,31 @@ func TestDefaultClient_RunCommandAsync_BigOutput(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "cat", + projectCmdOutputHandler: projectCmdOutputHandler, } filename := filepath.Join(tmp, "data") f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) @@ -184,8 +264,7 @@ func TestDefaultClient_RunCommandAsync_BigOutput(t *testing.T) { _, err = f.WriteString(s) Ok(t, err) } - log := logging.NewNoopLogger(t) - _, outCh := client.RunCommandAsync(log, tmp, []string{filename}, map[string]string{}, nil, "workspace") + _, outCh := client.RunCommandAsync(ctx, tmp, []string{filename}, map[string]string{}, nil, "workspace") out, err := waitCh(outCh) Ok(t, err) @@ -196,14 +275,33 @@ func TestDefaultClient_RunCommandAsync_StderrOutput(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", + projectCmdOutputHandler: projectCmdOutputHandler, } - log := logging.NewNoopLogger(t) - _, outCh := client.RunCommandAsync(log, tmp, []string{"stderr", ">&2"}, map[string]string{}, nil, "workspace") + _, outCh := client.RunCommandAsync(ctx, tmp, []string{"stderr", ">&2"}, map[string]string{}, nil, "workspace") out, err := waitCh(outCh) Ok(t, err) @@ -214,14 +312,33 @@ func TestDefaultClient_RunCommandAsync_ExitOne(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", + projectCmdOutputHandler: projectCmdOutputHandler, } - log := logging.NewNoopLogger(t) - _, outCh := client.RunCommandAsync(log, tmp, []string{"dying", "&&", "exit", "1"}, map[string]string{}, nil, "workspace") + _, outCh := client.RunCommandAsync(ctx, tmp, []string{"dying", "&&", "exit", "1"}, map[string]string{}, nil, "workspace") out, err := waitCh(outCh) ErrEquals(t, fmt.Sprintf(`running "echo dying && exit 1" in %q: exit status 1`, tmp), err) @@ -233,14 +350,34 @@ func TestDefaultClient_RunCommandAsync_Input(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp, cleanup := TempDir(t) + logger := logging.NewNoopLogger(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + + ctx := models.ProjectCommandContext{ + Log: logger, + Workspace: "default", + RepoRelDir: ".", + User: models.User{Username: "username"}, + EscapedCommentArgs: []string{"comment", "args"}, + ProjectName: "projectname", + Pull: models.PullRequest{ + Num: 2, + }, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + }, + } defer cleanup() client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "read", + projectCmdOutputHandler: projectCmdOutputHandler, } - log := logging.NewNoopLogger(t) - inCh, outCh := client.RunCommandAsync(log, tmp, []string{"a", "&&", "echo", "$a"}, map[string]string{}, nil, "workspace") + + inCh, outCh := client.RunCommandAsync(ctx, tmp, []string{"a", "&&", "echo", "$a"}, map[string]string{}, nil, "workspace") inCh <- "echo me\n" out, err := waitCh(outCh) diff --git a/server/core/terraform/terraform_client_test.go b/server/core/terraform/terraform_client_test.go index dc8203806a..420e55eb7a 100644 --- a/server/core/terraform/terraform_client_test.go +++ b/server/core/terraform/terraform_client_test.go @@ -27,6 +27,8 @@ import ( "github.com/runatlantis/atlantis/cmd" "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/core/terraform/mocks" + "github.com/runatlantis/atlantis/server/events/models" + handlermocks "github.com/runatlantis/atlantis/server/handlers/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) @@ -59,6 +61,12 @@ Your version of Terraform is out of date! The latest version is 0.11.13. You can update by downloading from www.terraform.io/downloads.html ` tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Workspace: "default", + RepoRelDir: ".", + } defer cleanup() logger := logging.NewNoopLogger(t) @@ -69,13 +77,13 @@ is 0.11.13. You can update by downloading from www.terraform.io/downloads.html Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true) + c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(logger, tmp, nil, map[string]string{"test": "123"}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{"test": "123"}, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -90,6 +98,12 @@ is 0.11.13. You can update by downloading from www.terraform.io/downloads.html ` logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Workspace: "default", + RepoRelDir: ".", + } defer cleanup() // We're testing this by adding our own "fake" terraform binary to path that @@ -98,13 +112,13 @@ is 0.11.13. You can update by downloading from www.terraform.io/downloads.html Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true) + c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(logger, tmp, nil, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -114,12 +128,13 @@ is 0.11.13. You can update by downloading from www.terraform.io/downloads.html func TestNewClient_NoTF(t *testing.T) { logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() defer cleanup() // Set PATH to only include our empty directory. defer tempSetEnv(t, "PATH", tmp)() - _, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true) + _, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, projectCmdOutputHandler) ErrEquals(t, "terraform not found in $PATH. Set --default-tf-version or download terraform from https://www.terraform.io/downloads.html", err) } @@ -129,6 +144,12 @@ func TestNewClient_DefaultTFFlagInPath(t *testing.T) { fakeBinOut := "Terraform v0.11.10\n" logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Workspace: "default", + RepoRelDir: ".", + } defer cleanup() // We're testing this by adding our own "fake" terraform binary to path that @@ -137,13 +158,13 @@ func TestNewClient_DefaultTFFlagInPath(t *testing.T) { Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true) + c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(logger, tmp, nil, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -152,8 +173,13 @@ func TestNewClient_DefaultTFFlagInPath(t *testing.T) { // bin dir that we use it. func TestNewClient_DefaultTFFlagInBinDir(t *testing.T) { fakeBinOut := "Terraform v0.11.10\n" - logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Workspace: "default", + RepoRelDir: ".", + } defer cleanup() // Add our fake binary to {datadir}/bin/terraform{version}. @@ -161,13 +187,13 @@ func TestNewClient_DefaultTFFlagInBinDir(t *testing.T) { Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(logging.NewNoopLogger(t), binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true) + c, err := terraform.NewClient(logging.NewNoopLogger(t), binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) - output, err := c.RunCommandWithVersion(logger, tmp, nil, map[string]string{}, nil, "") + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } @@ -177,6 +203,12 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Workspace: "default", + RepoRelDir: ".", + } defer cleanup() // Set PATH to empty so there's no TF available. @@ -188,7 +220,7 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { err := os.WriteFile(params[0].(string), []byte("#!/bin/sh\necho '\nTerraform v0.11.10\n'"), 0700) // #nosec G306 return []pegomock.ReturnValue{err} }) - c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, "https://my-mirror.releases.mycompany.com", mockDownloader, true) + c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, "https://my-mirror.releases.mycompany.com", mockDownloader, true, projectCmdOutputHandler) Ok(t, err) Ok(t, err) @@ -203,7 +235,8 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { // Reset PATH so that it has sh. Ok(t, os.Setenv("PATH", orig)) - output, err := c.RunCommandWithVersion(logger, tmp, nil, map[string]string{}, nil, "") + + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, nil, "") Ok(t, err) Equals(t, "\nTerraform v0.11.10\n\n", output) } @@ -212,8 +245,9 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { func TestNewClient_BadVersion(t *testing.T) { logger := logging.NewNoopLogger(t) _, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() defer cleanup() - _, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true) + _, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, projectCmdOutputHandler) ErrEquals(t, "Malformed version: malformed", err) } @@ -222,6 +256,12 @@ func TestRunCommandWithVersion_DLsTF(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Workspace: "default", + RepoRelDir: ".", + } defer cleanup() mockDownloader := mocks.NewMockDownloader() @@ -237,13 +277,15 @@ func TestRunCommandWithVersion_DLsTF(t *testing.T) { return []pegomock.ReturnValue{err} }) - c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true) + c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true, projectCmdOutputHandler) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) v, err := version.NewVersion("99.99.99") Ok(t, err) - output, err := c.RunCommandWithVersion(logger, tmp, nil, map[string]string{}, v, "") + + output, err := c.RunCommandWithVersion(ctx, tmp, []string{"terraform", "init"}, map[string]string{}, v, "") + Assert(t, err == nil, "err: %s: %s", err, output) Equals(t, "\nTerraform v99.99.99\n\n", output) } @@ -253,11 +295,12 @@ func TestEnsureVersion_downloaded(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) tmp, binDir, cacheDir, cleanup := mkSubDirs(t) + projectCmdOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() defer cleanup() mockDownloader := mocks.NewMockDownloader() - c, err := terraform.NewTestClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true) + c, err := terraform.NewTestClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true, projectCmdOutputHandler) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index b6fb657ff3..26abd4e9e8 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -78,7 +78,6 @@ func setup(t *testing.T) *vcsmocks.MockClient { workingDir = mocks.NewMockWorkingDir() pendingPlanFinder = mocks.NewMockPendingPlanFinder() commitUpdater = mocks.NewMockCommitStatusUpdater() - tmp, cleanup := TempDir(t) defer cleanup() defaultBoltDB, err := db.New(tmp) diff --git a/server/events/commit_status_updater.go b/server/events/commit_status_updater.go index f41ec0a2b8..a851cdbadd 100644 --- a/server/events/commit_status_updater.go +++ b/server/events/commit_status_updater.go @@ -43,18 +43,9 @@ type DefaultCommitStatusUpdater struct { TitleBuilder vcs.StatusTitleBuilder } -func (d *DefaultCommitStatusUpdater) UpdateCombined(repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName models.CommandName) error { - src := d.TitleBuilder.Build(cmdName.String()) - var descripWords string - switch status { - case models.PendingCommitStatus: - descripWords = "in progress..." - case models.FailedCommitStatus: - descripWords = "failed." - case models.SuccessCommitStatus: - descripWords = "succeeded." - } - descrip := fmt.Sprintf("%s %s", strings.Title(cmdName.String()), descripWords) +func (d *DefaultCommitStatusUpdater) UpdateCombined(repo models.Repo, pull models.PullRequest, status models.CommitStatus, command models.CommandName) error { + src := d.TitleBuilder.Build(command.String()) + descrip := fmt.Sprintf("%s %s", strings.Title(command.String()), d.statusDescription(status)) return d.Client.UpdateStatus(repo, pull, status, src, descrip, "") } @@ -79,10 +70,14 @@ func (d *DefaultCommitStatusUpdater) UpdateProject(ctx models.ProjectCommandCont if projectID == "" { projectID = fmt.Sprintf("%s/%s", ctx.RepoRelDir, ctx.Workspace) } - src := d.TitleBuilder.Build(cmdName.String(), vcs.StatusTitleOptions{ ProjectName: projectID, }) + descrip := fmt.Sprintf("%s %s", strings.Title(cmdName.String()), d.statusDescription(status)) + return d.Client.UpdateStatus(ctx.BaseRepo, ctx.Pull, status, src, descrip, url) +} + +func (d *DefaultCommitStatusUpdater) statusDescription(status models.CommitStatus) string { var descripWords string switch status { case models.PendingCommitStatus: @@ -92,6 +87,6 @@ func (d *DefaultCommitStatusUpdater) UpdateProject(ctx models.ProjectCommandCont case models.SuccessCommitStatus: descripWords = "succeeded." } - descrip := fmt.Sprintf("%s %s", strings.Title(cmdName.String()), descripWords) - return d.Client.UpdateStatus(ctx.BaseRepo, ctx.Pull, status, src, descrip, url) + + return descripWords } diff --git a/server/events/mocks/mock_log_stream_url_generator.go b/server/events/mocks/mock_log_stream_url_generator.go new file mode 100644 index 0000000000..2742aa8015 --- /dev/null +++ b/server/events/mocks/mock_log_stream_url_generator.go @@ -0,0 +1,109 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events (interfaces: JobsUrlGenerator) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockJobsUrlGenerator struct { + fail func(message string, callerSkip ...int) +} + +func NewMockJobsUrlGenerator(options ...pegomock.Option) *MockJobsUrlGenerator { + mock := &MockJobsUrlGenerator{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockJobsUrlGenerator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockJobsUrlGenerator) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockJobsUrlGenerator) GenerateProjectJobsUrl(pull models.PullRequest, p models.ProjectCommandContext) string { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockJobsUrlGenerator().") + } + params := []pegomock.Param{pull, p} + result := pegomock.GetGenericMockFrom(mock).Invoke("GenerateProjectJobsUrl", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) + var ret0 string + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + } + return ret0 +} + +func (mock *MockJobsUrlGenerator) VerifyWasCalledOnce() *VerifierMockJobsUrlGenerator { + return &VerifierMockJobsUrlGenerator{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockJobsUrlGenerator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockJobsUrlGenerator { + return &VerifierMockJobsUrlGenerator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockJobsUrlGenerator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockJobsUrlGenerator { + return &VerifierMockJobsUrlGenerator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockJobsUrlGenerator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockJobsUrlGenerator { + return &VerifierMockJobsUrlGenerator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockJobsUrlGenerator struct { + mock *MockJobsUrlGenerator + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockJobsUrlGenerator) GenerateProjectJobsUrl(pull models.PullRequest, p models.ProjectCommandContext) *MockJobsUrlGenerator_GenerateProjectJobsUrl_OngoingVerification { + params := []pegomock.Param{pull, p} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GenerateProjectJobsUrl", params, verifier.timeout) + return &MockJobsUrlGenerator_GenerateProjectJobsUrl_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockJobsUrlGenerator_GenerateProjectJobsUrl_OngoingVerification struct { + mock *MockJobsUrlGenerator + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockJobsUrlGenerator_GenerateProjectJobsUrl_OngoingVerification) GetCapturedArguments() (models.PullRequest, models.ProjectCommandContext) { + pull, p := c.GetAllCapturedArguments() + return pull[len(pull)-1], p[len(p)-1] +} + +func (c *MockJobsUrlGenerator_GenerateProjectJobsUrl_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest, _param1 []models.ProjectCommandContext) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.PullRequest, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.PullRequest) + } + _param1 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(models.ProjectCommandContext) + } + } + return +} diff --git a/server/events/models/fixtures/fixtures.go b/server/events/models/fixtures/fixtures.go index 8aec38b4fb..420c2e7705 100644 --- a/server/events/models/fixtures/fixtures.go +++ b/server/events/models/fixtures/fixtures.go @@ -13,7 +13,12 @@ package fixtures -import "github.com/runatlantis/atlantis/server/events/models" +import ( + "fmt" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/yaml/valid" +) var Pull = models.PullRequest{ Num: 1, @@ -50,3 +55,11 @@ var GitlabRepo = models.Repo{ var User = models.User{ Username: "lkysow", } + +var projectName = "test-project" + +var Project = valid.Project{ + Name: &projectName, +} + +var PullInfo = fmt.Sprintf("%s/%d/%s", GithubRepo.FullName, Pull.Num, *Project.Name) diff --git a/server/events/models/models.go b/server/events/models/models.go index 93af30539d..b978f6ba02 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -33,6 +33,7 @@ import ( const ( planfileSlashReplace = "::" + LogStreamingClearMsg = "\n-----Starting New Process-----" ) type PullReqStatus struct { @@ -422,6 +423,23 @@ func (p ProjectCommandContext) GetShowResultFileName() string { return fmt.Sprintf("%s-%s.json", projName, p.Workspace) } +// Gets a unique identifier for the current pull request as a single string +func (p ProjectCommandContext) PullInfo() string { + return BuildPullInfo(p.BaseRepo.FullName, p.Pull.Num, p.ProjectName, p.RepoRelDir, p.Workspace) +} + +func BuildPullInfo(repoName string, pullNum int, projectName string, relDir string, workspace string) string { + projectIdentifier := GetProjectIdentifier(relDir, projectName) + return fmt.Sprintf("%s/%d/%s/%s", repoName, pullNum, projectIdentifier, workspace) +} + +func GetProjectIdentifier(relRepoDir string, projectName string) string { + if projectName != "" { + return projectName + } + return strings.ReplaceAll(relRepoDir, "/", "-") +} + // SplitRepoFullName splits a repo full name up into its owner and repo // name segments. If the repoFullName is malformed, may return empty // strings for owner or repo. @@ -665,6 +683,14 @@ func (c CommandName) TitleString() string { return strings.Title(strings.ReplaceAll(strings.ToLower(c.String()), "_", " ")) } +type ProjectCmdOutputLine struct { + ProjectInfo string + + Line string + + ClearBuffBefore bool +} + // String returns the string representation of c. func (c CommandName) String() string { switch c { diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 6f9eacc4d5..c648c73b7e 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -20,9 +20,11 @@ import ( "strings" "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/events/yaml/valid" + "github.com/runatlantis/atlantis/server/handlers" "github.com/runatlantis/atlantis/server/logging" ) @@ -113,6 +115,50 @@ type ProjectCommandRunner interface { ProjectVersionCommandRunner } +// ProjectOutputWrapper is a decorator that creates a new PR status check per project. +// The status contains a url that outputs current progress of the terraform plan/apply command. +type ProjectOutputWrapper struct { + ProjectCommandRunner + ProjectCmdOutputHandler handlers.ProjectCommandOutputHandler +} + +func (p *ProjectOutputWrapper) Plan(ctx models.ProjectCommandContext) models.ProjectResult { + // Reset the buffer when running the plan. We only need to do this for plan, + // apply is a continuation of the same workflow + p.ProjectCmdOutputHandler.Clear(ctx) + return p.updateProjectPRStatus(models.PlanCommand, ctx, p.ProjectCommandRunner.Plan) +} + +func (p *ProjectOutputWrapper) Apply(ctx models.ProjectCommandContext) models.ProjectResult { + return p.updateProjectPRStatus(models.ApplyCommand, ctx, p.ProjectCommandRunner.Apply) +} + +func (p *ProjectOutputWrapper) updateProjectPRStatus(commandName models.CommandName, ctx models.ProjectCommandContext, execute func(ctx models.ProjectCommandContext) models.ProjectResult) models.ProjectResult { + // Create a PR status to track project's plan status. The status will + // include a link to view the progress of atlantis plan command in real + // time + if err := p.ProjectCmdOutputHandler.SetJobURLWithStatus(ctx, commandName, models.PendingCommitStatus); err != nil { + ctx.Log.Err("updating project PR status", err) + } + + // ensures we are differentiating between project level command and overall command + result := execute(ctx) + + if result.Error != nil || result.Failure != "" { + if err := p.ProjectCmdOutputHandler.SetJobURLWithStatus(ctx, commandName, models.FailedCommitStatus); err != nil { + ctx.Log.Err("updating project PR status", err) + } + + return result + } + + if err := p.ProjectCmdOutputHandler.SetJobURLWithStatus(ctx, commandName, models.SuccessCommitStatus); err != nil { + ctx.Log.Err("updating project PR status", err) + } + + return result +} + // DefaultProjectCommandRunner implements ProjectCommandRunner. type DefaultProjectCommandRunner struct { Locker ProjectLocker @@ -125,6 +171,7 @@ type DefaultProjectCommandRunner struct { VersionStepRunner StepRunner RunStepRunner CustomStepRunner EnvStepRunner EnvStepRunner + PullApprovedChecker runtime.PullApprovedChecker WorkingDir WorkingDir Webhooks WebhooksSender WorkingDirLocker WorkingDirLocker @@ -312,6 +359,7 @@ func (p *DefaultProjectCommandRunner) doPlan(ctx models.ProjectCommandContext) ( } outputs, err := p.runSteps(ctx.Steps, ctx, projAbsPath) + if err != nil { if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) @@ -354,6 +402,7 @@ func (p *DefaultProjectCommandRunner) doApply(ctx models.ProjectCommandContext) defer unlockFn() outputs, err := p.runSteps(ctx.Steps, ctx, absPath) + p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck Workspace: ctx.Workspace, User: ctx.User, @@ -362,9 +411,11 @@ func (p *DefaultProjectCommandRunner) doApply(ctx models.ProjectCommandContext) Success: err == nil, Directory: ctx.RepoRelDir, }) + if err != nil { return "", "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } + return strings.Join(outputs, "\n"), "", nil } @@ -398,6 +449,7 @@ func (p *DefaultProjectCommandRunner) doVersion(ctx models.ProjectCommandContext func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx models.ProjectCommandContext, absPath string) ([]string, error) { var outputs []string + envs := make(map[string]string) for _, step := range steps { var out string diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go index 4ece1f1946..5c63eb877d 100644 --- a/server/events/project_command_runner_test.go +++ b/server/events/project_command_runner_test.go @@ -14,6 +14,8 @@ package events_test import ( + "errors" + "fmt" "os" "testing" @@ -26,6 +28,7 @@ import ( "github.com/runatlantis/atlantis/server/events/mocks/matchers" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/yaml/valid" + handlermocks "github.com/runatlantis/atlantis/server/handlers/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) @@ -50,6 +53,7 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { ApplyStepRunner: mockApply, RunStepRunner: mockRun, EnvStepRunner: &realEnv, + PullApprovedChecker: nil, WorkingDir: mockWorkingDir, Webhooks: nil, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), @@ -112,7 +116,6 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { Assert(t, res.PlanSuccess != nil, "exp plan success") Equals(t, "https://lock-key", res.PlanSuccess.LockURL) Equals(t, "run\napply\nplan\ninit", res.PlanSuccess.TerraformOutput) - expSteps := []string{"run", "apply", "plan", "init", "env"} for _, step := range expSteps { switch step { @@ -128,6 +131,112 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) { } } +func TestProjectOutputWrapper(t *testing.T) { + RegisterMockTestingT(t) + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Steps: []valid.Step{ + { + StepName: "plan", + }, + }, + Workspace: "default", + RepoRelDir: ".", + } + + cases := []struct { + Description string + Failure bool + Error bool + Success bool + CommandName models.CommandName + }{ + { + Description: "plan success", + Success: true, + CommandName: models.PlanCommand, + }, + { + Description: "plan failure", + Failure: true, + CommandName: models.PlanCommand, + }, + { + Description: "plan error", + Error: true, + CommandName: models.PlanCommand, + }, + { + Description: "apply success", + Success: true, + CommandName: models.ApplyCommand, + }, + { + Description: "apply failure", + Failure: true, + CommandName: models.ApplyCommand, + }, + { + Description: "apply error", + Error: true, + CommandName: models.ApplyCommand, + }, + } + + for _, c := range cases { + t.Run(c.Description, func(t *testing.T) { + var prjResult models.ProjectResult + var expCommitStatus models.CommitStatus + + mockProjectCommandOutputHandler := handlermocks.NewMockProjectCommandOutputHandler() + mockProjectCommandRunner := mocks.NewMockProjectCommandRunner() + + runner := &events.ProjectOutputWrapper{ + ProjectCmdOutputHandler: mockProjectCommandOutputHandler, + ProjectCommandRunner: mockProjectCommandRunner, + } + + if c.Success { + prjResult = models.ProjectResult{ + PlanSuccess: &models.PlanSuccess{}, + ApplySuccess: "exists", + } + expCommitStatus = models.SuccessCommitStatus + } else if c.Failure { + prjResult = models.ProjectResult{ + Failure: "failure", + } + expCommitStatus = models.FailedCommitStatus + } else if c.Error { + prjResult = models.ProjectResult{ + Error: errors.New("error"), + } + expCommitStatus = models.FailedCommitStatus + } + + When(mockProjectCommandRunner.Plan(matchers.AnyModelsProjectCommandContext())).ThenReturn(prjResult) + When(mockProjectCommandRunner.Apply(matchers.AnyModelsProjectCommandContext())).ThenReturn(prjResult) + + switch c.CommandName { + case models.PlanCommand: + runner.Plan(ctx) + case models.ApplyCommand: + runner.Apply(ctx) + } + + mockProjectCommandOutputHandler.VerifyWasCalled(Once()).SetJobURLWithStatus(ctx, c.CommandName, models.PendingCommitStatus) + mockProjectCommandOutputHandler.VerifyWasCalled(Once()).SetJobURLWithStatus(ctx, c.CommandName, expCommitStatus) + + switch c.CommandName { + case models.PlanCommand: + mockProjectCommandRunner.VerifyWasCalledOnce().Plan(ctx) + case models.ApplyCommand: + mockProjectCommandRunner.VerifyWasCalledOnce().Apply(ctx) + } + }) + } +} + // Test what happens if there's no working dir. This signals that the project // was never planned. func TestDefaultProjectCommandRunner_ApplyNotCloned(t *testing.T) { @@ -370,6 +479,54 @@ func TestDefaultProjectCommandRunner_Apply(t *testing.T) { } } +// Test that it runs the expected apply steps. +func TestDefaultProjectCommandRunner_ApplyRunStepFailure(t *testing.T) { + RegisterMockTestingT(t) + mockApply := mocks.NewMockStepRunner() + mockWorkingDir := mocks.NewMockWorkingDir() + mockLocker := mocks.NewMockProjectLocker() + mockSender := mocks.NewMockWebhooksSender() + applyReqHandler := &events.AggregateApplyRequirements{ + WorkingDir: mockWorkingDir, + } + + runner := events.DefaultProjectCommandRunner{ + Locker: mockLocker, + LockURLGenerator: mockURLGenerator{}, + ApplyStepRunner: mockApply, + WorkingDir: mockWorkingDir, + WorkingDirLocker: events.NewDefaultWorkingDirLocker(), + AggregateApplyRequirements: applyReqHandler, + Webhooks: mockSender, + } + repoDir, cleanup := TempDir(t) + defer cleanup() + When(mockWorkingDir.GetWorkingDir( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest(), + AnyString(), + )).ThenReturn(repoDir, nil) + + ctx := models.ProjectCommandContext{ + Log: logging.NewNoopLogger(t), + Steps: []valid.Step{ + { + StepName: "apply", + }, + }, + Workspace: "default", + ApplyRequirements: []string{}, + RepoRelDir: ".", + } + expEnvs := map[string]string{} + When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", fmt.Errorf("something went wrong")) + + res := runner.Apply(ctx) + Assert(t, res.ApplySuccess == "", "exp apply failure") + + mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) +} + // Test run and env steps. We don't use mocks for this test since we're // not running any Terraform. func TestDefaultProjectCommandRunner_RunEnvSteps(t *testing.T) { diff --git a/server/events/pull_closed_executor.go b/server/events/pull_closed_executor.go index 2ecaebe042..e20fb4b50d 100644 --- a/server/events/pull_closed_executor.go +++ b/server/events/pull_closed_executor.go @@ -16,6 +16,7 @@ package events import ( "bytes" "fmt" + "io" "sort" "strings" "text/template" @@ -28,6 +29,7 @@ import ( "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/handlers" ) //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_pull_cleaner.go PullCleaner @@ -42,11 +44,13 @@ type PullCleaner interface { // PullClosedExecutor executes the tasks required to clean up a closed pull // request. type PullClosedExecutor struct { - Locker locking.Locker - VCSClient vcs.Client - WorkingDir WorkingDir - Logger logging.SimpleLogging - DB *db.BoltDB + Locker locking.Locker + VCSClient vcs.Client + WorkingDir WorkingDir + Logger logging.SimpleLogging + DB *db.BoltDB + PullClosedTemplate PullCleanupTemplate + LogStreamResourceCleaner handlers.ResourceCleaner } type templatedProject struct { @@ -59,8 +63,31 @@ var pullClosedTemplate = template.Must(template.New("").Parse( "{{ range . }}\n" + "- dir: `{{ .RepoRelDir }}` {{ .Workspaces }}{{ end }}")) +type PullCleanupTemplate interface { + Execute(wr io.Writer, data interface{}) error +} + +type PullClosedEventTemplate struct{} + +func (t *PullClosedEventTemplate) Execute(wr io.Writer, data interface{}) error { + return pullClosedTemplate.Execute(wr, data) +} + // CleanUpPull cleans up after a closed pull request. func (p *PullClosedExecutor) CleanUpPull(repo models.Repo, pull models.PullRequest) error { + pullStatus, err := p.DB.GetPullStatus(pull) + if err != nil { + // Log and continue to clean up other resources. + p.Logger.Err("retrieving pull status: %s", err) + } + + if pullStatus != nil { + for _, project := range pullStatus.Projects { + projectKey := models.BuildPullInfo(pullStatus.Pull.BaseRepo.FullName, pull.Num, project.ProjectName, project.RepoRelDir, project.Workspace) + p.LogStreamResourceCleaner.CleanUp(projectKey) + } + } + if err := p.WorkingDir.Delete(repo, pull); err != nil { return errors.Wrap(err, "cleaning workspace") } diff --git a/server/events/pull_closed_executor_test.go b/server/events/pull_closed_executor_test.go index e5d5953bbf..c1d1f8f9bb 100644 --- a/server/events/pull_closed_executor_test.go +++ b/server/events/pull_closed_executor_test.go @@ -14,10 +14,14 @@ package events_test import ( - "errors" + "io/ioutil" "testing" + "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/db" + "github.com/runatlantis/atlantis/server/handlers" + "github.com/stretchr/testify/assert" + bolt "go.etcd.io/bbolt" . "github.com/petergtz/pegomock" lockmocks "github.com/runatlantis/atlantis/server/core/locking/mocks" @@ -27,6 +31,8 @@ import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/fixtures" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + handlermocks "github.com/runatlantis/atlantis/server/handlers/mocks" + loggermocks "github.com/runatlantis/atlantis/server/logging/mocks" . "github.com/runatlantis/atlantis/testing" ) @@ -34,10 +40,16 @@ func TestCleanUpPullWorkspaceErr(t *testing.T) { t.Log("when workspace.Delete returns an error, we return it") RegisterMockTestingT(t) w := mocks.NewMockWorkingDir() + tmp, cleanup := TempDir(t) + defer cleanup() + db, err := db.New(tmp) + Ok(t, err) pce := events.PullClosedExecutor{ - WorkingDir: w, + WorkingDir: w, + PullClosedTemplate: &events.PullClosedEventTemplate{}, + DB: db, } - err := errors.New("err") + err = errors.New("err") When(w.Delete(fixtures.GithubRepo, fixtures.Pull)).ThenReturn(err) actualErr := pce.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) Equals(t, "cleaning workspace: err", actualErr.Error()) @@ -48,11 +60,17 @@ func TestCleanUpPullUnlockErr(t *testing.T) { RegisterMockTestingT(t) w := mocks.NewMockWorkingDir() l := lockmocks.NewMockLocker() + tmp, cleanup := TempDir(t) + defer cleanup() + db, err := db.New(tmp) + Ok(t, err) pce := events.PullClosedExecutor{ - Locker: l, - WorkingDir: w, + Locker: l, + WorkingDir: w, + DB: db, + PullClosedTemplate: &events.PullClosedEventTemplate{}, } - err := errors.New("err") + err = errors.New("err") When(l.UnlockByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(nil, err) actualErr := pce.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) Equals(t, "cleaning up locks: err", actualErr.Error()) @@ -171,3 +189,100 @@ func TestCleanUpPullComments(t *testing.T) { }() } } + +func TestCleanUpLogStreaming(t *testing.T) { + RegisterMockTestingT(t) + + t.Run("Should Clean Up Log Streaming Resources When PR is closed", func(t *testing.T) { + prjStatusUpdater := handlermocks.NewMockProjectStatusUpdater() + prjJobURLGenerator := handlermocks.NewMockProjectJobURLGenerator() + + // Create Log streaming resources + prjCmdOutput := make(chan *models.ProjectCmdOutputLine) + prjCmdOutHandler := handlers.NewAsyncProjectCommandOutputHandler(prjCmdOutput, prjStatusUpdater, prjJobURLGenerator, logger) + ctx := models.ProjectCommandContext{ + BaseRepo: fixtures.GithubRepo, + Pull: fixtures.Pull, + ProjectName: *fixtures.Project.Name, + Workspace: "default", + } + + go prjCmdOutHandler.Handle() + prjCmdOutHandler.Send(ctx, "Test Message") + + // Create boltdb and add pull request. + var lockBucket = "bucket" + var configBucket = "configBucket" + var pullsBucketName = "pulls" + + f, err := ioutil.TempFile("", "") + if err != nil { + panic(errors.Wrap(err, "failed to create temp file")) + } + path := f.Name() + f.Close() // nolint: errcheck + + // Open the database. + boltDB, err := bolt.Open(path, 0600, nil) + if err != nil { + panic(errors.Wrap(err, "could not start bolt DB")) + } + if err := boltDB.Update(func(tx *bolt.Tx) error { + if _, err := tx.CreateBucketIfNotExists([]byte(pullsBucketName)); err != nil { + return errors.Wrap(err, "failed to create bucket") + } + return nil + }); err != nil { + panic(errors.Wrap(err, "could not create bucket")) + } + db, _ := db.NewWithDB(boltDB, lockBucket, configBucket) + result := []models.ProjectResult{ + { + RepoRelDir: fixtures.GithubRepo.FullName, + Workspace: "default", + ProjectName: *fixtures.Project.Name, + }, + } + + // Create a new record for pull + _, err = db.UpdatePullWithResults(fixtures.Pull, result) + Ok(t, err) + + workingDir := mocks.NewMockWorkingDir() + locker := lockmocks.NewMockLocker() + client := vcsmocks.NewMockClient() + logger := loggermocks.NewMockSimpleLogging() + + pullClosedExecutor := events.PullClosedExecutor{ + Locker: locker, + WorkingDir: workingDir, + DB: db, + VCSClient: client, + PullClosedTemplate: &events.PullClosedEventTemplate{}, + LogStreamResourceCleaner: prjCmdOutHandler, + Logger: logger, + } + + locks := []models.ProjectLock{ + { + Project: models.NewProject(fixtures.GithubRepo.FullName, ""), + Workspace: "default", + }, + } + When(locker.UnlockByPull(fixtures.GithubRepo.FullName, fixtures.Pull.Num)).ThenReturn(locks, nil) + + // Clean up. + err = pullClosedExecutor.CleanUpPull(fixtures.GithubRepo, fixtures.Pull) + Ok(t, err) + + close(prjCmdOutput) + _, _, comment, _ := client.VerifyWasCalledOnce().CreateComment(matchers.AnyModelsRepo(), AnyInt(), AnyString(), AnyString()).GetCapturedArguments() + expectedComment := "Locks and plans deleted for the projects and workspaces modified in this pull request:\n\n" + "- dir: `.` workspace: `default`" + Equals(t, expectedComment, comment) + + // Assert log streaming resources are cleaned up. + dfPrjCmdOutputHandler := prjCmdOutHandler.(*handlers.AsyncProjectCommandOutputHandler) + assert.Empty(t, dfPrjCmdOutputHandler.GetProjectOutputBuffer(ctx.PullInfo())) + assert.Empty(t, dfPrjCmdOutputHandler.GetReceiverBufferForPull(ctx.PullInfo())) + }) +} diff --git a/server/events/terraform/ansi/strip.go b/server/events/terraform/ansi/strip.go new file mode 100644 index 0000000000..fa8265de2b --- /dev/null +++ b/server/events/terraform/ansi/strip.go @@ -0,0 +1,13 @@ +package ansi + +import ( + "regexp" +) + +const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + +var re = regexp.MustCompile(ansi) + +func Strip(str string) string { + return re.ReplaceAllString(str, "") +} diff --git a/server/handlers/mocks/matchers/chan_of_string.go b/server/handlers/mocks/matchers/chan_of_string.go new file mode 100644 index 0000000000..e1bfee5726 --- /dev/null +++ b/server/handlers/mocks/matchers/chan_of_string.go @@ -0,0 +1,31 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" +) + +func AnyChanOfString() chan string { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(chan string))(nil)).Elem())) + var nullValue chan string + return nullValue +} + +func EqChanOfString(value chan string) chan string { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue chan string + return nullValue +} + +func NotEqChanOfString(value chan string) chan string { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue chan string + return nullValue +} + +func ChanOfStringThat(matcher pegomock.ArgumentMatcher) chan string { + pegomock.RegisterMatcher(matcher) + var nullValue chan string + return nullValue +} diff --git a/server/handlers/mocks/matchers/http_header.go b/server/handlers/mocks/matchers/http_header.go new file mode 100644 index 0000000000..7531557917 --- /dev/null +++ b/server/handlers/mocks/matchers/http_header.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + http "net/http" +) + +func AnyHttpHeader() http.Header { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(http.Header))(nil)).Elem())) + var nullValue http.Header + return nullValue +} + +func EqHttpHeader(value http.Header) http.Header { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue http.Header + return nullValue +} + +func NotEqHttpHeader(value http.Header) http.Header { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue http.Header + return nullValue +} + +func HttpHeaderThat(matcher pegomock.ArgumentMatcher) http.Header { + pegomock.RegisterMatcher(matcher) + var nullValue http.Header + return nullValue +} diff --git a/server/handlers/mocks/matchers/http_responsewriter.go b/server/handlers/mocks/matchers/http_responsewriter.go new file mode 100644 index 0000000000..1927eca531 --- /dev/null +++ b/server/handlers/mocks/matchers/http_responsewriter.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + http "net/http" +) + +func AnyHttpResponseWriter() http.ResponseWriter { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(http.ResponseWriter))(nil)).Elem())) + var nullValue http.ResponseWriter + return nullValue +} + +func EqHttpResponseWriter(value http.ResponseWriter) http.ResponseWriter { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue http.ResponseWriter + return nullValue +} + +func NotEqHttpResponseWriter(value http.ResponseWriter) http.ResponseWriter { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue http.ResponseWriter + return nullValue +} + +func HttpResponseWriterThat(matcher pegomock.ArgumentMatcher) http.ResponseWriter { + pegomock.RegisterMatcher(matcher) + var nullValue http.ResponseWriter + return nullValue +} diff --git a/server/handlers/mocks/matchers/map_of_chan_of_string_to_bool.go b/server/handlers/mocks/matchers/map_of_chan_of_string_to_bool.go new file mode 100644 index 0000000000..5cd33d3bac --- /dev/null +++ b/server/handlers/mocks/matchers/map_of_chan_of_string_to_bool.go @@ -0,0 +1,31 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" +) + +func AnyMapOfChanOfStringToBool() map[chan string]bool { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(map[chan string]bool))(nil)).Elem())) + var nullValue map[chan string]bool + return nullValue +} + +func EqMapOfChanOfStringToBool(value map[chan string]bool) map[chan string]bool { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue map[chan string]bool + return nullValue +} + +func NotEqMapOfChanOfStringToBool(value map[chan string]bool) map[chan string]bool { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue map[chan string]bool + return nullValue +} + +func MapOfChanOfStringToBoolThat(matcher pegomock.ArgumentMatcher) map[chan string]bool { + pegomock.RegisterMatcher(matcher) + var nullValue map[chan string]bool + return nullValue +} diff --git a/server/handlers/mocks/matchers/models_commandname.go b/server/handlers/mocks/matchers/models_commandname.go new file mode 100644 index 0000000000..f586b4d216 --- /dev/null +++ b/server/handlers/mocks/matchers/models_commandname.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyModelsCommandName() models.CommandName { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.CommandName))(nil)).Elem())) + var nullValue models.CommandName + return nullValue +} + +func EqModelsCommandName(value models.CommandName) models.CommandName { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue models.CommandName + return nullValue +} + +func NotEqModelsCommandName(value models.CommandName) models.CommandName { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue models.CommandName + return nullValue +} + +func ModelsCommandNameThat(matcher pegomock.ArgumentMatcher) models.CommandName { + pegomock.RegisterMatcher(matcher) + var nullValue models.CommandName + return nullValue +} diff --git a/server/handlers/mocks/matchers/models_commitstatus.go b/server/handlers/mocks/matchers/models_commitstatus.go new file mode 100644 index 0000000000..1e10ed7823 --- /dev/null +++ b/server/handlers/mocks/matchers/models_commitstatus.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyModelsCommitStatus() models.CommitStatus { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.CommitStatus))(nil)).Elem())) + var nullValue models.CommitStatus + return nullValue +} + +func EqModelsCommitStatus(value models.CommitStatus) models.CommitStatus { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue models.CommitStatus + return nullValue +} + +func NotEqModelsCommitStatus(value models.CommitStatus) models.CommitStatus { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue models.CommitStatus + return nullValue +} + +func ModelsCommitStatusThat(matcher pegomock.ArgumentMatcher) models.CommitStatus { + pegomock.RegisterMatcher(matcher) + var nullValue models.CommitStatus + return nullValue +} diff --git a/server/handlers/mocks/matchers/models_projectcommandcontext.go b/server/handlers/mocks/matchers/models_projectcommandcontext.go new file mode 100644 index 0000000000..535f8b9671 --- /dev/null +++ b/server/handlers/mocks/matchers/models_projectcommandcontext.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyModelsProjectCommandContext() models.ProjectCommandContext { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.ProjectCommandContext))(nil)).Elem())) + var nullValue models.ProjectCommandContext + return nullValue +} + +func EqModelsProjectCommandContext(value models.ProjectCommandContext) models.ProjectCommandContext { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue models.ProjectCommandContext + return nullValue +} + +func NotEqModelsProjectCommandContext(value models.ProjectCommandContext) models.ProjectCommandContext { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue models.ProjectCommandContext + return nullValue +} + +func ModelsProjectCommandContextThat(matcher pegomock.ArgumentMatcher) models.ProjectCommandContext { + pegomock.RegisterMatcher(matcher) + var nullValue models.ProjectCommandContext + return nullValue +} diff --git a/server/handlers/mocks/matchers/ptr_to_http_request.go b/server/handlers/mocks/matchers/ptr_to_http_request.go new file mode 100644 index 0000000000..dfbfc18674 --- /dev/null +++ b/server/handlers/mocks/matchers/ptr_to_http_request.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + http "net/http" +) + +func AnyPtrToHttpRequest() *http.Request { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*http.Request))(nil)).Elem())) + var nullValue *http.Request + return nullValue +} + +func EqPtrToHttpRequest(value *http.Request) *http.Request { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue *http.Request + return nullValue +} + +func NotEqPtrToHttpRequest(value *http.Request) *http.Request { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue *http.Request + return nullValue +} + +func PtrToHttpRequestThat(matcher pegomock.ArgumentMatcher) *http.Request { + pegomock.RegisterMatcher(matcher) + var nullValue *http.Request + return nullValue +} diff --git a/server/handlers/mocks/matchers/slice_of_byte.go b/server/handlers/mocks/matchers/slice_of_byte.go new file mode 100644 index 0000000000..9515313456 --- /dev/null +++ b/server/handlers/mocks/matchers/slice_of_byte.go @@ -0,0 +1,31 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" +) + +func AnySliceOfByte() []byte { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*([]byte))(nil)).Elem())) + var nullValue []byte + return nullValue +} + +func EqSliceOfByte(value []byte) []byte { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue []byte + return nullValue +} + +func NotEqSliceOfByte(value []byte) []byte { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue []byte + return nullValue +} + +func SliceOfByteThat(matcher pegomock.ArgumentMatcher) []byte { + pegomock.RegisterMatcher(matcher) + var nullValue []byte + return nullValue +} diff --git a/server/handlers/mocks/matchers/slice_of_string.go b/server/handlers/mocks/matchers/slice_of_string.go new file mode 100644 index 0000000000..f9281819dd --- /dev/null +++ b/server/handlers/mocks/matchers/slice_of_string.go @@ -0,0 +1,31 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" +) + +func AnySliceOfString() []string { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*([]string))(nil)).Elem())) + var nullValue []string + return nullValue +} + +func EqSliceOfString(value []string) []string { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue []string + return nullValue +} + +func NotEqSliceOfString(value []string) []string { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue []string + return nullValue +} + +func SliceOfStringThat(matcher pegomock.ArgumentMatcher) []string { + pegomock.RegisterMatcher(matcher) + var nullValue []string + return nullValue +} diff --git a/server/handlers/mocks/mock_project_command_output_handler.go b/server/handlers/mocks/mock_project_command_output_handler.go new file mode 100644 index 0000000000..f119e70290 --- /dev/null +++ b/server/handlers/mocks/mock_project_command_output_handler.go @@ -0,0 +1,325 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/handlers (interfaces: ProjectCommandOutputHandler) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockProjectCommandOutputHandler struct { + fail func(message string, callerSkip ...int) +} + +func NewMockProjectCommandOutputHandler(options ...pegomock.Option) *MockProjectCommandOutputHandler { + mock := &MockProjectCommandOutputHandler{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockProjectCommandOutputHandler) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockProjectCommandOutputHandler) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockProjectCommandOutputHandler) CleanUp(_param0 string) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{_param0} + pegomock.GetGenericMockFrom(mock).Invoke("CleanUp", params, []reflect.Type{}) +} + +func (mock *MockProjectCommandOutputHandler) Clear(_param0 models.ProjectCommandContext) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{_param0} + pegomock.GetGenericMockFrom(mock).Invoke("Clear", params, []reflect.Type{}) +} + +func (mock *MockProjectCommandOutputHandler) Deregister(_param0 string, _param1 chan string) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{_param0, _param1} + pegomock.GetGenericMockFrom(mock).Invoke("Deregister", params, []reflect.Type{}) +} + +func (mock *MockProjectCommandOutputHandler) Handle() { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{} + pegomock.GetGenericMockFrom(mock).Invoke("Handle", params, []reflect.Type{}) +} + +func (mock *MockProjectCommandOutputHandler) Register(_param0 string, _param1 chan string) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{_param0, _param1} + pegomock.GetGenericMockFrom(mock).Invoke("Register", params, []reflect.Type{}) +} + +func (mock *MockProjectCommandOutputHandler) Send(_param0 models.ProjectCommandContext, _param1 string) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{_param0, _param1} + pegomock.GetGenericMockFrom(mock).Invoke("Send", params, []reflect.Type{}) +} + +func (mock *MockProjectCommandOutputHandler) SetJobURLWithStatus(_param0 models.ProjectCommandContext, _param1 models.CommandName, _param2 models.CommitStatus) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") + } + params := []pegomock.Param{_param0, _param1, _param2} + result := pegomock.GetGenericMockFrom(mock).Invoke("SetJobURLWithStatus", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockProjectCommandOutputHandler) VerifyWasCalledOnce() *VerifierMockProjectCommandOutputHandler { + return &VerifierMockProjectCommandOutputHandler{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockProjectCommandOutputHandler) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectCommandOutputHandler { + return &VerifierMockProjectCommandOutputHandler{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockProjectCommandOutputHandler) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectCommandOutputHandler { + return &VerifierMockProjectCommandOutputHandler{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockProjectCommandOutputHandler) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectCommandOutputHandler { + return &VerifierMockProjectCommandOutputHandler{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockProjectCommandOutputHandler struct { + mock *MockProjectCommandOutputHandler + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockProjectCommandOutputHandler) CleanUp(_param0 string) *MockProjectCommandOutputHandler_CleanUp_OngoingVerification { + params := []pegomock.Param{_param0} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CleanUp", params, verifier.timeout) + return &MockProjectCommandOutputHandler_CleanUp_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_CleanUp_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_CleanUp_OngoingVerification) GetCapturedArguments() string { + _param0 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1] +} + +func (c *MockProjectCommandOutputHandler_CleanUp_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} + +func (verifier *VerifierMockProjectCommandOutputHandler) Clear(_param0 models.ProjectCommandContext) *MockProjectCommandOutputHandler_Clear_OngoingVerification { + params := []pegomock.Param{_param0} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Clear", params, verifier.timeout) + return &MockProjectCommandOutputHandler_Clear_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_Clear_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_Clear_OngoingVerification) GetCapturedArguments() models.ProjectCommandContext { + _param0 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1] +} + +func (c *MockProjectCommandOutputHandler_Clear_OngoingVerification) GetAllCapturedArguments() (_param0 []models.ProjectCommandContext) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.ProjectCommandContext) + } + } + return +} + +func (verifier *VerifierMockProjectCommandOutputHandler) Deregister(_param0 string, _param1 chan string) *MockProjectCommandOutputHandler_Deregister_OngoingVerification { + params := []pegomock.Param{_param0, _param1} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Deregister", params, verifier.timeout) + return &MockProjectCommandOutputHandler_Deregister_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_Deregister_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_Deregister_OngoingVerification) GetCapturedArguments() (string, chan string) { + _param0, _param1 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1] +} + +func (c *MockProjectCommandOutputHandler_Deregister_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []chan string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([]chan string, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(chan string) + } + } + return +} + +func (verifier *VerifierMockProjectCommandOutputHandler) Handle() *MockProjectCommandOutputHandler_Handle_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Handle", params, verifier.timeout) + return &MockProjectCommandOutputHandler_Handle_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_Handle_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_Handle_OngoingVerification) GetCapturedArguments() { +} + +func (c *MockProjectCommandOutputHandler_Handle_OngoingVerification) GetAllCapturedArguments() { +} + +func (verifier *VerifierMockProjectCommandOutputHandler) Register(_param0 string, _param1 chan string) *MockProjectCommandOutputHandler_Register_OngoingVerification { + params := []pegomock.Param{_param0, _param1} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Register", params, verifier.timeout) + return &MockProjectCommandOutputHandler_Register_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_Register_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_Register_OngoingVerification) GetCapturedArguments() (string, chan string) { + _param0, _param1 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1] +} + +func (c *MockProjectCommandOutputHandler_Register_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []chan string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([]chan string, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(chan string) + } + } + return +} + +func (verifier *VerifierMockProjectCommandOutputHandler) Send(_param0 models.ProjectCommandContext, _param1 string) *MockProjectCommandOutputHandler_Send_OngoingVerification { + params := []pegomock.Param{_param0, _param1} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Send", params, verifier.timeout) + return &MockProjectCommandOutputHandler_Send_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_Send_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_Send_OngoingVerification) GetCapturedArguments() (models.ProjectCommandContext, string) { + _param0, _param1 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1] +} + +func (c *MockProjectCommandOutputHandler_Send_OngoingVerification) GetAllCapturedArguments() (_param0 []models.ProjectCommandContext, _param1 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.ProjectCommandContext) + } + _param1 = make([]string, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(string) + } + } + return +} + +func (verifier *VerifierMockProjectCommandOutputHandler) SetJobURLWithStatus(_param0 models.ProjectCommandContext, _param1 models.CommandName, _param2 models.CommitStatus) *MockProjectCommandOutputHandler_SetJobURLWithStatus_OngoingVerification { + params := []pegomock.Param{_param0, _param1, _param2} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SetJobURLWithStatus", params, verifier.timeout) + return &MockProjectCommandOutputHandler_SetJobURLWithStatus_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandOutputHandler_SetJobURLWithStatus_OngoingVerification struct { + mock *MockProjectCommandOutputHandler + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandOutputHandler_SetJobURLWithStatus_OngoingVerification) GetCapturedArguments() (models.ProjectCommandContext, models.CommandName, models.CommitStatus) { + _param0, _param1, _param2 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1], _param2[len(_param2)-1] +} + +func (c *MockProjectCommandOutputHandler_SetJobURLWithStatus_OngoingVerification) GetAllCapturedArguments() (_param0 []models.ProjectCommandContext, _param1 []models.CommandName, _param2 []models.CommitStatus) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.ProjectCommandContext) + } + _param1 = make([]models.CommandName, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(models.CommandName) + } + _param2 = make([]models.CommitStatus, len(c.methodInvocations)) + for u, param := range params[2] { + _param2[u] = param.(models.CommitStatus) + } + } + return +} diff --git a/server/handlers/mocks/mock_project_job_url_generator.go b/server/handlers/mocks/mock_project_job_url_generator.go new file mode 100644 index 0000000000..7386a383cc --- /dev/null +++ b/server/handlers/mocks/mock_project_job_url_generator.go @@ -0,0 +1,109 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/handlers (interfaces: ProjectJobURLGenerator) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockProjectJobURLGenerator struct { + fail func(message string, callerSkip ...int) +} + +func NewMockProjectJobURLGenerator(options ...pegomock.Option) *MockProjectJobURLGenerator { + mock := &MockProjectJobURLGenerator{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockProjectJobURLGenerator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockProjectJobURLGenerator) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockProjectJobURLGenerator) GenerateProjectJobURL(p models.ProjectCommandContext) (string, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectJobURLGenerator().") + } + params := []pegomock.Param{p} + result := pegomock.GetGenericMockFrom(mock).Invoke("GenerateProjectJobURL", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 string + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + +func (mock *MockProjectJobURLGenerator) VerifyWasCalledOnce() *VerifierMockProjectJobURLGenerator { + return &VerifierMockProjectJobURLGenerator{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockProjectJobURLGenerator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectJobURLGenerator { + return &VerifierMockProjectJobURLGenerator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockProjectJobURLGenerator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectJobURLGenerator { + return &VerifierMockProjectJobURLGenerator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockProjectJobURLGenerator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectJobURLGenerator { + return &VerifierMockProjectJobURLGenerator{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockProjectJobURLGenerator struct { + mock *MockProjectJobURLGenerator + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockProjectJobURLGenerator) GenerateProjectJobURL(p models.ProjectCommandContext) *MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification { + params := []pegomock.Param{p} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GenerateProjectJobURL", params, verifier.timeout) + return &MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification struct { + mock *MockProjectJobURLGenerator + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification) GetCapturedArguments() models.ProjectCommandContext { + p := c.GetAllCapturedArguments() + return p[len(p)-1] +} + +func (c *MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification) GetAllCapturedArguments() (_param0 []models.ProjectCommandContext) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.ProjectCommandContext) + } + } + return +} diff --git a/server/handlers/mocks/mock_project_status_updater.go b/server/handlers/mocks/mock_project_status_updater.go new file mode 100644 index 0000000000..dd5a42d40a --- /dev/null +++ b/server/handlers/mocks/mock_project_status_updater.go @@ -0,0 +1,117 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/handlers (interfaces: ProjectStatusUpdater) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockProjectStatusUpdater struct { + fail func(message string, callerSkip ...int) +} + +func NewMockProjectStatusUpdater(options ...pegomock.Option) *MockProjectStatusUpdater { + mock := &MockProjectStatusUpdater{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockProjectStatusUpdater) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockProjectStatusUpdater) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockProjectStatusUpdater) UpdateProject(ctx models.ProjectCommandContext, cmdName models.CommandName, status models.CommitStatus, url string) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectStatusUpdater().") + } + params := []pegomock.Param{ctx, cmdName, status, url} + result := pegomock.GetGenericMockFrom(mock).Invoke("UpdateProject", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockProjectStatusUpdater) VerifyWasCalledOnce() *VerifierMockProjectStatusUpdater { + return &VerifierMockProjectStatusUpdater{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockProjectStatusUpdater) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectStatusUpdater { + return &VerifierMockProjectStatusUpdater{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockProjectStatusUpdater) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectStatusUpdater { + return &VerifierMockProjectStatusUpdater{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockProjectStatusUpdater) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectStatusUpdater { + return &VerifierMockProjectStatusUpdater{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockProjectStatusUpdater struct { + mock *MockProjectStatusUpdater + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockProjectStatusUpdater) UpdateProject(ctx models.ProjectCommandContext, cmdName models.CommandName, status models.CommitStatus, url string) *MockProjectStatusUpdater_UpdateProject_OngoingVerification { + params := []pegomock.Param{ctx, cmdName, status, url} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdateProject", params, verifier.timeout) + return &MockProjectStatusUpdater_UpdateProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectStatusUpdater_UpdateProject_OngoingVerification struct { + mock *MockProjectStatusUpdater + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectStatusUpdater_UpdateProject_OngoingVerification) GetCapturedArguments() (models.ProjectCommandContext, models.CommandName, models.CommitStatus, string) { + ctx, cmdName, status, url := c.GetAllCapturedArguments() + return ctx[len(ctx)-1], cmdName[len(cmdName)-1], status[len(status)-1], url[len(url)-1] +} + +func (c *MockProjectStatusUpdater_UpdateProject_OngoingVerification) GetAllCapturedArguments() (_param0 []models.ProjectCommandContext, _param1 []models.CommandName, _param2 []models.CommitStatus, _param3 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.ProjectCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.ProjectCommandContext) + } + _param1 = make([]models.CommandName, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(models.CommandName) + } + _param2 = make([]models.CommitStatus, len(c.methodInvocations)) + for u, param := range params[2] { + _param2[u] = param.(models.CommitStatus) + } + _param3 = make([]string, len(c.methodInvocations)) + for u, param := range params[3] { + _param3[u] = param.(string) + } + } + return +} diff --git a/server/handlers/mocks/mock_resource_cleaner.go b/server/handlers/mocks/mock_resource_cleaner.go new file mode 100644 index 0000000000..430dd2709f --- /dev/null +++ b/server/handlers/mocks/mock_resource_cleaner.go @@ -0,0 +1,97 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/handlers (interfaces: ResourceCleaner) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + "reflect" + "time" +) + +type MockResourceCleaner struct { + fail func(message string, callerSkip ...int) +} + +func NewMockResourceCleaner(options ...pegomock.Option) *MockResourceCleaner { + mock := &MockResourceCleaner{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockResourceCleaner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockResourceCleaner) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockResourceCleaner) CleanUp(_param0 string) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockResourceCleaner().") + } + params := []pegomock.Param{_param0} + pegomock.GetGenericMockFrom(mock).Invoke("CleanUp", params, []reflect.Type{}) +} + +func (mock *MockResourceCleaner) VerifyWasCalledOnce() *VerifierMockResourceCleaner { + return &VerifierMockResourceCleaner{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockResourceCleaner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockResourceCleaner { + return &VerifierMockResourceCleaner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockResourceCleaner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockResourceCleaner { + return &VerifierMockResourceCleaner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockResourceCleaner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockResourceCleaner { + return &VerifierMockResourceCleaner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockResourceCleaner struct { + mock *MockResourceCleaner + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockResourceCleaner) CleanUp(_param0 string) *MockResourceCleaner_CleanUp_OngoingVerification { + params := []pegomock.Param{_param0} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CleanUp", params, verifier.timeout) + return &MockResourceCleaner_CleanUp_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockResourceCleaner_CleanUp_OngoingVerification struct { + mock *MockResourceCleaner + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockResourceCleaner_CleanUp_OngoingVerification) GetCapturedArguments() string { + _param0 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1] +} + +func (c *MockResourceCleaner_CleanUp_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} diff --git a/server/handlers/project_command_output_handler.go b/server/handlers/project_command_output_handler.go new file mode 100644 index 0000000000..b530aa5ef3 --- /dev/null +++ b/server/handlers/project_command_output_handler.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "sync" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" +) + +// AsyncProjectCommandOutputHandler is a handler to transport terraform client +// outputs to the front end. +type AsyncProjectCommandOutputHandler struct { + projectCmdOutput chan *models.ProjectCmdOutputLine + + projectOutputBuffers map[string][]string + projectOutputBuffersLock sync.RWMutex + + receiverBuffers map[string]map[chan string]bool + receiverBuffersLock sync.RWMutex + + projectStatusUpdater ProjectStatusUpdater + projectJobURLGenerator ProjectJobURLGenerator + + logger logging.SimpleLogging +} + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_job_url_generator.go ProjectJobURLGenerator + +// ProjectJobURLGenerator generates urls to view project's progress. +type ProjectJobURLGenerator interface { + GenerateProjectJobURL(p models.ProjectCommandContext) (string, error) +} + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_status_updater.go ProjectStatusUpdater + +type ProjectStatusUpdater interface { + // UpdateProject sets the commit status for the project represented by + // ctx. + UpdateProject(ctx models.ProjectCommandContext, cmdName models.CommandName, status models.CommitStatus, url string) error +} + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_command_output_handler.go ProjectCommandOutputHandler + +type ProjectCommandOutputHandler interface { + // Clear clears the buffer from previous terraform output lines + Clear(ctx models.ProjectCommandContext) + + // Send will enqueue the msg and wait for Handle() to receive the message. + Send(ctx models.ProjectCommandContext, msg string) + + // Register registers a channel and blocks until it is caught up. Callers should call this asynchronously when attempting + // to read the channel in the same goroutine + Register(projectInfo string, receiver chan string) + + // Deregister removes a channel from successive updates and closes it. + Deregister(projectInfo string, receiver chan string) + + // Listens for msg from channel + Handle() + + // SetJobURLWithStatus sets the commit status for the project represented by + // ctx and updates the status with and url to a job. + SetJobURLWithStatus(ctx models.ProjectCommandContext, cmdName models.CommandName, status models.CommitStatus) error + + ResourceCleaner +} + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_resource_cleaner.go ResourceCleaner + +type ResourceCleaner interface { + CleanUp(pull string) +} + +func NewAsyncProjectCommandOutputHandler( + projectCmdOutput chan *models.ProjectCmdOutputLine, + projectStatusUpdater ProjectStatusUpdater, + projectJobURLGenerator ProjectJobURLGenerator, + logger logging.SimpleLogging, +) ProjectCommandOutputHandler { + return &AsyncProjectCommandOutputHandler{ + projectCmdOutput: projectCmdOutput, + logger: logger, + receiverBuffers: map[string]map[chan string]bool{}, + projectStatusUpdater: projectStatusUpdater, + projectJobURLGenerator: projectJobURLGenerator, + projectOutputBuffers: map[string][]string{}, + } +} + +func (p *AsyncProjectCommandOutputHandler) Send(ctx models.ProjectCommandContext, msg string) { + p.projectCmdOutput <- &models.ProjectCmdOutputLine{ + ProjectInfo: ctx.PullInfo(), + Line: msg, + } +} + +func (p *AsyncProjectCommandOutputHandler) Register(projectInfo string, receiver chan string) { + p.addChan(receiver, projectInfo) +} + +func (p *AsyncProjectCommandOutputHandler) Handle() { + for msg := range p.projectCmdOutput { + if msg.ClearBuffBefore { + p.clearLogLines(msg.ProjectInfo) + } + p.writeLogLine(msg.ProjectInfo, msg.Line) + } +} + +func (p *AsyncProjectCommandOutputHandler) Clear(ctx models.ProjectCommandContext) { + p.projectCmdOutput <- &models.ProjectCmdOutputLine{ + ProjectInfo: ctx.PullInfo(), + Line: models.LogStreamingClearMsg, + ClearBuffBefore: true, + } +} + +func (p *AsyncProjectCommandOutputHandler) SetJobURLWithStatus(ctx models.ProjectCommandContext, cmdName models.CommandName, status models.CommitStatus) error { + url, err := p.projectJobURLGenerator.GenerateProjectJobURL(ctx) + + if err != nil { + return err + } + return p.projectStatusUpdater.UpdateProject(ctx, cmdName, status, url) +} + +func (p *AsyncProjectCommandOutputHandler) clearLogLines(pull string) { + p.projectOutputBuffersLock.Lock() + delete(p.projectOutputBuffers, pull) + p.projectOutputBuffersLock.Unlock() +} + +func (p *AsyncProjectCommandOutputHandler) addChan(ch chan string, pull string) { + p.projectOutputBuffersLock.RLock() + buffer := p.projectOutputBuffers[pull] + p.projectOutputBuffersLock.RUnlock() + + for _, line := range buffer { + ch <- line + } + + // add the channel to our registry after we backfill the contents of the buffer, + // to prevent new messages coming in interleaving with this backfill. + p.receiverBuffersLock.Lock() + if p.receiverBuffers[pull] == nil { + p.receiverBuffers[pull] = map[chan string]bool{} + } + p.receiverBuffers[pull][ch] = true + p.receiverBuffersLock.Unlock() +} + +//Add log line to buffer and send to all current channels +func (p *AsyncProjectCommandOutputHandler) writeLogLine(pull string, line string) { + p.receiverBuffersLock.Lock() + for ch := range p.receiverBuffers[pull] { + select { + case ch <- line: + default: + // Client ws conn could be closed in two ways: + // 1. Client closes the conn gracefully -> the closeHandler() is executed which + // closes the channel and cleans up resources. + // 2. Client does not close the conn and the closeHandler() is not executed -> the + // receiverChan will be blocking for N number of messages (equal to buffer size) + // before we delete the channel and clean up the resources. + delete(p.receiverBuffers[pull], ch) + } + } + p.receiverBuffersLock.Unlock() + + // No need to write to projectOutputBuffers if clear msg. + if line == models.LogStreamingClearMsg { + return + } + + p.projectOutputBuffersLock.Lock() + if p.projectOutputBuffers[pull] == nil { + p.projectOutputBuffers[pull] = []string{} + } + p.projectOutputBuffers[pull] = append(p.projectOutputBuffers[pull], line) + p.projectOutputBuffersLock.Unlock() +} + +//Remove channel, so client no longer receives Terraform output +func (p *AsyncProjectCommandOutputHandler) Deregister(pull string, ch chan string) { + p.logger.Debug("Removing channel for %s", pull) + p.receiverBuffersLock.Lock() + delete(p.receiverBuffers[pull], ch) + p.receiverBuffersLock.Unlock() +} + +func (p *AsyncProjectCommandOutputHandler) GetReceiverBufferForPull(pull string) map[chan string]bool { + return p.receiverBuffers[pull] +} + +func (p *AsyncProjectCommandOutputHandler) GetProjectOutputBuffer(pull string) []string { + return p.projectOutputBuffers[pull] +} + +func (p *AsyncProjectCommandOutputHandler) CleanUp(pull string) { + p.projectOutputBuffersLock.Lock() + delete(p.projectOutputBuffers, pull) + p.projectOutputBuffersLock.Unlock() + + // Only delete the pull record from receiver buffers. + // WS channel will be closed when the user closes the browser tab + // in closeHanlder(). + p.receiverBuffersLock.Lock() + delete(p.receiverBuffers, pull) + p.receiverBuffersLock.Unlock() +} + +// NoopProjectOutputHandler is a mock that doesn't do anything +type NoopProjectOutputHandler struct{} + +func (p *NoopProjectOutputHandler) Send(ctx models.ProjectCommandContext, msg string) { +} + +func (p *NoopProjectOutputHandler) Register(projectInfo string, receiver chan string) {} +func (p *NoopProjectOutputHandler) Deregister(projectInfo string, receiver chan string) {} + +func (p *NoopProjectOutputHandler) Handle() { +} + +func (p *NoopProjectOutputHandler) Clear(ctx models.ProjectCommandContext) { +} + +func (p *NoopProjectOutputHandler) SetJobURLWithStatus(ctx models.ProjectCommandContext, cmdName models.CommandName, status models.CommitStatus) error { + return nil +} + +func (p *NoopProjectOutputHandler) CleanUp(pull string) { +} diff --git a/server/handlers/project_command_output_handler_test.go b/server/handlers/project_command_output_handler_test.go new file mode 100644 index 0000000000..f644881815 --- /dev/null +++ b/server/handlers/project_command_output_handler_test.go @@ -0,0 +1,216 @@ +package handlers_test + +import ( + "errors" + "sync" + "testing" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/handlers" + "github.com/runatlantis/atlantis/server/handlers/mocks" + "github.com/runatlantis/atlantis/server/handlers/mocks/matchers" + "github.com/runatlantis/atlantis/server/logging" + "github.com/stretchr/testify/assert" + + . "github.com/petergtz/pegomock" + . "github.com/runatlantis/atlantis/testing" +) + +func createTestProjectCmdContext(t *testing.T) models.ProjectCommandContext { + logger := logging.NewNoopLogger(t) + return models.ProjectCommandContext{ + BaseRepo: models.Repo{ + Name: "test-repo", + Owner: "test-org", + }, + HeadRepo: models.Repo{ + Name: "test-repo", + Owner: "test-org", + }, + Pull: models.PullRequest{ + Num: 1, + HeadBranch: "master", + BaseBranch: "master", + Author: "test-user", + }, + User: models.User{ + Username: "test-user", + }, + Log: logger, + Workspace: "myworkspace", + RepoRelDir: "test-dir", + ProjectName: "test-project", + } +} + +func createProjectCommandOutputHandler(t *testing.T) handlers.ProjectCommandOutputHandler { + logger := logging.NewNoopLogger(t) + prjCmdOutputChan := make(chan *models.ProjectCmdOutputLine) + projectStatusUpdater := mocks.NewMockProjectStatusUpdater() + projectJobURLGenerator := mocks.NewMockProjectJobURLGenerator() + prjCmdOutputHandler := handlers.NewAsyncProjectCommandOutputHandler( + prjCmdOutputChan, + projectStatusUpdater, + projectJobURLGenerator, + logger, + ) + + go func() { + prjCmdOutputHandler.Handle() + }() + + return prjCmdOutputHandler +} + +func TestProjectCommandOutputHandler(t *testing.T) { + Msg := "Test Terraform Output" + ctx := createTestProjectCmdContext(t) + + t.Run("receive message from main channel", func(t *testing.T) { + var wg sync.WaitGroup + var expectedMsg string + projectOutputHandler := createProjectCommandOutputHandler(t) + + ch := make(chan string) + + // register channel and backfill from buffer + // Note: We call this synchronously because otherwise + // there could be a race where we are unable to register the channel + // before sending messages due to the way we lock our buffer memory cache + projectOutputHandler.Register(ctx.PullInfo(), ch) + + wg.Add(1) + + // read from channel + go func() { + for msg := range ch { + expectedMsg = msg + wg.Done() + } + }() + + projectOutputHandler.Send(ctx, Msg) + wg.Wait() + close(ch) + + // Wait for the msg to be read. + wg.Wait() + Equals(t, expectedMsg, Msg) + }) + + t.Run("clear buffer", func(t *testing.T) { + var wg sync.WaitGroup + + projectOutputHandler := createProjectCommandOutputHandler(t) + + ch := make(chan string) + + // register channel and backfill from buffer + // Note: We call this synchronously because otherwise + // there could be a race where we are unable to register the channel + // before sending messages due to the way we lock our buffer memory cache + projectOutputHandler.Register(ctx.PullInfo(), ch) + + wg.Add(1) + // read from channel asynchronously + go func() { + for msg := range ch { + // we are done once we receive the clear message. + // prior message doesn't matter for this test. + if msg == models.LogStreamingClearMsg { + wg.Done() + } + } + }() + + // send regular message followed by clear message + projectOutputHandler.Send(ctx, Msg) + projectOutputHandler.Clear(ctx) + wg.Wait() + close(ch) + + dfProjectOutputHandler, ok := projectOutputHandler.(*handlers.AsyncProjectCommandOutputHandler) + assert.True(t, ok) + + assert.Empty(t, dfProjectOutputHandler.GetProjectOutputBuffer(ctx.PullInfo())) + }) + + t.Run("copies buffer to new channels", func(t *testing.T) { + var wg sync.WaitGroup + + projectOutputHandler := createProjectCommandOutputHandler(t) + + // send first message to populated the buffer + projectOutputHandler.Send(ctx, Msg) + + ch := make(chan string) + + receivedMsgs := []string{} + + wg.Add(1) + // read from channel asynchronously + go func() { + for msg := range ch { + receivedMsgs = append(receivedMsgs, msg) + + // we're only expecting two messages here. + if len(receivedMsgs) >= 2 { + wg.Done() + } + } + }() + // register channel and backfill from buffer + // Note: We call this synchronously because otherwise + // there could be a race where we are unable to register the channel + // before sending messages due to the way we lock our buffer memory cache + projectOutputHandler.Register(ctx.PullInfo(), ch) + + projectOutputHandler.Send(ctx, Msg) + wg.Wait() + close(ch) + + expectedMsgs := []string{Msg, Msg} + assert.Equal(t, len(expectedMsgs), len(receivedMsgs)) + for i := range expectedMsgs { + assert.Equal(t, expectedMsgs[i], receivedMsgs[i]) + } + }) + + t.Run("update project status with project jobs url", func(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + prjCmdOutputChan := make(chan *models.ProjectCmdOutputLine) + projectStatusUpdater := mocks.NewMockProjectStatusUpdater() + projectJobURLGenerator := mocks.NewMockProjectJobURLGenerator() + prjCmdOutputHandler := handlers.NewAsyncProjectCommandOutputHandler( + prjCmdOutputChan, + projectStatusUpdater, + projectJobURLGenerator, + logger, + ) + + When(projectJobURLGenerator.GenerateProjectJobURL(matchers.EqModelsProjectCommandContext(ctx))).ThenReturn("url-to-project-jobs", nil) + err := prjCmdOutputHandler.SetJobURLWithStatus(ctx, models.PlanCommand, models.PendingCommitStatus) + Ok(t, err) + + projectStatusUpdater.VerifyWasCalledOnce().UpdateProject(ctx, models.PlanCommand, models.PendingCommitStatus, "url-to-project-jobs") + }) + + t.Run("update project status with project jobs url error", func(t *testing.T) { + RegisterMockTestingT(t) + logger := logging.NewNoopLogger(t) + prjCmdOutputChan := make(chan *models.ProjectCmdOutputLine) + projectStatusUpdater := mocks.NewMockProjectStatusUpdater() + projectJobURLGenerator := mocks.NewMockProjectJobURLGenerator() + prjCmdOutputHandler := handlers.NewAsyncProjectCommandOutputHandler( + prjCmdOutputChan, + projectStatusUpdater, + projectJobURLGenerator, + logger, + ) + + When(projectJobURLGenerator.GenerateProjectJobURL(matchers.EqModelsProjectCommandContext(ctx))).ThenReturn("url-to-project-jobs", errors.New("some error")) + err := prjCmdOutputHandler.SetJobURLWithStatus(ctx, models.PlanCommand, models.PendingCommitStatus) + assert.Error(t, err) + }) +} diff --git a/server/handlers/websocket_handler.go b/server/handlers/websocket_handler.go new file mode 100644 index 0000000000..3b98a5a54c --- /dev/null +++ b/server/handlers/websocket_handler.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/websocket" + "github.com/runatlantis/atlantis/server/logging" +) + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_websocket_handler.go WebsocketHandler + +type WebsocketHandler interface { + Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (WebsocketConnectionWrapper, error) + SetReadHandler(w WebsocketConnectionWrapper) + SetCloseHandler(w WebsocketConnectionWrapper, receiver chan string) +} + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_websocket_connection_wrapper.go WebsocketConnectionWrapper + +type WebsocketConnectionWrapper interface { + ReadMessage() (messageType int, p []byte, err error) + WriteMessage(messageType int, data []byte) error + SetCloseHandler(h func(code int, text string) error) +} + +type DefaultWebsocketHandler struct { + handler websocket.Upgrader + Logger logging.SimpleLogging +} + +func NewWebsocketHandler(logger logging.SimpleLogging) WebsocketHandler { + h := websocket.Upgrader{} + h.CheckOrigin = func(r *http.Request) bool { return true } + return &DefaultWebsocketHandler{ + handler: h, + Logger: logger, + } +} + +func (wh *DefaultWebsocketHandler) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (WebsocketConnectionWrapper, error) { + return wh.handler.Upgrade(w, r, responseHeader) +} + +func (wh *DefaultWebsocketHandler) SetReadHandler(w WebsocketConnectionWrapper) { + for { + _, _, err := w.ReadMessage() + if err != nil { + wh.Logger.Warn("Failed to read WS message: %s", err) + return + } + } +} + +func (wh *DefaultWebsocketHandler) SetCloseHandler(w WebsocketConnectionWrapper, receiver chan string) { + w.SetCloseHandler(func(code int, text string) error { + // Close the channnel after websocket connection closed. + // Will gracefully exit the ProjectCommandOutputHandler.Receive() call and cleanup. + close(receiver) + return nil + }) +} diff --git a/server/middleware.go b/server/middleware.go index e51ed1775a..5aee2ebb6d 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -21,52 +21,18 @@ import ( ) // NewRequestLogger creates a RequestLogger. -func NewRequestLogger(s *Server) *RequestLogger { - return &RequestLogger{ - s.Logger, - s.WebAuthentication, - s.WebUsername, - s.WebPassword, - } +func NewRequestLogger(logger logging.SimpleLogging) *RequestLogger { + return &RequestLogger{logger} } -// RequestLogger logs requests and their response codes -// as well as handle the basicauth on the requests +// RequestLogger logs requests and their response codes. type RequestLogger struct { - logger logging.SimpleLogging - WebAuthentication bool - WebUsername string - WebPassword string + logger logging.SimpleLogging } // ServeHTTP implements the middleware function. It logs all requests at DEBUG level. func (l *RequestLogger) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { l.logger.Debug("%s %s – from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr) - allowed := false - if !l.WebAuthentication || - r.URL.Path == "/events" || - r.URL.Path == "/healthz" || - r.URL.Path == "/status" { - allowed = true - } else { - user, pass, ok := r.BasicAuth() - if ok { - r.SetBasicAuth(user, pass) - l.logger.Debug("user: %s / pass: %s >> url: %s", user, pass, r.URL.RequestURI()) - if user == l.WebUsername && pass == l.WebPassword { - l.logger.Debug("[VALID] user: %s / pass: %s >> url: %s", user, pass, r.URL.RequestURI()) - allowed = true - } else { - allowed = false - l.logger.Info("[INVALID] user: %s / pass: %s >> url: %s", user, pass, r.URL.RequestURI()) - } - } - } - if !allowed { - rw.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) - http.Error(rw, "Unauthorized", http.StatusUnauthorized) - } else { - next(rw, r) - } + next(rw, r) l.logger.Debug("%s %s – respond HTTP %d", r.Method, r.URL.RequestURI(), rw.(negroni.ResponseWriter).Status()) } diff --git a/server/router.go b/server/router.go index 18e1055949..5607294703 100644 --- a/server/router.go +++ b/server/router.go @@ -1,9 +1,12 @@ package server import ( + "fmt" "net/url" "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" ) // Router can be used to retrieve Atlantis URLs. It acts as an intermediary @@ -15,6 +18,8 @@ type Router struct { // LockViewRouteName is the named route for the lock view that can be Get'd // from the Underlying router. LockViewRouteName string + // ProjectJobsViewRouteName is the named route for the projects active jobs + ProjectJobsViewRouteName string // LockViewRouteIDQueryParam is the query parameter needed to construct the // lock view: underlying.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id"). LockViewRouteIDQueryParam string @@ -33,3 +38,20 @@ func (r *Router) GenerateLockURL(lockID string) string { // golang likes to double escape the lockURL path when using url.Parse(). return r.AtlantisURL.String() + lockURL.String() } + +func (r *Router) GenerateProjectJobURL(ctx models.ProjectCommandContext) (string, error) { + pull := ctx.Pull + projectIdentifier := models.GetProjectIdentifier(ctx.RepoRelDir, ctx.ProjectName) + jobURL, err := r.Underlying.Get(r.ProjectJobsViewRouteName).URL( + "org", pull.BaseRepo.Owner, + "repo", pull.BaseRepo.Name, + "pull", fmt.Sprintf("%d", pull.Num), + "project", projectIdentifier, + "workspace", ctx.Workspace, + ) + if err != nil { + return "", errors.Wrapf(err, "creating job url for %s/%d/%s/%s", pull.BaseRepo.FullName, pull.Num, projectIdentifier, ctx.Workspace) + } + + return r.AtlantisURL.String() + jobURL.String(), nil +} diff --git a/server/router_test.go b/server/router_test.go index 3a79d56404..ccabee44de 100644 --- a/server/router_test.go +++ b/server/router_test.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" "github.com/runatlantis/atlantis/server" + "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) @@ -60,3 +61,57 @@ func TestRouter_GenerateLockURL(t *testing.T) { }) } } + +func setupJobsRouter(t *testing.T) *server.Router { + atlantisURL, err := server.ParseAtlantisURL("http://localhost:4141") + Ok(t, err) + + underlyingRouter := mux.NewRouter() + underlyingRouter.HandleFunc("/jobs/{org}/{repo}/{pull}/{project}/{workspace}", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Name("project-jobs-detail") + + return &server.Router{ + AtlantisURL: atlantisURL, + Underlying: underlyingRouter, + ProjectJobsViewRouteName: "project-jobs-detail", + } +} + +func TestGenerateProjectJobURL_ShouldGenerateURLWithProjectNameWhenProjectNameSpecified(t *testing.T) { + router := setupJobsRouter(t) + ctx := models.ProjectCommandContext{ + Pull: models.PullRequest{ + BaseRepo: models.Repo{ + Owner: "test-owner", + Name: "test-repo", + }, + Num: 1, + }, + ProjectName: "test-project", + Workspace: "default", + } + expectedURL := "http://localhost:4141/jobs/test-owner/test-repo/1/test-project/default" + gotURL, err := router.GenerateProjectJobURL(ctx) + Ok(t, err) + + Equals(t, expectedURL, gotURL) +} + +func TestGenerateProjectJobURL_ShouldGenerateURLWithDirectoryAndWorkspaceWhenProjectNameNotSpecified(t *testing.T) { + router := setupJobsRouter(t) + ctx := models.ProjectCommandContext{ + Pull: models.PullRequest{ + BaseRepo: models.Repo{ + Owner: "test-owner", + Name: "test-repo", + }, + Num: 1, + }, + RepoRelDir: "ops/terraform/test-root", + Workspace: "default", + } + expectedURL := "http://localhost:4141/jobs/test-owner/test-repo/1/ops-terraform-test-root/default" + gotURL, err := router.GenerateProjectJobURL(ctx) + Ok(t, err) + + Equals(t, expectedURL, gotURL) +} diff --git a/server/server.go b/server/server.go index b21d6e5ce6..c29af04774 100644 --- a/server/server.go +++ b/server/server.go @@ -34,6 +34,7 @@ import ( "github.com/mitchellh/go-homedir" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/events/yaml/valid" + "github.com/runatlantis/atlantis/server/handlers" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/mux" @@ -41,6 +42,7 @@ import ( "github.com/runatlantis/atlantis/server/controllers" events_controllers "github.com/runatlantis/atlantis/server/controllers/events" "github.com/runatlantis/atlantis/server/controllers/templates" + "github.com/runatlantis/atlantis/server/controllers/websocket" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/core/runtime/policy" @@ -67,11 +69,11 @@ const ( // route. ex: // mux.Router.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id") LockViewRouteIDQueryParam = "id" - + // ProjectJobsViewRouteName is the named route in mux.Router for the log stream view. + ProjectJobsViewRouteName = "project-jobs-detail" // binDirName is the name of the directory inside our data dir where // we download binaries. BinDirName = "bin" - // terraformPluginCacheDir is the name of the dir inside our data dir // where we tell terraform to cache plugins and modules. TerraformPluginCacheDirName = "plugin-cache" @@ -92,14 +94,15 @@ type Server struct { GithubAppController *controllers.GithubAppController LocksController *controllers.LocksController StatusController *controllers.StatusController + JobsController *controllers.JobsController IndexTemplate templates.TemplateWriter LockDetailTemplate templates.TemplateWriter + ProjectJobsTemplate templates.TemplateWriter + ProjectJobsErrorTemplate templates.TemplateWriter SSLCertFile string SSLKeyFile string Drainer *events.Drainer - WebAuthentication bool - WebUsername string - WebPassword string + ProjectCmdOutputHandler handlers.ProjectCommandOutputHandler } // Config holds config for server that isn't passed in by the user. @@ -276,7 +279,6 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient, TitleBuilder: vcs.StatusTitleBuilder{TitlePrefix: userConfig.VCSStatusName}} - binDir, err := mkSubDir(userConfig.DataDir, BinDirName) if err != nil { @@ -289,6 +291,36 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { return nil, err } + parsedURL, err := ParseAtlantisURL(userConfig.AtlantisURL) + if err != nil { + return nil, errors.Wrapf(err, + "parsing --%s flag %q", config.AtlantisURLFlag, userConfig.AtlantisURL) + } + + underlyingRouter := mux.NewRouter() + router := &Router{ + AtlantisURL: parsedURL, + LockViewRouteIDQueryParam: LockViewRouteIDQueryParam, + LockViewRouteName: LockViewRouteName, + ProjectJobsViewRouteName: ProjectJobsViewRouteName, + Underlying: underlyingRouter, + } + + var projectCmdOutputHandler handlers.ProjectCommandOutputHandler + // When TFE is enabled log streaming is not necessary. + + if userConfig.TFEToken != "" { + projectCmdOutputHandler = &handlers.NoopProjectOutputHandler{} + } else { + projectCmdOutput := make(chan *models.ProjectCmdOutputLine) + projectCmdOutputHandler = handlers.NewAsyncProjectCommandOutputHandler( + projectCmdOutput, + commitStatusUpdater, + router, + logger, + ) + } + terraformClient, err := terraform.NewClient( logger, binDir, @@ -299,7 +331,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { config.DefaultTFVersionFlag, userConfig.TFDownloadURL, &terraform.DefaultDownloader{}, - true) + true, + projectCmdOutputHandler) // The flag.Lookup call is to detect if we're running in a unit test. If we // are, then we don't error out because we don't have/want terraform // installed on our CI system where the unit tests run. @@ -357,11 +390,6 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { DB: boltdb, } - parsedURL, err := ParseAtlantisURL(userConfig.AtlantisURL) - if err != nil { - return nil, errors.Wrapf(err, - "parsing --%s flag %q", config.AtlantisURLFlag, userConfig.AtlantisURL) - } validator := &yaml.ParserValidator{} globalCfg := valid.NewGlobalCfgFromArgs( @@ -384,19 +412,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } } - underlyingRouter := mux.NewRouter() - router := &Router{ - AtlantisURL: parsedURL, - LockViewRouteIDQueryParam: LockViewRouteIDQueryParam, - LockViewRouteName: LockViewRouteName, - Underlying: underlyingRouter, - } pullClosedExecutor := &events.PullClosedExecutor{ - VCSClient: vcsClient, - Locker: lockingClient, - WorkingDir: workingDir, - Logger: logger, - DB: boltdb, + VCSClient: vcsClient, + Locker: lockingClient, + WorkingDir: workingDir, + Logger: logger, + DB: boltdb, + LogStreamResourceCleaner: projectCmdOutputHandler, + PullClosedTemplate: &events.PullClosedEventTemplate{}, } eventParser := &events.EventParser{ GithubUser: userConfig.GithubUser, @@ -520,11 +543,16 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GlobalAutomerge: userConfig.Automerge, } + projectOutputWrapper := &events.ProjectOutputWrapper{ + ProjectCmdOutputHandler: projectCmdOutputHandler, + ProjectCommandRunner: projectCommandRunner, + } + policyCheckCommandRunner := events.NewPolicyCheckCommandRunner( dbUpdater, pullUpdater, commitStatusUpdater, - projectCommandRunner, + projectOutputWrapper, userConfig.ParallelPoolSize, userConfig.SilenceVCSStatusNoProjects, ) @@ -537,7 +565,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { workingDir, commitStatusUpdater, projectCommandBuilder, - projectCommandRunner, + projectOutputWrapper, dbUpdater, pullUpdater, policyCheckCommandRunner, @@ -554,7 +582,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { applyLockingClient, commitStatusUpdater, projectCommandBuilder, - projectCommandRunner, + projectOutputWrapper, autoMerger, pullUpdater, dbUpdater, @@ -568,7 +596,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner( commitStatusUpdater, projectCommandBuilder, - projectCommandRunner, + projectOutputWrapper, pullUpdater, dbUpdater, userConfig.SilenceNoProjects, @@ -584,7 +612,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { versionCommandRunner := events.NewVersionCommandRunner( pullUpdater, projectCommandBuilder, - projectCommandRunner, + projectOutputWrapper, userConfig.ParallelPoolSize, userConfig.SilenceNoProjects, ) @@ -638,6 +666,23 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { DB: boltdb, DeleteLockCommand: deleteLockCommand, } + + wsMux := websocket.NewMultiplexor( + logger, + controllers.ProjectInfoKeyGenerator{}, + projectCmdOutputHandler, + ) + + jobsController := &controllers.JobsController{ + AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, + Logger: logger, + ProjectJobsTemplate: templates.ProjectJobsTemplate, + ProjectJobsErrorTemplate: templates.ProjectJobsErrorTemplate, + Db: boltdb, + WsMux: wsMux, + } + eventsController := &events_controllers.VCSEventsController{ CommandRunner: commandRunner, PullCleaner: pullClosedExecutor, @@ -679,15 +724,16 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { VCSEventsController: eventsController, GithubAppController: githubAppController, LocksController: locksController, + JobsController: jobsController, StatusController: statusController, IndexTemplate: templates.IndexTemplate, LockDetailTemplate: templates.LockTemplate, + ProjectJobsTemplate: templates.ProjectJobsTemplate, + ProjectJobsErrorTemplate: templates.ProjectJobsErrorTemplate, SSLKeyFile: userConfig.SSLKeyFile, SSLCertFile: userConfig.SSLCertFile, Drainer: drainer, - WebAuthentication: userConfig.WebBasicAuth, - WebUsername: userConfig.WebUsername, - WebPassword: userConfig.WebPassword, + ProjectCmdOutputHandler: projectCmdOutputHandler, }, nil } @@ -707,12 +753,15 @@ func (s *Server) Start() error { s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}") s.Router.HandleFunc("/lock", s.LocksController.GetLock).Methods("GET"). Queries(LockViewRouteIDQueryParam, fmt.Sprintf("{%s}", LockViewRouteIDQueryParam)).Name(LockViewRouteName) + s.Router.HandleFunc("/jobs/{org}/{repo}/{pull}/{project}/{workspace}", s.JobsController.GetProjectJobs).Methods("GET").Name(ProjectJobsViewRouteName) + s.Router.HandleFunc("/jobs/{org}/{repo}/{pull}/{project}/{workspace}/ws", s.JobsController.GetProjectJobsWS).Methods("GET") + n := negroni.New(&negroni.Recovery{ Logger: log.New(os.Stdout, "", log.LstdFlags), PrintStack: false, StackAll: false, StackSize: 1024 * 8, - }, NewRequestLogger(s)) + }, NewRequestLogger(s.Logger)) n.UseHandler(s.Router) defer s.Logger.Flush() @@ -722,6 +771,10 @@ func (s *Server) Start() error { // Stop on SIGINTs and SIGTERMs. signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + s.ProjectCmdOutputHandler.Handle() + }() + server := &http.Server{Addr: fmt.Sprintf(":%d", s.Port), Handler: n} go func() { s.Logger.Info("Atlantis started - listening on port %v", s.Port)