├── .dockerignore ├── .github └── workflows │ ├── lint.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .go-version ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── filter.go ├── migration.go ├── mock.go ├── root.go ├── version.go └── version_test.go ├── docker-compose.yml ├── entrypoint.sh ├── filter ├── awsv4upgrade │ ├── all.go │ ├── aws_s3_bucket.go │ ├── aws_s3_bucket_acceleration_status.go │ ├── aws_s3_bucket_acceleration_status_test.go │ ├── aws_s3_bucket_acl.go │ ├── aws_s3_bucket_acl_test.go │ ├── aws_s3_bucket_cors_rule.go │ ├── aws_s3_bucket_cors_rule_test.go │ ├── aws_s3_bucket_grant.go │ ├── aws_s3_bucket_grant_test.go │ ├── aws_s3_bucket_lifecycle_rule.go │ ├── aws_s3_bucket_lifecycle_rule_test.go │ ├── aws_s3_bucket_logging.go │ ├── aws_s3_bucket_logging_test.go │ ├── aws_s3_bucket_object_lock_configuration.go │ ├── aws_s3_bucket_object_lock_configuration_test.go │ ├── aws_s3_bucket_policy.go │ ├── aws_s3_bucket_policy_test.go │ ├── aws_s3_bucket_replication_configuration.go │ ├── aws_s3_bucket_replication_configuration_test.go │ ├── aws_s3_bucket_request_payer.go │ ├── aws_s3_bucket_request_payer_test.go │ ├── aws_s3_bucket_server_side_encryption_configuration.go │ ├── aws_s3_bucket_server_side_encryption_configuration_test.go │ ├── aws_s3_bucket_test.go │ ├── aws_s3_bucket_versioning.go │ ├── aws_s3_bucket_versioning_test.go │ ├── aws_s3_bucket_website.go │ ├── aws_s3_bucket_website_test.go │ ├── provider_aws.go │ ├── provider_aws_s3_force_path_style.go │ ├── provider_aws_s3_force_path_style_test.go │ └── provider_aws_test.go └── factory.go ├── go.mod ├── go.sum ├── main.go ├── migration ├── conflict.go ├── conflict_test.go ├── plan.go ├── plan_analyzer.go ├── plan_analyzer_test.go ├── plan_test.go ├── resolver.go ├── schema │ ├── aws │ │ ├── aws.go │ │ ├── s3.go │ │ └── s3_test.go │ ├── dictionary.go │ ├── dictionary_test.go │ ├── import_id_func.go │ └── import_id_func_test.go ├── state_action.go ├── state_action_test.go ├── state_import_resolver.go ├── state_import_resolver_test.go ├── state_migration.go ├── state_migration_test.go ├── subject.go ├── subject_test.go └── test-fixtures │ ├── import_full.tfplan.json │ ├── import_simple.tfplan.json │ ├── invalid.tfplan.json │ └── unknown_format_version.tfplan.json ├── scripts ├── localstack │ ├── init.sh │ └── wait_s3_bucket_exists.sh └── testacc │ ├── all.sh │ └── awsv4upgrade.sh ├── test-fixtures └── awsv4upgrade │ └── aws_s3_bucket │ ├── acceleration_status │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── acl │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── cors_rule │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── count │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── for_each │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── full │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── grant │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── lifecycle_rule │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── logging │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── object_lock_configuration │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── policy │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── replication_configuration │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── request_payer │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── server_side_encryption_configuration │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── simple │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ ├── versioning │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl │ └── website │ ├── config.tf │ ├── main.tf │ └── tfmigrate_want.hcl ├── tfeditor ├── filter_block.go ├── filter_block_test.go ├── filter_file.go ├── filter_file_test.go ├── filter_vertical_formatter.go └── filter_vertical_formatter_test.go └── tfwrite ├── attribute.go ├── attribute_test.go ├── block.go ├── block_test.go ├── data_source.go ├── data_source_test.go ├── file.go ├── file_test.go ├── hclwritex.go ├── hclwritex_test.go ├── helper_test.go ├── locals.go ├── locals_test.go ├── module.go ├── module_test.go ├── moved.go ├── moved_test.go ├── nested_block.go ├── nested_block_test.go ├── output.go ├── output_test.go ├── provider.go ├── provider_test.go ├── resource.go ├── resource_test.go ├── terraform.go ├── terraform_test.go ├── variable.go └── variable_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .terraform/ 3 | bin/ 4 | tmp/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 5 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 20 | with: 21 | go-version-file: '.go-version' 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 24 | with: 25 | version: v1.59.1 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | permissions: 3 | contents: write 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v[0-9]+.*" 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 5 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 21 | with: 22 | go-version-file: '.go-version' 23 | - name: Generate github app token 24 | uses: actions/create-github-app-token@31c86eb3b33c9b601a1f60f98dcbfd1d70f379b4 # v1.10.3 25 | id: app-token 26 | with: 27 | app-id: ${{ secrets.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | owner: ${{ github.repository_owner }} 30 | repositories: homebrew-tfedit 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 33 | with: 34 | version: "~> v2" 35 | args: release --clean 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | paths-ignore: 10 | - '**.md' 11 | pull_request: 12 | branches: 13 | - master 14 | paths-ignore: 15 | - '**.md' 16 | 17 | jobs: 18 | test: 19 | runs-on: ${{ matrix.os }} 20 | timeout-minutes: 5 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest, macOS-latest] 24 | steps: 25 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 27 | with: 28 | go-version-file: '.go-version' 29 | - name: test 30 | run: make test 31 | testacc: 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 10 34 | strategy: 35 | matrix: 36 | terraform: 37 | - 1.9.0 38 | - 1.8.5 39 | env: 40 | TERRAFORM_VERSION: ${{ matrix.terraform }} 41 | steps: 42 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 43 | - name: docker build 44 | run: docker compose build 45 | - name: start localstack 46 | run: | 47 | docker compose up -d localstack 48 | docker compose run --rm dockerize -wait tcp://localstack:4566 -timeout 60s 49 | docker compose exec -T localstack /docker-entrypoint-initaws.d/wait_s3_bucket_exists.sh 50 | - name: terraform --version 51 | run: docker compose run --rm tfedit terraform --version 52 | - name: testacc 53 | run: docker compose run --rm tfedit make testacc 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .terraform/ 3 | bin/ 4 | tmp/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.22 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # https://golangci-lint.run/usage/configuration/ 2 | linters: 3 | disable-all: true 4 | enable: 5 | - errcheck 6 | - goimports 7 | - gosec 8 | - gosimple 9 | - govet 10 | - ineffassign 11 | - revive 12 | - staticcheck 13 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - binary: tfedit 4 | goos: 5 | - darwin 6 | - linux 7 | goarch: 8 | - amd64 9 | - arm64 10 | ldflags: 11 | - -s -w 12 | env: 13 | - CGO_ENABLED=0 14 | release: 15 | prerelease: auto 16 | changelog: 17 | filters: 18 | exclude: 19 | - Merge pull request 20 | - Merge branch 21 | - Update README 22 | - Update CHANGELOG 23 | brews: 24 | - repository: 25 | owner: minamijoyo 26 | name: homebrew-tfedit 27 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 28 | commit_author: 29 | name: "Masayuki Morita" 30 | email: minamijoyo@gmail.com 31 | homepage: https://github.com/minamijoyo/tfedit 32 | description: "A refactoring tool for Terraform" 33 | skip_upload: auto 34 | test: | 35 | system "#{bin}/tfedit version" 36 | install: | 37 | bin.install "tfedit" 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master (Unreleased) 2 | 3 | ## 0.2.2 (2024/08/14) 4 | 5 | ENHANCEMENTS: 6 | 7 | * Use docker compose command instead of docker-compose ([#63](https://github.com/minamijoyo/tfedit/pull/63)) 8 | * Update golangci lint to v1.59.1 ([#64](https://github.com/minamijoyo/tfedit/pull/64)) 9 | * Update setup-go to v5 ([#65](https://github.com/minamijoyo/tfedit/pull/65)) 10 | * Update actions/checkout to v4 ([#66](https://github.com/minamijoyo/tfedit/pull/66)) 11 | * Update Go to v1.22 ([#67](https://github.com/minamijoyo/tfedit/pull/67)) 12 | * Add support for Terraform 1.9 ([#68](https://github.com/minamijoyo/tfedit/pull/68)) 13 | * Update goreleaser to v2 ([#69](https://github.com/minamijoyo/tfedit/pull/69)) 14 | * Switch to the official action for creating GitHub App token ([#70](https://github.com/minamijoyo/tfedit/pull/70)) 15 | 16 | ## 0.2.1 (2023/01/13) 17 | 18 | NEW FEATURES: 19 | 20 | * Complete all primitive top-level block types ([#59](https://github.com/minamijoyo/tfedit/pull/59)) 21 | * Rename references for website_domain and website_endpoint ([#60](https://github.com/minamijoyo/tfedit/pull/60)) 22 | 23 | ENHANCEMENTS: 24 | 25 | * Update Go to v1.19 ([#55](https://github.com/minamijoyo/tfedit/pull/55)) 26 | * Update Terraform to v1.3.6 ([#56](https://github.com/minamijoyo/tfedit/pull/56)) 27 | * Update localstack to v1.3.1 ([#58](https://github.com/minamijoyo/tfedit/pull/58)) 28 | 29 | ## 0.2.0 (2022/12/19) 30 | 31 | BREAKING CHANGES: 32 | 33 | * Redesigning the interface as a library ([#54](https://github.com/minamijoyo/tfedit/pull/54)) 34 | 35 | NEW FEATURES: 36 | 37 | * Add support for provider meta argument ([#51](https://github.com/minamijoyo/tfedit/pull/51)) 38 | * Rename s3_force_path_style to s3_use_path_style in provider aws block ([#52](https://github.com/minamijoyo/tfedit/pull/52)) 39 | * Add DataSource type to tfwrite package ([#53](https://github.com/minamijoyo/tfedit/pull/53)) 40 | 41 | ## 0.1.3 (2022/08/12) 42 | 43 | ENHANCEMENTS: 44 | 45 | * Use GitHub App token for updating brew formula on release ([#46](https://github.com/minamijoyo/tfedit/pull/46)) 46 | 47 | ## 0.1.2 (2022/07/06) 48 | 49 | BUG FIXES: 50 | 51 | * Map mfa_delete true/false => Enabled/Disabled for aws_s3_bucket_versioning ([#41](https://github.com/minamijoyo/tfedit/pull/41)) 52 | * Suppress creating a migration file when no action ([#42](https://github.com/minamijoyo/tfedit/pull/42)) 53 | * Suppress adding invalid days_after_initiation for aws_s3_bucket_lifecycle_configuration ([#43](https://github.com/minamijoyo/tfedit/pull/43)) 54 | * Fix invalid filter and tags for aws_s3_bucket_lifecycle_configuration ([#45](https://github.com/minamijoyo/tfedit/pull/45)) 55 | 56 | ENHANCEMENTS: 57 | 58 | * Use a native cache feature in actions/setup-go ([#44](https://github.com/minamijoyo/tfedit/pull/44)) 59 | 60 | ## 0.1.1 (2022/06/16) 61 | 62 | ENHANCEMENTS: 63 | 64 | * Update Go to v1.17.10 and Alpine to v3.16 ([#36](https://github.com/minamijoyo/tfedit/pull/36)) 65 | * Update hcl to v2.12.0 and hcledit to v0.2.4 ([#37](https://github.com/minamijoyo/tfedit/pull/37)) 66 | * Update Go to v1.18.3 ([#38](https://github.com/minamijoyo/tfedit/pull/38)) 67 | * Update terraform-json to v0.14.0 ([#39](https://github.com/minamijoyo/tfedit/pull/39)) 68 | 69 | ## 0.1.0 (2022/06/07) 70 | 71 | ENHANCEMENTS: 72 | 73 | * Add a note about an limitation of aws_s3_bucket.lifecycle_rule.id ([#28](https://github.com/minamijoyo/tfedit/pull/28)) 74 | * Add support for Terraform v1.2 ([#31](https://github.com/minamijoyo/tfedit/pull/31)) 75 | * Read Go version from .go-version on GitHub Actions ([#32](https://github.com/minamijoyo/tfedit/pull/32)) 76 | 77 | ## 0.0.3 (2022/05/10) 78 | 79 | NEW FEATURES: 80 | 81 | * Add support for count and for_each ([#26](https://github.com/minamijoyo/tfedit/pull/26)) 82 | 83 | ## 0.0.2 (2022/05/02) 84 | 85 | NEW FEATURES: 86 | 87 | * Generate a migration file for import from Terraform plan ([#25](https://github.com/minamijoyo/tfedit/pull/25)) 88 | 89 | ## 0.0.1 (2022/04/15) 90 | 91 | Initial release 92 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG TERRAFORM_VERSION=latest 2 | FROM hashicorp/terraform:$TERRAFORM_VERSION AS terraform 3 | 4 | FROM golang:1.22-alpine3.20 5 | RUN apk --no-cache add make git bash curl jq 6 | 7 | # A workaround for a permission issue of git. 8 | # Since UIDs are different between host and container, 9 | # the .git directory is untrusted by default. 10 | # We need to allow it explicitly. 11 | # https://github.com/actions/checkout/issues/760 12 | RUN git config --global --add safe.directory /work 13 | 14 | # Install terraform 15 | COPY --from=terraform /bin/terraform /usr/local/bin/ 16 | 17 | # Install tfupdate 18 | ENV TFUPDATE_VERSION 0.6.7 19 | RUN curl -fsSL https://github.com/minamijoyo/tfupdate/releases/download/v${TFUPDATE_VERSION}/tfupdate_${TFUPDATE_VERSION}_linux_amd64.tar.gz \ 20 | | tar -xzC /usr/local/bin && chmod +x /usr/local/bin/tfupdate 21 | 22 | # Install tfmigrate 23 | ENV TFMIGRATE_VERSION 0.3.9 24 | RUN curl -fsSL https://github.com/minamijoyo/tfmigrate/releases/download/v${TFMIGRATE_VERSION}/tfmigrate_${TFMIGRATE_VERSION}_linux_amd64.tar.gz \ 25 | | tar -xzC /usr/local/bin && chmod +x /usr/local/bin/tfmigrate 26 | 27 | WORKDIR /work 28 | 29 | COPY go.mod go.sum ./ 30 | RUN go mod download 31 | 32 | COPY . . 33 | RUN make install 34 | 35 | ENTRYPOINT ["./entrypoint.sh"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Masayuki Morita 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 | NAME := tfedit 2 | 3 | .DEFAULT_GOAL := build 4 | 5 | .PHONY: deps 6 | deps: 7 | go mod download 8 | 9 | .PHONY: build 10 | build: deps 11 | go build -o bin/$(NAME) 12 | 13 | .PHONY: install 14 | install: deps 15 | go install 16 | 17 | .PHONY: lint 18 | lint: 19 | golangci-lint run ./... 20 | 21 | .PHONY: test 22 | test: build 23 | go test ./... 24 | 25 | .PHONY: testacc 26 | testacc: install testacc-awsv4upgrade-simple testacc-awsv4upgrade-full 27 | 28 | .PHONY: testacc-awsv4upgrade-simple 29 | testacc-awsv4upgrade-simple: install 30 | scripts/testacc/awsv4upgrade.sh run simple 31 | 32 | .PHONY: testacc-awsv4upgrade-full 33 | testacc-awsv4upgrade-full: install 34 | scripts/testacc/awsv4upgrade.sh run full 35 | 36 | .PHONY: testacc-awsv4upgrade-debug 37 | testacc-awsv4upgrade-debug: install 38 | scripts/testacc/awsv4upgrade.sh $(ARG) 39 | 40 | .PHONY: testacc-all 41 | testacc-all: install 42 | scripts/testacc/all.sh 43 | 44 | .PHONY: check 45 | check: lint test 46 | -------------------------------------------------------------------------------- /cmd/filter.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/minamijoyo/tfedit/filter" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func init() { 12 | filterCmd := newFilterCmd() 13 | flags := filterCmd.PersistentFlags() 14 | flags.StringP("file", "f", "-", "A path to input Terraform configuration file") 15 | flags.BoolP("update", "u", false, "Update files in-place") 16 | _ = viper.BindPFlag("filter.file", flags.Lookup("file")) 17 | _ = viper.BindPFlag("filter.update", flags.Lookup("update")) 18 | 19 | RootCmd.AddCommand(filterCmd) 20 | } 21 | 22 | func newFilterCmd() *cobra.Command { 23 | cmd := &cobra.Command{ 24 | Use: "filter", 25 | Short: "Apply a built-in filter", 26 | RunE: func(cmd *cobra.Command, _ []string) error { 27 | return cmd.Help() 28 | }, 29 | } 30 | 31 | cmd.AddCommand( 32 | newFilterAwsv4upgradeCmd(), 33 | ) 34 | 35 | return cmd 36 | } 37 | 38 | func newFilterAwsv4upgradeCmd() *cobra.Command { 39 | cmd := &cobra.Command{ 40 | Use: "awsv4upgrade", 41 | Short: "Apply a built-in filter for awsv4upgrade", 42 | Long: `Apply a built-in filter for awsv4upgrade 43 | 44 | Upgrade configurations to AWS provider v4. 45 | `, 46 | RunE: runFilterAwsv4upgradeCmd, 47 | } 48 | 49 | return cmd 50 | } 51 | 52 | func runFilterAwsv4upgradeCmd(cmd *cobra.Command, args []string) error { 53 | if len(args) != 0 { 54 | return fmt.Errorf("expected 0 argument, but got %d arguments", len(args)) 55 | } 56 | 57 | file := viper.GetString("filter.file") 58 | update := viper.GetBool("filter.update") 59 | filter, err := filter.NewFilterByType("awsv4upgrade") 60 | if err != nil { 61 | return err 62 | } 63 | 64 | c := newDefaultClient(cmd) 65 | return c.Edit(file, update, filter) 66 | } 67 | -------------------------------------------------------------------------------- /cmd/migration.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/minamijoyo/tfedit/migration" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func init() { 14 | RootCmd.AddCommand(newMigrationCmd()) 15 | } 16 | 17 | func newMigrationCmd() *cobra.Command { 18 | cmd := &cobra.Command{ 19 | Use: "migration", 20 | Short: "Generate a migration file for state operations", 21 | RunE: func(cmd *cobra.Command, _ []string) error { 22 | return cmd.Help() 23 | }, 24 | } 25 | 26 | cmd.AddCommand( 27 | newMigrationFromplanCmd(), 28 | ) 29 | 30 | return cmd 31 | } 32 | 33 | func newMigrationFromplanCmd() *cobra.Command { 34 | cmd := &cobra.Command{ 35 | Use: "fromplan", 36 | Short: "Generate a migration file from Terraform JSON plan file", 37 | Long: `Generate a migration file from Terraform JSON plan file 38 | 39 | Read a Terraform plan file in JSON format and 40 | generate a migration file in tfmigrate HCL format. 41 | Currently, only import actions required by awsv4upgrade are supported. 42 | `, 43 | RunE: runMigrationFromplanCmd, 44 | } 45 | 46 | flags := cmd.Flags() 47 | flags.StringP("file", "f", "-", "A path to input Terraform JSON plan file") 48 | flags.StringP("out", "o", "-", "Write a migration file to a given path") 49 | flags.StringP("dir", "d", "", "Set a dir attribute in a migration file") 50 | _ = viper.BindPFlag("migration.fromplan.file", flags.Lookup("file")) 51 | _ = viper.BindPFlag("migration.fromplan.out", flags.Lookup("out")) 52 | _ = viper.BindPFlag("migration.fromplan.dir", flags.Lookup("dir")) 53 | 54 | return cmd 55 | } 56 | 57 | func runMigrationFromplanCmd(cmd *cobra.Command, args []string) error { 58 | if len(args) != 0 { 59 | return fmt.Errorf("expected 0 argument, but got %d arguments", len(args)) 60 | } 61 | 62 | planFile := viper.GetString("migration.fromplan.file") 63 | migrationFile := viper.GetString("migration.fromplan.out") 64 | migrationDir := viper.GetString("migration.fromplan.dir") 65 | 66 | var planJSON []byte 67 | var err error 68 | if planFile == "-" { 69 | planJSON, err = io.ReadAll(cmd.InOrStdin()) 70 | if err != nil { 71 | return fmt.Errorf("failed to read input from stdin: %s", err) 72 | } 73 | } else { 74 | planJSON, err = os.ReadFile(planFile) 75 | if err != nil { 76 | return fmt.Errorf("failed to read file: %s", err) 77 | } 78 | } 79 | 80 | output, err := migration.GenerateFromPlan(planJSON, migrationDir) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // Suppress creating a migration file when no action. 86 | // It is not only redundant, but also causes an error as an invalid migration 87 | // file when loaded by tfmigrate. 88 | if len(output) == 0 { 89 | // Intentionally does not return errors so that we can simply ignore 90 | // irrelevant directories when processing multiple directories. 91 | return nil 92 | } 93 | 94 | if migrationFile == "-" { 95 | fmt.Fprint(cmd.OutOrStdout(), string(output)) 96 | } else { 97 | // nolint: gosec 98 | // G306: Expect WriteFile permissions to be 0600 or less 99 | // In general, a migration file is expected to commit to git and it does 100 | // not contain any credentials, so there is no problem. 101 | if err := os.WriteFile(migrationFile, output, 0644); err != nil { 102 | return fmt.Errorf("failed to write file: %s", err) 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /cmd/mock.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // newMockCmd is a helper function which returns a *cobra.Command 12 | // whose in/out/err streams are mocked for testing. 13 | func newMockCmd(cmd *cobra.Command, input string) *cobra.Command { 14 | inStream := bytes.NewBufferString(input) 15 | outStream := new(bytes.Buffer) 16 | errStream := new(bytes.Buffer) 17 | 18 | cmd.SetIn(inStream) 19 | cmd.SetOut(outStream) 20 | cmd.SetErr(errStream) 21 | 22 | return cmd 23 | } 24 | 25 | // assertMockCmd is a high-level test helper to run a given mock command with 26 | // arguments and check if an error and its stdout are expected. 27 | func assertMockCmd(t *testing.T, cmd *cobra.Command, args []string, ok bool, want string) { 28 | err := runMockCmd(cmd, args) 29 | 30 | stderr := mockErr(cmd) 31 | if ok && err != nil { 32 | t.Fatalf("unexpected err = %s, stderr: \n%s", err, stderr) 33 | } 34 | 35 | stdout := mockOut(cmd) 36 | if !ok && err == nil { 37 | t.Fatalf("expected to return an error, but no error, stdout: \n%s", stdout) 38 | } 39 | 40 | if stdout != want { 41 | t.Fatalf("got:\n%s\nwant:\n%s", stdout, want) 42 | } 43 | } 44 | 45 | // runMockCmd is a helper function which parses flags and invokes a given mock 46 | // command. 47 | func runMockCmd(cmd *cobra.Command, args []string) error { 48 | cmdFlags := cmd.Flags() 49 | if err := cmdFlags.Parse(args); err != nil { 50 | return fmt.Errorf("failed to parse arguments: %s", err) 51 | } 52 | 53 | return cmd.RunE(cmd, cmdFlags.Args()) 54 | } 55 | 56 | // mockErr is a helper function which returns a string written to mocked err stream. 57 | func mockErr(cmd *cobra.Command) string { 58 | return cmd.ErrOrStderr().(*bytes.Buffer).String() 59 | } 60 | 61 | // mockOut is a helper function which returns a string written to mocked out stream. 62 | func mockOut(cmd *cobra.Command) string { 63 | return cmd.OutOrStdout().(*bytes.Buffer).String() 64 | } 65 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/minamijoyo/hcledit/editor" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // RootCmd is a top level command instance 11 | var RootCmd = &cobra.Command{ 12 | Use: "tfedit", 13 | Short: "A refactoring tool for Terraform", 14 | SilenceErrors: true, 15 | SilenceUsage: true, 16 | } 17 | 18 | func init() { 19 | setDefaultStream(RootCmd) 20 | } 21 | 22 | func setDefaultStream(cmd *cobra.Command) { 23 | cmd.SetIn(os.Stdin) 24 | cmd.SetOut(os.Stdout) 25 | cmd.SetErr(os.Stderr) 26 | } 27 | 28 | func newDefaultClient(cmd *cobra.Command) editor.Client { 29 | o := &editor.Option{ 30 | InStream: cmd.InOrStdin(), 31 | OutStream: cmd.OutOrStdout(), 32 | ErrStream: cmd.ErrOrStderr(), 33 | } 34 | return editor.NewClient(o) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | version = "0.2.2" 11 | ) 12 | 13 | func init() { 14 | RootCmd.AddCommand(newVersionCmd()) 15 | } 16 | 17 | func newVersionCmd() *cobra.Command { 18 | cmd := &cobra.Command{ 19 | Use: "version", 20 | Short: "Print version", 21 | RunE: runVersionCmd, 22 | } 23 | 24 | return cmd 25 | } 26 | 27 | func runVersionCmd(cmd *cobra.Command, _ []string) error { 28 | _, err := fmt.Fprintln(cmd.OutOrStdout(), version) 29 | return err 30 | } 31 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVersion(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | args []string 11 | ok bool 12 | want string 13 | }{ 14 | { 15 | name: "simple", 16 | args: []string{}, 17 | ok: true, 18 | want: version + "\n", 19 | }, 20 | } 21 | 22 | for _, tc := range cases { 23 | t.Run(tc.name, func(t *testing.T) { 24 | cmd := newMockCmd(newVersionCmd(), "") 25 | assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | tfedit: 4 | build: 5 | context: . 6 | args: 7 | TERRAFORM_VERSION: ${TERRAFORM_VERSION:-latest} 8 | volumes: 9 | - ".:/work" 10 | environment: 11 | CGO_ENABLED: 0 # disable cgo for go test 12 | LOCALSTACK_ENDPOINT: "http://localstack:4566" 13 | # Use the same filesystem to avoid a checksum mismatch error 14 | # or a file busy error caused by asynchronous IO. 15 | TF_PLUGIN_CACHE_DIR: "/tmp/plugin-cache" 16 | depends_on: 17 | - localstack 18 | 19 | localstack: 20 | image: localstack/localstack:1.3.1 21 | ports: 22 | - "4566:4566" 23 | environment: 24 | DEBUG: "true" 25 | SERVICES: "s3" 26 | DEFAULT_REGION: "ap-northeast-1" 27 | # This s3 bucket is used for only remote state storage for testing 28 | # and is not a target for upgrade. 29 | S3_BUCKET: "tfstate-test" 30 | volumes: 31 | - "./scripts/localstack:/docker-entrypoint-initaws.d" # initialize scripts on startup 32 | 33 | dockerize: 34 | image: jwilder/dockerize 35 | depends_on: 36 | - localstack 37 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Create a plugin cache directory in advance. 5 | # The terraform command doesn't create it automatically. 6 | if [ -n "${TF_PLUGIN_CACHE_DIR}" ]; then 7 | mkdir -p "${TF_PLUGIN_CACHE_DIR}" 8 | fi 9 | 10 | exec "$@" 11 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/all.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | "github.com/minamijoyo/hcledit/editor" 6 | "github.com/minamijoyo/tfedit/tfeditor" 7 | ) 8 | 9 | // AllFilter is a filter implementation for upgrading configurations 10 | // to AWS provider v4. 11 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade 12 | type AllFilter struct { 13 | } 14 | 15 | var _ editor.Filter = (*AllFilter)(nil) 16 | 17 | // NewAllFilter creates a new instance of AllFilter. 18 | func NewAllFilter() editor.Filter { 19 | return &AllFilter{} 20 | } 21 | 22 | // Filter upgrades configurations to AWS provider v4. 23 | func (f *AllFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { 24 | mf := tfeditor.NewMultiBlockFilter([]tfeditor.BlockFilter{ 25 | NewProviderAWSFilter(), 26 | NewAWSS3BucketFilter(), 27 | }) 28 | 29 | bf := tfeditor.NewFileFilter(mf) 30 | return bf.Filter(inFile) 31 | } 32 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfeditor" 5 | "github.com/minamijoyo/tfedit/tfwrite" 6 | ) 7 | 8 | // AWSS3BucketFilter is a filter implementation for upgrading arguments of 9 | // aws_s3_bucket to AWS provider v4. 10 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#s3-bucket-refactor 11 | type AWSS3BucketFilter struct { 12 | filters []tfeditor.BlockFilter 13 | } 14 | 15 | var _ tfeditor.BlockFilter = (*AWSS3BucketFilter)(nil) 16 | 17 | // NewAWSS3BucketFilter creates a new instance of AWSS3BucketFilter. 18 | func NewAWSS3BucketFilter() tfeditor.BlockFilter { 19 | filters := []tfeditor.BlockFilter{ 20 | tfeditor.ResourceFilterFunc(AWSS3BucketAccelerationStatusResourceFilter), 21 | tfeditor.ResourceFilterFunc(AWSS3BucketACLResourceFilter), 22 | tfeditor.ResourceFilterFunc(AWSS3BucketCorsRuleResourceFilter), 23 | tfeditor.ResourceFilterFunc(AWSS3BucketGrantResourceFilter), 24 | tfeditor.ResourceFilterFunc(AWSS3BucketLifecycleRuleResourceFilter), 25 | tfeditor.ResourceFilterFunc(AWSS3BucketLoggingResourceFilter), 26 | tfeditor.ResourceFilterFunc(AWSS3BucketObjectLockConfigurationResourceFilter), 27 | tfeditor.ResourceFilterFunc(AWSS3BucketPolicyResourceFilter), 28 | tfeditor.ResourceFilterFunc(AWSS3BucketReplicationConfigurationResourceFilter), 29 | tfeditor.ResourceFilterFunc(AWSS3BucketRequestPayerResourceFilter), 30 | tfeditor.ResourceFilterFunc(AWSS3BucketServerSideEncryptionConfigurationResourceFilter), 31 | tfeditor.ResourceFilterFunc(AWSS3BucketVersioningResourceFilter), 32 | tfeditor.BlockFilterFunc(AWSS3BucketWebsiteBlockFilter), 33 | 34 | // Remove redundant TokenNewLine tokens in the resource block after removing nested blocks. 35 | // Since VerticalFormat clears tokens internally, we should call it at the end. 36 | tfeditor.NewVerticalFormatterBlockFilter("resource", "aws_s3_bucket"), 37 | } 38 | 39 | return &AWSS3BucketFilter{filters: filters} 40 | } 41 | 42 | // BlockFilter upgrades arguments of aws_s3_bucket to AWS provider v4. 43 | // Some rules have not been implemented yet. 44 | func (f *AWSS3BucketFilter) BlockFilter(inFile *tfwrite.File, block tfwrite.Block) (*tfwrite.File, error) { 45 | m := tfeditor.NewMultiBlockFilter(f.filters) 46 | return m.BlockFilter(inFile, block) 47 | } 48 | 49 | // setParentBucket is a helper method for setting the followings: 50 | // - copy provider, count and for_each meta arguments 51 | // - set a bucket argument of a new `aws_s3_bucket_*` resource to the original `aws_s3_bucket` resource. 52 | func setParentBucket(newResource *tfwrite.Resource, oldResource *tfwrite.Resource) { 53 | // copy provider, count and for_each meta arguments 54 | newResource.CopyAttribute(oldResource, "provider") 55 | newResource.CopyAttribute(oldResource, "count") 56 | newResource.CopyAttribute(oldResource, "for_each") 57 | 58 | // set a bucket argument 59 | newResource.SetAttributeByReference("bucket", oldResource, "id") 60 | } 61 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_acceleration_status.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3BucketAccelerationStatusResourceFilter is a filter 8 | // implementation for upgrading the acceleration_status argument of aws_s3_bucket. 9 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#acceleration_status-argument 10 | func AWSS3BucketAccelerationStatusResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 11 | if resource.SchemaType() != "aws_s3_bucket" { 12 | return inFile, nil 13 | } 14 | 15 | oldAttribute := "acceleration_status" 16 | newResourceType := "aws_s3_bucket_accelerate_configuration" 17 | 18 | attr := resource.GetAttribute(oldAttribute) 19 | if attr == nil { 20 | return inFile, nil 21 | } 22 | 23 | resourceName := resource.Name() 24 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 25 | inFile.AppendBlock(newResource) 26 | setParentBucket(newResource, resource) 27 | 28 | // Map an `acceleration_status` attribute to an `status` attribute. 29 | // acceleration_status = "Enabled" => status = "Enabled" 30 | status := attr.ValueAsTokens() 31 | newResource.SetAttributeRaw("status", status) 32 | 33 | resource.RemoveAttribute(oldAttribute) 34 | 35 | return inFile, nil 36 | } 37 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_acceleration_status_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketAccelerationStatusFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | acceleration_status = "Enabled" 23 | } 24 | `, 25 | ok: true, 26 | want: ` 27 | resource "aws_s3_bucket" "example" { 28 | bucket = "tfedit-test" 29 | } 30 | 31 | resource "aws_s3_bucket_accelerate_configuration" "example" { 32 | bucket = aws_s3_bucket.example.id 33 | status = "Enabled" 34 | } 35 | `, 36 | }, 37 | { 38 | name: "argument not found", 39 | src: ` 40 | resource "aws_s3_bucket" "example" { 41 | bucket = "tfedit-test" 42 | foo = "bar" 43 | } 44 | `, 45 | ok: true, 46 | want: ` 47 | resource "aws_s3_bucket" "example" { 48 | bucket = "tfedit-test" 49 | foo = "bar" 50 | } 51 | `, 52 | }, 53 | } 54 | 55 | for _, tc := range cases { 56 | t.Run(tc.name, func(t *testing.T) { 57 | filter := buildTestResourceFilter(AWSS3BucketAccelerationStatusResourceFilter) 58 | o := editor.NewEditOperator(filter) 59 | output, err := o.Apply([]byte(tc.src), "test") 60 | if tc.ok && err != nil { 61 | t.Fatalf("unexpected err = %s", err) 62 | } 63 | 64 | got := string(output) 65 | if !tc.ok && err == nil { 66 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 67 | } 68 | 69 | if diff := cmp.Diff(got, tc.want); diff != "" { 70 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_acl.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3BucketACLResourceFilter is a filter implementation for upgrading the 8 | // acl argument of aws_s3_bucket. 9 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#acl-argument 10 | func AWSS3BucketACLResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 11 | if resource.SchemaType() != "aws_s3_bucket" { 12 | return inFile, nil 13 | } 14 | 15 | oldAttribute := "acl" 16 | newResourceType := "aws_s3_bucket_acl" 17 | 18 | attr := resource.GetAttribute(oldAttribute) 19 | if attr == nil { 20 | return inFile, nil 21 | } 22 | 23 | resourceName := resource.Name() 24 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 25 | inFile.AppendBlock(newResource) 26 | setParentBucket(newResource, resource) 27 | newResource.AppendAttribute(attr) 28 | resource.RemoveAttribute(oldAttribute) 29 | 30 | return inFile, nil 31 | } 32 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_acl_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketACLFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | acl = "private" 23 | } 24 | `, 25 | ok: true, 26 | want: ` 27 | resource "aws_s3_bucket" "example" { 28 | bucket = "tfedit-test" 29 | } 30 | 31 | resource "aws_s3_bucket_acl" "example" { 32 | bucket = aws_s3_bucket.example.id 33 | acl = "private" 34 | } 35 | `, 36 | }, 37 | { 38 | name: "argument not found", 39 | src: ` 40 | resource "aws_s3_bucket" "example" { 41 | bucket = "tfedit-test" 42 | foo = "bar" 43 | } 44 | `, 45 | ok: true, 46 | want: ` 47 | resource "aws_s3_bucket" "example" { 48 | bucket = "tfedit-test" 49 | foo = "bar" 50 | } 51 | `, 52 | }, 53 | } 54 | 55 | for _, tc := range cases { 56 | t.Run(tc.name, func(t *testing.T) { 57 | filter := buildTestResourceFilter(AWSS3BucketACLResourceFilter) 58 | o := editor.NewEditOperator(filter) 59 | output, err := o.Apply([]byte(tc.src), "test") 60 | if tc.ok && err != nil { 61 | t.Fatalf("unexpected err = %s", err) 62 | } 63 | 64 | got := string(output) 65 | if !tc.ok && err == nil { 66 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 67 | } 68 | 69 | if diff := cmp.Diff(got, tc.want); diff != "" { 70 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_cors_rule.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3BucketCorsRuleResourceFilter is a filter implementation for upgrading 8 | // the cors_rule argument of aws_s3_bucket. 9 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#cors_rule-argument 10 | func AWSS3BucketCorsRuleResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 11 | if resource.SchemaType() != "aws_s3_bucket" { 12 | return inFile, nil 13 | } 14 | 15 | oldNestedBlock := "cors_rule" 16 | newResourceType := "aws_s3_bucket_cors_configuration" 17 | 18 | nestedBlocks := resource.FindNestedBlocksByType(oldNestedBlock) 19 | if len(nestedBlocks) == 0 { 20 | return inFile, nil 21 | } 22 | 23 | resourceName := resource.Name() 24 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 25 | inFile.AppendBlock(newResource) 26 | setParentBucket(newResource, resource) 27 | 28 | for _, nestedBlock := range nestedBlocks { 29 | newResource.AppendNestedBlock(nestedBlock) 30 | resource.RemoveNestedBlock(nestedBlock) 31 | } 32 | 33 | return inFile, nil 34 | } 35 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_cors_rule_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketCorsRuleFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | 23 | cors_rule { 24 | allowed_headers = ["*"] 25 | allowed_methods = ["PUT", "POST"] 26 | allowed_origins = ["https://s3-website-test.hashicorp.com"] 27 | expose_headers = ["ETag"] 28 | max_age_seconds = 3000 29 | } 30 | } 31 | `, 32 | ok: true, 33 | want: ` 34 | resource "aws_s3_bucket" "example" { 35 | bucket = "tfedit-test" 36 | 37 | } 38 | 39 | resource "aws_s3_bucket_cors_configuration" "example" { 40 | bucket = aws_s3_bucket.example.id 41 | 42 | cors_rule { 43 | allowed_headers = ["*"] 44 | allowed_methods = ["PUT", "POST"] 45 | allowed_origins = ["https://s3-website-test.hashicorp.com"] 46 | expose_headers = ["ETag"] 47 | max_age_seconds = 3000 48 | } 49 | } 50 | `, 51 | }, 52 | { 53 | name: "multiple rules", 54 | src: ` 55 | resource "aws_s3_bucket" "example" { 56 | bucket = "tfedit-test" 57 | 58 | cors_rule { 59 | allowed_headers = ["*"] 60 | allowed_methods = ["PUT", "POST"] 61 | allowed_origins = ["https://s3-website-test.hashicorp.com"] 62 | expose_headers = ["ETag"] 63 | max_age_seconds = 3000 64 | } 65 | 66 | cors_rule { 67 | allowed_methods = ["GET"] 68 | allowed_origins = ["*"] 69 | } 70 | } 71 | `, 72 | ok: true, 73 | want: ` 74 | resource "aws_s3_bucket" "example" { 75 | bucket = "tfedit-test" 76 | 77 | 78 | } 79 | 80 | resource "aws_s3_bucket_cors_configuration" "example" { 81 | bucket = aws_s3_bucket.example.id 82 | 83 | cors_rule { 84 | allowed_headers = ["*"] 85 | allowed_methods = ["PUT", "POST"] 86 | allowed_origins = ["https://s3-website-test.hashicorp.com"] 87 | expose_headers = ["ETag"] 88 | max_age_seconds = 3000 89 | } 90 | 91 | cors_rule { 92 | allowed_methods = ["GET"] 93 | allowed_origins = ["*"] 94 | } 95 | } 96 | `, 97 | }, 98 | { 99 | name: "argument not found", 100 | src: ` 101 | resource "aws_s3_bucket" "example" { 102 | bucket = "tfedit-test" 103 | foo {} 104 | } 105 | `, 106 | ok: true, 107 | want: ` 108 | resource "aws_s3_bucket" "example" { 109 | bucket = "tfedit-test" 110 | foo {} 111 | } 112 | `, 113 | }, 114 | } 115 | 116 | for _, tc := range cases { 117 | t.Run(tc.name, func(t *testing.T) { 118 | filter := buildTestResourceFilter(AWSS3BucketCorsRuleResourceFilter) 119 | o := editor.NewEditOperator(filter) 120 | output, err := o.Apply([]byte(tc.src), "test") 121 | if tc.ok && err != nil { 122 | t.Fatalf("unexpected err = %s", err) 123 | } 124 | 125 | got := string(output) 126 | if !tc.ok && err == nil { 127 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 128 | } 129 | 130 | if diff := cmp.Diff(got, tc.want); diff != "" { 131 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_grant.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | "github.com/zclconf/go-cty/cty" 6 | ) 7 | 8 | // AWSS3BucketGrantResourceFilter is a filter implementation for 9 | // upgrading the grant argument of aws_s3_bucket. 10 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#grant-argument 11 | func AWSS3BucketGrantResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 12 | if resource.SchemaType() != "aws_s3_bucket" { 13 | return inFile, nil 14 | } 15 | 16 | oldNestedBlock := "grant" 17 | newResourceType := "aws_s3_bucket_acl" 18 | 19 | nestedBlocks := resource.FindNestedBlocksByType(oldNestedBlock) 20 | if len(nestedBlocks) == 0 { 21 | return inFile, nil 22 | } 23 | 24 | resourceName := resource.Name() 25 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 26 | inFile.AppendBlock(newResource) 27 | setParentBucket(newResource, resource) 28 | 29 | acpBlock := tfwrite.NewEmptyNestedBlock("access_control_policy") 30 | newResource.AppendNestedBlock(acpBlock) 31 | 32 | for _, nestedBlock := range nestedBlocks { 33 | // Split permissions to each grant block 34 | // A permissions attribute of grant block was a list in v3, 35 | // but in v4 we need to set each permission to each grant block respectively. 36 | // grant { 37 | // type = "Group" 38 | // permissions = ["READ_ACP", "WRITE"] 39 | // uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" 40 | // } 41 | // => 42 | // grant { 43 | // grantee { 44 | // type = "Group" 45 | // uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" 46 | // } 47 | // permission = "READ_ACP" 48 | // } 49 | // 50 | // grant { 51 | // grantee { 52 | // type = "Group" 53 | // uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" 54 | // } 55 | // permission = "WRITE" 56 | //} 57 | permissionsAttr := nestedBlock.GetAttribute("permissions") 58 | if permissionsAttr == nil { 59 | // The `permissions` attrubute is required, skip if not found. 60 | continue 61 | } 62 | 63 | // `["READ_ACP", "WRITE"]` => ["READ_ACP", "WRITE"] 64 | permissions := tfwrite.SplitTokensAsList(permissionsAttr.ValueAsTokens()) 65 | if permissions == nil { 66 | // The `permissions` attrubute cannot be parsed as a list. 67 | // If the `permissions` attribute is passed as a variable or generated by a function, 68 | // it cannot be split automatically. 69 | continue 70 | } 71 | 72 | for _, permission := range permissions { 73 | grantBlock := tfwrite.NewEmptyNestedBlock("grant") 74 | acpBlock.AppendNestedBlock(grantBlock) 75 | granteeBlock := tfwrite.NewEmptyNestedBlock("grantee") 76 | grantBlock.AppendNestedBlock(granteeBlock) 77 | nestedBlock.RemoveAttribute("permissions") 78 | granteeBlock.AppendUnwrappedNestedBlockBody(nestedBlock) 79 | grantBlock.SetAttributeRaw("permission", permission) 80 | } 81 | 82 | resource.RemoveNestedBlock(nestedBlock) 83 | } 84 | 85 | ownerBlock := tfwrite.NewEmptyNestedBlock("owner") 86 | acpBlock.AppendNestedBlock(ownerBlock) 87 | // A grant argument of aws_s3_bucket in v3 doesn’t have an owner block, 88 | // https://registry.terraform.io/providers/hashicorp/aws/3.74.3/docs/resources/s3_bucket#grant 89 | // but an access_control_policy argument of aws_s3_bucket_acl in v4 has an owner block as required. 90 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_acl#access_control_policy 91 | // There is no way to set it automatically without the AWS API call. 92 | ownerBlock.SetAttributeValue("id", cty.StringVal("set_aws_canonical_user_id")) 93 | 94 | return inFile, nil 95 | } 96 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_grant_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketGrantFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | data "aws_canonical_user_id" "current_user" {} 21 | 22 | resource "aws_s3_bucket" "example" { 23 | bucket = "tfedit-test" 24 | 25 | grant { 26 | id = data.aws_canonical_user_id.current_user.id 27 | type = "CanonicalUser" 28 | permissions = ["FULL_CONTROL"] 29 | } 30 | 31 | grant { 32 | type = "Group" 33 | permissions = ["READ_ACP", "WRITE"] 34 | uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" 35 | } 36 | } 37 | `, 38 | ok: true, 39 | want: ` 40 | data "aws_canonical_user_id" "current_user" {} 41 | 42 | resource "aws_s3_bucket" "example" { 43 | bucket = "tfedit-test" 44 | 45 | 46 | } 47 | 48 | resource "aws_s3_bucket_acl" "example" { 49 | bucket = aws_s3_bucket.example.id 50 | 51 | access_control_policy { 52 | 53 | grant { 54 | 55 | grantee { 56 | 57 | id = data.aws_canonical_user_id.current_user.id 58 | type = "CanonicalUser" 59 | } 60 | permission = "FULL_CONTROL" 61 | } 62 | 63 | grant { 64 | 65 | grantee { 66 | 67 | type = "Group" 68 | uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" 69 | } 70 | permission = "READ_ACP" 71 | } 72 | 73 | grant { 74 | 75 | grantee { 76 | 77 | type = "Group" 78 | uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" 79 | } 80 | permission = "WRITE" 81 | } 82 | 83 | owner { 84 | id = "set_aws_canonical_user_id" 85 | } 86 | } 87 | } 88 | `, 89 | }, 90 | { 91 | name: "argument not found", 92 | src: ` 93 | resource "aws_s3_bucket" "example" { 94 | bucket = "tfedit-test" 95 | foo = "bar" 96 | } 97 | `, 98 | ok: true, 99 | want: ` 100 | resource "aws_s3_bucket" "example" { 101 | bucket = "tfedit-test" 102 | foo = "bar" 103 | } 104 | `, 105 | }, 106 | } 107 | 108 | for _, tc := range cases { 109 | t.Run(tc.name, func(t *testing.T) { 110 | filter := buildTestResourceFilter(AWSS3BucketGrantResourceFilter) 111 | o := editor.NewEditOperator(filter) 112 | output, err := o.Apply([]byte(tc.src), "test") 113 | if tc.ok && err != nil { 114 | t.Fatalf("unexpected err = %s", err) 115 | } 116 | 117 | got := string(output) 118 | if !tc.ok && err == nil { 119 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 120 | } 121 | 122 | if diff := cmp.Diff(got, tc.want); diff != "" { 123 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 124 | } 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_logging.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3BucketLoggingResourceFilter is a filter implementation for upgrading 8 | // the logging argument of aws_s3_bucket. 9 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#logging-argument 10 | func AWSS3BucketLoggingResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 11 | if resource.SchemaType() != "aws_s3_bucket" { 12 | return inFile, nil 13 | } 14 | 15 | oldNestedBlock := "logging" 16 | newResourceType := "aws_s3_bucket_logging" 17 | 18 | nestedBlocks := resource.FindNestedBlocksByType(oldNestedBlock) 19 | if len(nestedBlocks) == 0 { 20 | return inFile, nil 21 | } 22 | 23 | resourceName := resource.Name() 24 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 25 | inFile.AppendBlock(newResource) 26 | setParentBucket(newResource, resource) 27 | newResource.AppendUnwrappedNestedBlockBody(nestedBlocks[0]) 28 | resource.RemoveNestedBlock(nestedBlocks[0]) 29 | 30 | return inFile, nil 31 | } 32 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_logging_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketLoggingFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "log" { 21 | bucket = "tfedit-log" 22 | 23 | # You must give the log-delivery group WRITE and READ_ACP permissions to the target bucket 24 | acl = "log-delivery-write" 25 | } 26 | 27 | resource "aws_s3_bucket" "example" { 28 | bucket = "tfedit-test" 29 | 30 | logging { 31 | target_bucket = aws_s3_bucket.log.id 32 | target_prefix = "log/" 33 | } 34 | } 35 | `, 36 | ok: true, 37 | want: ` 38 | resource "aws_s3_bucket" "log" { 39 | bucket = "tfedit-log" 40 | 41 | # You must give the log-delivery group WRITE and READ_ACP permissions to the target bucket 42 | acl = "log-delivery-write" 43 | } 44 | 45 | resource "aws_s3_bucket" "example" { 46 | bucket = "tfedit-test" 47 | 48 | } 49 | 50 | resource "aws_s3_bucket_logging" "example" { 51 | bucket = aws_s3_bucket.example.id 52 | 53 | target_bucket = aws_s3_bucket.log.id 54 | target_prefix = "log/" 55 | } 56 | `, 57 | }, 58 | { 59 | name: "argument not found", 60 | src: ` 61 | resource "aws_s3_bucket" "example" { 62 | bucket = "tfedit-test" 63 | foo {} 64 | } 65 | `, 66 | ok: true, 67 | want: ` 68 | resource "aws_s3_bucket" "example" { 69 | bucket = "tfedit-test" 70 | foo {} 71 | } 72 | `, 73 | }, 74 | } 75 | 76 | for _, tc := range cases { 77 | t.Run(tc.name, func(t *testing.T) { 78 | filter := buildTestResourceFilter(AWSS3BucketLoggingResourceFilter) 79 | o := editor.NewEditOperator(filter) 80 | output, err := o.Apply([]byte(tc.src), "test") 81 | if tc.ok && err != nil { 82 | t.Fatalf("unexpected err = %s", err) 83 | } 84 | 85 | got := string(output) 86 | if !tc.ok && err == nil { 87 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 88 | } 89 | 90 | if diff := cmp.Diff(got, tc.want); diff != "" { 91 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_object_lock_configuration.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | "github.com/zclconf/go-cty/cty" 6 | ) 7 | 8 | // AWSS3BucketObjectLockConfigurationResourceFilter is a filter implementation 9 | // for upgrading the object_lock_configuration argument of aws_s3_bucket. 10 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#object_lock_configuration-rule-argument 11 | func AWSS3BucketObjectLockConfigurationResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 12 | if resource.SchemaType() != "aws_s3_bucket" { 13 | return inFile, nil 14 | } 15 | 16 | oldNestedBlock := "object_lock_configuration" 17 | newResourceType := "aws_s3_bucket_object_lock_configuration" 18 | 19 | nestedBlocks := resource.FindNestedBlocksByType(oldNestedBlock) 20 | if len(nestedBlocks) == 0 { 21 | return inFile, nil 22 | } 23 | 24 | resourceName := resource.Name() 25 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 26 | inFile.AppendBlock(newResource) 27 | setParentBucket(newResource, resource) 28 | 29 | objectLockBlock := nestedBlocks[0] 30 | 31 | // Move a rule block to a new resource 32 | ruleBlocks := objectLockBlock.FindNestedBlocksByType("rule") 33 | for _, ruleBlock := range ruleBlocks { 34 | newResource.AppendNestedBlock(ruleBlock) 35 | } 36 | 37 | // Map an `object_lock_configuration.object_lock_enabled` attribute 38 | // to a top-level `object_lock_enabled` attribute. 39 | // In addition, the valid type is now bool. 40 | // object_lock_enabled = "Enabled" => true 41 | enabledAttr := objectLockBlock.GetAttribute("object_lock_enabled") 42 | if enabledAttr != nil { 43 | enabled, err := enabledAttr.ValueAsString() 44 | if err == nil { 45 | switch enabled { 46 | case `"Enabled"`: 47 | resource.SetAttributeValue("object_lock_enabled", cty.BoolVal(true)) 48 | // case `"Disabled"`: 49 | // "Disabled" is not defined as an old valid value. 50 | // The top-level `object_lock_enabled` attribute is optional and the default is false. No op. 51 | default: 52 | // If the value is a variable, not literal, we cannot rewrite it automatically. 53 | // Set original raw tokens as it is. 54 | resource.SetAttributeRaw("object_lock_enabled", enabledAttr.ValueAsTokens()) 55 | } 56 | } 57 | } 58 | resource.RemoveNestedBlock(objectLockBlock) 59 | 60 | return inFile, nil 61 | } 62 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_object_lock_configuration_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketObjectLockConfigurationFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | 23 | object_lock_configuration { 24 | object_lock_enabled = "Enabled" 25 | 26 | rule { 27 | default_retention { 28 | mode = "COMPLIANCE" 29 | days = 3 30 | } 31 | } 32 | } 33 | } 34 | `, 35 | ok: true, 36 | want: ` 37 | resource "aws_s3_bucket" "example" { 38 | bucket = "tfedit-test" 39 | 40 | object_lock_enabled = true 41 | } 42 | 43 | resource "aws_s3_bucket_object_lock_configuration" "example" { 44 | bucket = aws_s3_bucket.example.id 45 | 46 | rule { 47 | default_retention { 48 | mode = "COMPLIANCE" 49 | days = 3 50 | } 51 | } 52 | } 53 | `, 54 | }, 55 | { 56 | name: "argument not found", 57 | src: ` 58 | resource "aws_s3_bucket" "example" { 59 | bucket = "tfedit-test" 60 | foo {} 61 | } 62 | `, 63 | ok: true, 64 | want: ` 65 | resource "aws_s3_bucket" "example" { 66 | bucket = "tfedit-test" 67 | foo {} 68 | } 69 | `, 70 | }, 71 | } 72 | 73 | for _, tc := range cases { 74 | t.Run(tc.name, func(t *testing.T) { 75 | filter := buildTestResourceFilter(AWSS3BucketObjectLockConfigurationResourceFilter) 76 | o := editor.NewEditOperator(filter) 77 | output, err := o.Apply([]byte(tc.src), "test") 78 | if tc.ok && err != nil { 79 | t.Fatalf("unexpected err = %s", err) 80 | } 81 | 82 | got := string(output) 83 | if !tc.ok && err == nil { 84 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 85 | } 86 | 87 | if diff := cmp.Diff(got, tc.want); diff != "" { 88 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_policy.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3BucketPolicyResourceFilter is a filter implementation for upgrading the 8 | // policy argument of aws_s3_bucket. 9 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#policy-argument 10 | func AWSS3BucketPolicyResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 11 | if resource.SchemaType() != "aws_s3_bucket" { 12 | return inFile, nil 13 | } 14 | 15 | oldAttribute := "policy" 16 | newResourceType := "aws_s3_bucket_policy" 17 | 18 | attr := resource.GetAttribute(oldAttribute) 19 | if attr == nil { 20 | return inFile, nil 21 | } 22 | 23 | resourceName := resource.Name() 24 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 25 | inFile.AppendBlock(newResource) 26 | setParentBucket(newResource, resource) 27 | newResource.AppendAttribute(attr) 28 | resource.RemoveAttribute(oldAttribute) 29 | 30 | return inFile, nil 31 | } 32 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_policy_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketPolicyFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | policy = < payer = "Requester" 30 | payer := attr.ValueAsTokens() 31 | newResource.SetAttributeRaw("payer", payer) 32 | 33 | resource.RemoveAttribute(oldAttribute) 34 | 35 | return inFile, nil 36 | } 37 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_request_payer_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketRequestPayerFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | request_payer = "Requester" 23 | } 24 | `, 25 | ok: true, 26 | want: ` 27 | resource "aws_s3_bucket" "example" { 28 | bucket = "tfedit-test" 29 | } 30 | 31 | resource "aws_s3_bucket_request_payment_configuration" "example" { 32 | bucket = aws_s3_bucket.example.id 33 | payer = "Requester" 34 | } 35 | `, 36 | }, 37 | { 38 | name: "argument not found", 39 | src: ` 40 | resource "aws_s3_bucket" "example" { 41 | bucket = "tfedit-test" 42 | foo = "bar" 43 | } 44 | `, 45 | ok: true, 46 | want: ` 47 | resource "aws_s3_bucket" "example" { 48 | bucket = "tfedit-test" 49 | foo = "bar" 50 | } 51 | `, 52 | }, 53 | } 54 | 55 | for _, tc := range cases { 56 | t.Run(tc.name, func(t *testing.T) { 57 | filter := buildTestResourceFilter(AWSS3BucketRequestPayerResourceFilter) 58 | o := editor.NewEditOperator(filter) 59 | output, err := o.Apply([]byte(tc.src), "test") 60 | if tc.ok && err != nil { 61 | t.Fatalf("unexpected err = %s", err) 62 | } 63 | 64 | got := string(output) 65 | if !tc.ok && err == nil { 66 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 67 | } 68 | 69 | if diff := cmp.Diff(got, tc.want); diff != "" { 70 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_server_side_encryption_configuration.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3BucketServerSideEncryptionConfigurationResourceFilter is a filter 8 | // implementation for upgrading the server_side_encryption_configuration 9 | // argument of aws_s3_bucket. 10 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#server_side_encryption_configuration-argument 11 | func AWSS3BucketServerSideEncryptionConfigurationResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 12 | if resource.SchemaType() != "aws_s3_bucket" { 13 | return inFile, nil 14 | } 15 | 16 | oldNestedBlock := "server_side_encryption_configuration" 17 | newResourceType := "aws_s3_bucket_server_side_encryption_configuration" 18 | 19 | nestedBlocks := resource.FindNestedBlocksByType(oldNestedBlock) 20 | if len(nestedBlocks) == 0 { 21 | return inFile, nil 22 | } 23 | 24 | resourceName := resource.Name() 25 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 26 | inFile.AppendBlock(newResource) 27 | setParentBucket(newResource, resource) 28 | newResource.AppendUnwrappedNestedBlockBody(nestedBlocks[0]) 29 | resource.RemoveNestedBlock(nestedBlocks[0]) 30 | 31 | return inFile, nil 32 | } 33 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_server_side_encryption_configuration_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketServerSideEncryptionConfigurationFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | 23 | server_side_encryption_configuration { 24 | rule { 25 | apply_server_side_encryption_by_default { 26 | kms_master_key_id = "aws/s3" 27 | sse_algorithm = "aws:kms" 28 | } 29 | } 30 | } 31 | } 32 | `, 33 | ok: true, 34 | want: ` 35 | resource "aws_s3_bucket" "example" { 36 | bucket = "tfedit-test" 37 | 38 | } 39 | 40 | resource "aws_s3_bucket_server_side_encryption_configuration" "example" { 41 | bucket = aws_s3_bucket.example.id 42 | 43 | rule { 44 | apply_server_side_encryption_by_default { 45 | kms_master_key_id = "aws/s3" 46 | sse_algorithm = "aws:kms" 47 | } 48 | } 49 | } 50 | `, 51 | }, 52 | { 53 | name: "argument not found", 54 | src: ` 55 | resource "aws_s3_bucket" "example" { 56 | bucket = "tfedit-test" 57 | foo {} 58 | } 59 | `, 60 | ok: true, 61 | want: ` 62 | resource "aws_s3_bucket" "example" { 63 | bucket = "tfedit-test" 64 | foo {} 65 | } 66 | `, 67 | }, 68 | } 69 | 70 | for _, tc := range cases { 71 | t.Run(tc.name, func(t *testing.T) { 72 | filter := buildTestResourceFilter(AWSS3BucketServerSideEncryptionConfigurationResourceFilter) 73 | o := editor.NewEditOperator(filter) 74 | output, err := o.Apply([]byte(tc.src), "test") 75 | if tc.ok && err != nil { 76 | t.Fatalf("unexpected err = %s", err) 77 | } 78 | 79 | got := string(output) 80 | if !tc.ok && err == nil { 81 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 82 | } 83 | 84 | if diff := cmp.Diff(got, tc.want); diff != "" { 85 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_versioning.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | "github.com/zclconf/go-cty/cty" 6 | ) 7 | 8 | // AWSS3BucketVersioningResourceFilter is a filter implementation for upgrading 9 | // the versioning argument of aws_s3_bucket. 10 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#versioning-argument 11 | func AWSS3BucketVersioningResourceFilter(inFile *tfwrite.File, resource *tfwrite.Resource) (*tfwrite.File, error) { 12 | if resource.SchemaType() != "aws_s3_bucket" { 13 | return inFile, nil 14 | } 15 | 16 | oldNestedBlock := "versioning" 17 | newResourceType := "aws_s3_bucket_versioning" 18 | newNestedBlock := "versioning_configuration" 19 | 20 | nestedBlocks := resource.FindNestedBlocksByType(oldNestedBlock) 21 | if len(nestedBlocks) == 0 { 22 | return inFile, nil 23 | } 24 | 25 | resourceName := resource.Name() 26 | newResource := tfwrite.NewEmptyResource(newResourceType, resourceName) 27 | inFile.AppendBlock(newResource) 28 | setParentBucket(newResource, resource) 29 | 30 | nestedBlock := nestedBlocks[0] 31 | 32 | // Rename a `versioning` block to a `versioning_configuration` block 33 | nestedBlock.SetType(newNestedBlock) 34 | 35 | // Map an `enabled` attribute to a `status` attribute 36 | // enabled = true => status = "Enabled" 37 | // enabled = false => status = "Suspended" 38 | enabledAttr := nestedBlock.GetAttribute("enabled") 39 | if enabledAttr != nil { 40 | enabled, err := enabledAttr.ValueAsString() 41 | if err == nil { 42 | switch enabled { 43 | case "true": 44 | nestedBlock.SetAttributeValue("status", cty.StringVal("Enabled")) 45 | case "false": 46 | nestedBlock.SetAttributeValue("status", cty.StringVal("Suspended")) 47 | default: 48 | // If the value is a variable, not literal, we cannot rewrite it automatically. 49 | // Set original raw tokens as it is. 50 | nestedBlock.SetAttributeRaw("status", enabledAttr.ValueAsTokens()) 51 | } 52 | } 53 | nestedBlock.RemoveAttribute("enabled") 54 | } 55 | 56 | // Map an `mfa_delete` attribute. 57 | // true => "Enabled" 58 | // false => "Disabled" 59 | // There is also the mfa argument in v4, but it seems practically meaningless. Simply ignore it. 60 | mfaDeleteAttr := nestedBlock.GetAttribute("mfa_delete") 61 | if mfaDeleteAttr != nil { 62 | mfaDelete, err := mfaDeleteAttr.ValueAsString() 63 | if err == nil { 64 | switch mfaDelete { 65 | case "true": 66 | nestedBlock.SetAttributeValue("mfa_delete", cty.StringVal("Enabled")) 67 | case "false": 68 | nestedBlock.SetAttributeValue("mfa_delete", cty.StringVal("Disabled")) 69 | default: 70 | // If the value is a variable, not literal, we cannot rewrite it automatically. 71 | // Set original raw tokens as it is. 72 | nestedBlock.SetAttributeRaw("mfa_delete", mfaDeleteAttr.ValueAsTokens()) 73 | } 74 | } 75 | } 76 | 77 | newResource.AppendNestedBlock(nestedBlock) 78 | resource.RemoveNestedBlock(nestedBlock) 79 | 80 | return inFile, nil 81 | } 82 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_versioning_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketVersioningFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "enabled = true", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | 23 | versioning { 24 | enabled = true 25 | } 26 | } 27 | `, 28 | ok: true, 29 | want: ` 30 | resource "aws_s3_bucket" "example" { 31 | bucket = "tfedit-test" 32 | 33 | } 34 | 35 | resource "aws_s3_bucket_versioning" "example" { 36 | bucket = aws_s3_bucket.example.id 37 | 38 | versioning_configuration { 39 | status = "Enabled" 40 | } 41 | } 42 | `, 43 | }, 44 | { 45 | name: "enabled = false", 46 | src: ` 47 | resource "aws_s3_bucket" "example" { 48 | bucket = "tfedit-test" 49 | 50 | versioning { 51 | enabled = false 52 | } 53 | } 54 | `, 55 | ok: true, 56 | want: ` 57 | resource "aws_s3_bucket" "example" { 58 | bucket = "tfedit-test" 59 | 60 | } 61 | 62 | resource "aws_s3_bucket_versioning" "example" { 63 | bucket = aws_s3_bucket.example.id 64 | 65 | versioning_configuration { 66 | status = "Suspended" 67 | } 68 | } 69 | `, 70 | }, 71 | { 72 | name: "enabled = var.enabled", 73 | src: ` 74 | resource "aws_s3_bucket" "example" { 75 | bucket = "tfedit-test" 76 | 77 | versioning { 78 | enabled = var.enabled 79 | } 80 | } 81 | `, 82 | ok: true, 83 | want: ` 84 | resource "aws_s3_bucket" "example" { 85 | bucket = "tfedit-test" 86 | 87 | } 88 | 89 | resource "aws_s3_bucket_versioning" "example" { 90 | bucket = aws_s3_bucket.example.id 91 | 92 | versioning_configuration { 93 | status = var.enabled 94 | } 95 | } 96 | `, 97 | }, 98 | { 99 | name: "argument not found", 100 | src: ` 101 | resource "aws_s3_bucket" "example" { 102 | bucket = "tfedit-test" 103 | foo {} 104 | } 105 | `, 106 | ok: true, 107 | want: ` 108 | resource "aws_s3_bucket" "example" { 109 | bucket = "tfedit-test" 110 | foo {} 111 | } 112 | `, 113 | }, 114 | { 115 | name: "mfa_delete = true", 116 | src: ` 117 | resource "aws_s3_bucket" "example" { 118 | bucket = "tfedit-test" 119 | 120 | versioning { 121 | enabled = true 122 | mfa_delete = true 123 | } 124 | } 125 | `, 126 | ok: true, 127 | want: ` 128 | resource "aws_s3_bucket" "example" { 129 | bucket = "tfedit-test" 130 | 131 | } 132 | 133 | resource "aws_s3_bucket_versioning" "example" { 134 | bucket = aws_s3_bucket.example.id 135 | 136 | versioning_configuration { 137 | mfa_delete = "Enabled" 138 | status = "Enabled" 139 | } 140 | } 141 | `, 142 | }, 143 | { 144 | name: "mfa_delete = false", 145 | src: ` 146 | resource "aws_s3_bucket" "example" { 147 | bucket = "tfedit-test" 148 | 149 | versioning { 150 | enabled = true 151 | mfa_delete = false 152 | } 153 | } 154 | `, 155 | ok: true, 156 | want: ` 157 | resource "aws_s3_bucket" "example" { 158 | bucket = "tfedit-test" 159 | 160 | } 161 | 162 | resource "aws_s3_bucket_versioning" "example" { 163 | bucket = aws_s3_bucket.example.id 164 | 165 | versioning_configuration { 166 | mfa_delete = "Disabled" 167 | status = "Enabled" 168 | } 169 | } 170 | `, 171 | }, 172 | } 173 | 174 | for _, tc := range cases { 175 | t.Run(tc.name, func(t *testing.T) { 176 | filter := buildTestResourceFilter(AWSS3BucketVersioningResourceFilter) 177 | o := editor.NewEditOperator(filter) 178 | output, err := o.Apply([]byte(tc.src), "test") 179 | if tc.ok && err != nil { 180 | t.Fatalf("unexpected err = %s", err) 181 | } 182 | 183 | got := string(output) 184 | if !tc.ok && err == nil { 185 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 186 | } 187 | 188 | if diff := cmp.Diff(got, tc.want); diff != "" { 189 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%v", got, tc.want, diff) 190 | } 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/aws_s3_bucket_website_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestAWSS3BucketWebsiteFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | resource "aws_s3_bucket" "example" { 21 | bucket = "tfedit-test" 22 | 23 | website { 24 | index_document = "index.html" 25 | error_document = "error.html" 26 | } 27 | } 28 | `, 29 | ok: true, 30 | want: ` 31 | resource "aws_s3_bucket" "example" { 32 | bucket = "tfedit-test" 33 | 34 | } 35 | 36 | resource "aws_s3_bucket_website_configuration" "example" { 37 | bucket = aws_s3_bucket.example.id 38 | 39 | index_document { 40 | suffix = "index.html" 41 | } 42 | 43 | error_document { 44 | key = "error.html" 45 | } 46 | 47 | } 48 | `, 49 | }, 50 | { 51 | name: "argument not found", 52 | src: ` 53 | resource "aws_s3_bucket" "example" { 54 | bucket = "tfedit-test" 55 | foo {} 56 | } 57 | `, 58 | ok: true, 59 | want: ` 60 | resource "aws_s3_bucket" "example" { 61 | bucket = "tfedit-test" 62 | foo {} 63 | } 64 | `, 65 | }, 66 | { 67 | name: "rename references for website_domain and website_endpoint", 68 | src: ` 69 | resource "aws_route53_zone" "test" { 70 | name = "example.com" 71 | } 72 | 73 | resource "aws_route53_record" "alias" { 74 | zone_id = aws_route53_zone.test.zone_id 75 | name = "www" 76 | type = "A" 77 | 78 | alias { 79 | zone_id = aws_s3_bucket.example.hosted_zone_id 80 | name = aws_s3_bucket.example.website_domain 81 | evaluate_target_health = true 82 | } 83 | } 84 | 85 | output "test_endpoint" { 86 | value = aws_s3_bucket.example.website_endpoint 87 | } 88 | 89 | resource "aws_s3_bucket" "example" { 90 | bucket = "tfedit-test" 91 | 92 | website { 93 | index_document = "index.html" 94 | error_document = "error.html" 95 | } 96 | } 97 | `, 98 | ok: true, 99 | want: ` 100 | resource "aws_route53_zone" "test" { 101 | name = "example.com" 102 | } 103 | 104 | resource "aws_route53_record" "alias" { 105 | zone_id = aws_route53_zone.test.zone_id 106 | name = "www" 107 | type = "A" 108 | 109 | alias { 110 | zone_id = aws_s3_bucket.example.hosted_zone_id 111 | name = aws_s3_bucket_website_configuration.example.website_domain 112 | evaluate_target_health = true 113 | } 114 | } 115 | 116 | output "test_endpoint" { 117 | value = aws_s3_bucket_website_configuration.example.website_endpoint 118 | } 119 | 120 | resource "aws_s3_bucket" "example" { 121 | bucket = "tfedit-test" 122 | 123 | } 124 | 125 | resource "aws_s3_bucket_website_configuration" "example" { 126 | bucket = aws_s3_bucket.example.id 127 | 128 | index_document { 129 | suffix = "index.html" 130 | } 131 | 132 | error_document { 133 | key = "error.html" 134 | } 135 | 136 | } 137 | `, 138 | }, 139 | } 140 | 141 | for _, tc := range cases { 142 | t.Run(tc.name, func(t *testing.T) { 143 | filter := buildTestBlockFilter(AWSS3BucketWebsiteBlockFilter) 144 | o := editor.NewEditOperator(filter) 145 | output, err := o.Apply([]byte(tc.src), "test") 146 | if tc.ok && err != nil { 147 | t.Fatalf("unexpected err = %s", err) 148 | } 149 | 150 | got := string(output) 151 | if !tc.ok && err == nil { 152 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 153 | } 154 | 155 | if diff := cmp.Diff(got, tc.want); diff != "" { 156 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 157 | } 158 | }) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/provider_aws.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfeditor" 5 | "github.com/minamijoyo/tfedit/tfwrite" 6 | ) 7 | 8 | // ProviderAWSFilter is a filter implementation for upgrading arguments of 9 | // provider aws block to v4. 10 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#new-provider-arguments 11 | type ProviderAWSFilter struct { 12 | filters []tfeditor.BlockFilter 13 | } 14 | 15 | var _ tfeditor.BlockFilter = (*ProviderAWSFilter)(nil) 16 | 17 | // NewProviderAWSFilter creates a new instance of ProviderAWSFilter. 18 | func NewProviderAWSFilter() tfeditor.BlockFilter { 19 | filters := []tfeditor.BlockFilter{ 20 | 21 | tfeditor.ProviderFilterFunc(AWSS3ForcePathStyleProviderFilter), 22 | } 23 | return &ProviderAWSFilter{filters: filters} 24 | } 25 | 26 | // BlockFilter upgrades arguments of provider aws block to v4. 27 | // Some rules have not been implemented yet. 28 | func (f *ProviderAWSFilter) BlockFilter(inFile *tfwrite.File, block tfwrite.Block) (*tfwrite.File, error) { 29 | m := tfeditor.NewMultiBlockFilter(f.filters) 30 | return m.BlockFilter(inFile, block) 31 | } 32 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/provider_aws_s3_force_path_style.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/tfwrite" 5 | ) 6 | 7 | // AWSS3ForcePathStyleProviderFilter is a filter implementation for upgrading 8 | // the s3_force_path_style argument of provider aws block. 9 | // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-4-upgrade#s3_use_path_style 10 | func AWSS3ForcePathStyleProviderFilter(inFile *tfwrite.File, provider *tfwrite.Provider) (*tfwrite.File, error) { 11 | if provider.SchemaType() != "aws" { 12 | return inFile, nil 13 | } 14 | 15 | oldAttribute := "s3_force_path_style" 16 | newAttribute := "s3_use_path_style" 17 | 18 | // Rename a s3_force_path_style attribute to s3_use_path_style. 19 | attr := provider.GetAttribute(oldAttribute) 20 | if attr != nil { 21 | provider.SetAttributeRaw(newAttribute, attr.ValueAsTokens()) 22 | provider.RemoveAttribute(oldAttribute) 23 | } 24 | 25 | return inFile, nil 26 | } 27 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/provider_aws_s3_force_path_style_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | ) 9 | 10 | func TestProviderAWSS3ForcePathStyleFilter(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | src string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | name: "simple", 19 | src: ` 20 | provider "aws" { 21 | s3_force_path_style = true 22 | } 23 | `, 24 | ok: true, 25 | want: ` 26 | provider "aws" { 27 | s3_use_path_style = true 28 | } 29 | `, 30 | }, 31 | { 32 | name: "argument not found", 33 | src: ` 34 | provider "aws" { 35 | region = "ap-northeast-1" 36 | } 37 | `, 38 | ok: true, 39 | want: ` 40 | provider "aws" { 41 | region = "ap-northeast-1" 42 | } 43 | `, 44 | }, 45 | } 46 | 47 | for _, tc := range cases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | filter := buildTestProviderFilter(AWSS3ForcePathStyleProviderFilter) 50 | o := editor.NewEditOperator(filter) 51 | output, err := o.Apply([]byte(tc.src), "test") 52 | if tc.ok && err != nil { 53 | t.Fatalf("unexpected err = %s", err) 54 | } 55 | 56 | got := string(output) 57 | if !tc.ok && err == nil { 58 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 59 | } 60 | 61 | if diff := cmp.Diff(got, tc.want); diff != "" { 62 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /filter/awsv4upgrade/provider_aws_test.go: -------------------------------------------------------------------------------- 1 | package awsv4upgrade 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/minamijoyo/hcledit/editor" 8 | "github.com/minamijoyo/tfedit/tfeditor" 9 | ) 10 | 11 | // buildTestProviderFilter is a helper function which builds an editor filter for testing. 12 | func buildTestProviderFilter(f tfeditor.ProviderFilterFunc) editor.Filter { 13 | return tfeditor.NewFileFilter( 14 | &ProviderAWSFilter{ 15 | filters: []tfeditor.BlockFilter{ 16 | tfeditor.ProviderFilterFunc(f), 17 | }, 18 | }, 19 | ) 20 | } 21 | 22 | func TestProviderAWSFilter(t *testing.T) { 23 | cases := []struct { 24 | name string 25 | src string 26 | ok bool 27 | want string 28 | }{ 29 | { 30 | name: "simple", 31 | src: ` 32 | provider "aws" { 33 | region = "ap-northeast-1" 34 | 35 | access_key = "dummy" 36 | secret_key = "dummy" 37 | skip_credentials_validation = true 38 | skip_metadata_api_check = true 39 | skip_region_validation = true 40 | skip_requesting_account_id = true 41 | 42 | # mock endpoints with localstack 43 | endpoints { 44 | s3 = "http://localstack:4566" 45 | iam = "http://localstack:4566" 46 | } 47 | 48 | s3_force_path_style = true 49 | } 50 | `, 51 | ok: true, 52 | want: ` 53 | provider "aws" { 54 | region = "ap-northeast-1" 55 | 56 | access_key = "dummy" 57 | secret_key = "dummy" 58 | skip_credentials_validation = true 59 | skip_metadata_api_check = true 60 | skip_region_validation = true 61 | skip_requesting_account_id = true 62 | 63 | # mock endpoints with localstack 64 | endpoints { 65 | s3 = "http://localstack:4566" 66 | iam = "http://localstack:4566" 67 | } 68 | 69 | s3_use_path_style = true 70 | } 71 | `, 72 | }, 73 | { 74 | name: "multiple providers", 75 | src: ` 76 | provider "aws" { 77 | alias = "foo" 78 | 79 | s3_force_path_style = true 80 | } 81 | 82 | provider "aws" { 83 | alias = "bar" 84 | 85 | s3_force_path_style = true 86 | } 87 | `, 88 | ok: true, 89 | want: ` 90 | provider "aws" { 91 | alias = "foo" 92 | 93 | s3_use_path_style = true 94 | } 95 | 96 | provider "aws" { 97 | alias = "bar" 98 | 99 | s3_use_path_style = true 100 | } 101 | `, 102 | }, 103 | { 104 | name: "argument not found", 105 | src: ` 106 | provider "aws" { 107 | alias = "foo" 108 | } 109 | `, 110 | ok: true, 111 | want: ` 112 | provider "aws" { 113 | alias = "foo" 114 | } 115 | `, 116 | }, 117 | { 118 | name: "provider type not found", 119 | src: ` 120 | provider "google" { 121 | alias = "foo" 122 | } 123 | `, 124 | ok: true, 125 | want: ` 126 | provider "google" { 127 | alias = "foo" 128 | } 129 | `, 130 | }, 131 | } 132 | 133 | for _, tc := range cases { 134 | t.Run(tc.name, func(t *testing.T) { 135 | filter := tfeditor.NewFileFilter(NewProviderAWSFilter()) 136 | o := editor.NewEditOperator(filter) 137 | output, err := o.Apply([]byte(tc.src), "test") 138 | if tc.ok && err != nil { 139 | t.Fatalf("unexpected err = %s", err) 140 | } 141 | 142 | got := string(output) 143 | if !tc.ok && err == nil { 144 | t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) 145 | } 146 | 147 | if diff := cmp.Diff(got, tc.want); diff != "" { 148 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 149 | } 150 | }) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /filter/factory.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/minamijoyo/hcledit/editor" 7 | "github.com/minamijoyo/tfedit/filter/awsv4upgrade" 8 | ) 9 | 10 | // NewFilterByType is a factory method for Filter by type. 11 | func NewFilterByType(filterType string) (editor.Filter, error) { 12 | switch filterType { 13 | case "awsv4upgrade": 14 | return awsv4upgrade.NewAllFilter(), nil 15 | default: 16 | return nil, fmt.Errorf("unknown filter type: %s", filterType) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/minamijoyo/tfedit 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 7 | github.com/google/go-cmp v0.5.8 8 | github.com/hashicorp/hcl/v2 v2.12.0 9 | github.com/hashicorp/logutils v1.0.0 10 | github.com/hashicorp/terraform-json v0.14.0 11 | github.com/minamijoyo/hcledit v0.2.5 12 | github.com/spf13/cobra v1.3.0 13 | github.com/spf13/viper v1.10.1 14 | github.com/zclconf/go-cty v1.10.0 15 | golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 16 | ) 17 | 18 | require ( 19 | github.com/agext/levenshtein v1.2.1 // indirect 20 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 21 | github.com/fsnotify/fsnotify v1.5.1 // indirect 22 | github.com/hashicorp/go-version v1.5.0 // indirect 23 | github.com/hashicorp/hcl v1.0.0 // indirect 24 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 25 | github.com/magiconair/properties v1.8.5 // indirect 26 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect 27 | github.com/mitchellh/mapstructure v1.4.3 // indirect 28 | github.com/pelletier/go-toml v1.9.4 // indirect 29 | github.com/spf13/afero v1.6.0 // indirect 30 | github.com/spf13/cast v1.4.1 // indirect 31 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 32 | github.com/spf13/pflag v1.0.5 // indirect 33 | github.com/subosito/gotenv v1.2.0 // indirect 34 | golang.org/x/sys v0.1.0 // indirect 35 | golang.org/x/text v0.3.7 // indirect 36 | gopkg.in/ini.v1 v1.66.2 // indirect 37 | gopkg.in/yaml.v2 v2.4.0 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | 9 | "github.com/hashicorp/logutils" 10 | "github.com/minamijoyo/tfedit/cmd" 11 | ) 12 | 13 | func main() { 14 | log.SetOutput(logOutput()) 15 | log.Printf("[INFO] CLI args: %#v", os.Args) 16 | if err := cmd.RootCmd.Execute(); err != nil { 17 | fmt.Fprintf(os.Stderr, "%v\n", err) 18 | os.Exit(1) 19 | } 20 | } 21 | 22 | func logOutput() io.Writer { 23 | levels := []logutils.LogLevel{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} 24 | minLevel := os.Getenv("TFEDIT_LOG") 25 | 26 | // default log writer is null device. 27 | writer := io.Discard 28 | if minLevel != "" { 29 | writer = os.Stderr 30 | } 31 | 32 | filter := &logutils.LevelFilter{ 33 | Levels: levels, 34 | MinLevel: logutils.LogLevel(minLevel), 35 | Writer: writer, 36 | } 37 | 38 | return filter 39 | } 40 | -------------------------------------------------------------------------------- /migration/conflict.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "fmt" 5 | 6 | tfjson "github.com/hashicorp/terraform-json" 7 | "github.com/minamijoyo/tfedit/migration/schema" 8 | ) 9 | 10 | // Conflict is a planned resource change. 11 | // It also has a status of whether it has already been resolved. 12 | type Conflict struct { 13 | // A planned resource change. 14 | rc *tfjson.ResourceChange 15 | // A flag indicating that it has already been resolved. 16 | // The state mv operation reduces two conflicts to a single state migration 17 | // action, so we need a flag to see if it has already been processed. 18 | resolved bool 19 | } 20 | 21 | // NewConflict returns a new instance of Conflict. 22 | func NewConflict(rc *tfjson.ResourceChange) *Conflict { 23 | return &Conflict{ 24 | rc: rc, 25 | resolved: false, 26 | } 27 | } 28 | 29 | // MarkAsResolved marks the conflict as resolved. 30 | func (c *Conflict) MarkAsResolved() { 31 | c.resolved = true 32 | } 33 | 34 | // IsResolved return true if the conflict has already been resolved. 35 | func (c *Conflict) IsResolved() bool { 36 | return c.resolved 37 | } 38 | 39 | // PlannedActionType returns a string that represents the type of action. 40 | // Currently some actions that may be included in the plan are not supported. 41 | // It returns "unknown" if not supported. 42 | // The valid values are: 43 | // - create 44 | // - unknown 45 | func (c *Conflict) PlannedActionType() string { 46 | switch { 47 | case c.rc.Change.Actions.Create(): 48 | return "create" 49 | default: 50 | return "unknown" 51 | } 52 | 53 | } 54 | 55 | // ResourceType returns a resource type. (e.g. aws_s3_bucket_acl) 56 | func (c *Conflict) ResourceType() string { 57 | return c.rc.Type 58 | } 59 | 60 | // Address returns an absolute address. (e.g. aws_s3_bucket_acl.example) 61 | func (c *Conflict) Address() string { 62 | return c.rc.Address 63 | } 64 | 65 | // ResourceAfter retruns a planned resource after change. 66 | // It doesn't contains attributes known after apply. 67 | func (c *Conflict) ResourceAfter() (schema.Resource, error) { 68 | after, ok := c.rc.Change.After.(map[string]interface{}) 69 | if !ok { 70 | return nil, fmt.Errorf("failed to cast the ResourceChange.Change.After object to Resource: %#v", c.rc.Change.After) 71 | } 72 | 73 | return schema.Resource(after), nil 74 | } 75 | -------------------------------------------------------------------------------- /migration/plan.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | tfjson "github.com/hashicorp/terraform-json" 8 | ) 9 | 10 | // Plan is a type which wraps Plan of terraform-json and exposes some 11 | // operations which we need. 12 | type Plan struct { 13 | raw tfjson.Plan 14 | } 15 | 16 | // NewPlan parses a plan file in JSON format and creates a new instance of 17 | // Plan. 18 | func NewPlan(planJSON []byte) (*Plan, error) { 19 | var raw tfjson.Plan 20 | 21 | if err := json.Unmarshal(planJSON, &raw); err != nil { 22 | return nil, fmt.Errorf("failed to parse plan file: %s", err) 23 | } 24 | 25 | plan := &Plan{ 26 | raw: raw, 27 | } 28 | 29 | return plan, nil 30 | } 31 | 32 | // ResourceChanges returns a list of changes in plan. 33 | func (p *Plan) ResourceChanges() []*tfjson.ResourceChange { 34 | return p.raw.ResourceChanges 35 | } 36 | -------------------------------------------------------------------------------- /migration/plan_analyzer.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/migration/schema" 5 | "github.com/minamijoyo/tfedit/migration/schema/aws" 6 | ) 7 | 8 | // PlanAnalyzer is an interface that abstracts the analysis rules of plan. 9 | type PlanAnalyzer interface { 10 | // Analyze analyzes a given plan and generates a state migration so that 11 | // the plan results in no changes. 12 | // The dir is set to a dir attribute in a migration file. 13 | Analyze(plan *Plan, dir string) (*StateMigration, error) 14 | } 15 | 16 | // defaultPlanAnalyzer is a default implementation for PlanAnalyzer. 17 | // This is a predefined rule-based analyzer. 18 | type defaultPlanAnalyzer struct { 19 | // A dictionary for provider schema. 20 | dictionary *schema.Dictionary 21 | // A list of rules used for analysis. 22 | resolvers []Resolver 23 | } 24 | 25 | var _ PlanAnalyzer = (*defaultPlanAnalyzer)(nil) 26 | 27 | // NewDefaultPlanAnalyzer returns a new instance of defaultPlanAnalyzer. 28 | // The current implementation only supports import, but allows us to compose 29 | // multiple resolvers for future extension. 30 | func NewDefaultPlanAnalyzer(d *schema.Dictionary) PlanAnalyzer { 31 | return &defaultPlanAnalyzer{ 32 | dictionary: d, 33 | resolvers: []Resolver{ 34 | NewStateImportResolver(d), 35 | }, 36 | } 37 | } 38 | 39 | // Analyze analyzes a given plan and generates a state migration so that 40 | // the plan results in no changes. 41 | // The dir is set to a dir attribute in a migration file. 42 | func (a *defaultPlanAnalyzer) Analyze(plan *Plan, dir string) (*StateMigration, error) { 43 | subject := NewSubject(plan) 44 | 45 | migration := NewStateMigration("fromplan", dir) 46 | current := subject 47 | for _, r := range a.resolvers { 48 | next, actions, err := r.Resolve(current) 49 | if err != nil { 50 | return nil, err 51 | } 52 | migration.AppendActions(actions...) 53 | current = next 54 | } 55 | 56 | return migration, nil 57 | } 58 | 59 | // GenerateFromPlan returns bytes of a migration file which reverts a given 60 | // planned changes. 61 | // The dir is set to a dir attribute in a migration file. 62 | func GenerateFromPlan(planJSON []byte, dir string) ([]byte, error) { 63 | plan, err := NewPlan(planJSON) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | dictionary := NewDefaultDictionary() 69 | analyzer := NewDefaultPlanAnalyzer(dictionary) 70 | migration, err := analyzer.Analyze(plan, dir) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return migration.Render() 76 | } 77 | 78 | // NewDefaultDictionary returns a default built-in Dictionary. 79 | func NewDefaultDictionary() *schema.Dictionary { 80 | d := schema.NewDictionary() 81 | aws.RegisterSchema(d) 82 | return d 83 | } 84 | -------------------------------------------------------------------------------- /migration/plan_analyzer_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestGenerateFromPlan(t *testing.T) { 11 | cases := []struct { 12 | desc string 13 | planFile string 14 | dir string 15 | ok bool 16 | want string 17 | }{ 18 | { 19 | desc: "import simple", 20 | planFile: "test-fixtures/import_simple.tfplan.json", 21 | dir: "", 22 | ok: true, 23 | want: `migration "state" "fromplan" { 24 | actions = [ 25 | "import aws_s3_bucket_acl.example tfedit-test,private", 26 | ] 27 | } 28 | `, 29 | }, 30 | { 31 | desc: "import simple with dir", 32 | planFile: "test-fixtures/import_simple.tfplan.json", 33 | dir: "foo", 34 | ok: true, 35 | want: `migration "state" "fromplan" { 36 | dir = "foo" 37 | actions = [ 38 | "import aws_s3_bucket_acl.example tfedit-test,private", 39 | ] 40 | } 41 | `, 42 | }, 43 | { 44 | desc: "import full", 45 | planFile: "test-fixtures/import_full.tfplan.json", 46 | dir: "", 47 | ok: true, 48 | want: `migration "state" "fromplan" { 49 | actions = [ 50 | "import aws_s3_bucket_accelerate_configuration.example tfedit-test", 51 | "import aws_s3_bucket_acl.example tfedit-test,private", 52 | "import aws_s3_bucket_acl.log tfedit-log,log-delivery-write", 53 | "import aws_s3_bucket_cors_configuration.example tfedit-test", 54 | "import aws_s3_bucket_lifecycle_configuration.example tfedit-test", 55 | "import aws_s3_bucket_logging.example tfedit-test", 56 | "import aws_s3_bucket_object_lock_configuration.example tfedit-test", 57 | "import aws_s3_bucket_policy.example tfedit-test", 58 | "import aws_s3_bucket_replication_configuration.example tfedit-test", 59 | "import aws_s3_bucket_request_payment_configuration.example tfedit-test", 60 | "import aws_s3_bucket_server_side_encryption_configuration.example tfedit-test", 61 | "import aws_s3_bucket_versioning.example tfedit-test", 62 | "import aws_s3_bucket_website_configuration.example tfedit-test", 63 | ] 64 | } 65 | `, 66 | }, 67 | } 68 | 69 | for _, tc := range cases { 70 | t.Run(tc.desc, func(t *testing.T) { 71 | planJSON, err := os.ReadFile(tc.planFile) 72 | if err != nil { 73 | t.Fatalf("failed to read file: %s", err) 74 | } 75 | 76 | output, err := GenerateFromPlan(planJSON, tc.dir) 77 | if tc.ok && err != nil { 78 | t.Fatalf("unexpected err = %s", err) 79 | } 80 | 81 | got := string(output) 82 | if !tc.ok && err == nil { 83 | t.Fatalf("expected to return an error, but no error, got: %s", got) 84 | } 85 | 86 | if diff := cmp.Diff(got, tc.want); diff != "" { 87 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 88 | } 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /migration/plan_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewPlan(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | planFile string 12 | ok bool 13 | }{ 14 | { 15 | desc: "valid", 16 | planFile: "test-fixtures/import_simple.tfplan.json", 17 | ok: true, 18 | }, 19 | { 20 | desc: "invalid", 21 | planFile: "test-fixtures/invalid.tfplan.json", 22 | ok: false, 23 | }, 24 | { 25 | desc: "unknown format version", 26 | planFile: "test-fixtures/unknown_format_version.tfplan.json", 27 | ok: false, 28 | }, 29 | } 30 | 31 | for _, tc := range cases { 32 | t.Run(tc.desc, func(t *testing.T) { 33 | planJSON, err := os.ReadFile(tc.planFile) 34 | if err != nil { 35 | t.Fatalf("failed to read file: %s", err) 36 | } 37 | 38 | got, err := NewPlan(planJSON) 39 | if tc.ok && err != nil { 40 | t.Fatalf("unexpected err = %s", err) 41 | } 42 | 43 | if !tc.ok && err == nil { 44 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /migration/resolver.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | // Resolver is an interface that abstracts a rule for solving a subject. 4 | type Resolver interface { 5 | // Resolve tries to resolve some conflicts in a given subject and returns the 6 | // updated subject and state migration actions. 7 | Resolve(s *Subject) (*Subject, []StateAction, error) 8 | } 9 | -------------------------------------------------------------------------------- /migration/schema/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import "github.com/minamijoyo/tfedit/migration/schema" 4 | 5 | // RegisterSchema defines calculation functions of import ID for each resource type. 6 | func RegisterSchema(d *schema.Dictionary) { 7 | registerS3Schema(d) 8 | } 9 | -------------------------------------------------------------------------------- /migration/schema/aws/s3.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/minamijoyo/tfedit/migration/schema" 7 | ) 8 | 9 | func registerS3Schema(d *schema.Dictionary) { 10 | d.RegisterImportIDFuncMap(map[string]schema.ImportIDFunc{ 11 | "aws_s3_bucket_accelerate_configuration": schema.ImportIDFuncByAttribute("bucket"), 12 | "aws_s3_bucket_acl": importIDFuncAWSS3BucketACL, 13 | "aws_s3_bucket_cors_configuration": schema.ImportIDFuncByAttribute("bucket"), 14 | "aws_s3_bucket_lifecycle_configuration": schema.ImportIDFuncByAttribute("bucket"), 15 | "aws_s3_bucket_logging": schema.ImportIDFuncByAttribute("bucket"), 16 | "aws_s3_bucket_object_lock_configuration": schema.ImportIDFuncByAttribute("bucket"), 17 | "aws_s3_bucket_policy": schema.ImportIDFuncByAttribute("bucket"), 18 | "aws_s3_bucket_replication_configuration": schema.ImportIDFuncByAttribute("bucket"), 19 | "aws_s3_bucket_request_payment_configuration": schema.ImportIDFuncByAttribute("bucket"), 20 | "aws_s3_bucket_server_side_encryption_configuration": schema.ImportIDFuncByAttribute("bucket"), 21 | "aws_s3_bucket_versioning": schema.ImportIDFuncByAttribute("bucket"), 22 | "aws_s3_bucket_website_configuration": schema.ImportIDFuncByAttribute("bucket"), 23 | }) 24 | } 25 | 26 | // importIDFuncAWSS3BucketACL is an implementation of importIDFunc for aws_s3_bucket_acl. 27 | // https://registry.terraform.io/providers/hashicorp%20%20/aws/latest/docs/resources/s3_bucket_acl#import 28 | func importIDFuncAWSS3BucketACL(r schema.Resource) (string, error) { 29 | // The acl argument conflicts with access_control_policy 30 | switch { 31 | case r["acl"] != nil && r["access_control_policy"] == nil: // acl 32 | return schema.ImportIDFuncByMultiAttributes([]string{"bucket", "acl"}, ",")(r) 33 | case r["acl"] == nil && r["access_control_policy"] != nil: // grant 34 | return schema.ImportIDFuncByAttribute("bucket")(r) 35 | default: 36 | return "", fmt.Errorf("failed to detect an ID of aws_s3_bucket_acl resource for import: %#v", r) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /migration/schema/aws/s3_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfedit/migration/schema" 8 | ) 9 | 10 | func TestImportIDFuncAWSS3BucketACL(t *testing.T) { 11 | cases := []struct { 12 | desc string 13 | resource string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | desc: "acl", 19 | resource: ` 20 | { 21 | "acl": "private", 22 | "bucket": "tfedit-test", 23 | "expected_bucket_owner": null 24 | } 25 | `, 26 | ok: true, 27 | want: "tfedit-test,private", 28 | }, 29 | { 30 | desc: "grant", 31 | resource: ` 32 | { 33 | "access_control_policy": [ 34 | { 35 | "grant": [ 36 | { 37 | "grantee": [ 38 | { 39 | "email_address": "", 40 | "id": "", 41 | "type": "Group", 42 | "uri": "http://acs.amazonaws.com/groups/s3/LogDelivery" 43 | } 44 | ], 45 | "permission": "READ_ACP" 46 | }, 47 | { 48 | "grantee": [ 49 | { 50 | "email_address": "", 51 | "id": "", 52 | "type": "Group", 53 | "uri": "http://acs.amazonaws.com/groups/s3/LogDelivery" 54 | } 55 | ], 56 | "permission": "WRITE" 57 | }, 58 | { 59 | "grantee": [ 60 | { 61 | "email_address": "", 62 | "id": "bcaf1ffd86f41161ca5fb16fd081034f", 63 | "type": "CanonicalUser", 64 | "uri": "" 65 | } 66 | ], 67 | "permission": "FULL_CONTROL" 68 | } 69 | ], 70 | "owner": [ 71 | { 72 | "id": "set_aws_canonical_user_id" 73 | } 74 | ] 75 | } 76 | ], 77 | "acl": null, 78 | "bucket": "tfedit-test", 79 | "expected_bucket_owner": null 80 | } 81 | `, 82 | ok: true, 83 | want: "tfedit-test", 84 | }, 85 | { 86 | desc: "invalid", 87 | resource: ` 88 | { 89 | "bucket": "tfedit-test", 90 | "expected_bucket_owner": null 91 | } 92 | `, 93 | ok: false, 94 | want: "", 95 | }, 96 | { 97 | desc: "conflict", 98 | resource: ` 99 | { 100 | "acl": "private", 101 | "bucket": "tfedit-test", 102 | "expected_bucket_owner": null, 103 | "access_control_policy": [] 104 | } 105 | `, 106 | ok: false, 107 | want: "", 108 | }, 109 | } 110 | 111 | for _, tc := range cases { 112 | t.Run(tc.desc, func(t *testing.T) { 113 | var r schema.Resource 114 | if err := json.Unmarshal([]byte(tc.resource), &r); err != nil { 115 | t.Fatalf("failed to unmarshal json: %s", err) 116 | } 117 | 118 | got, err := importIDFuncAWSS3BucketACL(r) 119 | 120 | if tc.ok && err != nil { 121 | t.Fatalf("unexpected err = %s", err) 122 | } 123 | 124 | if !tc.ok && err == nil { 125 | t.Fatalf("expected to return an error, but no error, got: %s", got) 126 | } 127 | 128 | if got != tc.want { 129 | t.Errorf("got = %s, but want = %s", got, tc.want) 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /migration/schema/dictionary.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Dictionary is a map which defines ImportIDFunc for each resource type. 8 | type Dictionary struct { 9 | importIDMap map[string]ImportIDFunc 10 | } 11 | 12 | // NewDictionary returns a new instance of Dictionary. 13 | func NewDictionary() *Dictionary { 14 | return &Dictionary{ 15 | importIDMap: make(map[string]ImportIDFunc), 16 | } 17 | } 18 | 19 | // RegisterImportIDFunc registers an ImportIDFunc for a given resource type. 20 | func (d *Dictionary) RegisterImportIDFunc(resourceType string, f ImportIDFunc) { 21 | d.importIDMap[resourceType] = f 22 | } 23 | 24 | // RegisterImportIDFuncMap is a helper method to register a map of ImportIDFunc. 25 | func (d *Dictionary) RegisterImportIDFuncMap(importIDFuncMap map[string]ImportIDFunc) { 26 | for k, v := range importIDFuncMap { 27 | d.RegisterImportIDFunc(k, v) 28 | } 29 | } 30 | 31 | // ImportID calculates an import ID from a given resource. 32 | func (d *Dictionary) ImportID(resourceType string, r Resource) (string, error) { 33 | f, ok := d.importIDMap[resourceType] 34 | if !ok { 35 | return "", fmt.Errorf("unknown resource type for import: %s", resourceType) 36 | } 37 | return f(r) 38 | } 39 | -------------------------------------------------------------------------------- /migration/schema/dictionary_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestDictionaryImportID(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | importIDMap map[string]ImportIDFunc 12 | resourceType string 13 | resource string 14 | ok bool 15 | want string 16 | }{ 17 | { 18 | desc: "simple", 19 | importIDMap: map[string]ImportIDFunc{ 20 | "foo_test1": ImportIDFuncByAttribute("foo1"), 21 | "foo_test2": ImportIDFuncByAttribute("foo2"), 22 | }, 23 | resourceType: "foo_test2", 24 | resource: ` 25 | { 26 | "foo1": "FOO1", 27 | "foo2": "FOO2", 28 | "bar": 1, 29 | "baz": null 30 | } 31 | `, 32 | ok: true, 33 | want: "FOO2", 34 | }, 35 | { 36 | desc: "resource type not found", 37 | importIDMap: map[string]ImportIDFunc{ 38 | "foo_test1": ImportIDFuncByAttribute("foo1"), 39 | "foo_test2": ImportIDFuncByAttribute("foo2"), 40 | }, 41 | resourceType: "foo_test3", 42 | resource: ` 43 | { 44 | "foo1": "FOO1", 45 | "foo2": "FOO2", 46 | "bar": 1, 47 | "baz": null 48 | } 49 | `, 50 | ok: false, 51 | want: "", 52 | }, 53 | { 54 | desc: "invalid resource", 55 | importIDMap: map[string]ImportIDFunc{ 56 | "foo_test1": ImportIDFuncByAttribute("foo1"), 57 | "foo_test2": ImportIDFuncByAttribute("foo2"), 58 | "foo_test3": ImportIDFuncByAttribute("bar"), 59 | }, 60 | resourceType: "foo_test3", 61 | resource: ` 62 | { 63 | "foo1": "FOO1", 64 | "foo2": "FOO2", 65 | "bar": 1, 66 | "baz": null 67 | } 68 | `, 69 | ok: false, 70 | want: "", 71 | }, 72 | } 73 | 74 | for _, tc := range cases { 75 | t.Run(tc.desc, func(t *testing.T) { 76 | var r Resource 77 | if err := json.Unmarshal([]byte(tc.resource), &r); err != nil { 78 | t.Fatalf("failed to unmarshal json: %s", err) 79 | } 80 | 81 | d := NewDictionary() 82 | d.RegisterImportIDFuncMap(tc.importIDMap) 83 | got, err := d.ImportID(tc.resourceType, r) 84 | 85 | if tc.ok && err != nil { 86 | t.Fatalf("unexpected err = %s", err) 87 | } 88 | 89 | if !tc.ok && err == nil { 90 | t.Fatalf("expected to return an error, but no error, got: %s", got) 91 | } 92 | 93 | if got != tc.want { 94 | t.Errorf("got = %s, but want = %s", got, tc.want) 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /migration/schema/import_id_func.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Resource is a type which is equivalent to a type of 9 | // Plan.ResourceChanges[].Change.After in hashicorp/terraform-json, 10 | // but map[string]interface{} is too generic, 11 | // so we give it a friendly alias. 12 | // https://pkg.go.dev/github.com/hashicorp/terraform-json#Change 13 | type Resource map[string]interface{} 14 | 15 | // ImportIDFunc is a type of function which calculates an import ID from a given resource. 16 | type ImportIDFunc func(r Resource) (string, error) 17 | 18 | // ImportIDFuncByAttribute is a helper method to define an ImportIDFunc which 19 | // simply uses a specific single attribute as an import ID. 20 | func ImportIDFuncByAttribute(key string) ImportIDFunc { 21 | return func(r Resource) (string, error) { 22 | id, ok := r[key].(string) 23 | if !ok { 24 | return "", fmt.Errorf("failed to cast %s = %#v to string as import ID", key, r[key]) 25 | } 26 | 27 | return id, nil 28 | } 29 | } 30 | 31 | // ImportIDFuncByMultiAttributes is a helper method to define an ImportIDFunc which 32 | // joins multiple attributes by a given separater. 33 | func ImportIDFuncByMultiAttributes(keys []string, sep string) ImportIDFunc { 34 | return func(r Resource) (string, error) { 35 | elems := []string{} 36 | for _, key := range keys { 37 | e, ok := r[key].(string) 38 | if !ok { 39 | return "", fmt.Errorf("failed to cast %s = %#v to string as an element of import ID", key, r[key]) 40 | } 41 | elems = append(elems, e) 42 | } 43 | 44 | return strings.Join(elems, sep), nil 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /migration/schema/import_id_func_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestImportIDFuncByAttribute(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | resource string 12 | key string 13 | ok bool 14 | want string 15 | }{ 16 | { 17 | desc: "simple", 18 | resource: ` 19 | { 20 | "foo": "FOO", 21 | "bar": 1, 22 | "baz": null 23 | } 24 | `, 25 | key: "foo", 26 | ok: true, 27 | want: "FOO", 28 | }, 29 | { 30 | desc: "type cast error", 31 | resource: ` 32 | { 33 | "foo": "FOO", 34 | "bar": 1, 35 | "baz": null 36 | } 37 | `, 38 | key: "bar", 39 | ok: false, 40 | want: "", 41 | }, 42 | { 43 | desc: "found null", 44 | resource: ` 45 | { 46 | "foo": "FOO", 47 | "bar": 1, 48 | "baz": null 49 | } 50 | `, 51 | key: "baz", 52 | ok: false, 53 | want: "", 54 | }, 55 | { 56 | desc: "not found", 57 | resource: ` 58 | { 59 | "foo": "FOO", 60 | "bar": 1, 61 | "baz": null 62 | } 63 | `, 64 | key: "qux", 65 | ok: false, 66 | want: "", 67 | }, 68 | } 69 | 70 | for _, tc := range cases { 71 | t.Run(tc.desc, func(t *testing.T) { 72 | var r Resource 73 | if err := json.Unmarshal([]byte(tc.resource), &r); err != nil { 74 | t.Fatalf("failed to unmarshal json: %s", err) 75 | } 76 | 77 | got, err := ImportIDFuncByAttribute(tc.key)(r) 78 | 79 | if tc.ok && err != nil { 80 | t.Fatalf("unexpected err = %s", err) 81 | } 82 | 83 | if !tc.ok && err == nil { 84 | t.Fatalf("expected to return an error, but no error, got: %s", got) 85 | } 86 | 87 | if got != tc.want { 88 | t.Errorf("got = %s, but want = %s", got, tc.want) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestImportIDFuncByMultiAttributes(t *testing.T) { 95 | cases := []struct { 96 | desc string 97 | resource string 98 | keys []string 99 | sep string 100 | ok bool 101 | want string 102 | }{ 103 | { 104 | desc: "simple", 105 | resource: ` 106 | { 107 | "foo1": "FOO1", 108 | "foo2": "FOO2", 109 | "bar": 1, 110 | "baz": null 111 | } 112 | `, 113 | keys: []string{"foo1", "foo2"}, 114 | sep: ",", 115 | ok: true, 116 | want: "FOO1,FOO2", 117 | }, 118 | { 119 | desc: "type cast error", 120 | resource: ` 121 | { 122 | "foo1": "FOO1", 123 | "foo2": "FOO2", 124 | "bar": 1, 125 | "baz": null 126 | } 127 | `, 128 | keys: []string{"foo1", "bar"}, 129 | sep: ",", 130 | ok: false, 131 | want: "", 132 | }, 133 | { 134 | desc: "found null", 135 | resource: ` 136 | { 137 | "foo1": "FOO1", 138 | "foo2": "FOO2", 139 | "bar": 1, 140 | "baz": null 141 | } 142 | `, 143 | keys: []string{"foo1", "baz"}, 144 | sep: ",", 145 | ok: false, 146 | want: "", 147 | }, 148 | { 149 | desc: "not found", 150 | resource: ` 151 | { 152 | "foo1": "FOO1", 153 | "foo2": "FOO2", 154 | "bar": 1, 155 | "baz": null 156 | } 157 | `, 158 | keys: []string{"foo1", "qux"}, 159 | sep: ",", 160 | ok: false, 161 | want: "", 162 | }, 163 | } 164 | 165 | for _, tc := range cases { 166 | t.Run(tc.desc, func(t *testing.T) { 167 | var r Resource 168 | if err := json.Unmarshal([]byte(tc.resource), &r); err != nil { 169 | t.Fatalf("failed to unmarshal json: %s", err) 170 | } 171 | 172 | got, err := ImportIDFuncByMultiAttributes(tc.keys, tc.sep)(r) 173 | 174 | if tc.ok && err != nil { 175 | t.Fatalf("unexpected err = %s", err) 176 | } 177 | 178 | if !tc.ok && err == nil { 179 | t.Fatalf("expected to return an error, but no error, got: %s", got) 180 | } 181 | 182 | if got != tc.want { 183 | t.Errorf("got = %s, but want = %s", got, tc.want) 184 | } 185 | }) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /migration/state_action.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2/hclwrite" 7 | "github.com/zclconf/go-cty/cty" 8 | ) 9 | 10 | // StateAction is an interface of action for state migration. 11 | type StateAction interface { 12 | // MigrationAction returns a string of action for state migration. 13 | // It escapes special characters in HCL for use as an action in a tfmigrate's 14 | // migration file. 15 | MigrationAction() string 16 | } 17 | 18 | // actionEscape is a helper function which escapes special characters in HCL for 19 | // use as an action in a tfmigrate's migration file. 20 | func actionEscape(raw string) string { 21 | // Since the hclwrite.escapeQuotedStringLit() is unexported, 22 | // implement the HCL escaping relying on the fact that hclwrite tokens are 23 | // implicitly escaped when generated. 24 | tokens := hclwrite.TokensForValue(cty.StringVal(raw)) 25 | 26 | // The TokensForValue() wraps tokens with double quotes. 27 | // Remove `"` TokenOQuote at head and `"` TokenCQuote at tail 28 | unquoted := tokens[1 : len(tokens)-1] 29 | escaped := string(unquoted.Bytes()) 30 | 31 | // If escaping was required, enclose it in single quotes 32 | // so that a shell does not interpret double quotes. 33 | // It is needed to use the result as an action in a tfmigrate's migration file. 34 | if raw != escaped { 35 | return "'" + escaped + "'" 36 | } 37 | return raw 38 | } 39 | 40 | // StateImportAction implements the StateAction interface. 41 | type StateImportAction struct { 42 | address string 43 | id string 44 | } 45 | 46 | var _ StateAction = (*StateImportAction)(nil) 47 | 48 | // NewStateImportAction returns a new instance of StateImportAction. 49 | func NewStateImportAction(address string, id string) StateAction { 50 | return &StateImportAction{ 51 | address: address, 52 | id: id, 53 | } 54 | } 55 | 56 | // MigrationAction returns a string of action for state migration. 57 | // It escapes special characters in HCL for use as an action in a tfmigrate's 58 | // migration file. 59 | func (a *StateImportAction) MigrationAction() string { 60 | return fmt.Sprintf("import %s %s", actionEscape(a.address), actionEscape(a.id)) 61 | } 62 | -------------------------------------------------------------------------------- /migration/state_action_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestActionEscape(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | raw string 11 | want string 12 | }{ 13 | { 14 | desc: "noop", 15 | raw: "foo", 16 | want: "foo", 17 | }, 18 | { 19 | desc: "double quote", 20 | raw: `"foo"`, 21 | want: `'\"foo\"'`, 22 | }, 23 | } 24 | 25 | for _, tc := range cases { 26 | t.Run(tc.desc, func(t *testing.T) { 27 | got := actionEscape(tc.raw) 28 | 29 | if got != tc.want { 30 | t.Errorf("got = %s, but want = %s", got, tc.want) 31 | } 32 | }) 33 | } 34 | } 35 | 36 | func TestStateImportActionMigrationAction(t *testing.T) { 37 | cases := []struct { 38 | desc string 39 | address string 40 | id string 41 | want string 42 | }{ 43 | { 44 | desc: "simple", 45 | address: "foo_bar.example", 46 | id: "test", 47 | want: "import foo_bar.example test", 48 | }, 49 | { 50 | desc: "count", 51 | address: "foo_bar.example[0]", 52 | id: "test", 53 | want: "import foo_bar.example[0] test", 54 | }, 55 | { 56 | desc: "for_each", 57 | address: `foo_bar.example["foo"]`, 58 | id: "test", 59 | want: `import 'foo_bar.example[\"foo\"]' test`, 60 | }, 61 | } 62 | 63 | for _, tc := range cases { 64 | t.Run(tc.desc, func(t *testing.T) { 65 | a := NewStateImportAction(tc.address, tc.id) 66 | got := a.MigrationAction() 67 | 68 | if got != tc.want { 69 | t.Errorf("got = %s, but want = %s", got, tc.want) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /migration/state_import_resolver.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "github.com/minamijoyo/tfedit/migration/schema" 5 | ) 6 | 7 | // StateImportResolver is an implementation of Resolver for import. 8 | type StateImportResolver struct { 9 | // A dictionary for provider schema. 10 | dictionary *schema.Dictionary 11 | } 12 | 13 | var _ Resolver = (*StateImportResolver)(nil) 14 | 15 | // NewStateImportResolver returns a new instance of StateImportResolver. 16 | func NewStateImportResolver(d *schema.Dictionary) Resolver { 17 | return &StateImportResolver{ 18 | dictionary: d, 19 | } 20 | } 21 | 22 | // Resolve tries to resolve some conflicts in a given subject and returns the 23 | // updated subject and state migration actions. 24 | // It translates a planned create action into an import state migration. 25 | func (r *StateImportResolver) Resolve(s *Subject) (*Subject, []StateAction, error) { 26 | actions := []StateAction{} 27 | for _, c := range s.UnresolvedConflicts() { 28 | switch c.PlannedActionType() { 29 | case "create": 30 | resource, err := c.ResourceAfter() 31 | if err != nil { 32 | return nil, nil, err 33 | } 34 | 35 | importID, err := r.dictionary.ImportID(c.ResourceType(), resource) 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | 40 | action := NewStateImportAction(c.Address(), importID) 41 | actions = append(actions, action) 42 | c.MarkAsResolved() 43 | } 44 | } 45 | 46 | return s, actions, nil 47 | } 48 | -------------------------------------------------------------------------------- /migration/state_migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | ) 8 | 9 | // StateMigration is a type which corresponds to tfmigrate.StateMigratorConfig 10 | // and config.MigrationBlock in minamijoyo/tfmigrate. 11 | // The current implementation doesn't encode migration actions to a file 12 | // directly with gohcl, so we define only what we need here. 13 | type StateMigration struct { 14 | // A name label of migration block 15 | Name string 16 | // A working directory for executing terraform command. 17 | Dir string 18 | // A list of state action. 19 | Actions []StateAction 20 | } 21 | 22 | var migrationTemplate = `migration "state" "{{ .Name }}" { 23 | {{- if ne .Dir "" }} 24 | dir = "{{ .Dir }}" 25 | {{- end }} 26 | actions = [ 27 | {{- range .Actions }} 28 | "{{ .MigrationAction }}", 29 | {{- end }} 30 | ] 31 | } 32 | ` 33 | 34 | var compiledMigrationTemplate = template.Must(template.New("migration").Parse(migrationTemplate)) 35 | 36 | // NewStateMigration returns a new instance of StateMigration. 37 | func NewStateMigration(name string, dir string) *StateMigration { 38 | return &StateMigration{ 39 | Name: name, 40 | Dir: dir, 41 | } 42 | } 43 | 44 | // AppendActions appends a list of actions to migration. 45 | func (m *StateMigration) AppendActions(actions ...StateAction) { 46 | m.Actions = append(m.Actions, actions...) 47 | } 48 | 49 | // Render converts a state migration config to bytes. 50 | // Return an empty slice when no action without error. 51 | // Encoding StateMigratorConfig directly with gohcl has some problems. 52 | // An array contains multiple elements is output as one line. It's not readable 53 | // for multiple actions. In additon, the default value is set explicitly, it's 54 | // not only redundant but also increases cognitive load for user who isn't 55 | // familiar with tfmigrate. 56 | // So we use text/template to render a migration file. 57 | func (m *StateMigration) Render() ([]byte, error) { 58 | // Return an empty slice when no action without error. 59 | if len(m.Actions) == 0 { 60 | return []byte{}, nil 61 | } 62 | 63 | var output bytes.Buffer 64 | if err := compiledMigrationTemplate.Execute(&output, m); err != nil { 65 | return nil, fmt.Errorf("failed to render migration file: %s", err) 66 | } 67 | 68 | return output.Bytes(), nil 69 | } 70 | -------------------------------------------------------------------------------- /migration/state_migration_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestStateMigrationRender(t *testing.T) { 10 | cases := []struct { 11 | desc string 12 | name string 13 | dir string 14 | actions []StateAction 15 | m *StateMigration 16 | ok bool 17 | want string 18 | }{ 19 | { 20 | desc: "simple", 21 | name: "mytest", 22 | dir: "", 23 | actions: []StateAction{ 24 | &StateImportAction{ 25 | address: "foo_bar.example1", 26 | id: "test1", 27 | }, 28 | &StateImportAction{ 29 | address: "foo_bar.example2", 30 | id: "test2", 31 | }, 32 | }, 33 | ok: true, 34 | want: `migration "state" "mytest" { 35 | actions = [ 36 | "import foo_bar.example1 test1", 37 | "import foo_bar.example2 test2", 38 | ] 39 | } 40 | `, 41 | }, 42 | { 43 | desc: "empty", 44 | name: "mytest", 45 | dir: "", 46 | actions: []StateAction{}, 47 | ok: true, 48 | want: "", 49 | }, 50 | { 51 | desc: "simple with dir", 52 | name: "mytest", 53 | dir: "tmp/dir1", 54 | actions: []StateAction{ 55 | &StateImportAction{ 56 | address: "foo_bar.example1", 57 | id: "test1", 58 | }, 59 | &StateImportAction{ 60 | address: "foo_bar.example2", 61 | id: "test2", 62 | }, 63 | }, 64 | ok: true, 65 | want: `migration "state" "mytest" { 66 | dir = "tmp/dir1" 67 | actions = [ 68 | "import foo_bar.example1 test1", 69 | "import foo_bar.example2 test2", 70 | ] 71 | } 72 | `, 73 | }, 74 | { 75 | desc: "count", 76 | name: "mytest", 77 | dir: "", 78 | actions: []StateAction{ 79 | &StateImportAction{ 80 | address: "foo_bar.example[0]", 81 | id: "test-0", 82 | }, 83 | &StateImportAction{ 84 | address: "foo_bar.example[1]", 85 | id: "test-1", 86 | }, 87 | }, 88 | ok: true, 89 | want: `migration "state" "mytest" { 90 | actions = [ 91 | "import foo_bar.example[0] test-0", 92 | "import foo_bar.example[1] test-1", 93 | ] 94 | } 95 | `, 96 | }, 97 | { 98 | desc: "for_each", 99 | name: "mytest", 100 | dir: "", 101 | actions: []StateAction{ 102 | &StateImportAction{ 103 | address: "foo_bar.example[\"foo\"]", 104 | id: "test-foo", 105 | }, 106 | &StateImportAction{ 107 | address: "foo_bar.example[\"bar\"]", 108 | id: "test-bar", 109 | }, 110 | }, 111 | ok: true, 112 | want: `migration "state" "mytest" { 113 | actions = [ 114 | "import 'foo_bar.example[\"foo\"]' test-foo", 115 | "import 'foo_bar.example[\"bar\"]' test-bar", 116 | ] 117 | } 118 | `, 119 | }, 120 | } 121 | 122 | for _, tc := range cases { 123 | t.Run(tc.desc, func(t *testing.T) { 124 | m := NewStateMigration(tc.name, tc.dir) 125 | m.AppendActions(tc.actions...) 126 | output, err := m.Render() 127 | if tc.ok && err != nil { 128 | t.Fatalf("unexpected err = %s", err) 129 | } 130 | 131 | got := string(output) 132 | if !tc.ok && err == nil { 133 | t.Fatalf("expected to return an error, but no error, got: %s", got) 134 | } 135 | 136 | if diff := cmp.Diff(got, tc.want); diff != "" { 137 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, tc.want, diff) 138 | } 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /migration/subject.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | // Subject is a problem to be solved. It contains multiple conflicts. 4 | type Subject struct { 5 | // A list of conflicts to be solved. 6 | conflicts []*Conflict 7 | } 8 | 9 | // NewSubject finds conflicts contained in a given plan and defines a problem. 10 | func NewSubject(plan *Plan) *Subject { 11 | conflicts := []*Conflict{} 12 | for _, rc := range plan.ResourceChanges() { 13 | if !rc.Change.Actions.NoOp() { 14 | c := NewConflict(rc) 15 | conflicts = append(conflicts, c) 16 | } 17 | } 18 | 19 | return &Subject{ 20 | conflicts: conflicts, 21 | } 22 | } 23 | 24 | // UnresolvedConflicts returns a list of unresolved conflicts. 25 | func (s *Subject) UnresolvedConflicts() []*Conflict { 26 | ret := []*Conflict{} 27 | for _, c := range s.conflicts { 28 | if !c.IsResolved() { 29 | ret = append(ret, c) 30 | } 31 | } 32 | 33 | return ret 34 | } 35 | 36 | // IsResolved returns true if all conflicts have been resolved, otherwise false. 37 | func (s *Subject) IsResolved() bool { 38 | for _, c := range s.conflicts { 39 | if !c.IsResolved() { 40 | return false 41 | } 42 | } 43 | 44 | return true 45 | } 46 | -------------------------------------------------------------------------------- /migration/subject_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewSubject(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | planFile string 12 | ok bool 13 | resolved bool 14 | want int 15 | }{ 16 | { 17 | desc: "simple", 18 | planFile: "test-fixtures/import_simple.tfplan.json", 19 | ok: true, 20 | resolved: false, 21 | want: 1, 22 | }, 23 | } 24 | 25 | for _, tc := range cases { 26 | t.Run(tc.desc, func(t *testing.T) { 27 | planJSON, err := os.ReadFile(tc.planFile) 28 | if err != nil { 29 | t.Fatalf("failed to read file: %s", err) 30 | } 31 | 32 | plan, err := NewPlan(planJSON) 33 | if err != nil { 34 | t.Fatalf("failed to new plan: %s", err) 35 | } 36 | 37 | s := NewSubject(plan) 38 | if tc.ok && err != nil { 39 | t.Fatalf("unexpected err = %s", err) 40 | } 41 | 42 | if !tc.ok && err == nil { 43 | t.Fatalf("expected to return an error, but no error, got: %#v", s) 44 | } 45 | 46 | if tc.ok { 47 | if s.IsResolved() != tc.resolved { 48 | t.Errorf("unexpected the resolved status of subject. got = %t, but want = %t", s.IsResolved(), tc.resolved) 49 | } 50 | got := len(s.UnresolvedConflicts()) 51 | if got != tc.want { 52 | t.Errorf("got = %d, but want = %d", got, tc.want) 53 | } 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestSubjectUnresolvedConflicts(t *testing.T) { 60 | cases := []struct { 61 | desc string 62 | s *Subject 63 | want int 64 | }{ 65 | { 66 | desc: "unresolved", 67 | s: &Subject{ 68 | conflicts: []*Conflict{ 69 | {resolved: true}, 70 | {resolved: false}, 71 | }, 72 | }, 73 | want: 1, 74 | }, 75 | { 76 | desc: "resolved", 77 | s: &Subject{ 78 | conflicts: []*Conflict{ 79 | {resolved: true}, 80 | {resolved: true}, 81 | }, 82 | }, 83 | want: 0, 84 | }, 85 | } 86 | 87 | for _, tc := range cases { 88 | t.Run(tc.desc, func(t *testing.T) { 89 | got := len(tc.s.UnresolvedConflicts()) 90 | if got != tc.want { 91 | t.Errorf("got = %d, but want = %d", got, tc.want) 92 | } 93 | }) 94 | } 95 | } 96 | 97 | func TestSubjectIsResolved(t *testing.T) { 98 | cases := []struct { 99 | desc string 100 | s *Subject 101 | want bool 102 | }{ 103 | { 104 | desc: "unresolved", 105 | s: &Subject{ 106 | conflicts: []*Conflict{ 107 | {resolved: true}, 108 | {resolved: false}, 109 | }, 110 | }, 111 | want: false, 112 | }, 113 | { 114 | desc: "resolved", 115 | s: &Subject{ 116 | conflicts: []*Conflict{ 117 | {resolved: true}, 118 | {resolved: true}, 119 | }, 120 | }, 121 | want: true, 122 | }, 123 | } 124 | 125 | for _, tc := range cases { 126 | t.Run(tc.desc, func(t *testing.T) { 127 | got := tc.s.IsResolved() 128 | if got != tc.want { 129 | t.Errorf("got = %t, but want = %t", got, tc.want) 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /migration/test-fixtures/invalid.tfplan.json: -------------------------------------------------------------------------------- 1 | { "invalid": 2 | -------------------------------------------------------------------------------- /migration/test-fixtures/unknown_format_version.tfplan.json: -------------------------------------------------------------------------------- 1 | { "format_version": "0" } 2 | -------------------------------------------------------------------------------- /scripts/localstack/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | awslocal s3 mb s3://"$S3_BUCKET" 3 | -------------------------------------------------------------------------------- /scripts/localstack/wait_s3_bucket_exists.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | awslocal s3api wait bucket-exists --bucket "$S3_BUCKET" 3 | -------------------------------------------------------------------------------- /scripts/testacc/all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | script_full_path=$(dirname "$0") 6 | 7 | # test simple 8 | bash "$script_full_path/awsv4upgrade.sh" run simple 9 | 10 | # test all 11 | repo_root_dir="$(git rev-parse --show-toplevel)" 12 | fixturesdir="$repo_root_dir/test-fixtures/awsv4upgrade/aws_s3_bucket/" 13 | 14 | # Exclude grant because owner id cannot be set automatically. 15 | fixtures=$( 16 | find $fixturesdir -type d -mindepth 1 -maxdepth 1 -exec basename {} \; | sort \ 17 | | grep -v -e '^grant$' \ 18 | ) 19 | 20 | for fixture in ${fixtures} 21 | do 22 | echo $fixture 23 | bash "$script_full_path/awsv4upgrade.sh" run $fixture 24 | done 25 | -------------------------------------------------------------------------------- /scripts/testacc/awsv4upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | usage() 6 | { 7 | cat << EOF 8 | Usage: `basename $0` 9 | 10 | Arguments: 11 | command: A name of step tu run. Valid values are: 12 | run | setup | upgrade | filter | generate | migrate | cleanup 13 | fixture: A name of fixture in test-fixtures/awsv4upgrade/aws_s3_bucket/ 14 | EOF 15 | } 16 | 17 | setup() 18 | { 19 | terraform init -input=false -no-color -from-module="$FIXTUREDIR" 20 | terraform -v 21 | 22 | terraform apply -input=false -no-color -auto-approve 23 | terraform state list 24 | } 25 | 26 | upgrade() 27 | { 28 | tfupdate provider aws -v "~> 4.9" . 29 | terraform init -input=false -no-color -upgrade 30 | terraform -v 31 | } 32 | 33 | filter() 34 | { 35 | terraform validate -json -no-color 36 | before_count=$(terraform validate -json -no-color | jq '[.error_count, .warning_count] | add') 37 | if [[ $before_count -eq 0 ]]; then 38 | echo "expected to an error before filter" 39 | exit 1 40 | fi 41 | 42 | cat main.tf 43 | 44 | find . -type f -name '*.tf' -print0 | xargs -0 -I {} tfedit filter awsv4upgrade -u -f {} 45 | 46 | cat main.tf 47 | 48 | terraform validate -json -no-color 49 | after_count=$(terraform validate -json -no-color | jq '[.error_count, .warning_count] | add') 50 | if [[ $after_count -ne 0 ]]; then 51 | echo "expected to no error after filter" 52 | exit 1 53 | fi 54 | } 55 | 56 | generate() 57 | { 58 | terraform plan -input=false -no-color -out=tmp.tfplan 59 | terraform show -json tmp.tfplan | tfedit migration fromplan -o=tfmigrate_fromplan.hcl 60 | cat tfmigrate_fromplan.hcl 61 | diff -u tfmigrate_want.hcl tfmigrate_fromplan.hcl 62 | rm -f tmp.tfplan 63 | } 64 | 65 | migrate() 66 | { 67 | tfmigrate apply tfmigrate_fromplan.hcl 68 | terraform plan -input=false -no-color -detailed-exitcode 69 | terraform state list 70 | } 71 | 72 | cleanup() 73 | { 74 | terraform destroy -input=false -no-color -auto-approve 75 | find ./ -mindepth 1 -delete 76 | } 77 | 78 | run() 79 | { 80 | setup 81 | upgrade 82 | filter 83 | generate 84 | migrate 85 | cleanup 86 | } 87 | 88 | # main 89 | if [[ $# -ne 2 ]]; then 90 | usage 91 | exit 1 92 | fi 93 | 94 | set -x 95 | 96 | COMMAND=$1 97 | FIXTURE=$2 98 | 99 | REPO_ROOT_DIR="$(git rev-parse --show-toplevel)" 100 | WORKDIR="$REPO_ROOT_DIR/tmp/testacc/awsv4upgrade/aws_s3_bucket/$FIXTURE" 101 | FIXTUREDIR="$REPO_ROOT_DIR/test-fixtures/awsv4upgrade/aws_s3_bucket/$FIXTURE/" 102 | mkdir -p "$WORKDIR" 103 | pushd "$WORKDIR" 104 | 105 | case "$COMMAND" in 106 | run | setup | upgrade | filter | generate | migrate | cleanup ) 107 | "$COMMAND" 108 | RET=$? 109 | ;; 110 | *) 111 | usage 112 | RET=1 113 | ;; 114 | esac 115 | 116 | popd 117 | exit $RET 118 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/acceleration_status/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # https://www.terraform.io/docs/backends/types/s3.html 3 | backend "s3" { 4 | region = "ap-northeast-1" 5 | bucket = "tfstate-test" 6 | key = "test/terraform.tfstate" 7 | 8 | # mock s3/iam endpoint with localstack 9 | endpoint = "http://localstack:4566" 10 | iam_endpoint = "http://localstack:4566" 11 | access_key = "dummy" 12 | secret_key = "dummy" 13 | skip_credentials_validation = true 14 | skip_metadata_api_check = true 15 | force_path_style = true 16 | } 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "3.74.3" 22 | } 23 | } 24 | } 25 | 26 | # https://www.terraform.io/docs/providers/aws/index.html 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#localstack 28 | provider "aws" { 29 | region = "ap-northeast-1" 30 | 31 | access_key = "dummy" 32 | secret_key = "dummy" 33 | skip_credentials_validation = true 34 | skip_metadata_api_check = true 35 | skip_region_validation = true 36 | skip_requesting_account_id = true 37 | 38 | # mock endpoints with localstack 39 | endpoints { 40 | s3 = "http://localstack:4566" 41 | iam = "http://localstack:4566" 42 | } 43 | 44 | s3_force_path_style = true 45 | } 46 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/acceleration_status/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "example" { 2 | bucket = "tfedit-test" 3 | acceleration_status = "Enabled" 4 | } 5 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/acceleration_status/tfmigrate_want.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "fromplan" { 2 | actions = [ 3 | "import aws_s3_bucket_accelerate_configuration.example tfedit-test", 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/acl/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # https://www.terraform.io/docs/backends/types/s3.html 3 | backend "s3" { 4 | region = "ap-northeast-1" 5 | bucket = "tfstate-test" 6 | key = "test/terraform.tfstate" 7 | 8 | # mock s3/iam endpoint with localstack 9 | endpoint = "http://localstack:4566" 10 | iam_endpoint = "http://localstack:4566" 11 | access_key = "dummy" 12 | secret_key = "dummy" 13 | skip_credentials_validation = true 14 | skip_metadata_api_check = true 15 | force_path_style = true 16 | } 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "3.74.3" 22 | } 23 | } 24 | } 25 | 26 | # https://www.terraform.io/docs/providers/aws/index.html 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#localstack 28 | provider "aws" { 29 | region = "ap-northeast-1" 30 | 31 | access_key = "dummy" 32 | secret_key = "dummy" 33 | skip_credentials_validation = true 34 | skip_metadata_api_check = true 35 | skip_region_validation = true 36 | skip_requesting_account_id = true 37 | 38 | # mock endpoints with localstack 39 | endpoints { 40 | s3 = "http://localstack:4566" 41 | iam = "http://localstack:4566" 42 | } 43 | 44 | s3_force_path_style = true 45 | } 46 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/acl/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "example" { 2 | bucket = "tfedit-test" 3 | acl = "private" 4 | } 5 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/acl/tfmigrate_want.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "fromplan" { 2 | actions = [ 3 | "import aws_s3_bucket_acl.example tfedit-test,private", 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/cors_rule/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # https://www.terraform.io/docs/backends/types/s3.html 3 | backend "s3" { 4 | region = "ap-northeast-1" 5 | bucket = "tfstate-test" 6 | key = "test/terraform.tfstate" 7 | 8 | # mock s3/iam endpoint with localstack 9 | endpoint = "http://localstack:4566" 10 | iam_endpoint = "http://localstack:4566" 11 | access_key = "dummy" 12 | secret_key = "dummy" 13 | skip_credentials_validation = true 14 | skip_metadata_api_check = true 15 | force_path_style = true 16 | } 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "3.74.3" 22 | } 23 | } 24 | } 25 | 26 | # https://www.terraform.io/docs/providers/aws/index.html 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#localstack 28 | provider "aws" { 29 | region = "ap-northeast-1" 30 | 31 | access_key = "dummy" 32 | secret_key = "dummy" 33 | skip_credentials_validation = true 34 | skip_metadata_api_check = true 35 | skip_region_validation = true 36 | skip_requesting_account_id = true 37 | 38 | # mock endpoints with localstack 39 | endpoints { 40 | s3 = "http://localstack:4566" 41 | iam = "http://localstack:4566" 42 | } 43 | 44 | s3_force_path_style = true 45 | } 46 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/cors_rule/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "example" { 2 | bucket = "tfedit-test" 3 | 4 | cors_rule { 5 | allowed_headers = ["*"] 6 | allowed_methods = ["PUT", "POST"] 7 | allowed_origins = ["https://s3-website-test.hashicorp.com"] 8 | expose_headers = ["ETag"] 9 | max_age_seconds = 3000 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/cors_rule/tfmigrate_want.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "fromplan" { 2 | actions = [ 3 | "import aws_s3_bucket_cors_configuration.example tfedit-test", 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/count/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # https://www.terraform.io/docs/backends/types/s3.html 3 | backend "s3" { 4 | region = "ap-northeast-1" 5 | bucket = "tfstate-test" 6 | key = "test/terraform.tfstate" 7 | 8 | # mock s3/iam endpoint with localstack 9 | endpoint = "http://localstack:4566" 10 | iam_endpoint = "http://localstack:4566" 11 | access_key = "dummy" 12 | secret_key = "dummy" 13 | skip_credentials_validation = true 14 | skip_metadata_api_check = true 15 | force_path_style = true 16 | } 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "3.74.3" 22 | } 23 | } 24 | } 25 | 26 | # https://www.terraform.io/docs/providers/aws/index.html 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#localstack 28 | provider "aws" { 29 | region = "ap-northeast-1" 30 | 31 | access_key = "dummy" 32 | secret_key = "dummy" 33 | skip_credentials_validation = true 34 | skip_metadata_api_check = true 35 | skip_region_validation = true 36 | skip_requesting_account_id = true 37 | 38 | # mock endpoints with localstack 39 | endpoints { 40 | s3 = "http://localstack:4566" 41 | iam = "http://localstack:4566" 42 | } 43 | 44 | s3_force_path_style = true 45 | } 46 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/count/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "example" { 2 | count = 2 3 | bucket = "tfedit-test-${count.index}" 4 | acl = "private" 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/count/tfmigrate_want.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "fromplan" { 2 | actions = [ 3 | "import aws_s3_bucket_acl.example[0] tfedit-test-0,private", 4 | "import aws_s3_bucket_acl.example[1] tfedit-test-1,private", 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/for_each/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # https://www.terraform.io/docs/backends/types/s3.html 3 | backend "s3" { 4 | region = "ap-northeast-1" 5 | bucket = "tfstate-test" 6 | key = "test/terraform.tfstate" 7 | 8 | # mock s3/iam endpoint with localstack 9 | endpoint = "http://localstack:4566" 10 | iam_endpoint = "http://localstack:4566" 11 | access_key = "dummy" 12 | secret_key = "dummy" 13 | skip_credentials_validation = true 14 | skip_metadata_api_check = true 15 | force_path_style = true 16 | } 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "3.74.3" 22 | } 23 | } 24 | } 25 | 26 | # https://www.terraform.io/docs/providers/aws/index.html 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#localstack 28 | provider "aws" { 29 | region = "ap-northeast-1" 30 | 31 | access_key = "dummy" 32 | secret_key = "dummy" 33 | skip_credentials_validation = true 34 | skip_metadata_api_check = true 35 | skip_region_validation = true 36 | skip_requesting_account_id = true 37 | 38 | # mock endpoints with localstack 39 | endpoints { 40 | s3 = "http://localstack:4566" 41 | iam = "http://localstack:4566" 42 | } 43 | 44 | s3_force_path_style = true 45 | } 46 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/for_each/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "example" { 2 | for_each = toset(["foo", "bar"]) 3 | bucket = "tfedit-test-${each.key}" 4 | acl = "private" 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/for_each/tfmigrate_want.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "fromplan" { 2 | actions = [ 3 | "import 'aws_s3_bucket_acl.example[\"bar\"]' tfedit-test-bar,private", 4 | "import 'aws_s3_bucket_acl.example[\"foo\"]' tfedit-test-foo,private", 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/full/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # https://www.terraform.io/docs/backends/types/s3.html 3 | backend "s3" { 4 | region = "ap-northeast-1" 5 | bucket = "tfstate-test" 6 | key = "test/terraform.tfstate" 7 | 8 | # mock s3/iam endpoint with localstack 9 | endpoint = "http://localstack:4566" 10 | iam_endpoint = "http://localstack:4566" 11 | access_key = "dummy" 12 | secret_key = "dummy" 13 | skip_credentials_validation = true 14 | skip_metadata_api_check = true 15 | force_path_style = true 16 | } 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "3.74.3" 22 | } 23 | } 24 | } 25 | 26 | # https://www.terraform.io/docs/providers/aws/index.html 27 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/custom-service-endpoints#localstack 28 | provider "aws" { 29 | region = "ap-northeast-1" 30 | 31 | access_key = "dummy" 32 | secret_key = "dummy" 33 | skip_credentials_validation = true 34 | skip_metadata_api_check = true 35 | skip_region_validation = true 36 | skip_requesting_account_id = true 37 | 38 | # mock endpoints with localstack 39 | endpoints { 40 | s3 = "http://localstack:4566" 41 | iam = "http://localstack:4566" 42 | } 43 | 44 | s3_force_path_style = true 45 | } 46 | -------------------------------------------------------------------------------- /test-fixtures/awsv4upgrade/aws_s3_bucket/full/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "log" { 2 | bucket = "tfedit-log" 3 | 4 | # You must give the log-delivery group WRITE and READ_ACP permissions to the target bucket 5 | acl = "log-delivery-write" 6 | } 7 | 8 | resource "aws_s3_bucket" "destination" { 9 | bucket = "tfedit-destination" 10 | } 11 | 12 | resource "aws_s3_bucket" "example" { 13 | bucket = "tfedit-test" 14 | acceleration_status = "Enabled" 15 | acl = "private" 16 | 17 | cors_rule { 18 | allowed_headers = ["*"] 19 | allowed_methods = ["PUT", "POST"] 20 | allowed_origins = ["https://s3-website-test.hashicorp.com"] 21 | expose_headers = ["ETag"] 22 | max_age_seconds = 3000 23 | } 24 | 25 | lifecycle_rule { 26 | id = "Keep previous version 30 days, then in Glacier another 60" 27 | enabled = true 28 | 29 | noncurrent_version_transition { 30 | days = 30 31 | storage_class = "GLACIER" 32 | } 33 | 34 | noncurrent_version_expiration { 35 | days = 90 36 | } 37 | } 38 | 39 | lifecycle_rule { 40 | id = "Delete old incomplete multi-part uploads" 41 | enabled = true 42 | abort_incomplete_multipart_upload_days = 7 43 | } 44 | 45 | logging { 46 | target_bucket = aws_s3_bucket.log.id 47 | target_prefix = "log/" 48 | } 49 | 50 | object_lock_configuration { 51 | object_lock_enabled = "Enabled" 52 | 53 | rule { 54 | default_retention { 55 | mode = "COMPLIANCE" 56 | days = 3 57 | } 58 | } 59 | } 60 | 61 | policy = < [`"foo"`, `"bar"`] 10 | // Returns nil if the input cannot be parsed as a list. 11 | func SplitTokensAsList(tokens hclwrite.Tokens) []hclwrite.Tokens { 12 | // At time of this writing, there is no way for this in hclwrite, 13 | // so this is a naive implementation. 14 | tmp := []hclwrite.Tokens{} 15 | begin := 0 16 | foundOBrack := false 17 | for ; begin < len(tokens); begin++ { 18 | // Find a `[` token 19 | if tokens[begin].Type == hclsyntax.TokenOBrack { 20 | foundOBrack = true 21 | break 22 | } 23 | } 24 | 25 | if !foundOBrack { 26 | return nil // `[` not found 27 | } 28 | 29 | begin++ // Move the begin cursor after the `[` 30 | end := begin 31 | foundCBrack := false 32 | for ; end < len(tokens); end++ { 33 | // Find a `]` token 34 | if tokens[end].Type == hclsyntax.TokenCBrack { 35 | elm := tokens[begin:end] 36 | if len(elm) > 0 { 37 | tmp = append(tmp, elm) 38 | } 39 | foundCBrack = true 40 | break 41 | } 42 | 43 | // Find a `,` token 44 | if tokens[end].Type == hclsyntax.TokenComma { 45 | tmp = append(tmp, tokens[begin:end]) 46 | begin = end + 1 // Move the begin cursor after the `,` 47 | } 48 | } 49 | 50 | if !foundCBrack { 51 | return nil // `]` not found 52 | } 53 | 54 | ret := []hclwrite.Tokens{} 55 | for _, t := range tmp { 56 | r := hclwrite.Tokens{} 57 | for _, elm := range t { 58 | // Remove a `\n` (new line) token 59 | if elm.Type == hclsyntax.TokenNewline { 60 | continue 61 | } 62 | r = append(r, elm) 63 | } 64 | if len(r) > 0 { 65 | ret = append(ret, r) 66 | } 67 | } 68 | 69 | return ret 70 | } 71 | -------------------------------------------------------------------------------- /tfwrite/hclwritex_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/davecgh/go-spew/spew" 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclwrite" 11 | "github.com/zclconf/go-cty/cty" 12 | ) 13 | 14 | func TestSplitTokensAsList(t *testing.T) { 15 | cases := []struct { 16 | desc string 17 | src string 18 | want []hclwrite.Tokens 19 | ok bool 20 | }{ 21 | { 22 | desc: "simple", 23 | src: ` 24 | foo { 25 | attr = ["foo", "bar"] 26 | } 27 | `, 28 | want: []hclwrite.Tokens{ 29 | hclwrite.TokensForValue(cty.StringVal("foo")), 30 | hclwrite.TokensForValue(cty.StringVal("bar")), 31 | }, 32 | ok: true, 33 | }, 34 | { 35 | desc: "empty string (invalid list)", 36 | src: ` 37 | foo { 38 | attr = "" 39 | } 40 | `, 41 | want: nil, 42 | ok: true, 43 | }, 44 | { 45 | desc: "empty list", 46 | src: ` 47 | foo { 48 | attr = [] 49 | } 50 | `, 51 | want: []hclwrite.Tokens{}, 52 | ok: true, 53 | }, 54 | { 55 | desc: "variable", 56 | src: ` 57 | foo { 58 | attr = [var.foo, var.bar] 59 | } 60 | `, 61 | want: []hclwrite.Tokens{ 62 | hclwrite.TokensForTraversal(hcl.Traversal{ 63 | hcl.TraverseRoot{Name: "var"}, 64 | hcl.TraverseAttr{Name: "foo"}, 65 | }), 66 | hclwrite.TokensForTraversal(hcl.Traversal{ 67 | hcl.TraverseRoot{Name: "var"}, 68 | hcl.TraverseAttr{Name: "bar"}, 69 | }), 70 | }, 71 | ok: true, 72 | }, 73 | { 74 | desc: "multi lines", 75 | src: ` 76 | foo { 77 | attr = [ 78 | "foo", 79 | "bar" 80 | ] 81 | } 82 | `, 83 | want: []hclwrite.Tokens{ 84 | hclwrite.TokensForValue(cty.StringVal("foo")), 85 | hclwrite.TokensForValue(cty.StringVal("bar")), 86 | }, 87 | ok: true, 88 | }, 89 | { 90 | desc: "multi lines with comma", 91 | src: ` 92 | foo { 93 | attr = [ 94 | "foo", 95 | "bar", 96 | ] 97 | } 98 | `, 99 | want: []hclwrite.Tokens{ 100 | hclwrite.TokensForValue(cty.StringVal("foo")), 101 | hclwrite.TokensForValue(cty.StringVal("bar")), 102 | }, 103 | ok: true, 104 | }, 105 | } 106 | 107 | for _, tc := range cases { 108 | t.Run(tc.desc, func(t *testing.T) { 109 | f := parseTestFile(t, tc.src) 110 | b := findFirstTestBlock(t, f) 111 | attr := b.GetAttribute("attr") 112 | tokens := attr.ValueAsTokens() 113 | got := SplitTokensAsList(tokens) 114 | 115 | if diff := cmp.Diff(got, tc.want, cmpopts.IgnoreFields(hclwrite.Token{}, "SpacesBefore")); diff != "" { 116 | t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%v", spew.Sdump(got), spew.Sdump(tc.want), diff) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tfwrite/helper_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/hashicorp/hcl/v2/hclwrite" 8 | ) 9 | 10 | // parseTestFile is a helper for parsing a test file. 11 | func parseTestFile(t *testing.T, src string) *File { 12 | t.Helper() 13 | f, diags := hclwrite.ParseConfig([]byte(src), "", hcl.Pos{Line: 1, Column: 1}) 14 | if len(diags) != 0 { 15 | for _, diag := range diags { 16 | t.Logf("- %s", diag.Error()) 17 | } 18 | t.Fatalf("unexpected diagnostics") 19 | } 20 | 21 | return NewFile(f) 22 | } 23 | 24 | // printTestFile is a helper for print a test file. 25 | func printTestFile(t *testing.T, f *File) string { 26 | t.Helper() 27 | bytes := f.Raw().BuildTokens(nil).Bytes() 28 | return string(hclwrite.Format(bytes)) 29 | } 30 | 31 | // findFirstTestBlock is a test helper for find the first block. 32 | func findFirstTestBlock(t *testing.T, f *File) *block { 33 | t.Helper() 34 | blocks := f.Raw().Body().Blocks() 35 | return newBlock(blocks[0]) 36 | } 37 | 38 | // findTestBlocks is a test helper for find blocks. 39 | func findTestBlocks(t *testing.T, f *File) []*block { 40 | t.Helper() 41 | var blocks []*block 42 | for _, b := range f.Raw().Body().Blocks() { 43 | blocks = append(blocks, newBlock(b)) 44 | } 45 | return blocks 46 | } 47 | -------------------------------------------------------------------------------- /tfwrite/locals.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Locals represents a locals block. 8 | // It implements the Block interface. 9 | type Locals struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Locals)(nil) 14 | 15 | // NewLocals creates a new instance of Locals. 16 | func NewLocals(block *hclwrite.Block) *Locals { 17 | b := newBlock(block) 18 | return &Locals{block: b} 19 | } 20 | 21 | // NewEmptyLocals creates a new Locals with an empty body. 22 | func NewEmptyLocals() *Locals { 23 | block := hclwrite.NewBlock("locals", []string{}) 24 | return NewLocals(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/locals_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLocalsType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | locals {} 18 | `, 19 | want: "locals", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewLocals(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestLocalsSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | locals {} 48 | `, 49 | want: "", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewLocals(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tfwrite/module.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Module represents a module block. 8 | // It implements the Block interface. 9 | type Module struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Module)(nil) 14 | 15 | // NewModule creates a new instance of Module. 16 | func NewModule(block *hclwrite.Block) *Module { 17 | b := newBlock(block) 18 | return &Module{block: b} 19 | } 20 | 21 | // NewEmptyModule creates a new Module with an empty body. 22 | func NewEmptyModule(moduleType string) *Module { 23 | block := hclwrite.NewBlock("module", []string{moduleType}) 24 | return NewModule(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/module_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestModuleType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | module "foo" {} 18 | `, 19 | want: "module", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewModule(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestModuleSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | module "foo" {} 48 | `, 49 | want: "foo", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewModule(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tfwrite/moved.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Moved represents a moved block. 8 | // It implements the Block interface. 9 | type Moved struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Moved)(nil) 14 | 15 | // NewMoved creates a new instance of Moved. 16 | func NewMoved(block *hclwrite.Block) *Moved { 17 | b := newBlock(block) 18 | return &Moved{block: b} 19 | } 20 | 21 | // NewEmptyMoved creates a new Moved with an empty body. 22 | func NewEmptyMoved() *Moved { 23 | block := hclwrite.NewBlock("moved", []string{}) 24 | return NewMoved(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/moved_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMovedType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | moved {} 18 | `, 19 | want: "moved", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewMoved(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestMovedSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | moved {} 48 | `, 49 | want: "", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewMoved(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tfwrite/nested_block.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // NestedBlock represents a nested block. 8 | // It implements the Block interface. 9 | type NestedBlock struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*NestedBlock)(nil) 14 | 15 | // NewNestedBlock creates a new instance of NestedBlock. 16 | func NewNestedBlock(block *hclwrite.Block) *NestedBlock { 17 | b := newBlock(block) 18 | return &NestedBlock{block: b} 19 | } 20 | 21 | // NewEmptyNestedBlock creates a new NestedBlock with an empty body. 22 | func NewEmptyNestedBlock(blockType string) *NestedBlock { 23 | block := hclwrite.NewBlock(blockType, []string{}) 24 | return NewNestedBlock(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/nested_block_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNestedBlockType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | name string 12 | want string 13 | ok bool 14 | }{ 15 | { 16 | desc: "simple", 17 | src: ` 18 | foo { 19 | nested { 20 | bar = "baz" 21 | } 22 | } 23 | `, 24 | name: "nested", 25 | want: "nested", 26 | ok: true, 27 | }, 28 | } 29 | 30 | for _, tc := range cases { 31 | t.Run(tc.desc, func(t *testing.T) { 32 | f := parseTestFile(t, tc.src) 33 | b := findFirstTestBlock(t, f) 34 | nestedBlocks := b.FindNestedBlocksByType(tc.name) 35 | 36 | got := nestedBlocks[0].Type() 37 | if got != tc.want { 38 | t.Errorf("got = %s, but want = %s", got, tc.want) 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tfwrite/output.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Output represents a output block. 8 | // It implements the Block interface. 9 | type Output struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Output)(nil) 14 | 15 | // NewOutput creates a new instance of Output. 16 | func NewOutput(block *hclwrite.Block) *Output { 17 | b := newBlock(block) 18 | return &Output{block: b} 19 | } 20 | 21 | // NewEmptyOutput creates a new Output with an empty body. 22 | func NewEmptyOutput(outputType string) *Output { 23 | block := hclwrite.NewBlock("output", []string{outputType}) 24 | return NewOutput(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/output_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOutputType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | output "foo" {} 18 | `, 19 | want: "output", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewOutput(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestOutputSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | output "foo" {} 48 | `, 49 | want: "foo", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewOutput(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tfwrite/provider.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Provider represents a provider block. 8 | // It implements the Block interface. 9 | type Provider struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Provider)(nil) 14 | 15 | // NewProvider creates a new instance of Provider. 16 | func NewProvider(block *hclwrite.Block) *Provider { 17 | b := newBlock(block) 18 | return &Provider{block: b} 19 | } 20 | 21 | // NewEmptyProvider creates a new Provider with an empty body. 22 | func NewEmptyProvider(providerType string) *Provider { 23 | block := hclwrite.NewBlock("provider", []string{providerType}) 24 | return NewProvider(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/provider_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestProviderType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | provider "foo" {} 18 | `, 19 | want: "provider", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewProvider(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestProviderSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | provider "foo" {} 48 | `, 49 | want: "foo", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewProvider(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tfwrite/resource.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2" 5 | "github.com/hashicorp/hcl/v2/hclwrite" 6 | ) 7 | 8 | // Resource represents a resource block. 9 | // It implements the Block interface. 10 | type Resource struct { 11 | *block 12 | } 13 | 14 | var _ Block = (*Resource)(nil) 15 | 16 | // NewResource creates a new instance of Resource. 17 | func NewResource(block *hclwrite.Block) *Resource { 18 | b := newBlock(block) 19 | return &Resource{block: b} 20 | } 21 | 22 | // NewEmptyResource creates a new Resource with an empty body. 23 | func NewEmptyResource(resourceType string, resourceName string) *Resource { 24 | block := hclwrite.NewBlock("resource", []string{resourceType, resourceName}) 25 | return NewResource(block) 26 | } 27 | 28 | // Name returns a name of resource. 29 | // It returns the second label of block. 30 | func (r *Resource) Name() string { 31 | labels := r.block.raw.Labels() 32 | return labels[1] 33 | } 34 | 35 | // Count returns a meta argument of count. 36 | // It returns nil if not found. 37 | func (r *Resource) Count() *Attribute { 38 | return r.GetAttribute("count") 39 | } 40 | 41 | // ForEach returns a meta argument of for_each. 42 | // It returns nil if not found. 43 | func (r *Resource) ForEach() *Attribute { 44 | return r.GetAttribute("for_each") 45 | } 46 | 47 | // ReferableName returns a name of resource instance which can be referenced as 48 | // a part of address. 49 | // It contains an index reference if count or for_each is set. 50 | // If neither count nor for_each is set, it just returns the name. 51 | func (r *Resource) ReferableName() string { 52 | name := r.Name() 53 | 54 | if count := r.Count(); count != nil { 55 | return name + "[count.index]" 56 | } 57 | 58 | if forEach := r.ForEach(); forEach != nil { 59 | return name + "[each.key]" 60 | } 61 | 62 | return name 63 | } 64 | 65 | // SetAttributeByReference sets an attribute for a given name to a reference of 66 | // another resource. 67 | func (r *Resource) SetAttributeByReference(name string, refResource *Resource, refAttribute string) { 68 | traversal := hcl.Traversal{ 69 | hcl.TraverseRoot{Name: refResource.SchemaType()}, 70 | hcl.TraverseAttr{Name: refResource.ReferableName()}, 71 | hcl.TraverseAttr{Name: refAttribute}, 72 | } 73 | r.block.raw.Body().SetAttributeTraversal(name, traversal) 74 | } 75 | -------------------------------------------------------------------------------- /tfwrite/terraform.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Terraform represents a terraform block. 8 | // It implements the Block interface. 9 | type Terraform struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Terraform)(nil) 14 | 15 | // NewTerraform creates a new instance of Terraform. 16 | func NewTerraform(block *hclwrite.Block) *Terraform { 17 | b := newBlock(block) 18 | return &Terraform{block: b} 19 | } 20 | 21 | // NewEmptyTerraform creates a new Terraform with an empty body. 22 | func NewEmptyTerraform() *Terraform { 23 | block := hclwrite.NewBlock("terraform", []string{}) 24 | return NewTerraform(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/terraform_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTerraformType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | terraform {} 18 | `, 19 | want: "terraform", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewTerraform(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestTerraformSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | terraform {} 48 | `, 49 | want: "", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewTerraform(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tfwrite/variable.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2/hclwrite" 5 | ) 6 | 7 | // Variable represents a variable block. 8 | // It implements the Block interface. 9 | type Variable struct { 10 | *block 11 | } 12 | 13 | var _ Block = (*Variable)(nil) 14 | 15 | // NewVariable creates a new instance of Variable. 16 | func NewVariable(block *hclwrite.Block) *Variable { 17 | b := newBlock(block) 18 | return &Variable{block: b} 19 | } 20 | 21 | // NewEmptyVariable creates a new Variable with an empty body. 22 | func NewEmptyVariable(variableType string) *Variable { 23 | block := hclwrite.NewBlock("variable", []string{variableType}) 24 | return NewVariable(block) 25 | } 26 | -------------------------------------------------------------------------------- /tfwrite/variable_test.go: -------------------------------------------------------------------------------- 1 | package tfwrite 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVariableType(t *testing.T) { 8 | cases := []struct { 9 | desc string 10 | src string 11 | want string 12 | ok bool 13 | }{ 14 | { 15 | desc: "simple", 16 | src: ` 17 | variable "foo" {} 18 | `, 19 | want: "variable", 20 | ok: true, 21 | }, 22 | } 23 | 24 | for _, tc := range cases { 25 | t.Run(tc.desc, func(t *testing.T) { 26 | f := parseTestFile(t, tc.src) 27 | b := NewVariable(findFirstTestBlock(t, f).Raw()) 28 | 29 | got := b.Type() 30 | if got != tc.want { 31 | t.Errorf("got = %s, but want = %s", got, tc.want) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestVariableSchemaType(t *testing.T) { 38 | cases := []struct { 39 | desc string 40 | src string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "simple", 46 | src: ` 47 | variable "foo" {} 48 | `, 49 | want: "foo", 50 | ok: true, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.desc, func(t *testing.T) { 56 | f := parseTestFile(t, tc.src) 57 | b := NewVariable(findFirstTestBlock(t, f).Raw()) 58 | got := b.SchemaType() 59 | if got != tc.want { 60 | t.Errorf("got = %s, but want = %s", got, tc.want) 61 | } 62 | }) 63 | } 64 | } 65 | --------------------------------------------------------------------------------