diff --git a/cmd/terraform-j2md/main.go b/cmd/terraform-j2md/main.go index d2a5f2e..772f9f3 100644 --- a/cmd/terraform-j2md/main.go +++ b/cmd/terraform-j2md/main.go @@ -1,13 +1,23 @@ package main import ( + "flag" "fmt" "os" "github.com/reproio/terraform-j2md/internal/terraform" ) +var ( + escapeHTML = true +) + func main() { + noEscapeHTML := flag.Bool("no-escape-html", false, "prevent <, >, and & from being escaped in JSON strings") + flag.Parse() + if *noEscapeHTML { + escapeHTML = false + } os.Exit(run()) } @@ -17,7 +27,7 @@ func run() int { fmt.Fprintf(os.Stderr, "cannot parse input as Terraform plan JSON: %v", err) return 1 } - if err = planData.Render(os.Stdout); err != nil { + if err = planData.Render(os.Stdout, escapeHTML); err != nil { fmt.Fprintf(os.Stderr, "cannot render: %v", err) return 1 } diff --git a/internal/terraform/plan.go b/internal/terraform/plan.go index f05039a..47c472b 100644 --- a/internal/terraform/plan.go +++ b/internal/terraform/plan.go @@ -1,6 +1,7 @@ package terraform import ( + "bytes" "encoding/json" "fmt" "github.com/hashicorp/terraform-json/sanitize" @@ -51,12 +52,18 @@ type ResourceChangeData struct { ResourceChange *tfjson.ResourceChange } +type Config struct { + EscapeHTML bool +} + +var config Config + func (r ResourceChangeData) GetUnifiedDiffString() (string, error) { - before, err := json.MarshalIndent(r.ResourceChange.Change.Before, "", " ") + before, err := r.marshalChangeBefore() if err != nil { return "", fmt.Errorf("invalid resource changes (before): %w", err) } - after, err := json.MarshalIndent(r.ResourceChange.Change.After, "", " ") + after, err := r.marshalChangeAfter() if err != nil { return "", fmt.Errorf("invalid resource changes (after) : %w", err) } @@ -85,6 +92,26 @@ func (r ResourceChangeData) Header() string { } } +func (r ResourceChangeData) marshalChangeBefore() ([]byte, error) { + return r.marshalChange(r.ResourceChange.Change.Before) +} + +func (r ResourceChangeData) marshalChangeAfter() ([]byte, error) { + return r.marshalChange(r.ResourceChange.Change.After) +} + +func (r ResourceChangeData) marshalChange(v any) ([]byte, error) { + var buffer bytes.Buffer + enc := json.NewEncoder(&buffer) + enc.SetIndent("", " ") + enc.SetEscapeHTML(config.EscapeHTML) + err := enc.Encode(v) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + func (r ResourceChangeData) HeaderSuffix() string { switch { case r.ResourceChange.Change.Actions.Create(): @@ -99,7 +126,8 @@ func (r ResourceChangeData) HeaderSuffix() string { return "" } -func (plan *PlanData) Render(w io.Writer) error { +func (plan *PlanData) Render(w io.Writer, escapeHTML bool) error { + config.EscapeHTML = escapeHTML funcMap := template.FuncMap{ "codeFence": func() string { return "````````" diff --git a/test/plan_test/plan_test.go b/test/plan_test/plan_test.go index 1566a73..80c3e6f 100644 --- a/test/plan_test/plan_test.go +++ b/test/plan_test/plan_test.go @@ -48,54 +48,100 @@ func Test_newPlanData(t *testing.T) { } func Test_render(t *testing.T) { - tests := []struct { - name string - wantErr bool - }{ - {name: "no_changes", wantErr: false}, - {name: "single_add", wantErr: false}, - {name: "single_change", wantErr: false}, - {name: "single_destroy", wantErr: false}, - {name: "single_replace", wantErr: false}, - {name: "all_types_mixed", wantErr: false}, - {name: "aws_sample", wantErr: false}, - {name: "iam_policy", wantErr: false}, - {name: "include_code_fence", wantErr: false}, - {name: "include_module", wantErr: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - inputFilePath := testDataPath(tt.name, "show.json") - file, err := os.Open(inputFilePath) - if err != nil { - t.Errorf("cannot open input file: %s", inputFilePath) - return - } - defer file.Close() + t.Run("escape HTML characters", func(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + {name: "no_changes", wantErr: false}, + {name: "single_add", wantErr: false}, + {name: "single_change", wantErr: false}, + {name: "single_destroy", wantErr: false}, + {name: "single_replace", wantErr: false}, + {name: "all_types_mixed", wantErr: false}, + {name: "aws_sample", wantErr: false}, + {name: "iam_policy", wantErr: false}, + {name: "include_code_fence", wantErr: false}, + {name: "include_module", wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputFilePath := testDataPath(tt.name, "show.json") + file, err := os.Open(inputFilePath) + if err != nil { + t.Errorf("cannot open input file: %s", inputFilePath) + return + } + defer file.Close() - plan, err := terraform.NewPlanData(file) - if err != nil { - t.Errorf("cannot parse JSON as plan: %v", err) - return - } + plan, err := terraform.NewPlanData(file) + if err != nil { + t.Errorf("cannot parse JSON as plan: %v", err) + return + } - got := bytes.Buffer{} - err = plan.Render(&got) - if (err != nil) != tt.wantErr { - t.Errorf("render() error = %v, wantErr %v", err, tt.wantErr) - return - } + got := bytes.Buffer{} + err = plan.Render(&got, true) + if (err != nil) != tt.wantErr { + t.Errorf("render() error = %v, wantErr %v", err, tt.wantErr) + return + } - expectedFilePath := testDataPath(tt.name, "expected.md") - expected, err := os.ReadFile(expectedFilePath) - if err != nil { - t.Errorf("cannot open expected file: %s", expectedFilePath) - return - } - if got.String() != string(expected) { - t.Errorf("render() = %v, want %v", got.String(), string(expected)) - return - } - }) - } + expectedFilePath := testDataPath(tt.name, "expected.md") + expected, err := os.ReadFile(expectedFilePath) + if err != nil { + t.Errorf("cannot open expected file: %s", expectedFilePath) + return + } + if got.String() != string(expected) { + t.Errorf("render() = %v, want %v", got.String(), string(expected)) + return + } + }) + } + }) + + t.Run("not escape HTML characters", func(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + {name: "special_characters", wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputFilePath := testDataPath(tt.name, "show.json") + file, err := os.Open(inputFilePath) + if err != nil { + t.Errorf("cannot open input file: %s", inputFilePath) + return + } + defer file.Close() + + plan, err := terraform.NewPlanData(file) + if err != nil { + t.Errorf("cannot parse JSON as plan: %v", err) + return + } + + got := bytes.Buffer{} + err = plan.Render(&got, false) + if (err != nil) != tt.wantErr { + t.Errorf("render() error = %v, wantErr %v", err, tt.wantErr) + return + } + + expectedFilePath := testDataPath(tt.name, "expected.md") + expected, err := os.ReadFile(expectedFilePath) + if err != nil { + t.Errorf("cannot open expected file: %s", expectedFilePath) + return + } + if got.String() != string(expected) { + t.Errorf("render() = %v, want %v", got.String(), string(expected)) + return + } + }) + } + }) } diff --git a/test/testdata/all_types_mixed/expected.md b/test/testdata/all_types_mixed/expected.md index a42ae19..c52216a 100644 --- a/test/testdata/all_types_mixed/expected.md +++ b/test/testdata/all_types_mixed/expected.md @@ -11,33 +11,36 @@ ````````diff # env_variable.test2 will be updated in-place -@@ -1,5 +1,5 @@ +@@ -1,6 +1,6 @@ { "id": "test2", - "name": "test2", + "name": "test2_changed", "value": "REDACTED_SENSITIVE" } + ```````` ````````diff # env_variable.test3 will be destroyed -@@ -1,5 +1 @@ +@@ -1,6 +1,2 @@ -{ - "id": "test3", - "name": "test3", - "value": "REDACTED_SENSITIVE" -} +null + ```````` ````````diff # env_variable.test5 will be created -@@ -1 +1,3 @@ +@@ -1,2 +1,4 @@ -null +{ + "name": "test5" +} + ```````` ````````diff diff --git a/test/testdata/aws_sample/expected.md b/test/testdata/aws_sample/expected.md index fc1b4dc..d5b2dd7 100644 --- a/test/testdata/aws_sample/expected.md +++ b/test/testdata/aws_sample/expected.md @@ -12,7 +12,7 @@ ````````diff # aws_instance.test will be destroyed -@@ -1,92 +1 @@ +@@ -1,93 +1,2 @@ -{ - "ami": "ami-cbf90ecb", - "arn": "arn:aws:ec2:ap-northeast-1:999999999999:instance/i-0ecc384fa6f8d0623", @@ -106,11 +106,12 @@ - ] -} +null + ```````` ````````diff # aws_route_table.public-route will be created -@@ -1 +1,22 @@ +@@ -1,2 +1,23 @@ -null +{ + "route": [ @@ -134,16 +135,18 @@ + "timeouts": null, + "vpc_id": "vpc-0c08ee65bf93a360f" +} + ```````` ````````diff # aws_route_table_association.puclic-a will be created -@@ -1 +1,4 @@ +@@ -1,2 +1,5 @@ -null +{ + "gateway_id": null, + "subnet_id": "subnet-0342dca4d2a611266" +} + ```````` ````````diff diff --git a/test/testdata/include_module/expected.md b/test/testdata/include_module/expected.md index 5cd274c..e2c085c 100644 --- a/test/testdata/include_module/expected.md +++ b/test/testdata/include_module/expected.md @@ -5,11 +5,12 @@ ````````diff # module.test1.env_variable.test1 will be created -@@ -1 +1,3 @@ +@@ -1,2 +1,4 @@ -null +{ + "name": "test1" +} + ```````` diff --git a/test/testdata/single_add/expected.md b/test/testdata/single_add/expected.md index b5d4e68..9e0baa0 100644 --- a/test/testdata/single_add/expected.md +++ b/test/testdata/single_add/expected.md @@ -5,11 +5,12 @@ ````````diff # null_resource.foo will be created -@@ -1 +1,3 @@ +@@ -1,2 +1,4 @@ -null +{ + "triggers": null +} + ```````` diff --git a/test/testdata/single_change/expected.md b/test/testdata/single_change/expected.md index d8b62df..a2c46f0 100644 --- a/test/testdata/single_change/expected.md +++ b/test/testdata/single_change/expected.md @@ -5,13 +5,14 @@ ````````diff # env_variable.test1 will be updated in-place -@@ -1,5 +1,5 @@ +@@ -1,6 +1,6 @@ { "id": "test1", - "name": "test1", + "name": "test1_changed", "value": "REDACTED_SENSITIVE" } + ```````` diff --git a/test/testdata/single_destroy/expected.md b/test/testdata/single_destroy/expected.md index fcfe116..2110ecf 100644 --- a/test/testdata/single_destroy/expected.md +++ b/test/testdata/single_destroy/expected.md @@ -5,12 +5,13 @@ ````````diff # null_resource.foo will be destroyed -@@ -1,4 +1 @@ +@@ -1,5 +1,2 @@ -{ - "id": "7047514762471223910", - "triggers": null -} +null + ```````` diff --git a/test/testdata/special_characters/expected.md b/test/testdata/special_characters/expected.md new file mode 100644 index 0000000..673b293 --- /dev/null +++ b/test/testdata/special_characters/expected.md @@ -0,0 +1,35 @@ +### 1 to add, 0 to change, 0 to destroy, 0 to replace. +- add + - aws_instance.web +
Change details + +````````diff +# aws_instance.web will be created +@@ -1,2 +1,23 @@ +-null ++{ ++ "ami": "ami-04fc53a873660e525", ++ "credit_specification": [], ++ "get_password_data": false, ++ "hibernation": null, ++ "instance_type": "t3.micro", ++ "launch_template": [], ++ "source_dest_check": true, ++ "tags": { ++ "tag1": ">", ++ "tag2": "<", ++ "tag3": "&" ++ }, ++ "tags_all": { ++ "tag1": ">", ++ "tag2": "<", ++ "tag3": "&" ++ }, ++ "timeouts": null, ++ "user_data_replace_on_change": false, ++ "volume_tags": null ++} + +```````` + +
diff --git a/test/testdata/special_characters/show.json b/test/testdata/special_characters/show.json new file mode 100644 index 0000000..33979c8 --- /dev/null +++ b/test/testdata/special_characters/show.json @@ -0,0 +1,206 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 1, + "values": { + "ami": "ami-04fc53a873660e525", + "credit_specification": [], + "get_password_data": false, + "hibernation": null, + "instance_type": "t3.micro", + "launch_template": [], + "source_dest_check": true, + "tags": { + "tag1": ">", + "tag2": "<", + "tag3": "&" + }, + "tags_all": { + "tag1": ">", + "tag2": "<", + "tag3": "&" + }, + "timeouts": null, + "user_data_replace_on_change": false, + "volume_tags": null + }, + "sensitive_values": { + "capacity_reservation_specification": [], + "credit_specification": [], + "ebs_block_device": [], + "enclave_options": [], + "ephemeral_block_device": [], + "ipv6_addresses": [], + "launch_template": [], + "maintenance_options": [], + "metadata_options": [], + "network_interface": [], + "private_dns_name_options": [], + "root_block_device": [], + "secondary_private_ips": [], + "security_groups": [], + "tags": {}, + "tags_all": {}, + "vpc_security_group_ids": [] + } + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "ami": "ami-04fc53a873660e525", + "credit_specification": [], + "get_password_data": false, + "hibernation": null, + "instance_type": "t3.micro", + "launch_template": [], + "source_dest_check": true, + "tags": { + "tag1": ">", + "tag2": "<", + "tag3": "&" + }, + "tags_all": { + "tag1": ">", + "tag2": "<", + "tag3": "&" + }, + "timeouts": null, + "user_data_replace_on_change": false, + "volume_tags": null + }, + "after_unknown": { + "arn": true, + "associate_public_ip_address": true, + "availability_zone": true, + "capacity_reservation_specification": true, + "cpu_core_count": true, + "cpu_threads_per_core": true, + "credit_specification": [], + "disable_api_stop": true, + "disable_api_termination": true, + "ebs_block_device": true, + "ebs_optimized": true, + "enclave_options": true, + "ephemeral_block_device": true, + "host_id": true, + "host_resource_group_arn": true, + "iam_instance_profile": true, + "id": true, + "instance_initiated_shutdown_behavior": true, + "instance_state": true, + "ipv6_address_count": true, + "ipv6_addresses": true, + "key_name": true, + "launch_template": [], + "maintenance_options": true, + "metadata_options": true, + "monitoring": true, + "network_interface": true, + "outpost_arn": true, + "password_data": true, + "placement_group": true, + "placement_partition_number": true, + "primary_network_interface_id": true, + "private_dns": true, + "private_dns_name_options": true, + "private_ip": true, + "public_dns": true, + "public_ip": true, + "root_block_device": true, + "secondary_private_ips": true, + "security_groups": true, + "subnet_id": true, + "tags": {}, + "tags_all": {}, + "tenancy": true, + "user_data": true, + "user_data_base64": true, + "vpc_security_group_ids": true + }, + "before_sensitive": false, + "after_sensitive": { + "capacity_reservation_specification": [], + "credit_specification": [], + "ebs_block_device": [], + "enclave_options": [], + "ephemeral_block_device": [], + "ipv6_addresses": [], + "launch_template": [], + "maintenance_options": [], + "metadata_options": [], + "network_interface": [], + "private_dns_name_options": [], + "root_block_device": [], + "secondary_private_ips": [], + "security_groups": [], + "tags": {}, + "tags_all": {}, + "vpc_security_group_ids": [] + } + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "expressions": { + "region": { + "constant_value": "ap-northeast-1" + } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_config_key": "aws", + "expressions": { + "ami": { + "constant_value": "ami-04fc53a873660e525" + }, + "instance_type": { + "constant_value": "t3.micro" + }, + "tags": { + "constant_value": { + "tag1": ">", + "tag2": "<", + "tag3": "&" + } + } + }, + "schema_version": 1 + } + ] + } + }, + "timestamp": "2023-07-18T05:51:46Z" +}