diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index 0d496e0..0000000 --- a/.codecov.yml +++ /dev/null @@ -1,10 +0,0 @@ -coverage: - precision: 2 - round: down - range: 70...90 - -ignore: - - "main.go" - - "notifier/github/github.go" - - "notifier/slack/slack.go" - - "notifier/typetalk/typetalk.go" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ee7d337..46b27c7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # https://help.github.com/articles/about-codeowners/ -* @b4b4r07 @dtan4 +* @b4b4r07 @dtan4 @drlau @micnncim @KeisukeYamashita @tyuhara diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 790f470..8a6fb1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v1 with: - go-version: 1.13 + go-version: '1.14.x' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v1 with: diff --git a/README.md b/README.md index ff004d1..8d4234c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,12 @@ tfnotify ======== -[![][release-svg]][release] [![][test-svg]][test] [![][codecov-svg]][codecov] [![][goreportcard-svg]][goreportcard] +[![][release-svg]][release] [![][test-svg]][test] [![][goreportcard-svg]][goreportcard] [release]: https://github.com/mercari/tfnotify/actions?query=workflow%3Arelease [release-svg]: https://github.com/mercari/tfnotify/workflows/release/badge.svg [test]: https://github.com/mercari/tfnotify/actions?query=workflow%3Atest [test-svg]: https://github.com/mercari/tfnotify/workflows/test/badge.svg -[codecov]: https://codecov.io/gh/mercari/tfnotify -[codecov-svg]: https://codecov.io/gh/mercari/tfnotify/branch/master/graph/badge.svg [goreportcard]: https://goreportcard.com/report/github.com/mercari/tfnotify [goreportcard-svg]: https://goreportcard.com/badge/github.com/mercari/tfnotify @@ -44,7 +42,7 @@ $ go get -u github.com/mercari/tfnotify 2. Bind parsed results to Go templates 3. Notify it to any platform (e.g. GitHub) as you like -Detailed specifications such as templates and notification destinations can be customized from the configration files (described later). +Detailed specifications such as templates and notification destinations can be customized from the configuration files (described later). ## Usage @@ -380,6 +378,7 @@ Currently, supported CI are here: - Jenkins - GitLab CI - GitHub Actions +- Google Cloud Build ### Private Repository Considerations GitHub private repositories require the `repo` and `write:discussion` permissions. @@ -390,6 +389,18 @@ GitHub private repositories require the `repo` and `write:discussion` permission - Environment Variable - `PULL_REQUEST_NUMBER` or `PULL_REQUEST_URL` are required to set by user for Pull Request Usage +### Google Cloud Build Considerations + +- These environment variables are needed to be set using [substitutions](https://cloud.google.com/cloud-build/docs/configuring-builds/substitute-variable-values) + - `COMMIT_SHA` + - `BUILD_ID` + - `PROJECT_ID` + - `_PR_NUMBER` + - `_REGION` +- Recommended trigger events + - `terraform plan`: Pull request + - `terraform apply`: Push to branch + ## Committers * Masaki ISHIYAMA ([@b4b4r07](https://github.com/b4b4r07)) diff --git a/ci.go b/ci.go index 9cf6d5d..1b6a4f4 100644 --- a/ci.go +++ b/ci.go @@ -9,6 +9,8 @@ import ( ) var ( + defaultCloudBuildRegion = "global" + // https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request githubActionsPRRefRegexp = regexp.MustCompile(`refs/pull/\d+/merge`) ) @@ -163,3 +165,26 @@ func githubActions() (ci CI, err error) { return ci, err } + +func cloudbuild() (ci CI, err error) { + ci.PR.Number = 0 + ci.PR.Revision = os.Getenv("COMMIT_SHA") + + region := os.Getenv("_REGION") + if region == "" { + region = defaultCloudBuildRegion + } + + ci.URL = fmt.Sprintf( + "https://console.cloud.google.com/cloud-build/builds;region=%s/%s?project=%s", + region, + os.Getenv("BUILD_ID"), + os.Getenv("PROJECT_ID"), + ) + pr := os.Getenv("_PR_NUMBER") + if pr == "" { + return ci, nil + } + ci.PR.Number, err = strconv.Atoi(pr) + return ci, err +} diff --git a/ci_test.go b/ci_test.go index 8a87166..6a32797 100644 --- a/ci_test.go +++ b/ci_test.go @@ -776,3 +776,92 @@ func TestGitHubActions(t *testing.T) { } } } + +func TestCloudBuild(t *testing.T) { + envs := []string{ + "COMMIT_SHA", + "BUILD_ID", + "PROJECT_ID", + "_PR_NUMBER", + "REGION", + } + saveEnvs := make(map[string]string) + for _, key := range envs { + saveEnvs[key] = os.Getenv(key) + os.Unsetenv(key) + } + defer func() { + for key, value := range saveEnvs { + os.Setenv(key, value) + } + }() + + // https://cloud.google.com/cloud-build/docs/configuring-builds/substitute-variable-values + testCases := []struct { + fn func() + ci CI + ok bool + }{ + { + fn: func() { + os.Setenv("COMMIT_SHA", "abcdefg") + os.Setenv("BUILD_ID", "build-id") + os.Setenv("PROJECT_ID", "gcp-project-id") + os.Setenv("_PR_NUMBER", "123") + os.Setenv("_REGION", "asia-northeast1") + }, + ci: CI{ + PR: PullRequest{ + Revision: "abcdefg", + Number: 123, + }, + URL: "https://console.cloud.google.com/cloud-build/builds;region=asia-northeast1/build-id?project=gcp-project-id", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("COMMIT_SHA", "") + os.Setenv("BUILD_ID", "build-id") + os.Setenv("PROJECT_ID", "gcp-project-id") + os.Setenv("_PR_NUMBER", "") + os.Setenv("_REGION", "") + }, + ci: CI{ + PR: PullRequest{ + Revision: "", + Number: 0, + }, + URL: "https://console.cloud.google.com/cloud-build/builds;region=global/build-id?project=gcp-project-id", + }, + ok: true, + }, + { + fn: func() { + os.Setenv("COMMIT_SHA", "") + os.Setenv("BUILD_ID", "build-id") + os.Setenv("PROJECT_ID", "gcp-project-id") + os.Setenv("_PR_NUMBER", "abc") + }, + ci: CI{ + PR: PullRequest{ + Revision: "", + Number: 0, + }, + URL: "https://console.cloud.google.com/cloud-build/builds;region=global/build-id?project=gcp-project-id", + }, + ok: false, + }, + } + + for _, testCase := range testCases { + testCase.fn() + ci, err := cloudbuild() + if !reflect.DeepEqual(ci, testCase.ci) { + t.Errorf("got %q but want %q", ci, testCase.ci) + } + if (err == nil) != testCase.ok { + t.Errorf("got error %q", err) + } + } +} diff --git a/config/config.go b/config/config.go index d4411d9..b80f79c 100644 --- a/config/config.go +++ b/config/config.go @@ -146,6 +146,8 @@ func (cfg *Config) Validation() error { // ok pattern case "github-actions": // ok pattern + case "cloud-build", "cloudbuild": + // ok pattern default: return fmt.Errorf("%s: not supported yet", cfg.CI) } diff --git a/config/config_test.go b/config/config_test.go index e296373..74a79e4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -220,6 +220,14 @@ func TestValidation(t *testing.T) { contents: []byte("ci: gitlabci\n"), expected: "notifier is missing", }, + { + contents: []byte("ci: cloudbuild\n"), + expected: "notifier is missing", + }, + { + contents: []byte("ci: cloud-build\n"), + expected: "notifier is missing", + }, { contents: []byte("ci: circleci\nnotifier:\n github:\n"), expected: "notifier is missing", @@ -460,12 +468,6 @@ func TestFind(t *testing.T) { expect: "tfnotify.yml", ok: true, }, - { - // invalid config - file: "codecov.yml", - expect: "", - ok: false, - }, { // in case of no args passed file: "", diff --git a/main.go b/main.go index dd0436f..505c9d7 100644 --- a/main.go +++ b/main.go @@ -82,6 +82,11 @@ func (t *tfnotify) Run() error { if err != nil { return err } + case "cloud-build", "cloudbuild": + ci, err = cloudbuild() + if err != nil { + return err + } case "": return fmt.Errorf("CI service: required (e.g. circleci)") default: diff --git a/notifier/github/client.go b/notifier/github/client.go index 1c62bca..094e23d 100644 --- a/notifier/github/client.go +++ b/notifier/github/client.go @@ -10,9 +10,6 @@ import ( "golang.org/x/oauth2" ) -// EnvToken is GitHub API Token -const EnvToken = "GITHUB_TOKEN" - // EnvBaseURL is GitHub base URL. This can be set to a domain endpoint to use with GitHub Enterprise. const EnvBaseURL = "GITHUB_BASE_URL" @@ -69,10 +66,11 @@ type service struct { // NewClient returns Client initialized with Config func NewClient(cfg Config) (*Client, error) { token := cfg.Token - token = strings.TrimPrefix(token, "$") - if token == EnvToken { - token = os.Getenv(EnvToken) + + if strings.HasPrefix(token, "$") { + token = os.Getenv(strings.TrimPrefix(token, "$")) } + if token == "" { return &Client{}, errors.New("github token is missing") } diff --git a/notifier/github/client_test.go b/notifier/github/client_test.go index e84a117..79036d3 100644 --- a/notifier/github/client_test.go +++ b/notifier/github/client_test.go @@ -2,16 +2,11 @@ package github import ( "os" + "strings" "testing" ) func TestNewClient(t *testing.T) { - githubToken := os.Getenv(EnvToken) - defer func() { - os.Setenv(EnvToken, githubToken) - }() - os.Setenv(EnvToken, "") - testCases := []struct { config Config envToken string @@ -41,12 +36,24 @@ func TestNewClient(t *testing.T) { envToken: "", expect: "github token is missing", }, + { + // specify via env but not to be set env (part 3) + config: Config{Token: "$TFNOTIFY_GITHUB_TOKEN"}, + envToken: "", + expect: "github token is missing", + }, { // specify via env (part 2) config: Config{Token: "$GITHUB_TOKEN"}, envToken: "abcdefg", expect: "", }, + { + // specify via env (part 3) + config: Config{Token: "$TFNOTIFY_GITHUB_TOKEN"}, + envToken: "abcdefg", + expect: "", + }, { // no specification (part 1) config: Config{}, @@ -61,7 +68,11 @@ func TestNewClient(t *testing.T) { }, } for _, testCase := range testCases { - os.Setenv(EnvToken, testCase.envToken) + if strings.HasPrefix(testCase.config.Token, "$") { + key := strings.TrimPrefix(testCase.config.Token, "$") + os.Setenv(key, testCase.envToken) + } + _, err := NewClient(testCase.config) if err == nil { continue diff --git a/notifier/slack/notify.go b/notifier/slack/notify.go index fe44b3a..6ca44ab 100644 --- a/notifier/slack/notify.go +++ b/notifier/slack/notify.go @@ -43,6 +43,7 @@ func (s *NotifyService) Notify(body string) (exit int, err error) { Message: cfg.Message, Result: result.Result, Body: body, + Link: cfg.CI, }) text, err := template.Execute() if err != nil { diff --git a/terraform/parser.go b/terraform/parser.go index 80687dc..938afc4 100644 --- a/terraform/parser.go +++ b/terraform/parser.go @@ -61,8 +61,8 @@ func NewFmtParser() *FmtParser { // NewPlanParser is PlanParser initialized with its Regexp func NewPlanParser() *PlanParser { return &PlanParser{ - Pass: regexp.MustCompile(`(?m)^(Plan: \d|No changes.)`), - Fail: regexp.MustCompile(`(?m)^(Error: )`), + Pass: regexp.MustCompile(`(?m)^((Plan: \d|No changes.)|(Changes to Outputs:))`), + Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`), // "0 to destroy" should be treated as "no destroy" HasDestroy: regexp.MustCompile(`(?m)([1-9][0-9]* to destroy.)`), HasNoChanges: regexp.MustCompile(`(?m)^(No changes. Infrastructure is up-to-date.)`), @@ -73,7 +73,7 @@ func NewPlanParser() *PlanParser { func NewApplyParser() *ApplyParser { return &ApplyParser{ Pass: regexp.MustCompile(`(?m)^(Apply complete!)`), - Fail: regexp.MustCompile(`(?m)^(Error: )`), + Fail: regexp.MustCompile(`(?m)^(│\s{1})?(Error: )`), } } diff --git a/terraform/parser_test.go b/terraform/parser_test.go index 6f13626..3ff0c79 100644 --- a/terraform/parser_test.go +++ b/terraform/parser_test.go @@ -86,7 +86,81 @@ can't guarantee that exactly these actions will be performed if "terraform apply" is subsequently run. ` -const planFailureResult = ` +const planOnlyOutputChangesSuccessResult0_12 = ` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +data.terraform_remote_state.teams_platform_development: Refreshing state... +google_project.my_project: Refreshing state... +aws_iam_policy.datadog_aws_integration: Refreshing state... +aws_iam_user.teams_terraform: Refreshing state... +aws_iam_role.datadog_aws_integration: Refreshing state... +google_project_services.my_project: Refreshing state... +google_bigquery_dataset.gateway_access_log: Refreshing state... +aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state... +google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state... +google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state... +google_dns_managed_zone.tfnotifyapps_com: Refreshing state... +google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + +Plan: 0 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + aws_instance_name = "my-instance" + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +` + +const planOnlyOutputChangesSuccessResult0_15 = ` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +data.terraform_remote_state.teams_platform_development: Refreshing state... +google_project.my_project: Refreshing state... +aws_iam_policy.datadog_aws_integration: Refreshing state... +aws_iam_user.teams_terraform: Refreshing state... +aws_iam_role.datadog_aws_integration: Refreshing state... +google_project_services.my_project: Refreshing state... +google_bigquery_dataset.gateway_access_log: Refreshing state... +aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state... +google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state... +google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state... +google_dns_managed_zone.tfnotifyapps_com: Refreshing state... +google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + +Changes to Outputs: + + aws_instance_name = "my-instance" + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +` + +const planFailureResult0_12 = ` xxxxxxxxx xxxxxxxxx xxxxxxxxx @@ -99,6 +173,21 @@ Error: Error refreshing state: 4 error(s) occurred: * google_sql_user.proxyuser_main: 1 error(s) occurred: ` +const planFailureResult0_15 = ` +xxxxxxxxx +xxxxxxxxx +xxxxxxxxx + +╷ +│ Error: Error refreshing state: 4 error(s) occurred: +│ +│ * google_sql_database.main: 1 error(s) occurred: +│ +│ * google_sql_database.main: google_sql_database.main: Error reading SQL Database "main" in instance "main-master-instance": googleapi: Error 409: The instance or operation is not in an appropriate state to handle the request., invalidState +│ * google_sql_user.proxyuser_main: 1 error(s) occurred: +╵ +` + const planNoChanges = ` google_bigquery_dataset.tfnotify_echo: Refreshing state... google_project.team: Refreshing state... @@ -300,7 +389,7 @@ google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... Apply complete! Resources: 0 added, 0 changed, 0 destroyed. ` -const applyFailureResult = ` +const applyFailureResult0_12 = ` data.terraform_remote_state.teams_platform_development: Refreshing state... google_project.tfnotify_jp_tfnotify_prod: Refreshing state... google_project_services.tfnotify_jp_tfnotify_prod: Refreshing state... @@ -331,6 +420,36 @@ Error: Batch "project/tfnotify-jp-tfnotify-prod/services:batchEnable" for reques ` +const applyFailureResult0_15 = ` +data.terraform_remote_state.teams_platform_development: Refreshing state... +google_project.tfnotify_jp_tfnotify_prod: Refreshing state... +google_project_services.tfnotify_jp_tfnotify_prod: Refreshing state... +google_bigquery_dataset.gateway_access_log: Refreshing state... +google_compute_global_address.reviews_web_tfnotify_in: Refreshing state... +google_compute_global_address.chartmuseum_tfnotifyapps_com: Refreshing state... +google_storage_bucket.chartmuseum: Refreshing state... +google_storage_bucket.ark_tfnotify_prod: Refreshing state... +google_compute_global_address.reviews_api_tfnotify_in: Refreshing state... +google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state... +google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state... +aws_s3_bucket.terraform_backend: Refreshing state... +aws_s3_bucket.teams_terraform_private_modules: Refreshing state... +aws_iam_policy.datadog_aws_integration: Refreshing state... +aws_iam_role.datadog_aws_integration: Refreshing state... +aws_iam_user.teams_terraform: Refreshing state... +aws_iam_user_policy.teams_terraform: Refreshing state... +aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state... +google_dns_managed_zone.tfnotifyapps_com: Refreshing state... +google_dns_record_set.dev_tfnotifyapps_com: Refreshing state... + +╷ +│ Error: Batch "project/tfnotify-jp-tfnotify-prod/services:batchEnable" for request "Enable Project Services tfnotify-jp-tfnotify-prod: map[logging.googleapis.com:{}]" returned error: failed to send enable services request: googleapi: Error 403: The caller does not have permission, forbidden +│ +│ on .terraform/modules/tfnotify-jp-tfnotify-prod/google_project_service.tf line 6, in resource "google_project_service" "gcp_api_service": +│ 6: resource "google_project_service" "gcp_api_service" { +╵ +` + func TestDefaultParserParse(t *testing.T) { testCases := []struct { body string @@ -414,6 +533,32 @@ func TestPlanParserParse(t *testing.T) { Error: nil, }, }, + { + name: "plan output changes only pattern 0.12", + body: planOnlyOutputChangesSuccessResult0_12, + result: ParseResult{ + Result: "Plan: 0 to add, 0 to change, 0 to destroy.", + HasAddOrUpdateOnly: true, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 0, + Error: nil, + }, + }, + { + name: "plan output changes only pattern 0.15", + body: planOnlyOutputChangesSuccessResult0_15, + result: ParseResult{ + Result: "Changes to Outputs:", + HasAddOrUpdateOnly: true, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 0, + Error: nil, + }, + }, { name: "no stdin", body: "", @@ -428,8 +573,8 @@ func TestPlanParserParse(t *testing.T) { }, }, { - name: "plan ng pattern", - body: planFailureResult, + name: "plan ng pattern 0.12", + body: planFailureResult0_12, result: ParseResult{ Result: `Error: Error refreshing state: 4 error(s) occurred: @@ -445,6 +590,25 @@ func TestPlanParserParse(t *testing.T) { Error: nil, }, }, + { + name: "plan ng pattern 0.15", + body: planFailureResult0_15, + result: ParseResult{ + Result: `│ Error: Error refreshing state: 4 error(s) occurred: +│ +│ * google_sql_database.main: 1 error(s) occurred: +│ +│ * google_sql_database.main: google_sql_database.main: Error reading SQL Database "main" in instance "main-master-instance": googleapi: Error 409: The instance or operation is not in an appropriate state to handle the request., invalidState +│ * google_sql_user.proxyuser_main: 1 error(s) occurred: +╵`, + HasAddOrUpdateOnly: false, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: true, + ExitCode: 1, + Error: nil, + }, + }, { name: "plan no changes", body: planNoChanges, @@ -531,8 +695,8 @@ func TestApplyParserParse(t *testing.T) { }, }, { - name: "apply ng pattern", - body: applyFailureResult, + name: "apply ng pattern 0.12", + body: applyFailureResult0_12, result: ParseResult{ Result: `Error: Batch "project/tfnotify-jp-tfnotify-prod/services:batchEnable" for request "Enable Project Services tfnotify-jp-tfnotify-prod: map[logging.googleapis.com:{}]" returned error: failed to send enable services request: googleapi: Error 403: The caller does not have permission, forbidden @@ -544,6 +708,19 @@ func TestApplyParserParse(t *testing.T) { Error: nil, }, }, + { + name: "apply ng pattern 0.15", + body: applyFailureResult0_15, + result: ParseResult{ + Result: `│ Error: Batch "project/tfnotify-jp-tfnotify-prod/services:batchEnable" for request "Enable Project Services tfnotify-jp-tfnotify-prod: map[logging.googleapis.com:{}]" returned error: failed to send enable services request: googleapi: Error 403: The caller does not have permission, forbidden +│ +│ on .terraform/modules/tfnotify-jp-tfnotify-prod/google_project_service.tf line 6, in resource "google_project_service" "gcp_api_service": +│ 6: resource "google_project_service" "gcp_api_service" { +╵`, + ExitCode: 1, + Error: nil, + }, + }, } for _, testCase := range testCases { result := NewApplyParser().Parse(testCase.body) diff --git a/terraform/template.go b/terraform/template.go index 490b946..0126acb 100644 --- a/terraform/template.go +++ b/terraform/template.go @@ -219,7 +219,7 @@ func generateOutput(kind, template string, data map[string]interface{}, useRawOu return b.String(), nil } -// Execute binds the execution result of terraform command into tepmlate +// Execute binds the execution result of terraform command into template func (t *DefaultTemplate) Execute() (string, error) { data := map[string]interface{}{ "Title": t.Title, @@ -237,7 +237,7 @@ func (t *DefaultTemplate) Execute() (string, error) { return resp, nil } -// Execute binds the execution result of terraform fmt into tepmlate +// Execute binds the execution result of terraform fmt into template func (t *FmtTemplate) Execute() (string, error) { data := map[string]interface{}{ "Title": t.Title, @@ -255,7 +255,7 @@ func (t *FmtTemplate) Execute() (string, error) { return resp, nil } -// Execute binds the execution result of terraform plan into tepmlate +// Execute binds the execution result of terraform plan into template func (t *PlanTemplate) Execute() (string, error) { data := map[string]interface{}{ "Title": t.Title, @@ -291,7 +291,7 @@ func (t *DestroyWarningTemplate) Execute() (string, error) { return resp, nil } -// Execute binds the execution result of terraform apply into tepmlate +// Execute binds the execution result of terraform apply into template func (t *ApplyTemplate) Execute() (string, error) { data := map[string]interface{}{ "Title": t.Title,