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"`