diff --git a/cmd/server.go b/cmd/server.go index bc344a3c53..cd869dbd0f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -61,6 +61,7 @@ const ( DisableRepoLockingFlag = "disable-repo-locking" EnablePolicyChecksFlag = "enable-policy-checks" EnableRegExpCmdFlag = "enable-regexp-cmd" + EnableDiffMarkdownFormat = "enable-diff-markdown-format" GHHostnameFlag = "gh-hostname" GHTokenFlag = "gh-token" GHUserFlag = "gh-user" @@ -321,6 +322,10 @@ var boolFlags = map[string]boolFlag{ description: "Enable Atlantis to use regular expressions on plan/apply commands when \"-p\" flag is passed with it.", defaultValue: false, }, + EnableDiffMarkdownFormat: { + description: "Enable Atlantis to format Terraform plan output into a markdown-diff friendly format for color-coding purposes.", + defaultValue: false, + }, AllowDraftPRs: { description: "Enable autoplan for Github Draft Pull Requests", defaultValue: false, diff --git a/cmd/server_test.go b/cmd/server_test.go index 7e58a25121..388c79541c 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -108,6 +108,7 @@ var testFlags = map[string]interface{}{ DisableAutoplanFlag: true, EnablePolicyChecksFlag: false, EnableRegExpCmdFlag: false, + EnableDiffMarkdownFormat: false, } func TestExecute_Defaults(t *testing.T) { diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 44e92777d0..34487bc534 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -52,7 +52,7 @@ Values are chosen in this order: atlantis server --allow-draft-prs ``` Respond to pull requests from draft prs. Defaults to `false`. - + * ### `--allow-fork-prs` ```bash atlantis server --allow-fork-prs @@ -274,6 +274,14 @@ Values are chosen in this order: The command `atlantis apply -p .*` will bypass the restriction and run apply on every projects ::: +* ### `--enable-diff-markdown-format` + ```bash + atlantis server --enable-diff-markdown-format + ``` + Enable Atlantis to format Terraform plan output into a markdown-diff friendly format for color-coding purposes. + + Useful to enable for use with Github. + * ### `--gh-hostname` ```bash atlantis server --gh-hostname="my.github.enterprise.com" diff --git a/server/events/markdown_renderer.go b/server/events/markdown_renderer.go index 55d86dbee8..36e7a0c680 100644 --- a/server/events/markdown_renderer.go +++ b/server/events/markdown_renderer.go @@ -44,17 +44,19 @@ type MarkdownRenderer struct { DisableApply bool DisableMarkdownFolding bool DisableRepoLocking bool + EnableDiffMarkdownFormat bool } // commonData is data that all responses have. type commonData struct { - Command string - Verbose bool - Log string - PlansDeleted bool - DisableApplyAll bool - DisableApply bool - DisableRepoLocking bool + Command string + Verbose bool + Log string + PlansDeleted bool + DisableApplyAll bool + DisableApply bool + DisableRepoLocking bool + EnableDiffMarkdownFormat bool } // errData is data about an error response. @@ -77,10 +79,11 @@ type resultData struct { type planSuccessData struct { models.PlanSuccess - PlanSummary string - PlanWasDeleted bool - DisableApply bool - DisableRepoLocking bool + PlanSummary string + PlanWasDeleted bool + DisableApply bool + DisableRepoLocking bool + EnableDiffMarkdownFormat bool } type policyCheckSuccessData struct { @@ -99,13 +102,14 @@ type projectResultTmplData struct { func (m *MarkdownRenderer) Render(res CommandResult, cmdName models.CommandName, log string, verbose bool, vcsHost models.VCSHostType) string { commandStr := strings.Title(strings.Replace(cmdName.String(), "_", " ", -1)) common := commonData{ - Command: commandStr, - Verbose: verbose, - Log: log, - PlansDeleted: res.PlansDeleted, - DisableApplyAll: m.DisableApplyAll || m.DisableApply, - DisableApply: m.DisableApply, - DisableRepoLocking: m.DisableRepoLocking, + Command: commandStr, + Verbose: verbose, + Log: log, + PlansDeleted: res.PlansDeleted, + DisableApplyAll: m.DisableApplyAll || m.DisableApply, + DisableApply: m.DisableApply, + DisableRepoLocking: m.DisableRepoLocking, + EnableDiffMarkdownFormat: m.EnableDiffMarkdownFormat, } if res.Error != nil { return m.renderTemplate(unwrappedErrWithLogTmpl, errData{res.Error.Error(), common}) @@ -150,9 +154,9 @@ func (m *MarkdownRenderer) renderProjectResults(results []models.ProjectResult, }) } else if result.PlanSuccess != nil { if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { - resultData.Rendered = m.renderTemplate(planSuccessWrappedTmpl, planSuccessData{PlanSuccess: *result.PlanSuccess, PlanSummary: result.PlanSuccess.Summary(), PlanWasDeleted: common.PlansDeleted, DisableApply: common.DisableApply, DisableRepoLocking: common.DisableRepoLocking}) + resultData.Rendered = m.renderTemplate(planSuccessWrappedTmpl, planSuccessData{PlanSuccess: *result.PlanSuccess, PlanSummary: result.PlanSuccess.Summary(), PlanWasDeleted: common.PlansDeleted, DisableApply: common.DisableApply, DisableRepoLocking: common.DisableRepoLocking, EnableDiffMarkdownFormat: common.EnableDiffMarkdownFormat}) } else { - resultData.Rendered = m.renderTemplate(planSuccessUnwrappedTmpl, planSuccessData{PlanSuccess: *result.PlanSuccess, PlanWasDeleted: common.PlansDeleted, DisableApply: common.DisableApply, DisableRepoLocking: common.DisableRepoLocking}) + resultData.Rendered = m.renderTemplate(planSuccessUnwrappedTmpl, planSuccessData{PlanSuccess: *result.PlanSuccess, PlanWasDeleted: common.PlansDeleted, DisableApply: common.DisableApply, DisableRepoLocking: common.DisableRepoLocking, EnableDiffMarkdownFormat: common.EnableDiffMarkdownFormat}) } numPlanSuccesses++ } else if result.PolicyCheckSuccess != nil { @@ -300,14 +304,14 @@ var multiProjectVersionTmpl = template.Must(template.New("").Funcs(sprig.TxtFunc logTmpl)) var planSuccessUnwrappedTmpl = template.Must(template.New("").Parse( "```diff\n" + - "{{.TerraformOutput}}\n" + + "{{ if .EnableDiffMarkdownFormat }}{{.DiffMarkdownFormattedTerraformOutput}}{{else}}{{.TerraformOutput}}{{end}}\n" + "```\n\n" + planNextSteps + "{{ if .HasDiverged }}\n\n:warning: The branch we're merging into is ahead, it is recommended to pull new commits first.{{end}}")) var planSuccessWrappedTmpl = template.Must(template.New("").Parse( "
Show Output\n\n" + "```diff\n" + - "{{.TerraformOutput}}\n" + + "{{ if .EnableDiffMarkdownFormat }}{{.DiffMarkdownFormattedTerraformOutput}}{{else}}{{.TerraformOutput}}{{end}}\n" + "```\n\n" + planNextSteps + "\n" + "
" + "\n" + diff --git a/server/events/markdown_renderer_test.go b/server/events/markdown_renderer_test.go index c9fca18218..0679a700b6 100644 --- a/server/events/markdown_renderer_test.go +++ b/server/events/markdown_renderer_test.go @@ -2068,3 +2068,283 @@ $$$ }) } } + +func TestRenderProjectResultsWithEnableDiffMarkdownFormat(t *testing.T) { + tfOutput := `An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: +~ update in-place +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # module.redacted.aws_instance.redacted must be replaced +-/+ resource "aws_instance" "redacted" { + ~ ami = "ami-redacted" -> "ami-redacted" # forces replacement + ~ arn = "arn:aws:ec2:us-east-1:redacted:instance/i-redacted" -> (known after apply) + ~ associate_public_ip_address = false -> (known after apply) + availability_zone = "us-east-1b" + ~ cpu_core_count = 4 -> (known after apply) + ~ cpu_threads_per_core = 2 -> (known after apply) + - disable_api_termination = false -> null + - ebs_optimized = false -> null + get_password_data = false + - hibernation = false -> null + + host_id = (known after apply) + iam_instance_profile = "remote_redacted_profile" + ~ id = "i-redacted" -> (known after apply) + ~ instance_state = "running" -> (known after apply) + instance_type = "c5.2xlarge" + ~ ipv6_address_count = 0 -> (known after apply) + ~ ipv6_addresses = [] -> (known after apply) + key_name = "RedactedRedactedRedacted" + - monitoring = false -> null + + outpost_arn = (known after apply) + + password_data = (known after apply) + + placement_group = (known after apply) + ~ primary_network_interface_id = "eni-redacted" -> (known after apply) + ~ private_dns = "ip-redacted.ec2.internal" -> (known after apply) + ~ private_ip = "redacted" -> (known after apply) + + public_dns = (known after apply) + + public_ip = (known after apply) + ~ secondary_private_ips = [] -> (known after apply) + ~ security_groups = [] -> (known after apply) + source_dest_check = true + subnet_id = "subnet-redacted" + tags = { + "Name" = "redacted-redacted" + } + ~ tenancy = "default" -> (known after apply) + user_data = "redacted" + ~ volume_tags = {} -> (known after apply) + vpc_security_group_ids = [ + "sg-redactedsecuritygroup", + ] + + + ebs_block_device { + + delete_on_termination = (known after apply) + + device_name = (known after apply) + + encrypted = (known after apply) + + iops = (known after apply) + + kms_key_id = (known after apply) + + snapshot_id = (known after apply) + + volume_id = (known after apply) + + volume_size = (known after apply) + + volume_type = (known after apply) + } + + + ephemeral_block_device { + + device_name = (known after apply) + + no_device = (known after apply) + + virtual_name = (known after apply) + } + + ~ metadata_options { + ~ http_endpoint = "enabled" -> (known after apply) + ~ http_put_response_hop_limit = 1 -> (known after apply) + ~ http_tokens = "optional" -> (known after apply) + } + + + network_interface { + + delete_on_termination = (known after apply) + + device_index = (known after apply) + + network_interface_id = (known after apply) + } + + ~ root_block_device { + ~ delete_on_termination = true -> (known after apply) + ~ device_name = "/dev/sda1" -> (known after apply) + ~ encrypted = false -> (known after apply) + ~ iops = 600 -> (known after apply) + + kms_key_id = (known after apply) + ~ volume_id = "vol-redacted" -> (known after apply) + ~ volume_size = 200 -> (known after apply) + ~ volume_type = "gp2" -> (known after apply) + } + } + + # module.redacted.aws_route53_record.redacted_record will be updated in-place +~ resource "aws_route53_record" "redacted_record" { + fqdn = "redacted.redacted.redacted.io" + id = "redacted_redacted.redacted.redacted.io_A" + name = "redacted.redacted.redacted.io" + ~ records = [ + - "redacted", + ] -> (known after apply) + ttl = 300 + type = "A" + zone_id = "redacted" + } + +Plan: 1 to add, 1 to change, 1 to destroy. +` + cases := []struct { + Description string + Command models.CommandName + ProjectResults []models.ProjectResult + VCSHost models.VCSHostType + Expected string + }{ + { + "single successful plan with diff markdown formatted", + models.PlanCommand, + []models.ProjectResult{ + { + PlanSuccess: &models.PlanSuccess{ + TerraformOutput: tfOutput, + LockURL: "lock-url", + RePlanCmd: "atlantis plan -d path -w workspace", + ApplyCmd: "atlantis apply -d path -w workspace", + }, + Workspace: "workspace", + RepoRelDir: "path", + }, + }, + models.Github, + `Ran Plan for dir: $path$ workspace: $workspace$ + +
Show Output + +$$$diff +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: +! update in-place +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # module.redacted.aws_instance.redacted must be replaced +-/+ resource "aws_instance" "redacted" { +! ami = "ami-redacted" -> "ami-redacted" # forces replacement +! arn = "arn:aws:ec2:us-east-1:redacted:instance/i-redacted" -> (known after apply) +! associate_public_ip_address = false -> (known after apply) + availability_zone = "us-east-1b" +! cpu_core_count = 4 -> (known after apply) +! cpu_threads_per_core = 2 -> (known after apply) +- disable_api_termination = false -> null +- ebs_optimized = false -> null + get_password_data = false +- hibernation = false -> null ++ host_id = (known after apply) + iam_instance_profile = "remote_redacted_profile" +! id = "i-redacted" -> (known after apply) +! instance_state = "running" -> (known after apply) + instance_type = "c5.2xlarge" +! ipv6_address_count = 0 -> (known after apply) +! ipv6_addresses = [] -> (known after apply) + key_name = "RedactedRedactedRedacted" +- monitoring = false -> null ++ outpost_arn = (known after apply) ++ password_data = (known after apply) ++ placement_group = (known after apply) +! primary_network_interface_id = "eni-redacted" -> (known after apply) +! private_dns = "ip-redacted.ec2.internal" -> (known after apply) +! private_ip = "redacted" -> (known after apply) ++ public_dns = (known after apply) ++ public_ip = (known after apply) +! secondary_private_ips = [] -> (known after apply) +! security_groups = [] -> (known after apply) + source_dest_check = true + subnet_id = "subnet-redacted" + tags = { + "Name" = "redacted-redacted" + } +! tenancy = "default" -> (known after apply) + user_data = "redacted" +! volume_tags = {} -> (known after apply) + vpc_security_group_ids = [ + "sg-redactedsecuritygroup", + ] + ++ ebs_block_device { ++ delete_on_termination = (known after apply) ++ device_name = (known after apply) ++ encrypted = (known after apply) ++ iops = (known after apply) ++ kms_key_id = (known after apply) ++ snapshot_id = (known after apply) ++ volume_id = (known after apply) ++ volume_size = (known after apply) ++ volume_type = (known after apply) + } + ++ ephemeral_block_device { ++ device_name = (known after apply) ++ no_device = (known after apply) ++ virtual_name = (known after apply) + } + +! metadata_options { +! http_endpoint = "enabled" -> (known after apply) +! http_put_response_hop_limit = 1 -> (known after apply) +! http_tokens = "optional" -> (known after apply) + } + ++ network_interface { ++ delete_on_termination = (known after apply) ++ device_index = (known after apply) ++ network_interface_id = (known after apply) + } + +! root_block_device { +! delete_on_termination = true -> (known after apply) +! device_name = "/dev/sda1" -> (known after apply) +! encrypted = false -> (known after apply) +! iops = 600 -> (known after apply) ++ kms_key_id = (known after apply) +! volume_id = "vol-redacted" -> (known after apply) +! volume_size = 200 -> (known after apply) +! volume_type = "gp2" -> (known after apply) + } + } + + # module.redacted.aws_route53_record.redacted_record will be updated in-place +! resource "aws_route53_record" "redacted_record" { + fqdn = "redacted.redacted.redacted.io" + id = "redacted_redacted.redacted.redacted.io_A" + name = "redacted.redacted.redacted.io" +! records = [ +- "redacted", + ] -> (known after apply) + ttl = 300 + type = "A" + zone_id = "redacted" + } + +Plan: 1 to add, 1 to change, 1 to destroy. + +$$$ + +* :put_litter_in_its_place: To **delete** this plan click [here](lock-url) +* :repeat: To **plan** this project again, comment: + * $atlantis plan -d path -w workspace$ +
+Plan: 1 to add, 1 to change, 1 to destroy. + + +`, + }, + } + r := events.MarkdownRenderer{ + DisableApplyAll: true, + DisableApply: true, + EnableDiffMarkdownFormat: true, + } + for _, c := range cases { + t.Run(c.Description, func(t *testing.T) { + res := events.CommandResult{ + ProjectResults: c.ProjectResults, + } + for _, verbose := range []bool{true, false} { + t.Run(c.Description, func(t *testing.T) { + s := r.Render(res, c.Command, "log", verbose, c.VCSHost) + expWithBackticks := strings.Replace(c.Expected, "$", "`", -1) + if !verbose { + Equals(t, expWithBackticks, s) + } else { + Equals(t, expWithBackticks+"
Log\n

\n\n```\nlog```\n

\n", s) + } + }) + } + }) + } +} diff --git a/server/events/models/models.go b/server/events/models/models.go index 8d2baf9e55..fb66ac1230 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -518,6 +518,17 @@ func (p *PlanSuccess) Summary() string { return note + r.FindString(p.TerraformOutput) } +// DiffMarkdownFormattedTerraformOutput formats the Terraform output to match diff markdown format +func (p PlanSuccess) DiffMarkdownFormattedTerraformOutput() string { + diffKeywordRegex := regexp.MustCompile(`(?m)^( +)([-+~])`) + diffTildeRegex := regexp.MustCompile(`(?m)^~`) + + formattedTerraformOutput := diffKeywordRegex.ReplaceAllString(p.TerraformOutput, "$2$1") + formattedTerraformOutput = diffTildeRegex.ReplaceAllString(formattedTerraformOutput, "!") + + return formattedTerraformOutput +} + // PolicyCheckSuccess is the result of a successful policy check run. type PolicyCheckSuccess struct { // PolicyCheckOutput is the output from policy check binary(conftest|opa) diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index da44a86b91..03f744162f 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -117,6 +117,7 @@ type DefaultProjectCommandBuilder struct { SkipCloneNoChanges bool EnableRegExpCmd bool AutoplanFileList string + EnableDiffMarkdownFormat bool } // See ProjectCommandBuilder.BuildAutoplanCommands. diff --git a/server/server.go b/server/server.go index fd90e15daa..88e84cda7c 100644 --- a/server/server.go +++ b/server/server.go @@ -309,6 +309,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { DisableMarkdownFolding: userConfig.DisableMarkdownFolding, DisableApply: userConfig.DisableApply, DisableRepoLocking: userConfig.DisableRepoLocking, + EnableDiffMarkdownFormat: userConfig.EnableDiffMarkdownFormat, } boltdb, err := db.New(userConfig.DataDir) diff --git a/server/user_config.go b/server/user_config.go index c1ef18fcc9..4875ad7671 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -30,6 +30,7 @@ type UserConfig struct { DisableRepoLocking bool `mapstructure:"disable-repo-locking"` EnablePolicyChecksFlag bool `mapstructure:"enable-policy-checks"` EnableRegExpCmd bool `mapstructure:"enable-regexp-cmd"` + EnableDiffMarkdownFormat bool `mapstructure:"enable-diff-markdown-format"` GithubHostname string `mapstructure:"gh-hostname"` GithubToken string `mapstructure:"gh-token"` GithubUser string `mapstructure:"gh-user"`