├── .editorconfig ├── .github ├── dependabot.yaml └── workflows │ ├── ci.yaml │ └── integration.yaml ├── .gitignore ├── .terraformignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── action.yaml ├── doc └── release-procedure.md ├── gha ├── github_actions.go └── github_actions_test.go ├── go.mod ├── go.sum ├── integration ├── main.tf └── public_access_s3_policy.json └── main.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 4 10 | insert_final_newline = true 11 | 12 | [*.go] 13 | indent_style = tab 14 | 15 | [{*.yaml, *.yml, *.tf}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # reference: https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | - package-ecosystem: "docker" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | ci: 7 | name: CI 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - uses: actions/setup-go@v2 14 | with: 15 | go-version: 1.14 16 | 17 | - run: go get -v -t -d ./... 18 | - run: go test ./... 19 | - run: go build -v . 20 | - run: go vet 21 | - run: test -z $(gofmt -l .) 22 | 23 | docker-build: 24 | name: Build Docker image (smoke test) 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | 30 | - run: docker build . 31 | -------------------------------------------------------------------------------- /.github/workflows/integration.yaml: -------------------------------------------------------------------------------- 1 | name: Integration 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | jobs: 9 | integration: 10 | name: Integration 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: kvrhdn/gha-buildevents@main 15 | with: 16 | apikey: ${{ secrets.BUILDEVENT_APIKEY }} 17 | dataset: tfe-run_integration 18 | job-status: ${{ job.status }} 19 | 20 | - uses: actions/checkout@v2 21 | 22 | - name: Start step 'speculative-plan' 23 | run: | 24 | echo "STEP_ID=0" >> $GITHUB_ENV 25 | echo "STEP_START=$(date +%s)" >> $GITHUB_ENV 26 | 27 | - uses: ./ 28 | with: 29 | token: ${{ secrets.TFE_TOKEN }} 30 | workspace: tfe-run_integration 31 | type: plan 32 | wait-for-completion: true 33 | tf-vars: | 34 | run_number = ${{ github.run_number }} 35 | id: plan 36 | 37 | - name: Assert speculative plan has changes 38 | if: steps.plan.outputs.has-changes == 'false' 39 | run: | 40 | echo "Expected speculative plan to have changes, but had not" 41 | exit 1 42 | 43 | - name: Wrap up step 44 | run: | 45 | buildevents step $TRACE_ID $STEP_ID $STEP_START speculative-plan 46 | 47 | - name: Start step 'apply' 48 | run: | 49 | echo "STEP_ID=1" >> $GITHUB_ENV 50 | echo "STEP_START=$(date +%s)" >> $GITHUB_ENV 51 | 52 | - uses: ./ 53 | with: 54 | token: ${{ secrets.TFE_TOKEN }} 55 | workspace: tfe-run_integration 56 | message: "Integration run ${{ github.run_number }}" 57 | type: apply 58 | wait-for-completion: true 59 | tf-vars: | 60 | run_number = ${{ github.run_number }} 61 | id: apply 62 | 63 | - name: Assert run number is published to S3 64 | env: 65 | RUN_NUMBER: ${{ github.run_number }} 66 | S3_URL: ${{ steps.apply.outputs.tf-endpoint }} 67 | run: | 68 | EXPECTED="GitHub Actions run number: $RUN_NUMBER" 69 | ACTUAL=$(curl $S3_URL) 70 | 71 | if [ "$EXPECTED" != "$ACTUAL" ]; then 72 | echo "Data on S3 does not match with expectations!" 73 | echo "" 74 | echo "Got: '$ACTUAL'" 75 | echo "Expected: '$EXPECTED'" 76 | exit 1 77 | fi 78 | 79 | - name: Wrap up step 80 | run: | 81 | buildevents step $TRACE_ID $STEP_ID $STEP_START apply 82 | 83 | - name: Start step 'destroy' 84 | run: | 85 | echo "STEP_ID=2" >> $GITHUB_ENV 86 | echo "STEP_START=$(date +%s)" >> $GITHUB_ENV 87 | 88 | - uses: ./ 89 | with: 90 | token: ${{ secrets.TFE_TOKEN }} 91 | workspace: tfe-run_integration 92 | message: "Integration run ${{ github.run_number }} - destroy" 93 | type: destroy 94 | targets: | 95 | aws_s3_bucket_object.index 96 | aws_s3_bucket_object.error 97 | wait-for-completion: true 98 | tf-vars: | 99 | run_number = ${{ github.run_number }} 100 | id: destroy 101 | 102 | - name: Wrap up step 103 | run: | 104 | buildevents step $TRACE_ID $STEP_ID $STEP_START destroy 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # input parameters for local development 2 | input.json 3 | 4 | # terraform specific 5 | .terraform 6 | -------------------------------------------------------------------------------- /.terraformignore: -------------------------------------------------------------------------------- 1 | * 2 | !/integration/ 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16.7-alpine 2 | WORKDIR /app 3 | ADD . /app 4 | RUN cd /app && go build -o app 5 | ENTRYPOINT /app/app 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Koenraad Verheyden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | fmt: 2 | goimports -l -w . 3 | go mod tidy 4 | terraform fmt --recursive 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `tfe-run` Action 2 | 3 | [![CI](https://github.com/kvrhdn/tfe-run/workflows/CI/badge.svg)](https://github.com/kvrhdn/tfe-run/actions?query=workflow%3ACI) 4 | [![Integration](https://github.com/kvrhdn/tfe-run/workflows/Integration/badge.svg)](https://github.com/kvrhdn/tfe-run/actions?query=workflow%3AIntegration) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/kvrhdn/tfe-run)](https://goreportcard.com/report/github.com/kvrhdn/tfe-run) 6 | 7 | This GitHub Action creates a new run on Terraform Cloud. Integrate Terraform Cloud into your GitHub Actions workflow. 8 | 9 | This action creates runs using [the Terraform Cloud API][tfe-api] which provides more flexibility than using the CLI. Namely, you can: 10 | - define your own message (no more _"Queued manually using Terraform"_) 11 | - provide as many variables as you want 12 | - access the outputs from the Terraform state 13 | 14 | Internally, we leverage [the official Go API client from Hashicorp][go-tfe]. 15 | 16 | [tfe-api]: https://www.terraform.io/docs/cloud/run/api.html 17 | [go-tfe]: https://github.com/hashicorp/go-tfe/ 18 | 19 | ## How to use it 20 | 21 | ```yaml 22 | - uses: kvrhdn/tfe-run@v1 23 | with: 24 | token: ${{ secrets.TFE_TOKEN }} 25 | workspace: tfe-run 26 | message: | 27 | Run triggered using tfe-run (commit: ${{ github.SHA }}) 28 | id: tfe-run 29 | 30 | ... next steps can access the run URL with ${{ steps.tfe-run.outputs.run-url }} 31 | ``` 32 | 33 | Full option list: 34 | 35 | ```yaml 36 | - uses: kvrhdn/tfe-run@v1 37 | with: 38 | # Token used to communicate with the Terraform Cloud API. Must be a user or 39 | # team api token. 40 | token: ${{ secrets.TFE_TOKEN }} 41 | 42 | # Name of the organization on Terraform Cloud. Defaults to the GitHub 43 | # organization name. 44 | organization: kvrhdn 45 | 46 | # Name of the workspace on Terraform Cloud. 47 | workspace: tfe-run 48 | 49 | # Optional message to use as name of the run. 50 | message: | 51 | Run triggered using tfe-run (commit: ${{ github.SHA }}) 52 | 53 | # The directory that is uploaded to Terraform Cloud/Enterprise, defaults 54 | # to the repository root. Respsects .terraformignore. Note: this is 55 | # prefixed to the "Terraform Working Directory" in the remote workspace 56 | # settings. 57 | directory: integration/ 58 | 59 | # The type of run, allowed options are 'plan', 'apply' and 'destroy'. 60 | type: apply 61 | 62 | # An optional list of resource addresses to target. Should be a list of 63 | # strings separated by new lines. 64 | # 65 | # For more information about resource targeting, check https://www.terraform.io/docs/commands/plan.html#resource-targeting 66 | targets: | 67 | resource.name 68 | 69 | # Whether we should wait for the plan or run to be applied. This will block 70 | # until the run is finished. 71 | wait-for-completion: true 72 | 73 | # The contents of a auto.tfvars file that will be uploaded to Terraform 74 | # Cloud. This can be used to set Terraform variables. 75 | tf-vars: | 76 | run_number = ${{ github.run_number }} 77 | service = "example" 78 | 79 | # Optionally, assign this step an ID so you can refer to the outputs from the 80 | # action with ${{ steps..outputs. }} 81 | id: tfe-run 82 | ``` 83 | 84 | ### Inputs 85 | 86 | Name | Required | Description | Type | Default 87 | ---------------|----------|-----------------------------------------------------------------------------------------------------------------|--------|-------- 88 | `token` | yes | Token used to communicating with the Terraform Cloud API. Must be [a user or team api token][tfe-tokens]. | string | 89 | `organization` | | Name of the organization on Terraform Cloud. | string | The repository owner 90 | `workspace` | yes | Name of the workspace on Terraform Cloud. | string | 91 | `message` | | Optional message to use as name of the run. | string | _Queued by GitHub Actions (commit: $GITHUB_SHA)_ 92 | `directory` | | The directory that is uploaded to Terraform Cloud/Enterprise, defaults to repository root. Respects .terraformignore. Note: this is prefixed to the "Terraform Working Directory" in the remote workspace settings. | string | `./` 93 | `type` | | The type of run, allowed options are 'plan', 'apply' and 'destroy'. | string | `apply` 94 | `targets` | | An optional list of resource addresses to target. Should be a list of strings separated by new lines. | string | 95 | `wait-for-completion` | | Whether we should wait for the plan or run to be applied. This will block until the run is finished. | string | `false` 96 | `tf-vars` | | The contents of a auto.tfvars file that will be uploaded to Terraform Cloud. | string | 97 | 98 | [tfe-tokens]: https://www.terraform.io/docs/cloud/users-teams-organizations/api-tokens.html 99 | [tfe-speculative-run]: https://www.terraform.io/docs/cloud/run/index.html#speculative-plans 100 | 101 | ### Outputs 102 | 103 | Name | Description | Type 104 | --------------|---------------------------------------------------------------------------------------------------|----- 105 | `run-url` | URL of the run on Terraform Cloud | string 106 | `has-changes` | Whether the run has changes. | bool (`'true'` or `'false'`) 107 | `tf-**` | Outputs from the current Terraform state, prefixed with `tf-`. Only set for non-speculative runs. | string 108 | 109 | ## License 110 | 111 | This Action is distributed under the terms of the MIT license, see [LICENSE](./LICENSE) for details. 112 | 113 | ## Development 114 | 115 | For running tfe-run locally, see [development.md](./doc/development.md). 116 | 117 | For creating new release, see [release-procedure.md](./doc/release-procedure.md). 118 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: Terraform Cloud Run 2 | author: Koenraad Verheyden 3 | description: Create and customize a run on Terraform Cloud 4 | 5 | branding: 6 | icon: fast-forward 7 | color: purple 8 | 9 | inputs: 10 | token: 11 | description: | 12 | Token used to communicating with the Terraform Cloud API. Must be a user or team api token. 13 | required: true 14 | organization: 15 | description: | 16 | Name of the organization on Terraform Cloud, defaults to the owner of the GitHub repository. 17 | required: false 18 | default: ${{ github.repository_owner }} 19 | workspace: 20 | description: > 21 | Name of the workspace on Terraform Cloud. 22 | required: true 23 | speculative: 24 | description: | 25 | Whether to run a speculative plan. 26 | required: false 27 | default: 'false' 28 | deprecationMessage: | 29 | The property speculative is deprecated in favor of type. If type is set, speculative is ignored. 30 | type: 31 | description: | 32 | The type of run, allowed options are 'plan', 'apply' and 'destroy'. 33 | required: false 34 | default: 'apply' 35 | targets: 36 | description: | 37 | An optional list of resource addresses to target. Should be list separated by newlines. 38 | required: false 39 | default: '' 40 | wait-for-completion: 41 | description: | 42 | Whether we should wait for the plan or run to be applied. This will block until the run is finished. Defaults to true. 43 | required: false 44 | default: 'true' 45 | message: 46 | description: | 47 | Optional message to use as name of the run. 48 | required: false 49 | default: 'Queued by GitHub Actions (commit: ${{ github.sha }})' 50 | directory: 51 | description: | 52 | The directory that is uploaded to Terraform Enterprise, defaults to repository root. Respects .terraformignore. 53 | required: false 54 | tf-vars: 55 | description: | 56 | The contents of a auto.tfvars file that will be uploaded to Terraform Cloud. 57 | required: false 58 | 59 | outputs: 60 | run-url: 61 | description: URL of the run on Terraform Cloud. 62 | has-changes: 63 | description: Whether a speculative plan has changes or not. 64 | 65 | # tfe-run will also output all outputs from the Terraform workspace prefixed 66 | # with `tf-`. Since these are dynamic, they can't be listed in action.yaml. 67 | # For example, if a workspace has an output `endpoint`, this will exported by 68 | # tfe-run as `tf-endpoint`. 69 | 70 | runs: 71 | using: docker 72 | image: Dockerfile 73 | -------------------------------------------------------------------------------- /doc/release-procedure.md: -------------------------------------------------------------------------------- 1 | # Release procedure 2 | 3 | `tfe-run` follows [the recommendation from the GitHub Actions team][actions-versioning], namely that each release has a semantic version (i.e. `v1.0.1`). We also provide a major version tag (i.e. `v1`) that binds to the latest semantic version. 4 | 5 | [actions-versioning]: https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#versioning 6 | 7 | Follow these steps to create a new release: 8 | - create a new release from the Relases page. 9 | - assign it a tag with semantic version. 10 | - create or update the major version tag so it points to the latest release: 11 | ``` 12 | git tag -fa v1 -m "Update v1 tag" 13 | git push origin v1 --force 14 | ``` 15 | -------------------------------------------------------------------------------- /gha/github_actions.go: -------------------------------------------------------------------------------- 1 | // Package gha provides functions to interact with the GitHub Actions runtime. 2 | package gha 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/sethvargo/go-githubactions" 12 | ) 13 | 14 | // InGitHubActions indicates whether this application is being run within the 15 | // GitHub Actions environment. 16 | func InGitHubActions() bool { 17 | return os.Getenv("GITHUB_ACTIONS") == "true" 18 | } 19 | 20 | // PopulateFromInputs will populate the given struct with inputs supplied by 21 | // the GitHub Actions environment. Fields that should be populated must be 22 | // tagged with `gha:""`. If the empty string is given (`gha:""`) 23 | // the field name will be used as input key. 24 | // 25 | // Additional options can be supplied through the tags, separated by comma's: 26 | // - required: returns an error if the input is not present or empty string 27 | // 28 | // Example struct: 29 | // 30 | // type Example struct { 31 | // RunID string `gha:"run-id,required"` 32 | // Directory string `gha:""` 33 | // DryRun bool `gha:"dry-run"` 34 | // } 35 | // 36 | func PopulateFromInputs(v interface{}) (err error) { 37 | rv := reflect.ValueOf(v) 38 | 39 | if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Struct { 40 | return fmt.Errorf("invalid type %v, must be a pointer to a struct", reflect.TypeOf(v)) 41 | } 42 | 43 | structType := reflect.TypeOf(v).Elem() 44 | 45 | for i := 0; i < structType.NumField(); i++ { 46 | field := structType.Field(i) 47 | 48 | tag := field.Tag.Get("gha") 49 | 50 | inputName, isRequired := parseTagOptions(tag) 51 | if inputName == "" { 52 | inputName = field.Name 53 | } 54 | 55 | value := githubactions.GetInput(inputName) 56 | 57 | if isRequired && value == "" { 58 | return fmt.Errorf("field %v is required but was not supplied", field.Name) 59 | } 60 | 61 | valueField := rv.Elem().Field(i) 62 | 63 | if !valueField.CanSet() { 64 | return fmt.Errorf("field %v can not be set, is it exported?", field.Name) 65 | } 66 | 67 | switch valueField.Kind() { 68 | case reflect.String: 69 | valueField.SetString(value) 70 | case reflect.Bool: 71 | boolValue, err := strconv.ParseBool(value) 72 | if err != nil { 73 | return fmt.Errorf("could not parse input for field %v as bool, value: %v: %w", field.Name, value, err) 74 | } 75 | valueField.SetBool(boolValue) 76 | default: 77 | return fmt.Errorf("fields of type %v are not supported, only strings and booleans are", valueField.Kind()) 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func parseTagOptions(tag string) (inputName string, isRequired bool) { 85 | if tag == "" { 86 | return "", false 87 | } 88 | splitTag := strings.Split(tag, ",") 89 | inputName, options := splitTag[0], splitTag[1:] 90 | 91 | for _, option := range options { 92 | if option == "required" { 93 | isRequired = true 94 | break 95 | } 96 | } 97 | 98 | return 99 | } 100 | 101 | // WriteOutput writes an output parameter. 102 | func WriteOutput(name, value string) { 103 | githubactions.SetOutput(name, value) 104 | } 105 | -------------------------------------------------------------------------------- /gha/github_actions_test.go: -------------------------------------------------------------------------------- 1 | package gha 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type testStruct struct { 11 | Required string `gha:"required-field,required"` 12 | Optional string `gha:"optional-field"` 13 | WithoutTag string 14 | Boolean bool `gha:"boolean"` 15 | } 16 | 17 | func TestPopulateFromInputs(t *testing.T) { 18 | os.Clearenv() 19 | os.Setenv("INPUT_REQUIRED-FIELD", "foo") 20 | os.Setenv("INPUT_WITHOUTTAG", "bar") 21 | os.Setenv("INPUT_BOOLEAN", "true") 22 | 23 | var ts testStruct 24 | 25 | err := PopulateFromInputs(&ts) 26 | 27 | assert.NoError(t, err) 28 | assert.Equal(t, "foo", ts.Required) 29 | assert.Equal(t, "", ts.Optional) 30 | assert.Equal(t, "bar", ts.WithoutTag) 31 | assert.Equal(t, true, ts.Boolean) 32 | } 33 | 34 | func TestPopulateFromInputs_invalidInputType(t *testing.T) { 35 | os.Clearenv() 36 | 37 | aString := "foo" 38 | values := []interface{}{nil, 5, aString, &aString, testStruct{}} 39 | 40 | for _, v := range values { 41 | err := PopulateFromInputs(v) 42 | 43 | assert.Error(t, err) 44 | assert.Contains(t, err.Error(), "invalid type") 45 | } 46 | } 47 | 48 | func TestPopulateFromInputs_invalidBooleanInput(t *testing.T) { 49 | os.Clearenv() 50 | os.Setenv("INPUT_REQUIRED-FIELD", "foo") 51 | os.Setenv("INPUT_BOOLEAN-FIELD", "bar") 52 | 53 | var ts testStruct 54 | 55 | err := PopulateFromInputs(&ts) 56 | 57 | assert.Error(t, err) 58 | assert.Contains(t, err.Error(), "could not parse input for field Boolean as bool") 59 | } 60 | 61 | func TestPopulateFromInputs_missingRequiredField(t *testing.T) { 62 | os.Clearenv() 63 | 64 | var ts testStruct 65 | 66 | err := PopulateFromInputs(&ts) 67 | 68 | assert.Error(t, err) 69 | assert.Contains(t, err.Error(), "is required but was not supplied") 70 | } 71 | 72 | func TestPopulateFromInputs_unsupportedFieldType(t *testing.T) { 73 | os.Clearenv() 74 | 75 | type unsupportedStruct struct { 76 | Number int `gha:"number"` 77 | } 78 | 79 | var testStruct unsupportedStruct 80 | 81 | err := PopulateFromInputs(&testStruct) 82 | 83 | assert.Error(t, err) 84 | assert.Contains(t, err.Error(), "fields of type int are not supported") 85 | } 86 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kvrhdn/tfe-run 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/kvrhdn/go-tfe-run v0.3.3 7 | github.com/sethvargo/go-githubactions v0.3.0 8 | github.com/stretchr/testify v1.7.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 6 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 7 | github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6KdvN3Gig= 8 | github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 9 | github.com/hashicorp/go-retryablehttp v0.5.2 h1:AoISa4P4IsW0/m4T6St8Yw38gTl5GtBAgfkhYh1xAz4= 10 | github.com/hashicorp/go-retryablehttp v0.5.2/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= 11 | github.com/hashicorp/go-slug v0.4.1 h1:/jAo8dNuLgSImoLXaX7Od7QB4TfYCVPam+OpAt5bZqc= 12 | github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= 13 | github.com/hashicorp/go-tfe v0.12.0 h1:teL523WPxwYzL5Gjc2QFxExndrMfWY4BXS2/olVpULM= 14 | github.com/hashicorp/go-tfe v0.12.0/go.mod h1:oT0AG5u/ROzWiw8JZFLDY6FLh6AZnJIG0Ahhvp10txg= 15 | github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= 16 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 17 | github.com/kvrhdn/go-tfe-run v0.3.3 h1:eFjdAkZZXT9JDhM+4Z+RA8v0UFhv8itU4a0z3Sb9hg8= 18 | github.com/kvrhdn/go-tfe-run v0.3.3/go.mod h1:NsPloxDJ+hs6yDY10T9FKhJbRbS0Ty4QKhE1HQfU0BQ= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/sethvargo/go-githubactions v0.3.0 h1:Mhcb6rXI5/7RCJC3ktVbgkQ2iqJRoO8HjwpqD0eGPPQ= 22 | github.com/sethvargo/go-githubactions v0.3.0/go.mod h1:ugCoIFQjs7HxIwwYiY7ty6H9T+7Z4ey481HxqA3VRKE= 23 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 26 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 27 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 28 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d h1:Z4EH+5EffvBEhh37F0C0DnpklTMh00JOkjW5zK3ofBI= 30 | github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d/go.mod h1:BSTlc8jOjh0niykqEGVXOLXdi9o0r0kR8tCYiMvjFgw= 31 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= 32 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 36 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /integration/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | hostname = "app.terraform.io" 4 | organization = "kvrhdn" 5 | 6 | workspaces { 7 | name = "tfe-run_integration" 8 | } 9 | } 10 | } 11 | 12 | provider "aws" { 13 | version = "~> 2.57" 14 | region = "eu-central-1" 15 | } 16 | 17 | locals { 18 | bucket_name = "tfe-run-integration" 19 | tags = { 20 | project = "tfe-run" 21 | terraform = true 22 | } 23 | } 24 | 25 | variable "run_number" { 26 | description = "Run number that will be published on the bucket" 27 | type = number 28 | } 29 | 30 | resource "aws_s3_bucket" "main" { 31 | bucket = local.bucket_name 32 | acl = "public-read" 33 | 34 | policy = templatefile("public_access_s3_policy.json", { 35 | bucket_name = local.bucket_name 36 | }) 37 | 38 | force_destroy = true 39 | 40 | website { 41 | index_document = "index.txt" 42 | error_document = "error.html" 43 | } 44 | 45 | tags = local.tags 46 | } 47 | 48 | resource "aws_s3_bucket_object" "index" { 49 | bucket = aws_s3_bucket.main.id 50 | key = "index.txt" 51 | content = "GitHub Actions run number: ${var.run_number}" 52 | 53 | tags = local.tags 54 | } 55 | 56 | resource "aws_s3_bucket_object" "error" { 57 | bucket = aws_s3_bucket.main.id 58 | key = "error.html" 59 | content = "Visit kvrhdn/tfe-run: https://github.com/kvrhdn/tfe-run" 60 | 61 | tags = local.tags 62 | } 63 | 64 | output "endpoint" { 65 | value = aws_s3_bucket.main.website_endpoint 66 | } 67 | -------------------------------------------------------------------------------- /integration/public_access_s3_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "PublicReadGetObject", 6 | "Effect": "Allow", 7 | "Principal": "*", 8 | "Action": [ 9 | "s3:GetObject" 10 | ], 11 | "Resource": [ 12 | "arn:aws:s3:::${bucket_name}/*" 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | tferun "github.com/kvrhdn/go-tfe-run" 12 | "github.com/kvrhdn/tfe-run/gha" 13 | ) 14 | 15 | type input struct { 16 | Token string `gha:"token,required"` 17 | Organization string `gha:"organization,required"` 18 | Workspace string `gha:"workspace,required"` 19 | Message string 20 | Directory string 21 | Speculative bool 22 | Type string 23 | Targets string 24 | WaitForCompletion bool `gha:"wait-for-completion"` 25 | TfVars string `gha:"tf-vars"` 26 | } 27 | 28 | func main() { 29 | var input input 30 | var err error 31 | 32 | if !gha.InGitHubActions() { 33 | exitWithError(errors.New("tfe-run should only be run within GitHub Actions")) 34 | } 35 | 36 | err = gha.PopulateFromInputs(&input) 37 | if err != nil { 38 | exitWithError(fmt.Errorf("could not read inputs: %w", err)) 39 | } 40 | 41 | runType := asRunType(input.Type) 42 | 43 | // Speculative is deprecated, but if type is apply (the default) we still respect it 44 | if runType == tferun.RunTypeApply && input.Speculative { 45 | runType = tferun.RunTypePlan 46 | } 47 | 48 | ctx := context.Background() 49 | 50 | cfg := tferun.ClientConfig{ 51 | Token: input.Token, 52 | Organization: input.Organization, 53 | Workspace: input.Workspace, 54 | } 55 | c, err := tferun.NewClient(ctx, cfg) 56 | if err != nil { 57 | exitWithError(err) 58 | } 59 | 60 | options := tferun.RunOptions{ 61 | Message: notEmptyOrNil(input.Message), 62 | Directory: notEmptyOrNil(input.Directory), 63 | Type: runType, 64 | TargetAddrs: notAllEmptyOrNil(strings.Split(input.Targets, "\n")), 65 | WaitForCompletion: input.WaitForCompletion, 66 | TfVars: notEmptyOrNil(input.TfVars), 67 | } 68 | output, err := c.Run(ctx, options) 69 | if err != nil { 70 | fmt.Printf("Error: %v\n", err) 71 | os.Exit(1) 72 | } 73 | 74 | gha.WriteOutput("run-url", output.RunURL) 75 | if output.HasChanges != nil { 76 | gha.WriteOutput("has-changes", strconv.FormatBool(*output.HasChanges)) 77 | } 78 | 79 | outputs, err := c.GetTerraformOutputs(ctx) 80 | if err != nil { 81 | exitWithError(err) 82 | } 83 | 84 | for k, v := range outputs { 85 | gha.WriteOutput(fmt.Sprintf("tf-%v", k), v) 86 | } 87 | } 88 | 89 | func asRunType(s string) tferun.RunType { 90 | switch s { 91 | case "apply": 92 | return tferun.RunTypeApply 93 | case "plan": 94 | return tferun.RunTypePlan 95 | case "destroy": 96 | return tferun.RunTypeDestroy 97 | } 98 | exitWithError(fmt.Errorf("Type \"%s\" is not supported, must be plan, apply or destroy", s)) 99 | return 0 100 | } 101 | 102 | func notEmptyOrNil(s string) *string { 103 | if s == "" { 104 | return nil 105 | } 106 | return &s 107 | } 108 | 109 | func notAllEmptyOrNil(slice []string) []string { 110 | for _, s := range slice { 111 | if s != "" { 112 | return slice 113 | } 114 | } 115 | return nil 116 | } 117 | 118 | func exitWithError(err error) { 119 | fmt.Printf("Error: %v", err) 120 | os.Exit(1) 121 | } 122 | --------------------------------------------------------------------------------