Skip to content

Commit

Permalink
ci: 🎡 create terraform plan summary and post as comment to pr
Browse files Browse the repository at this point in the history
  • Loading branch information
jaskaransarkaria committed Oct 2, 2024
1 parent 2430aa9 commit 63389d5
Show file tree
Hide file tree
Showing 12 changed files with 14,888 additions and 30 deletions.
28 changes: 20 additions & 8 deletions pkg/environment/applier.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/exec"

"github.com/hashicorp/terraform-exec/tfexec"
tfjson "github.com/hashicorp/terraform-json"
"github.com/kelseyhightower/envconfig"
)

Expand All @@ -18,7 +19,7 @@ type Applier interface {
Initialize()
KubectlApply(namespace, directory string, dryRun bool) (string, error)
KubectlDelete(namespace, directory string, dryRun bool) (string, error)
TerraformInitAndPlan(namespace string, directory string) (string, error)
TerraformInitAndPlan(namespace string, directory string) (*tfjson.Plan, string, error)
TerraformInitAndApply(namespace string, directory string) (string, error)
TerraformInitAndDestroy(namespace string, directory string) (string, error)
TerraformDestroy(directory string) error
Expand Down Expand Up @@ -115,11 +116,11 @@ func (m *ApplierImpl) TerraformInitAndApply(namespace, directory string) (string
return out.String(), nil
}

func (m *ApplierImpl) TerraformInitAndPlan(namespace, directory string) (string, error) {
func (m *ApplierImpl) TerraformInitAndPlan(namespace, directory string) (*tfjson.Plan, string, error) {
var out bytes.Buffer
terraform, err := tfexec.NewTerraform(directory, m.terraformBinaryPath)
if err != nil {
return "", errors.New("unable to instantiate Terraform: " + err.Error())
return nil, "", errors.New("unable to instantiate Terraform: " + err.Error())
}

terraform.SetStdout(&out)
Expand All @@ -128,12 +129,12 @@ func (m *ApplierImpl) TerraformInitAndPlan(namespace, directory string) (string,
// Sometimes the error text would be useful in the command output that's
// displayed in the UI. For this reason, we append the error to the
// output before we return it.
errReturn := func(out bytes.Buffer, err error) (string, error) {
errReturn := func(out bytes.Buffer, err error) (*tfjson.Plan, string, error) {
if err != nil {
return fmt.Sprintf("%s\n%s", out.String(), err.Error()), err
return nil, fmt.Sprintf("%s\n%s", out.String(), err.Error()), err
}

return out.String(), nil
return nil, out.String(), nil
}

key := m.config.PipelineStateKeyPrefix + m.config.PipelineClusterState + "/" + namespace + "/terraform.tfstate"
Expand All @@ -147,13 +148,24 @@ func (m *ApplierImpl) TerraformInitAndPlan(namespace, directory string) (string,
return errReturn(out, err)
}

outOption := tfexec.Out("plan-" + namespace + ".out")
_, err = terraform.Plan(context.Background(), outOption)

tfPlan, _ := terraform.ShowPlanFile(context.Background(), "plan-"+namespace+".out")

tfPlan.UseJSONNumber(true)

if err != nil {
return nil, "", errors.New("unable to do Terraform Plan: " + err.Error())
}

// ignore if any changes or no changes.
_, err = terraform.Plan(context.Background())
if err != nil {
return "", errors.New("unable to do Terraform Plan: " + err.Error())
return nil, "", errors.New("unable to do Terraform Plan: " + err.Error())
}

return out.String(), nil
return tfPlan, out.String(), nil
}

func (m *ApplierImpl) TerraformInitAndDestroy(namespace, directory string) (string, error) {
Expand Down
93 changes: 93 additions & 0 deletions pkg/environment/createPlanSummary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package environment

import (
"fmt"

tfjson "github.com/hashicorp/terraform-json"
"github.com/ministryofjustice/cloud-platform-cli/pkg/github"
)

func CreateCommentBody(tfPlan *tfjson.Plan) string {
resourcesToUpdate := []string{}
resourcesToDelete := []string{}
resourcesToReplace := []string{}
resourcesUnchanged := []string{}
resourcesToCreate := []string{}
body := `
<h1>Terraform Plan Summary</h1>
`

for _, resource := range tfPlan.ResourceChanges {
changes := resource.Change

if len(changes.Actions) > 0 {
address := resource.Address
switch action := changes.Actions[0]; action {
case "no-op":
resourcesUnchanged = append(resourcesUnchanged, address)
break

Check failure on line 28 in pkg/environment/createPlanSummary.go

View workflow job for this annotation

GitHub Actions / staticcheck

redundant break statement (S1023)
case "create":
resourcesToCreate = append(resourcesToCreate, address)
break

Check failure on line 31 in pkg/environment/createPlanSummary.go

View workflow job for this annotation

GitHub Actions / staticcheck

redundant break statement (S1023)
case "delete":
if len(changes.Actions) > 1 {
resourcesToReplace = append(resourcesToReplace, address)
} else {
resourcesToDelete = append(resourcesToDelete, address)
}
break

Check failure on line 38 in pkg/environment/createPlanSummary.go

View workflow job for this annotation

GitHub Actions / staticcheck

redundant break statement (S1023)
case "update":
resourcesToUpdate = append(resourcesToUpdate, address)
break

Check failure on line 41 in pkg/environment/createPlanSummary.go

View workflow job for this annotation

GitHub Actions / staticcheck

redundant break statement (S1023)
default:
break
}

}
}

body += `
<details "open">
<summary>
<b>Terraform Plan: %d to be created, %d to be destroyed, %d to be updated, %d to be replaced and %d unchanged.</b>
</summary>
%s
%s
%s
%s
</details>
`
body = fmt.Sprintf(body, len(resourcesToCreate), len(resourcesToDelete), len(resourcesToUpdate), len(resourcesToReplace), len(resourcesUnchanged), details("create", "+", resourcesToCreate), details("destroy", "-", resourcesToDelete), details("update", "!", resourcesToUpdate), details("replace", "-+", resourcesToReplace))

if len(resourcesToCreate) == 0 && len(resourcesToReplace) == 0 && len(resourcesToUpdate) == 0 && len(resourcesToDelete) == 0 {
body = "\n```diff\n+ There are no terraform changes to apply```\n"
}

return body
}

func details(action, operator string, resources []string) string {
var str string

if len(resources) > 0 {
str = `
#### Resources to %s:
`
str = fmt.Sprintf(str, action)

str += "```diff\n"
for _, el := range resources {
str += operator + " " + el + "\n"
}

str += "```\n"
}

return str
}

func CreateComment(gh github.GithubIface, tfplan *tfjson.Plan, prNum int) bool {
body := CreateCommentBody(tfplan)

return gh.CreateComment(prNum, body)
}
122 changes: 122 additions & 0 deletions pkg/environment/createPlanSummary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package environment_test

import (
"fmt"
"io"
"os"
"testing"

tfjson "github.com/hashicorp/terraform-json"
"github.com/ministryofjustice/cloud-platform-cli/pkg/environment"
)

func readPlanFromJson(path string) *tfjson.Plan {
var initPlan tfjson.Plan

jsonFile, err := os.Open(path)
if err != nil {
fmt.Println(err)
}
defer jsonFile.Close()

byteVal, err := io.ReadAll(jsonFile)

Check failure on line 22 in pkg/environment/createPlanSummary_test.go

View workflow job for this annotation

GitHub Actions / staticcheck

this value of err is never used (SA4006)

unMarshallErr := initPlan.UnmarshalJSON(byteVal)

if unMarshallErr != nil {
return nil
}

return &initPlan
}

func Test_CreateCommentBody(t *testing.T) {
changesOutput := `
<h1>Terraform Plan Summary</h1>
<details "open">
<summary>
<b>Terraform Plan: %d to be created, %d to be destroyed, %d to be updated, %d to be replaced and %d unchanged.</b>
</summary>
%s
</details>
`

createChangesDiff := "#### Resources to create:\n```" + `diff
+ azurerm_linux_virtual_machine.calvinvm
+ azurerm_network_interface.calvin-nic
+ azurerm_network_interface_security_group_association.calvin-sg-nic
+ azurerm_network_security_group.calvin-security-group
+ azurerm_public_ip.calvin-ip
+ azurerm_resource_group.calvin
+ azurerm_storage_account.calvin-sa
+ azurerm_subnet.calvin-subnet
+ azurerm_virtual_network.calvin-vn
+ random_id.calvin-rid
+ tls_private_key.calvin_ssh
` + "```\n\n\n"

mixedChangesDiff := "#### Resources to create:\n```" + `diff
+ azurerm_linux_virtual_machine.calvinvm
+ azurerm_network_security_group.calvin-security-group
+ azurerm_public_ip.calvin-ip
+ azurerm_resource_group.calvin
+ azurerm_storage_account.calvin-sa
+ azurerm_subnet.calvin-subnet
+ azurerm_virtual_network.calvin-vn
+ random_id.calvin-rid
+ tls_private_key.calvin_ssh
` + "```\n\n\n#### Resources to destroy:\n```" + `diff
- azurerm_network_interface.calvin-nic
` + "```\n\n\n#### Resources to update:\n```" + `diff
! azurerm_network_interface_security_group_association.calvin-sg-nic
` + "```\n"

replacedChangesDiff := "#### Resources to create:\n```" + `diff
+ module.aks_lz.module.key_vault.azurerm_private_endpoint.key_vault
` + "```\n\n\n\n#### Resources to update:\n```" + `diff
! module.aks_lz.module.key_vault.azurerm_key_vault.key_vault
` + "```\n\n\n#### Resources to replace:\n```" + `diff
-+ module.aks_lz.azurerm_virtual_hub_connection.aks_vnet_hub_connection[0]
` + "```"

createChangesExpected := fmt.Sprintf(changesOutput, 11, 0, 0, 0, 0, createChangesDiff)
mixedChangesExpected := fmt.Sprintf(changesOutput, 9, 1, 1, 0, 0, mixedChangesDiff)
replacedChangesExpected := fmt.Sprintf(changesOutput, 1, 0, 1, 1, 35, replacedChangesDiff)

tfNoChangesPlan := readPlanFromJson("./fixtures/tf_nochanges.json")
tfChangesPlan01 := readPlanFromJson("./fixtures/tf_test01.json")
tfChangesPlan02 := readPlanFromJson("./fixtures/tf_test02.json")
tfChangesPlan03 := readPlanFromJson("./fixtures/tf_test03.json")

type args struct {
tfPlan *tfjson.Plan
}
tests := []struct {
name string
args args
want string
}{
{
"GIVEN a terraform plan with no changes THEN return a comment body stating so", args{tfNoChangesPlan}, "\n```diff\n+ There are no terraform changes to apply```\n",
},
{
"GIVEN a terraform plan with CREATE changes THEN return a comment body with correct changes", args{tfChangesPlan01}, createChangesExpected,
},
{
"GIVEN a terraform plan with CREATE, UPDATE, DESTROY changes THEN return a comment body with correct changes", args{tfChangesPlan02}, mixedChangesExpected,
},
{
"GIVEN a terraform plan with CREATE, UPDATE, DESTROY changes THEN return a comment body with correct changes", args{tfChangesPlan03}, replacedChangesExpected,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := environment.CreateCommentBody(tt.args.tfPlan); got != tt.want {
t.Errorf("createCommentBody() = %v, want %v", got, tt.want)
}
})
}
}
14 changes: 9 additions & 5 deletions pkg/environment/environmentApply.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"

gogithub "github.com/google/go-github/github"

tfjson "github.com/hashicorp/terraform-json"
"github.com/kelseyhightower/envconfig"
"github.com/ministryofjustice/cloud-platform-cli/pkg/github"
"github.com/ministryofjustice/cloud-platform-cli/pkg/slack"
Expand Down Expand Up @@ -364,17 +366,17 @@ func (a *Apply) deleteKubectl() (string, error) {
}

// planTerraform calls applier -> TerraformInitAndPlan and prints the output from applier
func (a *Apply) planTerraform() (string, error) {
func (a *Apply) planTerraform() (*tfjson.Plan, string, error) {
log.Printf("Running Terraform Plan for namespace: %v", a.Options.Namespace)

tfFolder := a.Dir + "/resources"

outputTerraform, err := a.Applier.TerraformInitAndPlan(a.Options.Namespace, tfFolder)
tfPlan, outputTerraform, err := a.Applier.TerraformInitAndPlan(a.Options.Namespace, tfFolder)
if err != nil {
err := fmt.Errorf("error running terraform on namespace %s: %v \n %v", a.Options.Namespace, err, outputTerraform)
return "", err
return nil, "", err
}
return outputTerraform, nil
return tfPlan, outputTerraform, nil
}

// applyTerraform calls applier -> TerraformInitAndApply and prints the output from applier
Expand Down Expand Up @@ -442,12 +444,14 @@ func (a *Apply) planNamespace() error {

exists, err := util.IsFilePathExists(repoPath + "/resources")
if err == nil && exists {
outputTerraform, err := applier.planTerraform()
tfPlan, outputTerraform, err := applier.planTerraform()
if err != nil {
return err
}

fmt.Println("\nOutput of terraform:")

CreateComment(a.GithubClient, tfPlan, a.Options.PRNumber)
util.RedactedEnv(os.Stdout, outputTerraform, a.Options.RedactedEnv)
} else {
fmt.Printf("Namespace %s does not have terraform resources folder, skipping terraform plan\n", a.Options.Namespace)
Expand Down
Loading

0 comments on commit 63389d5

Please sign in to comment.