├── .dockerignore ├── .github └── workflows │ ├── lint.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .go-version ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── command ├── apply.go ├── file_runner.go ├── file_runner_test.go ├── helper_test.go ├── history_runner.go ├── history_runner_test.go ├── list.go ├── list_test.go ├── meta.go ├── meta_test.go └── plan.go ├── compose.yaml ├── config ├── history.go ├── history_test.go ├── migration.go ├── migration_test.go ├── storage.go ├── storage_gcs_test.go ├── storage_local_test.go ├── storage_mock_test.go ├── storage_s3_test.go ├── storage_test.go ├── tfmigrate.go └── tfmigrate_test.go ├── entrypoint.sh ├── go.mod ├── go.sum ├── history ├── config.go ├── controller.go ├── controller_test.go ├── file.go ├── file_test.go ├── file_v1.go ├── file_v1_test.go ├── history.go └── history_test.go ├── main.go ├── scripts ├── localstack │ └── init │ │ ├── ready.d │ │ └── init.sh │ │ └── wait_s3_bucket_exists.sh └── testacc │ └── generate_plugin_cache.sh ├── storage ├── config.go ├── gcs │ ├── client.go │ ├── config.go │ ├── config_test.go │ ├── storage.go │ └── storage_test.go ├── local │ ├── config.go │ ├── config_test.go │ ├── storage.go │ └── storage_test.go ├── mock │ ├── config.go │ ├── config_test.go │ ├── storage.go │ └── storage_test.go ├── s3 │ ├── client.go │ ├── config.go │ ├── config_test.go │ ├── storage.go │ └── storage_test.go └── storage.go ├── test-fixtures ├── backend_s3 │ ├── config.tf │ └── main.tf ├── fake-gcs-server │ └── tfstate-test │ │ └── .keep ├── legacy-tfstate │ ├── main.tf │ └── terraform.tfstate ├── storage_gcs │ ├── .tfmigrate.hcl │ ├── config.tf │ ├── main.tf │ └── tfmigrate │ │ ├── mv_bar1.hcl │ │ └── mv_bar2.hcl.bk └── storage_s3 │ ├── .tfmigrate.hcl │ ├── config.tf │ ├── main.tf │ └── tfmigrate │ ├── mv_bar1.hcl │ └── mv_bar2.hcl.bk ├── tfexec ├── command.go ├── error.go ├── executor.go ├── executor_test.go ├── terraform.go ├── terraform_apply.go ├── terraform_apply_test.go ├── terraform_destroy.go ├── terraform_destroy_test.go ├── terraform_import.go ├── terraform_import_test.go ├── terraform_init.go ├── terraform_init_test.go ├── terraform_plan.go ├── terraform_plan_test.go ├── terraform_providers.go ├── terraform_providers_test.go ├── terraform_state_list.go ├── terraform_state_list_test.go ├── terraform_state_mv.go ├── terraform_state_mv_test.go ├── terraform_state_pull.go ├── terraform_state_pull_test.go ├── terraform_state_push.go ├── terraform_state_push_test.go ├── terraform_state_replace_provider.go ├── terraform_state_replace_provider_test.go ├── terraform_state_rm.go ├── terraform_state_rm_test.go ├── terraform_test.go ├── terraform_version.go ├── terraform_version_test.go ├── terraform_workspace_new.go ├── terraform_workspace_new_test.go ├── terraform_workspace_select.go ├── terraform_workspace_select_test.go ├── terraform_workspace_show.go ├── terraform_workspace_show_test.go └── test_helper.go └── tfmigrate ├── config.go ├── migrator.go ├── mock_migrator.go ├── mock_migrator_test.go ├── multi_state_action.go ├── multi_state_action_test.go ├── multi_state_migrator.go ├── multi_state_migrator_test.go ├── multi_state_mv_action.go ├── multi_state_mv_action_test.go ├── multi_state_xmv_action.go ├── multi_state_xmv_action_test.go ├── state_action.go ├── state_action_test.go ├── state_import_action.go ├── state_import_action_test.go ├── state_migrator.go ├── state_migrator_test.go ├── state_mv_action.go ├── state_mv_action_test.go ├── state_replace_provider_action.go ├── state_replace_provider_action_test.go ├── state_rm_action.go ├── state_rm_action_test.go ├── state_xmv_action.go ├── state_xmv_action_test.go ├── test_helper.go ├── test_helper_test.go ├── xmv_expander.go └── xmv_expander_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.64.8 26 | args: --timeout=5m 27 | -------------------------------------------------------------------------------- /.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-tfmigrate 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 | concurrency: 18 | group: test-${{ github.head_ref }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | test: 23 | runs-on: ${{ matrix.os }} 24 | timeout-minutes: 5 25 | strategy: 26 | matrix: 27 | os: [ubuntu-latest, macOS-latest] 28 | steps: 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 30 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 31 | with: 32 | go-version-file: '.go-version' 33 | - name: test 34 | run: make test 35 | testacc_terraform: 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 20 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | terraform: 42 | - 1.12.0 43 | - 1.11.4 44 | - 0.12.31 45 | env: 46 | TERRAFORM_VERSION: ${{ matrix.terraform }} 47 | steps: 48 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 49 | - name: docker build 50 | run: docker compose build 51 | - name: start localstack 52 | run: | 53 | docker compose up -d localstack 54 | docker compose run --rm dockerize -wait tcp://localstack:4566 -timeout 60s 55 | docker compose exec -T localstack /etc/localstack/init/wait_s3_bucket_exists.sh 56 | - name: terraform --version 57 | run: docker compose run --rm tfmigrate terraform --version 58 | - name: testacc 59 | run: docker compose run --rm tfmigrate make testacc 60 | testacc_opentofu: 61 | runs-on: ubuntu-latest 62 | timeout-minutes: 20 63 | strategy: 64 | fail-fast: false 65 | matrix: 66 | opentofu: 67 | - 1.9.1 68 | - 1.8.9 69 | - 1.6.3 70 | env: 71 | OPENTOFU_VERSION: ${{ matrix.opentofu }} 72 | TFMIGRATE_EXEC_PATH: tofu 73 | steps: 74 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 75 | - name: docker build 76 | run: docker compose build 77 | - name: start localstack 78 | run: | 79 | docker compose up -d localstack 80 | docker compose run --rm dockerize -wait tcp://localstack:4566 -timeout 60s 81 | docker compose exec -T localstack /etc/localstack/init/wait_s3_bucket_exists.sh 82 | - name: tofu --version 83 | run: docker compose run --rm tfmigrate tofu --version 84 | - name: testacc 85 | run: docker compose run --rm tfmigrate make testacc 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .terraform/ 3 | bin/ 4 | tmp/ 5 | dist/ 6 | .idea/ 7 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.24 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 | - misspell 14 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - binary: tfmigrate 4 | goos: 5 | - darwin 6 | - linux 7 | goarch: 8 | - amd64 9 | - arm64 10 | env: 11 | - CGO_ENABLED=0 12 | release: 13 | prerelease: auto 14 | changelog: 15 | filters: 16 | exclude: 17 | - Merge pull request 18 | - Merge branch 19 | - Update README 20 | - Update CHANGELOG 21 | brews: 22 | - repository: 23 | owner: minamijoyo 24 | name: homebrew-tfmigrate 25 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 26 | commit_author: 27 | name: "Masayuki Morita" 28 | email: minamijoyo@gmail.com 29 | homepage: https://github.com/minamijoyo/tfmigrate 30 | description: "A Terraform / OpenTofu state migration tool for GitOps" 31 | skip_upload: auto 32 | test: | 33 | system "#{bin}/tfmigrate -v" 34 | install: | 35 | bin.install "tfmigrate" 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG TERRAFORM_VERSION=latest 2 | FROM hashicorp/terraform:$TERRAFORM_VERSION AS terraform 3 | 4 | FROM alpine:3.21 AS opentofu 5 | ARG OPENTOFU_VERSION=latest 6 | ADD https://get.opentofu.org/install-opentofu.sh /install-opentofu.sh 7 | RUN chmod +x /install-opentofu.sh 8 | RUN apk add gpg gpg-agent 9 | RUN ./install-opentofu.sh --install-method standalone --opentofu-version $OPENTOFU_VERSION --install-path /usr/local/bin --symlink-path - 10 | 11 | FROM golang:1.24-alpine3.21 12 | RUN apk --no-cache add make git bash 13 | COPY --from=terraform /bin/terraform /usr/local/bin/ 14 | COPY --from=opentofu /usr/local/bin/tofu /usr/local/bin/ 15 | WORKDIR /work 16 | 17 | COPY go.mod go.sum ./ 18 | RUN go mod download 19 | 20 | COPY . . 21 | RUN make install 22 | 23 | ENTRYPOINT ["./entrypoint.sh"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 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 := tfmigrate 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 -buildvcs=false -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: generate-plugin-cache 26 | generate-plugin-cache: 27 | scripts/testacc/generate_plugin_cache.sh 28 | 29 | .PHONY: testacc 30 | testacc: build generate-plugin-cache 31 | TEST_ACC=1 go test -count=1 -failfast -timeout=20m ./... 32 | 33 | .PHONY: check 34 | check: lint test 35 | 36 | .PHONY: legacy-tfstate 37 | legacy-tfstate: 38 | # Generate a 0.12.31 tfstate file for use in replace-provider tests. 39 | docker run \ 40 | --interactive \ 41 | --rm \ 42 | --tty \ 43 | --volume $(shell pwd):/src \ 44 | --workdir /src/test-fixtures/legacy-tfstate \ 45 | --entrypoint /bin/sh \ 46 | hashicorp/terraform:0.12.31 \ 47 | -c \ 48 | "terraform init && \ 49 | terraform apply -auto-approve" 50 | -------------------------------------------------------------------------------- /command/apply.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | flag "github.com/spf13/pflag" 10 | ) 11 | 12 | // ApplyCommand is a command which computes a new state and pushes it to the remote state. 13 | type ApplyCommand struct { 14 | Meta 15 | backendConfig []string 16 | } 17 | 18 | // Run runs the procedure of this command. 19 | func (c *ApplyCommand) Run(args []string) int { 20 | cmdFlags := flag.NewFlagSet("apply", flag.ContinueOnError) 21 | cmdFlags.StringVar(&c.configFile, "config", defaultConfigFile, "A path to tfmigrate config file") 22 | cmdFlags.StringArrayVar(&c.backendConfig, "backend-config", nil, "A backend configuration for remote state") 23 | 24 | if err := cmdFlags.Parse(args); err != nil { 25 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 26 | return 1 27 | } 28 | 29 | var err error 30 | if c.config, err = newConfig(c.configFile); err != nil { 31 | c.UI.Error(fmt.Sprintf("failed to load config file: %s", err)) 32 | return 1 33 | } 34 | log.Printf("[DEBUG] [command] config: %#v\n", c.config) 35 | 36 | c.Option = newOption() 37 | c.Option.BackendConfig = c.backendConfig 38 | // The option may contain sensitive values such as environment variables. 39 | // So logging the option set log level to DEBUG instead of INFO. 40 | log.Printf("[DEBUG] [command] option: %#v\n", c.Option) 41 | 42 | if c.config.History == nil { 43 | // non-history mode 44 | if len(cmdFlags.Args()) != 1 { 45 | c.UI.Error(fmt.Sprintf("The command expects 1 argument, but got %d", len(cmdFlags.Args()))) 46 | c.UI.Error(c.Help()) 47 | return 1 48 | } 49 | 50 | migrationFile := cmdFlags.Arg(0) 51 | if err = c.applyWithoutHistory(migrationFile); err != nil { 52 | c.UI.Error(err.Error()) 53 | return 1 54 | } 55 | 56 | return 0 57 | } 58 | 59 | // history mode 60 | if len(cmdFlags.Args()) > 1 { 61 | c.UI.Error(fmt.Sprintf("The command expects 0 or 1 argument, but got %d", len(cmdFlags.Args()))) 62 | c.UI.Error(c.Help()) 63 | return 1 64 | } 65 | 66 | migrationFile := "" 67 | if len(cmdFlags.Args()) == 1 { 68 | // Apply a given single migration file and save it to history. 69 | migrationFile = cmdFlags.Arg(0) 70 | } 71 | 72 | // Apply all unapplied pending migrations and save them to history. 73 | if err = c.applyWithHistory(migrationFile); err != nil { 74 | c.UI.Error(err.Error()) 75 | return 1 76 | } 77 | 78 | return 0 79 | } 80 | 81 | // applyWithoutHistory is a helper function which applies a given migration file without history. 82 | func (c *ApplyCommand) applyWithoutHistory(filename string) error { 83 | fr, err := NewFileRunner(filename, c.config, c.Option) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | return fr.Apply(context.Background()) 89 | } 90 | 91 | // applyWithHistory is a helper function which applies all unapplied pending migrations and saves them to history. 92 | func (c *ApplyCommand) applyWithHistory(filename string) error { 93 | ctx := context.Background() 94 | hr, err := NewHistoryRunner(ctx, filename, c.config, c.Option) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | return hr.Apply(ctx) 100 | } 101 | 102 | // Help returns long-form help text. 103 | func (c *ApplyCommand) Help() string { 104 | helpText := ` 105 | Usage: tfmigrate apply [PATH] 106 | 107 | Apply computes a new state and pushes it to remote state. 108 | It will fail if terraform plan detects any diffs with the new state. 109 | 110 | Arguments 111 | PATH A path of migration file 112 | Required in non-history mode. Optional in history-mode. 113 | 114 | Options: 115 | --config A path to tfmigrate config file 116 | --backend-config=path A backend configuration, a path to backend configuration file or 117 | key=value format backend configuraion. 118 | This option is passed to terraform init when switching backend to remote. 119 | ` 120 | return strings.TrimSpace(helpText) 121 | } 122 | 123 | // Synopsis returns one-line help text. 124 | func (c *ApplyCommand) Synopsis() string { 125 | return "Compute a new state and push it to remote state" 126 | } 127 | -------------------------------------------------------------------------------- /command/file_runner.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/minamijoyo/tfmigrate/config" 10 | "github.com/minamijoyo/tfmigrate/tfmigrate" 11 | ) 12 | 13 | // FileRunner is a runner for a single migration file. 14 | type FileRunner struct { 15 | // A path to migration file. 16 | filename string 17 | // A global configuration. 18 | config *config.TfmigrateConfig 19 | // A definition of migration. 20 | mc *tfmigrate.MigrationConfig 21 | // A migrator instance to be run. 22 | m tfmigrate.Migrator 23 | } 24 | 25 | // NewFileRunner returns a new FileRunner instance. 26 | func NewFileRunner(filename string, config *config.TfmigrateConfig, option *tfmigrate.MigratorOption) (*FileRunner, error) { 27 | path := resolveMigrationFile(config.MigrationDir, filename) 28 | log.Printf("[INFO] [runner] load migration file: %s\n", path) 29 | mc, err := loadMigrationFile(path) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if option != nil { 35 | option.IsBackendTerraformCloud = config.IsBackendTerraformCloud 36 | } else { 37 | option = &tfmigrate.MigratorOption{ 38 | IsBackendTerraformCloud: false, 39 | } 40 | } 41 | 42 | m, err := mc.Migrator.NewMigrator(option) 43 | 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | r := &FileRunner{ 49 | filename: filename, 50 | config: config, 51 | mc: mc, 52 | m: m, 53 | } 54 | 55 | return r, nil 56 | } 57 | 58 | // loadMigrationFile is a helper function which reads and parses a migration file. 59 | func loadMigrationFile(filename string) (*tfmigrate.MigrationConfig, error) { 60 | source, err := os.ReadFile(filename) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | config, err := config.ParseMigrationFile(filename, source) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return config, nil 71 | } 72 | 73 | // Plan plans a single migration. 74 | func (r *FileRunner) Plan(ctx context.Context) error { 75 | return r.m.Plan(ctx) 76 | } 77 | 78 | // Apply applies a single migration. 79 | func (r *FileRunner) Apply(ctx context.Context) error { 80 | return r.m.Apply(ctx) 81 | } 82 | 83 | // MigrationConfig returns an instance of migration. 84 | // This is required for metadata stored in history 85 | func (r *FileRunner) MigrationConfig() *tfmigrate.MigrationConfig { 86 | return r.mc 87 | } 88 | 89 | // resolveMigrationFile returns a path of migration file in migration dir. 90 | // If a given filename is absolute path, just return it as it is. 91 | func resolveMigrationFile(migrationDir string, filename string) string { 92 | if filepath.IsAbs(filename) { 93 | return filename 94 | } 95 | return filepath.Join(migrationDir, filename) 96 | } 97 | -------------------------------------------------------------------------------- /command/file_runner_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/minamijoyo/tfmigrate/config" 9 | "github.com/minamijoyo/tfmigrate/tfmigrate" 10 | ) 11 | 12 | func TestLoadMigrationFile(t *testing.T) { 13 | cases := []struct { 14 | desc string 15 | source string 16 | want *tfmigrate.MigrationConfig 17 | ok bool 18 | }{ 19 | { 20 | desc: "mock", 21 | source: ` 22 | migration "mock" "test" { 23 | plan_error = true 24 | apply_error = false 25 | } 26 | `, 27 | want: &tfmigrate.MigrationConfig{ 28 | Type: "mock", 29 | Name: "test", 30 | Migrator: &tfmigrate.MockMigratorConfig{ 31 | PlanError: true, 32 | ApplyError: false, 33 | }, 34 | }, 35 | ok: true, 36 | }, 37 | } 38 | 39 | for _, tc := range cases { 40 | t.Run(tc.desc, func(t *testing.T) { 41 | path := setupMigrationFile(t, tc.source) 42 | 43 | got, err := loadMigrationFile(path) 44 | if tc.ok && err != nil { 45 | t.Fatalf("unexpected err: %s", err) 46 | } 47 | if !tc.ok && err == nil { 48 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 49 | } 50 | if tc.ok { 51 | if !reflect.DeepEqual(got, tc.want) { 52 | t.Errorf("got: %#v, want: %#v", got, tc.want) 53 | } 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestFileRunnerPlan(t *testing.T) { 60 | cases := []struct { 61 | desc string 62 | source string 63 | want *tfmigrate.MigrationConfig 64 | ok bool 65 | }{ 66 | { 67 | desc: "no error", 68 | source: ` 69 | migration "mock" "test" { 70 | plan_error = false 71 | apply_error = false 72 | } 73 | `, 74 | ok: true, 75 | }, 76 | { 77 | desc: "plan error", 78 | source: ` 79 | migration "mock" "test" { 80 | plan_error = true 81 | apply_error = false 82 | } 83 | `, 84 | ok: false, 85 | }, 86 | } 87 | 88 | for _, tc := range cases { 89 | t.Run(tc.desc, func(t *testing.T) { 90 | path := setupMigrationFile(t, tc.source) 91 | 92 | config := config.NewDefaultConfig() 93 | r, err := NewFileRunner(path, config, nil) 94 | if err != nil { 95 | t.Fatalf("failed to new file runner: %s", err) 96 | } 97 | 98 | err = r.Plan(context.Background()) 99 | if tc.ok && err != nil { 100 | t.Fatalf("unexpected err: %s", err) 101 | } 102 | if !tc.ok && err == nil { 103 | t.Fatal("expected to return an error, but no error") 104 | } 105 | }) 106 | } 107 | } 108 | 109 | func TestFileRunnerApply(t *testing.T) { 110 | cases := []struct { 111 | desc string 112 | source string 113 | want *tfmigrate.MigrationConfig 114 | ok bool 115 | }{ 116 | { 117 | desc: "no error", 118 | source: ` 119 | migration "mock" "test" { 120 | plan_error = false 121 | apply_error = false 122 | } 123 | `, 124 | ok: true, 125 | }, 126 | { 127 | desc: "plan error", 128 | source: ` 129 | migration "mock" "test" { 130 | plan_error = true 131 | apply_error = false 132 | } 133 | `, 134 | ok: false, 135 | }, 136 | { 137 | desc: "apply error", 138 | source: ` 139 | migration "mock" "test" { 140 | plan_error = false 141 | apply_error = true 142 | } 143 | `, 144 | ok: false, 145 | }, 146 | } 147 | 148 | for _, tc := range cases { 149 | t.Run(tc.desc, func(t *testing.T) { 150 | path := setupMigrationFile(t, tc.source) 151 | 152 | config := config.NewDefaultConfig() 153 | r, err := NewFileRunner(path, config, nil) 154 | if err != nil { 155 | t.Fatalf("failed to new file runner: %s", err) 156 | } 157 | 158 | err = r.Apply(context.Background()) 159 | if tc.ok && err != nil { 160 | t.Fatalf("unexpected err: %s", err) 161 | } 162 | if !tc.ok && err == nil { 163 | t.Fatal("expected to return an error, but no error") 164 | } 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /command/helper_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | // setupMigrationFile is a test helper for setting up a temporary migration file. 10 | // It returns a path of migration file. 11 | func setupMigrationFile(t *testing.T, source string) string { 12 | migrationDir, err := os.MkdirTemp("", "migrationDir") 13 | if err != nil { 14 | t.Fatalf("failed to craete migration dir: %s", err) 15 | } 16 | t.Cleanup(func() { os.RemoveAll(migrationDir) }) 17 | 18 | path := filepath.Join(migrationDir, "test.hcl") 19 | err = os.WriteFile(path, []byte(source), 0600) 20 | if err != nil { 21 | t.Fatalf("failed to write migration file: %s", err) 22 | } 23 | 24 | return path 25 | } 26 | 27 | // setupMigrationDir is a test helper for setting up a temporary migration dir. 28 | // A given migrations is a map of filename to source of migration file. 29 | // It returns a path of migration dir. 30 | func setupMigrationDir(t *testing.T, migrations map[string]string) string { 31 | migrationDir, err := os.MkdirTemp("", "migrationDir") 32 | if err != nil { 33 | t.Fatalf("failed to craete migration dir: %s", err) 34 | } 35 | t.Cleanup(func() { os.RemoveAll(migrationDir) }) 36 | 37 | for filename, source := range migrations { 38 | path := filepath.Join(migrationDir, filename) 39 | err = os.WriteFile(path, []byte(source), 0600) 40 | if err != nil { 41 | t.Fatalf("failed to write migration file: %s", err) 42 | } 43 | } 44 | 45 | return migrationDir 46 | } 47 | -------------------------------------------------------------------------------- /command/list.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/minamijoyo/tfmigrate/config" 10 | "github.com/minamijoyo/tfmigrate/history" 11 | flag "github.com/spf13/pflag" 12 | ) 13 | 14 | // ListCommand is a command which lists migrations. 15 | type ListCommand struct { 16 | Meta 17 | status string 18 | } 19 | 20 | // Run runs the procedure of this command. 21 | func (c *ListCommand) Run(args []string) int { 22 | cmdFlags := flag.NewFlagSet("list", flag.ContinueOnError) 23 | cmdFlags.StringVar(&c.configFile, "config", defaultConfigFile, "A path to tfmigrate config file") 24 | cmdFlags.StringVar(&c.status, "status", "all", "A filter for migration status") 25 | 26 | if err := cmdFlags.Parse(args); err != nil { 27 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 28 | return 1 29 | } 30 | 31 | var err error 32 | if c.config, err = newConfig(c.configFile); err != nil { 33 | c.UI.Error(fmt.Sprintf("failed to load config file: %s", err)) 34 | return 1 35 | } 36 | log.Printf("[DEBUG] [command] config: %#v\n", c.config) 37 | 38 | c.Option = newOption() 39 | // The option may contains sensitive values such as environment variables. 40 | // So logging the option set log level to DEBUG instead of INFO. 41 | log.Printf("[DEBUG] [command] option: %#v\n", c.Option) 42 | 43 | if c.config.History == nil { 44 | // non-history mode 45 | c.UI.Error("no history setting") 46 | return 1 47 | } 48 | 49 | // history mode 50 | ctx := context.Background() 51 | out, err := listMigrations(ctx, c.config, c.status) 52 | if err != nil { 53 | c.UI.Error(err.Error()) 54 | return 1 55 | } 56 | c.UI.Output(out) 57 | return 0 58 | } 59 | 60 | // listMigrations lists migrations. 61 | func listMigrations(ctx context.Context, config *config.TfmigrateConfig, status string) (string, error) { 62 | hc, err := history.NewController(ctx, config.MigrationDir, config.History) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | var migrations []string 68 | switch status { 69 | case "all": 70 | migrations = hc.Migrations() 71 | 72 | case "unapplied": 73 | migrations = hc.UnappliedMigrations() 74 | 75 | default: 76 | return "", fmt.Errorf("unknown filter for status: %s", status) 77 | } 78 | 79 | out := strings.Join(migrations, "\n") 80 | return out, nil 81 | } 82 | 83 | // Help returns long-form help text. 84 | func (c *ListCommand) Help() string { 85 | helpText := ` 86 | Usage: tfmigrate list 87 | 88 | List migrations. 89 | 90 | Options: 91 | --config A path to tfmigrate config file 92 | --status A filter for migration status 93 | Valid values are as follows: 94 | - all (default) 95 | - unapplied 96 | ` 97 | return strings.TrimSpace(helpText) 98 | } 99 | 100 | // Synopsis returns one-line help text. 101 | func (c *ListCommand) Synopsis() string { 102 | return "List migrations" 103 | } 104 | -------------------------------------------------------------------------------- /command/list_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/config" 8 | "github.com/minamijoyo/tfmigrate/history" 9 | "github.com/minamijoyo/tfmigrate/storage/mock" 10 | ) 11 | 12 | func TestListMigrations(t *testing.T) { 13 | migrations := map[string]string{ 14 | "20201109000001_test1.hcl": ` 15 | migration "mock" "test1" { 16 | plan_error = false 17 | apply_error = false 18 | } 19 | `, 20 | "20201109000002_test2.hcl": ` 21 | migration "mock" "test2" { 22 | plan_error = false 23 | apply_error = false 24 | } 25 | `, 26 | "20201109000003_test3.hcl": ` 27 | migration "mock" "test3" { 28 | plan_error = false 29 | apply_error = false 30 | } 31 | `, 32 | "20201109000004_test4.hcl": ` 33 | migration "mock" "test4" { 34 | plan_error = false 35 | apply_error = false 36 | } 37 | `, 38 | } 39 | historyFile := `{ 40 | "version": 1, 41 | "records": { 42 | "20201109000001_test1.hcl": { 43 | "type": "mock", 44 | "name": "test1", 45 | "applied_at": "2020-11-10T00:00:01Z" 46 | }, 47 | "20201109000002_test2.hcl": { 48 | "type": "mock", 49 | "name": "test2", 50 | "applied_at": "2020-11-10T00:00:02Z" 51 | } 52 | } 53 | }` 54 | 55 | cases := []struct { 56 | desc string 57 | status string 58 | migrations map[string]string 59 | historyFile string 60 | want string 61 | ok bool 62 | }{ 63 | { 64 | desc: "all", 65 | status: "all", 66 | migrations: migrations, 67 | historyFile: historyFile, 68 | want: `20201109000001_test1.hcl 69 | 20201109000002_test2.hcl 70 | 20201109000003_test3.hcl 71 | 20201109000004_test4.hcl`, 72 | ok: true, 73 | }, 74 | { 75 | desc: "unapplied", 76 | status: "unapplied", 77 | migrations: migrations, 78 | historyFile: historyFile, 79 | want: `20201109000003_test3.hcl 80 | 20201109000004_test4.hcl`, 81 | ok: true, 82 | }, 83 | { 84 | desc: "unknown status", 85 | status: "foo", 86 | migrations: migrations, 87 | historyFile: historyFile, 88 | want: "", 89 | ok: false, 90 | }, 91 | } 92 | 93 | for _, tc := range cases { 94 | t.Run(tc.desc, func(t *testing.T) { 95 | migrationDir := setupMigrationDir(t, tc.migrations) 96 | storage := &mock.Config{ 97 | Data: tc.historyFile, 98 | WriteError: false, 99 | ReadError: false, 100 | } 101 | config := &config.TfmigrateConfig{ 102 | MigrationDir: migrationDir, 103 | History: &history.Config{ 104 | Storage: storage, 105 | }, 106 | } 107 | got, err := listMigrations(context.Background(), config, tc.status) 108 | if tc.ok && err != nil { 109 | t.Fatalf("unexpected err: %s", err) 110 | } 111 | if !tc.ok && err == nil { 112 | t.Fatal("expected to return an error, but no error") 113 | } 114 | if got != tc.want { 115 | t.Errorf("got = %#v, want = %#v", got, tc.want) 116 | } 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /command/meta.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/minamijoyo/tfmigrate/config" 8 | "github.com/minamijoyo/tfmigrate/tfmigrate" 9 | "github.com/mitchellh/cli" 10 | ) 11 | 12 | // a default config file path 13 | const defaultConfigFile string = ".tfmigrate.hcl" 14 | 15 | // Meta are the meta-options that are available on all or most commands. 16 | type Meta struct { 17 | // UI is a user interface representing input and output. 18 | UI cli.Ui 19 | 20 | // A path to tfmigrate config file. 21 | configFile string 22 | 23 | // a global configuration for tfmigrate. 24 | config *config.TfmigrateConfig 25 | 26 | // Option customizes a behavior of Migrator. 27 | // It is used for shared settings across Migrator instances. 28 | Option *tfmigrate.MigratorOption 29 | } 30 | 31 | // newConfig loads the configuration file. 32 | func newConfig(filename string) (*config.TfmigrateConfig, error) { 33 | pathToLoad := filename // Start with the provided filename 34 | 35 | // If the provided filename is the default, check the environment variable. 36 | if filename == defaultConfigFile { 37 | envConfig := os.Getenv("TFMIGRATE_CONFIG") 38 | if envConfig != "" { 39 | // If TFMIGRATE_CONFIG is set, use its value. 40 | pathToLoad = envConfig 41 | } else { 42 | // If TFMIGRATE_CONFIG is not set, check if the default file exists. 43 | if _, err := os.Stat(defaultConfigFile); os.IsNotExist(err) { 44 | // If defaultConfigFile doesn't exist, return a default config. 45 | return config.NewDefaultConfig(), nil 46 | } 47 | // If defaultConfigFile exists, pathToLoad remains defaultConfigFile. 48 | } 49 | } 50 | 51 | log.Printf("[DEBUG] [command] load configuration file: %s\n", pathToLoad) 52 | return config.LoadConfigurationFile(pathToLoad) 53 | } 54 | 55 | func newOption() *tfmigrate.MigratorOption { 56 | return &tfmigrate.MigratorOption{ 57 | ExecPath: os.Getenv("TFMIGRATE_EXEC_PATH"), 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /command/meta_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestNewConfig(t *testing.T) { 9 | origWD, err := os.Getwd() 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | defer func() { 14 | if err := os.Chdir(origWD); err != nil { 15 | t.Fatalf("failed to restore working directory: %v", err) 16 | } 17 | }() 18 | 19 | tmp := t.TempDir() 20 | if err := os.Chdir(tmp); err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | defaultPath := ".tfmigrate.hcl" 25 | 26 | cases := []struct { 27 | desc string 28 | filename string 29 | setupEnv func() 30 | setupFile func() 31 | wantDir string 32 | wantErr bool 33 | }{ 34 | { 35 | desc: "config arg, env unset", 36 | filename: "arg.hcl", 37 | setupEnv: func() {}, 38 | setupFile: func() { 39 | content := ` 40 | tfmigrate { 41 | migration_dir = "arg" 42 | } 43 | ` 44 | if err := os.WriteFile("arg.hcl", []byte(content), 0600); err != nil { 45 | t.Fatal(err) 46 | } 47 | }, 48 | wantDir: "arg", 49 | wantErr: false, 50 | }, 51 | { 52 | desc: "no arg, env set", 53 | filename: defaultPath, 54 | setupEnv: func() { os.Setenv("TFMIGRATE_CONFIG", "env.hcl") }, 55 | setupFile: func() { 56 | content := ` 57 | tfmigrate { 58 | migration_dir = "env" 59 | } 60 | ` 61 | if err := os.WriteFile("env.hcl", []byte(content), 0600); err != nil { 62 | t.Fatal(err) 63 | } 64 | }, 65 | wantDir: "env", 66 | wantErr: false, 67 | }, 68 | { 69 | desc: "no arg, no env, file exists", 70 | filename: defaultPath, 71 | setupEnv: func() {}, 72 | setupFile: func() { 73 | content := ` 74 | tfmigrate { 75 | migration_dir = "foo" 76 | } 77 | ` 78 | if err := os.WriteFile(defaultPath, []byte(content), 0600); err != nil { 79 | t.Fatal(err) 80 | } 81 | }, 82 | wantDir: "foo", 83 | wantErr: false, 84 | }, 85 | { 86 | desc: "no arg, no env, no file", 87 | filename: defaultPath, 88 | setupEnv: func() {}, 89 | setupFile: func() {}, 90 | wantDir: ".", // Defined in NewDefaultConfig 91 | wantErr: false, 92 | }, 93 | { 94 | desc: "load error", 95 | filename: "doesnotexist.hcl", 96 | setupEnv: func() {}, 97 | setupFile: func() {}, 98 | wantErr: true, 99 | }, 100 | } 101 | 102 | for _, tc := range cases { 103 | t.Run(tc.desc, func(t *testing.T) { 104 | // cleanup 105 | os.Unsetenv("TFMIGRATE_CONFIG") 106 | os.Remove(defaultPath) 107 | 108 | tc.setupEnv() 109 | tc.setupFile() 110 | 111 | cfg, err := newConfig(tc.filename) 112 | if tc.wantErr { 113 | if err == nil { 114 | t.Fatalf("expected error, got nil") 115 | } 116 | return 117 | } 118 | if err != nil { 119 | t.Fatalf("unexpected error: %v", err) 120 | } 121 | 122 | if cfg.MigrationDir != tc.wantDir { 123 | t.Errorf("MigrationDir = %q; want %q", cfg.MigrationDir, tc.wantDir) 124 | } 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | tfmigrate: 3 | build: 4 | context: . 5 | args: 6 | TERRAFORM_VERSION: ${TERRAFORM_VERSION:-latest} 7 | OPENTOFU_VERSION: ${OPENTOFU_VERSION:-latest} 8 | volumes: 9 | - ".:/work" 10 | environment: 11 | CGO_ENABLED: 0 # disable cgo for go test 12 | LOCALSTACK_ENDPOINT: "http://localstack:4566" 13 | STORAGE_EMULATOR_HOST: "fake-gcs-server:4443" 14 | # Use the same filesystem to avoid a checksum mismatch error 15 | # or a file busy error caused by asynchronous IO. 16 | TF_PLUGIN_CACHE_DIR: "/tmp/plugin-cache" 17 | TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE: "true" 18 | # From observation, although we don’t have complete confidence in the root cause, 19 | # it appears that localstack sometimes misses API requests when run in parallel. 20 | TF_CLI_ARGS_apply: "--parallelism=1" 21 | TERRAFORM_VERSION: ${TERRAFORM_VERSION:-latest} 22 | OPENTOFU_VERSION: ${OPENTOFU_VERSION:-latest} 23 | TFMIGRATE_EXEC_PATH: 24 | depends_on: 25 | - localstack 26 | - fake-gcs-server 27 | 28 | localstack: 29 | image: localstack/localstack:2.0.2 30 | ports: 31 | - "4566:4566" 32 | environment: 33 | DEBUG: "true" 34 | DEFAULT_REGION: "ap-northeast-1" 35 | S3_BUCKET: "tfstate-test" 36 | volumes: 37 | - "./scripts/localstack/init:/etc/localstack/init" # initialize scripts on startup 38 | 39 | fake-gcs-server: 40 | image: fsouza/fake-gcs-server:1.38 41 | ports: 42 | - "4443:4443" 43 | volumes: 44 | - "./test-fixtures/fake-gcs-server:/data" 45 | command: ["-scheme", "http", "-public-host", "fake-gcs-server:4443"] 46 | 47 | dockerize: 48 | image: powerman/dockerize:0.16.3 49 | depends_on: 50 | - localstack 51 | - fake-gcs-server 52 | -------------------------------------------------------------------------------- /config/history.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2" 5 | "github.com/minamijoyo/tfmigrate/history" 6 | ) 7 | 8 | // HistoryBlock represents a block for migration history management in HCL. 9 | type HistoryBlock struct { 10 | // Storage is a block for migration history data store. 11 | Storage StorageBlock `hcl:"storage,block"` 12 | } 13 | 14 | // parseHistoryBlock parses a history block and returns a *history.Config. 15 | func parseHistoryBlock(b HistoryBlock, ctx *hcl.EvalContext) (*history.Config, error) { 16 | storage, err := parseStorageBlock(b.Storage, ctx) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | history := &history.Config{ 22 | Storage: storage, 23 | } 24 | 25 | return history, nil 26 | } 27 | -------------------------------------------------------------------------------- /config/history_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/history" 8 | "github.com/minamijoyo/tfmigrate/storage/local" 9 | ) 10 | 11 | func TestParseHistoryBlock(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | source string 15 | want *history.Config 16 | ok bool 17 | }{ 18 | { 19 | desc: "valid", 20 | source: ` 21 | tfmigrate { 22 | migration_dir = "tfmigrate" 23 | history { 24 | storage "local" { 25 | path = "tmp/history.json" 26 | } 27 | } 28 | } 29 | `, 30 | want: &history.Config{ 31 | Storage: &local.Config{ 32 | Path: "tmp/history.json", 33 | }, 34 | }, 35 | ok: true, 36 | }, 37 | { 38 | desc: "missing block (storage)", 39 | source: ` 40 | tfmigrate { 41 | migration_dir = "tfmigrate" 42 | history { 43 | } 44 | } 45 | `, 46 | want: nil, 47 | ok: false, 48 | }, 49 | } 50 | 51 | for _, tc := range cases { 52 | t.Run(tc.desc, func(t *testing.T) { 53 | config, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 54 | if tc.ok && err != nil { 55 | t.Fatalf("unexpected err: %s", err) 56 | } 57 | if !tc.ok && err == nil { 58 | t.Fatalf("expected to return an error, but no error, got: %#v", config) 59 | } 60 | if tc.ok { 61 | got := config.History 62 | if !reflect.DeepEqual(got, tc.want) { 63 | t.Errorf("got: %#v, want: %#v", got, tc.want) 64 | } 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /config/migration.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/hashicorp/hcl/v2/gohcl" 8 | "github.com/hashicorp/hcl/v2/hclsimple" 9 | "github.com/zclconf/go-cty/cty" 10 | 11 | "github.com/minamijoyo/tfmigrate/tfmigrate" 12 | ) 13 | 14 | // MigrationFile represents a config for migration written in HCL. 15 | type MigrationFile struct { 16 | // Migration is a migration block. 17 | // It must contain only one block, and multiple blocks are not allowed, 18 | // because it's hard to re-run the file if partially failed. 19 | Migration MigrationBlock `hcl:"migration,block"` 20 | } 21 | 22 | // MigrationBlock represents a migration block in HCL. 23 | type MigrationBlock struct { 24 | // Type is a type for migration. 25 | // Valid values are `state` and `multi_state`. 26 | Type string `hcl:"type,label"` 27 | // Name is an arbitrary name for migration. 28 | Name string `hcl:"name,label"` 29 | // Remain is a body of migration block. 30 | // We first decode only a block header and then decode schema depending on 31 | // its type label. 32 | Remain hcl.Body `hcl:",remain"` 33 | } 34 | 35 | // ParseMigrationFile parses a given source of migration file and returns a *tfmigrate.MigrationConfig. 36 | // Note that this method does not read a file and you should pass source of config in bytes. 37 | // The filename is used for error message and selecting HCL syntax (.hcl and .json). 38 | func ParseMigrationFile(filename string, source []byte) (*tfmigrate.MigrationConfig, error) { 39 | // Decode migration block header. 40 | var f MigrationFile 41 | 42 | ctx := &hcl.EvalContext{ 43 | Variables: map[string]cty.Value{ 44 | "env": envVarMap(), 45 | }, 46 | } 47 | 48 | err := hclsimple.Decode(filename, source, ctx, &f) 49 | if err != nil { 50 | return nil, fmt.Errorf("failed to decode migration file: %s, err: %s", filename, err) 51 | } 52 | 53 | migrator, err := parseMigrationBlock(f.Migration, ctx) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | config := &tfmigrate.MigrationConfig{ 59 | Type: f.Migration.Type, 60 | Name: f.Migration.Name, 61 | Migrator: migrator, 62 | } 63 | 64 | return config, nil 65 | } 66 | 67 | // parseMigrationBlock parses a migration block and returns a tfmigrate.MigratorConfig. 68 | func parseMigrationBlock(b MigrationBlock, ctx *hcl.EvalContext) (tfmigrate.MigratorConfig, error) { 69 | switch b.Type { 70 | case "mock": // only for testing 71 | return parseMockMigrationBlock(b, ctx) 72 | 73 | case "state": 74 | return parseStateMigrationBlock(b, ctx) 75 | 76 | case "multi_state": 77 | return parseMultiStateMigrationBlock(b, ctx) 78 | 79 | default: 80 | return nil, fmt.Errorf("unknown migration type: %s", b.Type) 81 | } 82 | } 83 | 84 | // parseMockMigrationBlock parses a migration block for mock and returns a tfmigrate.MigratorConfig. 85 | func parseMockMigrationBlock(b MigrationBlock, ctx *hcl.EvalContext) (tfmigrate.MigratorConfig, error) { 86 | var config tfmigrate.MockMigratorConfig 87 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 88 | if diags.HasErrors() { 89 | return nil, diags 90 | } 91 | 92 | return &config, nil 93 | } 94 | 95 | // parseStateMigrationBlock parses a migration block for state and returns a tfmigrate.MigratorConfig. 96 | func parseStateMigrationBlock(b MigrationBlock, ctx *hcl.EvalContext) (tfmigrate.MigratorConfig, error) { 97 | var config tfmigrate.StateMigratorConfig 98 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 99 | if diags.HasErrors() { 100 | return nil, diags 101 | } 102 | 103 | return &config, nil 104 | } 105 | 106 | // parseMultiStateMigrationBlock parses a migration block for multi_state and 107 | // returns a tfmigrate.MigratorConfig. 108 | func parseMultiStateMigrationBlock(b MigrationBlock, ctx *hcl.EvalContext) (tfmigrate.MigratorConfig, error) { 109 | var config tfmigrate.MultiStateMigratorConfig 110 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 111 | if diags.HasErrors() { 112 | return nil, diags 113 | } 114 | 115 | return &config, nil 116 | } 117 | -------------------------------------------------------------------------------- /config/storage.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/hashicorp/hcl/v2/gohcl" 8 | "github.com/minamijoyo/tfmigrate/storage" 9 | "github.com/minamijoyo/tfmigrate/storage/gcs" 10 | "github.com/minamijoyo/tfmigrate/storage/local" 11 | "github.com/minamijoyo/tfmigrate/storage/mock" 12 | "github.com/minamijoyo/tfmigrate/storage/s3" 13 | ) 14 | 15 | // StorageBlock represents a block for migration history data store in HCL. 16 | type StorageBlock struct { 17 | // Type is a type for storage. 18 | // Valid values are as follows: 19 | // - mock 20 | // - local 21 | // - s3 22 | Type string `hcl:"type,label"` 23 | // Remain is a body of storage block. 24 | // We first decode only a block header and then decode schema depending on 25 | // its type label. 26 | Remain hcl.Body `hcl:",remain"` 27 | } 28 | 29 | // parseStorageBlock parses a storage block and returns a storage.Config. 30 | func parseStorageBlock(b StorageBlock, ctx *hcl.EvalContext) (storage.Config, error) { 31 | switch b.Type { 32 | case "mock": // only for testing 33 | return parseMockStorageBlock(b, ctx) 34 | 35 | case "local": 36 | return parseLocalStorageBlock(b, ctx) 37 | 38 | case "s3": 39 | return parseS3StorageBlock(b, ctx) 40 | 41 | case "gcs": 42 | return parseGCSStorageBlock(b, ctx) 43 | 44 | default: 45 | return nil, fmt.Errorf("unknown history storage type: %s", b.Type) 46 | } 47 | } 48 | 49 | // parseMockStorageBlock parses a storage block for mock and returns a storage.Config. 50 | func parseMockStorageBlock(b StorageBlock, ctx *hcl.EvalContext) (storage.Config, error) { 51 | var config mock.Config 52 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 53 | if diags.HasErrors() { 54 | return nil, diags 55 | } 56 | 57 | return &config, nil 58 | } 59 | 60 | // parseLocalStorageBlock parses a storage block for local and returns a storage.Config. 61 | func parseLocalStorageBlock(b StorageBlock, ctx *hcl.EvalContext) (storage.Config, error) { 62 | var config local.Config 63 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 64 | if diags.HasErrors() { 65 | return nil, diags 66 | } 67 | 68 | return &config, nil 69 | } 70 | 71 | // parseS3StorageBlock parses a storage block for s3 and returns a storage.Config. 72 | func parseS3StorageBlock(b StorageBlock, ctx *hcl.EvalContext) (storage.Config, error) { 73 | var config s3.Config 74 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 75 | if diags.HasErrors() { 76 | return nil, diags 77 | } 78 | 79 | return &config, nil 80 | } 81 | 82 | func parseGCSStorageBlock(b StorageBlock, ctx *hcl.EvalContext) (storage.Config, error) { 83 | var config gcs.Config 84 | diags := gohcl.DecodeBody(b.Remain, ctx, &config) 85 | if diags.HasErrors() { 86 | return nil, diags 87 | } 88 | 89 | return &config, nil 90 | } 91 | -------------------------------------------------------------------------------- /config/storage_gcs_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | "github.com/minamijoyo/tfmigrate/storage/gcs" 9 | ) 10 | 11 | func TestParseGCSStorageBlock(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | env map[string]string 15 | source string 16 | want storage.Config 17 | ok bool 18 | }{ 19 | { 20 | desc: "valid (required)", 21 | source: ` 22 | tfmigrate { 23 | history { 24 | storage "gcs" { 25 | bucket = "tfmigrate-test" 26 | name = "tfmigrate/history.json" 27 | } 28 | } 29 | } 30 | `, 31 | want: &gcs.Config{ 32 | Bucket: "tfmigrate-test", 33 | Name: "tfmigrate/history.json", 34 | }, 35 | ok: true, 36 | }, 37 | { 38 | desc: "env vars", 39 | env: map[string]string{ 40 | "VAR_NAME": "env1", 41 | }, 42 | source: ` 43 | tfmigrate { 44 | history { 45 | storage "gcs" { 46 | bucket = "tfmigrate-test" 47 | name = "tfmigrate/${env.VAR_NAME}/history.json" 48 | } 49 | } 50 | } 51 | `, 52 | want: &gcs.Config{ 53 | Bucket: "tfmigrate-test", 54 | Name: "tfmigrate/env1/history.json", 55 | }, 56 | ok: true, 57 | }, 58 | { 59 | desc: "missing required attribute (bucket)", 60 | source: ` 61 | tfmigrate { 62 | history { 63 | storage "gcs" { 64 | name = "tfmigrate/history.json" 65 | } 66 | } 67 | } 68 | `, 69 | want: nil, 70 | ok: false, 71 | }, 72 | { 73 | desc: "missing required attribute (name)", 74 | source: ` 75 | tfmigrate { 76 | history { 77 | storage "gcs" { 78 | bucket = "tfmigrate-test" 79 | } 80 | } 81 | } 82 | `, 83 | want: nil, 84 | ok: false, 85 | }, 86 | } 87 | 88 | for _, tc := range cases { 89 | t.Run(tc.desc, func(t *testing.T) { 90 | for k, v := range tc.env { 91 | t.Setenv(k, v) 92 | } 93 | config, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 94 | if tc.ok && err != nil { 95 | t.Fatalf("unexpected err: %s", err) 96 | } 97 | if !tc.ok && err == nil { 98 | t.Fatalf("expected to return an error, but no error, got: %#v", config) 99 | } 100 | if tc.ok { 101 | got := config.History.Storage 102 | if !reflect.DeepEqual(got, tc.want) { 103 | t.Errorf("got: %#v, want: %#v", got, tc.want) 104 | } 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /config/storage_local_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | "github.com/minamijoyo/tfmigrate/storage/local" 9 | ) 10 | 11 | func TestParseLocalStorageBlock(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | env map[string]string 15 | source string 16 | want storage.Config 17 | ok bool 18 | }{ 19 | { 20 | desc: "valid", 21 | source: ` 22 | tfmigrate { 23 | history { 24 | storage "local" { 25 | path = "tmp/history.json" 26 | } 27 | } 28 | } 29 | `, 30 | want: &local.Config{ 31 | Path: "tmp/history.json", 32 | }, 33 | ok: true, 34 | }, 35 | { 36 | desc: "env vars", 37 | env: map[string]string{ 38 | "VAR_NAME": "env1", 39 | }, 40 | source: ` 41 | tfmigrate { 42 | history { 43 | storage "local" { 44 | path = "tmp/${env.VAR_NAME}/history.json" 45 | } 46 | } 47 | } 48 | `, 49 | want: &local.Config{ 50 | Path: "tmp/env1/history.json", 51 | }, 52 | ok: true, 53 | }, 54 | { 55 | desc: "missing required attribute (path)", 56 | source: ` 57 | tfmigrate { 58 | history { 59 | storage "local" { 60 | } 61 | } 62 | } 63 | `, 64 | want: nil, 65 | ok: false, 66 | }, 67 | } 68 | 69 | for _, tc := range cases { 70 | t.Run(tc.desc, func(t *testing.T) { 71 | for k, v := range tc.env { 72 | t.Setenv(k, v) 73 | } 74 | config, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 75 | if tc.ok && err != nil { 76 | t.Fatalf("unexpected err: %s", err) 77 | } 78 | if !tc.ok && err == nil { 79 | t.Fatalf("expected to return an error, but no error, got: %#v", config) 80 | } 81 | if tc.ok { 82 | got := config.History.Storage 83 | if !reflect.DeepEqual(got, tc.want) { 84 | t.Errorf("got: %#v, want: %#v", got, tc.want) 85 | } 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /config/storage_mock_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | "github.com/minamijoyo/tfmigrate/storage/mock" 9 | ) 10 | 11 | func TestParseMockStorageBlock(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | source string 15 | want storage.Config 16 | ok bool 17 | }{ 18 | { 19 | desc: "valid", 20 | source: ` 21 | tfmigrate { 22 | history { 23 | storage "mock" { 24 | data = "foo" 25 | write_error = true 26 | read_error = false 27 | } 28 | } 29 | } 30 | `, 31 | want: &mock.Config{ 32 | Data: "foo", 33 | WriteError: true, 34 | ReadError: false, 35 | }, 36 | ok: true, 37 | }, 38 | } 39 | 40 | for _, tc := range cases { 41 | t.Run(tc.desc, func(t *testing.T) { 42 | config, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 43 | if tc.ok && err != nil { 44 | t.Fatalf("unexpected err: %s", err) 45 | } 46 | if !tc.ok && err == nil { 47 | t.Fatalf("expected to return an error, but no error, got: %#v", config) 48 | } 49 | if tc.ok { 50 | got := config.History.Storage 51 | if !reflect.DeepEqual(got, tc.want) { 52 | t.Errorf("got: %#v, want: %#v", got, tc.want) 53 | } 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /config/storage_s3_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | "github.com/minamijoyo/tfmigrate/storage/s3" 9 | ) 10 | 11 | func TestParseS3StorageBlock(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | env map[string]string 15 | source string 16 | want storage.Config 17 | ok bool 18 | }{ 19 | { 20 | desc: "valid (required)", 21 | source: ` 22 | tfmigrate { 23 | history { 24 | storage "s3" { 25 | bucket = "tfmigrate-test" 26 | key = "tfmigrate/history.json" 27 | } 28 | } 29 | } 30 | `, 31 | want: &s3.Config{ 32 | Bucket: "tfmigrate-test", 33 | Key: "tfmigrate/history.json", 34 | }, 35 | ok: true, 36 | }, 37 | { 38 | desc: "valid (with optional)", 39 | source: ` 40 | tfmigrate { 41 | history { 42 | storage "s3" { 43 | bucket = "tfmigrate-test" 44 | key = "tfmigrate/history.json" 45 | 46 | region = "ap-northeast-1" 47 | endpoint = "http://localstack:4566" 48 | access_key = "dummy" 49 | secret_key = "dummy" 50 | profile = "dev" 51 | skip_credentials_validation = true 52 | skip_metadata_api_check = true 53 | force_path_style = true 54 | } 55 | } 56 | } 57 | `, 58 | want: &s3.Config{ 59 | Bucket: "tfmigrate-test", 60 | Key: "tfmigrate/history.json", 61 | Region: "ap-northeast-1", 62 | Endpoint: "http://localstack:4566", 63 | AccessKey: "dummy", 64 | SecretKey: "dummy", 65 | Profile: "dev", 66 | SkipCredentialsValidation: true, 67 | SkipMetadataAPICheck: true, 68 | ForcePathStyle: true, 69 | }, 70 | ok: true, 71 | }, 72 | { 73 | desc: "env vars", 74 | env: map[string]string{ 75 | "VAR_NAME": "env1", 76 | }, 77 | source: ` 78 | tfmigrate { 79 | history { 80 | storage "s3" { 81 | bucket = "tfmigrate-test" 82 | key = "tfmigrate/${env.VAR_NAME}/history.json" 83 | } 84 | } 85 | } 86 | `, 87 | want: &s3.Config{ 88 | Bucket: "tfmigrate-test", 89 | Key: "tfmigrate/env1/history.json", 90 | }, 91 | ok: true, 92 | }, 93 | { 94 | desc: "missing required attribute (bucket)", 95 | source: ` 96 | tfmigrate { 97 | history { 98 | storage "s3" { 99 | key = "tfmigrate/history.json" 100 | } 101 | } 102 | } 103 | `, 104 | want: nil, 105 | ok: false, 106 | }, 107 | { 108 | desc: "missing required attribute (key)", 109 | source: ` 110 | tfmigrate { 111 | history { 112 | storage "s3" { 113 | bucket = "tfmigrate-test" 114 | } 115 | } 116 | } 117 | `, 118 | want: nil, 119 | ok: false, 120 | }, 121 | } 122 | 123 | for _, tc := range cases { 124 | t.Run(tc.desc, func(t *testing.T) { 125 | for k, v := range tc.env { 126 | t.Setenv(k, v) 127 | } 128 | config, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 129 | if tc.ok && err != nil { 130 | t.Fatalf("unexpected err: %s", err) 131 | } 132 | if !tc.ok && err == nil { 133 | t.Fatalf("expected to return an error, but no error, got: %#v", config) 134 | } 135 | if tc.ok { 136 | got := config.History.Storage 137 | if !reflect.DeepEqual(got, tc.want) { 138 | t.Errorf("got: %#v, want: %#v", got, tc.want) 139 | } 140 | } 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /config/storage_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | "github.com/minamijoyo/tfmigrate/storage/local" 9 | ) 10 | 11 | func TestParseStorageBlock(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | source string 15 | want storage.Config 16 | ok bool 17 | }{ 18 | { 19 | desc: "valid", 20 | source: ` 21 | tfmigrate { 22 | history { 23 | storage "local" { 24 | path = "tmp/history.json" 25 | } 26 | } 27 | } 28 | `, 29 | want: &local.Config{ 30 | Path: "tmp/history.json", 31 | }, 32 | ok: true, 33 | }, 34 | { 35 | desc: "unknown type", 36 | source: ` 37 | tfmigrate { 38 | history { 39 | storage "foo" { 40 | } 41 | } 42 | } 43 | `, 44 | want: nil, 45 | ok: false, 46 | }, 47 | { 48 | desc: "missing type", 49 | source: ` 50 | tfmigrate { 51 | history { 52 | storage { 53 | } 54 | } 55 | } 56 | `, 57 | want: nil, 58 | ok: false, 59 | }, 60 | } 61 | 62 | for _, tc := range cases { 63 | t.Run(tc.desc, func(t *testing.T) { 64 | config, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 65 | if tc.ok && err != nil { 66 | t.Fatalf("unexpected err: %s", err) 67 | } 68 | if !tc.ok && err == nil { 69 | t.Fatalf("expected to return an error, but no error, got: %#v", config) 70 | } 71 | if tc.ok { 72 | got := config.History.Storage 73 | if !reflect.DeepEqual(got, tc.want) { 74 | t.Errorf("got: %#v, want: %#v", got, tc.want) 75 | } 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /config/tfmigrate.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclsimple" 10 | "github.com/zclconf/go-cty/cty" 11 | 12 | "github.com/minamijoyo/tfmigrate/history" 13 | ) 14 | 15 | // ConfigurationFile represents a file for CLI settings in HCL. 16 | type ConfigurationFile struct { 17 | // Tfmigrate is a top-level block. 18 | // It must contain only one block, and multiple blocks are not allowed. 19 | Tfmigrate TfmigrateBlock `hcl:"tfmigrate,block"` 20 | } 21 | 22 | // TfmigrateBlock represents a block for CLI settings in HCL. 23 | type TfmigrateBlock struct { 24 | // MigrationDir is a path to directory where migration files are stored. 25 | // Default to `.` (current directory). 26 | MigrationDir string `hcl:"migration_dir,optional"` 27 | // IsBackendTerraformCloud is a boolean indicating whether a backend is 28 | // stored remotely in Terraform Cloud. Defaults to false. 29 | IsBackendTerraformCloud bool `hcl:"is_backend_terraform_cloud,optional"` 30 | // History is a block for migration history management. 31 | History *HistoryBlock `hcl:"history,block"` 32 | } 33 | 34 | // TfmigrateConfig is a config for top-level CLI settings. 35 | // TfmigrateBlock is just used for parsing HCL and 36 | // TfmigrateConfig is used for building application logic. 37 | type TfmigrateConfig struct { 38 | // MigrationDir is a path to directory where migration files are stored. 39 | // Default to `.` (current directory). 40 | MigrationDir string 41 | // IsBackendTerraformCloud is a boolean representing whether the remote 42 | // backend is TerraformCloud. Defaults to a value of false. 43 | IsBackendTerraformCloud bool 44 | // History is a config for migration history management. 45 | History *history.Config 46 | } 47 | 48 | // LoadConfigurationFile is a helper function which reads and parses a given configuration file. 49 | func LoadConfigurationFile(filename string) (*TfmigrateConfig, error) { 50 | source, err := os.ReadFile(filename) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return ParseConfigurationFile(filename, source) 56 | } 57 | 58 | // envVarMap returns a map of environment variables. 59 | func envVarMap() cty.Value { 60 | envMap := make(map[string]cty.Value) 61 | for _, env := range os.Environ() { 62 | pair := strings.SplitN(env, "=", 2) 63 | envMap[pair[0]] = cty.StringVal(pair[1]) 64 | } 65 | return cty.MapVal(envMap) 66 | } 67 | 68 | // ParseConfigurationFile parses a given source of configuration file and 69 | // returns a TfmigrateConfig. 70 | // Note that this method does not read a file and you should pass source of config in bytes. 71 | // The filename is used for error message and selecting HCL syntax (.hcl and .json). 72 | func ParseConfigurationFile(filename string, source []byte) (*TfmigrateConfig, error) { 73 | // Decode tfmigrate block. 74 | var f ConfigurationFile 75 | ctx := &hcl.EvalContext{ 76 | Variables: map[string]cty.Value{ 77 | "env": envVarMap(), 78 | }, 79 | } 80 | err := hclsimple.Decode(filename, source, ctx, &f) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to decode setting file: %s, err: %s", filename, err) 83 | } 84 | 85 | config := NewDefaultConfig() 86 | if len(f.Tfmigrate.MigrationDir) > 0 { 87 | config.MigrationDir = f.Tfmigrate.MigrationDir 88 | } 89 | if f.Tfmigrate.IsBackendTerraformCloud { 90 | config.IsBackendTerraformCloud = f.Tfmigrate.IsBackendTerraformCloud 91 | } 92 | 93 | if f.Tfmigrate.History != nil { 94 | history, err := parseHistoryBlock(*f.Tfmigrate.History, ctx) 95 | if err != nil { 96 | return nil, err 97 | } 98 | config.History = history 99 | } 100 | 101 | return config, nil 102 | } 103 | 104 | // NewDefaultConfig returns a new instance of TfmigrateConfig. 105 | func NewDefaultConfig() *TfmigrateConfig { 106 | return &TfmigrateConfig{ 107 | MigrationDir: ".", 108 | IsBackendTerraformCloud: false, 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /config/tfmigrate_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/history" 8 | "github.com/minamijoyo/tfmigrate/storage/local" 9 | ) 10 | 11 | func TestParseConfigurationFile(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | env map[string]string 15 | source string 16 | want *TfmigrateConfig 17 | ok bool 18 | }{ 19 | { 20 | desc: "valid", 21 | env: nil, 22 | source: ` 23 | tfmigrate { 24 | migration_dir = "tfmigrate" 25 | history { 26 | storage "local" { 27 | path = "tmp/history.json" 28 | } 29 | } 30 | } 31 | `, 32 | want: &TfmigrateConfig{ 33 | MigrationDir: "tfmigrate", 34 | History: &history.Config{ 35 | Storage: &local.Config{ 36 | Path: "tmp/history.json", 37 | }, 38 | }, 39 | }, 40 | ok: true, 41 | }, 42 | { 43 | desc: "default migration_dir", 44 | env: nil, 45 | source: ` 46 | tfmigrate { 47 | history { 48 | storage "local" { 49 | path = "tmp/history.json" 50 | } 51 | } 52 | } 53 | `, 54 | want: &TfmigrateConfig{ 55 | MigrationDir: ".", 56 | History: &history.Config{ 57 | Storage: &local.Config{ 58 | Path: "tmp/history.json", 59 | }, 60 | }, 61 | }, 62 | ok: true, 63 | }, 64 | { 65 | desc: "env vars", 66 | env: map[string]string{ 67 | "VAR_NAME": "env1", 68 | }, 69 | source: ` 70 | tfmigrate { 71 | migration_dir = "tfmigrate/${env.VAR_NAME}" 72 | history { 73 | storage "local" { 74 | path = "tmp/${env.VAR_NAME}/history.json" 75 | } 76 | } 77 | } 78 | `, 79 | want: &TfmigrateConfig{ 80 | MigrationDir: "tfmigrate/env1", 81 | History: &history.Config{ 82 | Storage: &local.Config{ 83 | Path: "tmp/env1/history.json", 84 | }, 85 | }, 86 | }, 87 | ok: true, 88 | }, 89 | { 90 | desc: "missing block (history)", 91 | env: nil, 92 | source: ` 93 | tfmigrate { 94 | } 95 | `, 96 | want: &TfmigrateConfig{ 97 | MigrationDir: ".", 98 | History: nil, 99 | }, 100 | ok: true, 101 | }, 102 | { 103 | desc: "unknown block", 104 | env: nil, 105 | source: ` 106 | foo { 107 | } 108 | `, 109 | want: nil, 110 | ok: false, 111 | }, 112 | { 113 | desc: "empty file", 114 | env: nil, 115 | source: ``, 116 | want: nil, 117 | ok: false, 118 | }, 119 | } 120 | 121 | for _, tc := range cases { 122 | t.Run(tc.desc, func(t *testing.T) { 123 | for k, v := range tc.env { 124 | t.Setenv(k, v) 125 | } 126 | got, err := ParseConfigurationFile("test.hcl", []byte(tc.source)) 127 | if tc.ok && err != nil { 128 | t.Fatalf("unexpected err: %s", err) 129 | } 130 | if !tc.ok && err == nil { 131 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 132 | } 133 | if tc.ok { 134 | if !reflect.DeepEqual(got, tc.want) { 135 | t.Errorf("got: %#v, want: %#v", got, tc.want) 136 | } 137 | } 138 | }) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /history/config.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "github.com/minamijoyo/tfmigrate/storage" 5 | ) 6 | 7 | // Config is a set of configurations for migration history management. 8 | type Config struct { 9 | // MigrationDir is a path to directory where migration files are stored. 10 | MigrationDir string 11 | // Storage is an interface of factory method for Storage 12 | Storage storage.Config 13 | } 14 | -------------------------------------------------------------------------------- /history/file.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // FileHeader contains a meta data for file format. 9 | type FileHeader struct { 10 | // Version is a file format version. 11 | Version int `json:"version"` 12 | } 13 | 14 | // ParseHistoryFile parses bytes and returns a History instance. 15 | func ParseHistoryFile(b []byte) (*History, error) { 16 | version, err := detectHistoryFileVersion(b) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | switch version { 22 | case 1: 23 | return parseHistoryFileV1(b) 24 | 25 | default: 26 | return nil, fmt.Errorf("unknown history file version: %d", version) 27 | } 28 | } 29 | 30 | // detectHistoryFileVersion detects a file format version. 31 | func detectHistoryFileVersion(b []byte) (int, error) { 32 | // peek a file header 33 | var header FileHeader 34 | err := json.Unmarshal(b, &header) 35 | if err != nil { 36 | return 0, err 37 | } 38 | 39 | return header.Version, nil 40 | } 41 | -------------------------------------------------------------------------------- /history/file_test.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestParseHistoryFile(t *testing.T) { 11 | cases := []struct { 12 | desc string 13 | b []byte 14 | want *History 15 | ok bool 16 | }{ 17 | { 18 | desc: "v1", 19 | b: []byte(`{ 20 | "version": 1, 21 | "records": { 22 | "20201012010101_foo.hcl": { 23 | "type": "state", 24 | "name": "foo", 25 | "applied_at": "2020-10-13T01:02:03Z" 26 | }, 27 | "20201012020202_foo.hcl": { 28 | "type": "state", 29 | "name": "bar", 30 | "applied_at": "2020-10-13T04:05:06Z" 31 | } 32 | } 33 | }`), 34 | want: &History{ 35 | records: map[string]Record{ 36 | "20201012010101_foo.hcl": Record{ 37 | Type: "state", 38 | Name: "foo", 39 | AppliedAt: time.Date(2020, 10, 13, 1, 2, 3, 0, time.UTC), 40 | }, 41 | "20201012020202_foo.hcl": Record{ 42 | Type: "state", 43 | Name: "bar", 44 | AppliedAt: time.Date(2020, 10, 13, 4, 5, 6, 0, time.UTC), 45 | }, 46 | }, 47 | }, 48 | ok: true, 49 | }, 50 | { 51 | desc: "unknown version", 52 | b: []byte(`{ 53 | "version": 99, 54 | "foo": "bar" 55 | }`), 56 | want: nil, 57 | ok: false, 58 | }, 59 | } 60 | 61 | for _, tc := range cases { 62 | t.Run(tc.desc, func(t *testing.T) { 63 | got, err := ParseHistoryFile(tc.b) 64 | if tc.ok && err != nil { 65 | t.Fatalf("unexpected err: %s", err) 66 | } 67 | if !tc.ok && err == nil { 68 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 69 | } 70 | if tc.ok { 71 | if diff := cmp.Diff(*got, *tc.want, cmp.AllowUnexported(*got)); diff != "" { 72 | t.Errorf("got = %#v, want = %#v, diff = %s", got, tc.want, diff) 73 | } 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /history/file_v1.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | // FileV1 represents a data structure for history file format v1. 9 | // This is redundant and almost the same as History, but defines a separate 10 | // data structure for persistence in case of future format changes. 11 | type FileV1 struct { 12 | // Version is a file format version. It is always set to 1. 13 | Version int `json:"version"` 14 | // Records is a set of applied migration log. 15 | // Only success migrations are recorded. 16 | // A key is migration file name. 17 | // We record only the file name not to invalidate history when the migration 18 | // directory is moved. 19 | Records map[string]RecordV1 `json:"records"` 20 | } 21 | 22 | // RecordV1 represents an applied migration log. 23 | type RecordV1 struct { 24 | // Type is a migration type. 25 | Type string `json:"type"` 26 | // Name is a migration name. 27 | Name string `json:"name"` 28 | // AppliedAt is a timestamp when the migration was applied. 29 | // Note that we only record it when the migration was succeed. 30 | AppliedAt time.Time `json:"applied_at"` 31 | } 32 | 33 | // newFileV1 converts a History to a FileV1 instance. 34 | func newFileV1(h History) *FileV1 { 35 | m := make(map[string]RecordV1) 36 | for k, v := range h.records { 37 | r := newRecordV1(v) 38 | m[k] = r 39 | } 40 | 41 | return &FileV1{ 42 | Version: 1, 43 | Records: m, 44 | } 45 | } 46 | 47 | // newRecordV1 converts a Record to a RecordV1 instance. 48 | func newRecordV1(r Record) RecordV1 { 49 | return RecordV1(r) 50 | } 51 | 52 | // Serialize encodes a FileV1 instance to bytes. 53 | func (f *FileV1) Serialize() ([]byte, error) { 54 | return json.MarshalIndent(f, "", " ") 55 | } 56 | 57 | // parseHistoryFileV1 parses bytes and reteurns a History instance. 58 | func parseHistoryFileV1(b []byte) (*History, error) { 59 | var f FileV1 60 | 61 | err := json.Unmarshal(b, &f) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | h := f.toHistory() 67 | 68 | return &h, nil 69 | } 70 | 71 | // toHistory converts a FileV1 to a History instance. 72 | func (f *FileV1) toHistory() History { 73 | m := make(map[string]Record) 74 | for k, v := range f.Records { 75 | r := v.toRecord() 76 | m[k] = r 77 | } 78 | return History{ 79 | records: m, 80 | } 81 | } 82 | 83 | // toRecord converts a RecordV1 to a Record instance. 84 | func (r RecordV1) toRecord() Record { 85 | return Record(r) 86 | } 87 | -------------------------------------------------------------------------------- /history/history.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import "time" 4 | 5 | // History records applied migration logs. 6 | type History struct { 7 | // records is a set of applied migration log. 8 | // Only success migrations are recorded. 9 | // A key is migration file name. 10 | // We record only the file name not to invalidate history when the migration 11 | // directory is moved. 12 | records map[string]Record 13 | } 14 | 15 | // Record represents an applied migration log. 16 | type Record struct { 17 | // Type is a migration type. 18 | Type string 19 | // Name is a migration name. 20 | Name string 21 | // AppliedAt is a timestamp when the migration was applied. 22 | // Note that we only record it when the migration was succeed. 23 | AppliedAt time.Time 24 | } 25 | 26 | // newEmptyHistory initializes a new History. 27 | func newEmptyHistory() *History { 28 | records := make(map[string]Record) 29 | return &History{ 30 | records: records, 31 | } 32 | } 33 | 34 | // Add adds a new record to history. 35 | // If a given filename already exists, it updates the existing record. 36 | func (h *History) Add(filename string, r Record) { 37 | h.records[filename] = r 38 | } 39 | 40 | // Contains returns true if a given migration has been applied. 41 | func (h *History) Contains(filename string) bool { 42 | _, ok := h.records[filename] 43 | return ok 44 | } 45 | 46 | // Delete deletes a record from history. 47 | // If a given filename doesn't exist, no-op. 48 | func (h *History) Delete(filename string) { 49 | delete(h.records, filename) 50 | } 51 | 52 | // Clear deletes all records from history. 53 | func (h *History) Clear() { 54 | h.records = make(map[string]Record) 55 | } 56 | 57 | // Length returns a number of records in history. 58 | func (h *History) Length() int { 59 | return len(h.records) 60 | } 61 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/hashicorp/logutils" 11 | "github.com/minamijoyo/tfmigrate/command" 12 | "github.com/mitchellh/cli" 13 | ) 14 | 15 | // Version is a version number. 16 | var version = "0.4.2" 17 | 18 | func main() { 19 | log.SetOutput(logOutput()) 20 | log.Printf("[DEBUG] [main] start: %s", strings.Join(os.Args, " ")) 21 | log.Printf("[DEBUG] [main] tfmigrate version: %s", version) 22 | 23 | ui := &cli.BasicUi{ 24 | Writer: os.Stdout, 25 | } 26 | commands := initCommands(ui) 27 | 28 | args := os.Args[1:] 29 | 30 | c := &cli.CLI{ 31 | Name: "tfmigrate", 32 | Version: version, 33 | Args: args, 34 | Commands: commands, 35 | HelpWriter: os.Stdout, 36 | } 37 | 38 | exitStatus, err := c.Run() 39 | if err != nil { 40 | ui.Error(fmt.Sprintf("Failed to execute CLI: %s", err)) 41 | } 42 | 43 | os.Exit(exitStatus) 44 | } 45 | 46 | func logOutput() io.Writer { 47 | levels := []logutils.LogLevel{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} 48 | minLevel := os.Getenv("TFMIGRATE_LOG") 49 | if len(minLevel) == 0 { 50 | minLevel = "INFO" // default log level 51 | } 52 | 53 | // default log writer is null device. 54 | writer := io.Discard 55 | if minLevel != "" { 56 | writer = os.Stderr 57 | } 58 | 59 | filter := &logutils.LevelFilter{ 60 | Levels: levels, 61 | MinLevel: logutils.LogLevel(minLevel), 62 | Writer: writer, 63 | } 64 | 65 | return filter 66 | } 67 | 68 | func initCommands(ui cli.Ui) map[string]cli.CommandFactory { 69 | meta := command.Meta{ 70 | UI: ui, 71 | } 72 | 73 | commands := map[string]cli.CommandFactory{ 74 | "plan": func() (cli.Command, error) { 75 | return &command.PlanCommand{ 76 | Meta: meta, 77 | }, nil 78 | }, 79 | "apply": func() (cli.Command, error) { 80 | return &command.ApplyCommand{ 81 | Meta: meta, 82 | }, nil 83 | }, 84 | "list": func() (cli.Command, error) { 85 | return &command.ListCommand{ 86 | Meta: meta, 87 | }, nil 88 | }, 89 | } 90 | 91 | return commands 92 | } 93 | -------------------------------------------------------------------------------- /scripts/localstack/init/ready.d/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | awslocal s3 mb s3://"$S3_BUCKET" 3 | -------------------------------------------------------------------------------- /scripts/localstack/init/wait_s3_bucket_exists.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | awslocal s3api wait bucket-exists --bucket "$S3_BUCKET" 3 | -------------------------------------------------------------------------------- /scripts/testacc/generate_plugin_cache.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | # TFMIGRATE_EXEC_PATH can be set to tofu or terraform. 6 | TFMIGRATE_EXEC_PATH=${TFMIGRATE_EXEC_PATH:-terraform} 7 | 8 | # When using TF_PLUGIN_CACHE_DIR, terraform init is not concurrency safe. 9 | # https://github.com/hashicorp/terraform/issues/25849 10 | # Download the providers and generate a cache once before testing. 11 | mkdir -p "$TF_PLUGIN_CACHE_DIR" 12 | 13 | WORK_DIR="./tmp/generate_plugin_cache" 14 | mkdir -p "$WORK_DIR" 15 | pushd "$WORK_DIR" 16 | 17 | # Note that it must be written in Terraform v0.12 compatible. 18 | # In other words, you cannot specify a namespace. 19 | cat << EOF > main.tf 20 | terraform { 21 | required_providers { 22 | null = "3.2.1" 23 | time = "0.9.1" 24 | } 25 | } 26 | EOF 27 | 28 | # Starting with Terraform v1.4, the global plugin cache is ignored on the first 29 | # terraform init. This makes caching in CI meaningless. To utilize the cache, 30 | # we use a local filesystem mirror. Strictly speaking, the mirror is only 31 | # available in Terraform v0.13+, but it is hard to compare versions in bash, 32 | # so we use the mirror unless v0.x. 33 | # https://developer.hashicorp.com/terraform/cli/config/config-file#implied-local-mirror-directories 34 | if "$TFMIGRATE_EXEC_PATH" -v | grep 'Terraform v0\.'; then 35 | echo "skip creating an implied local mirror" 36 | else 37 | FS_MIRROR="/tmp/plugin-mirror" 38 | "$TFMIGRATE_EXEC_PATH" providers mirror "${FS_MIRROR}" 39 | 40 | cat << EOF > "$HOME/.terraformrc" 41 | provider_installation { 42 | filesystem_mirror { 43 | path = "/tmp/plugin-mirror" 44 | } 45 | } 46 | EOF 47 | fi 48 | 49 | "$TFMIGRATE_EXEC_PATH" init -input=false -no-color 50 | 51 | popd 52 | rm -rf "$WORK_DIR" 53 | -------------------------------------------------------------------------------- /storage/config.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | // Config is an interface of factory method for Storage 4 | type Config interface { 5 | // NewStorage returns a new instance of Storage. 6 | NewStorage() (Storage, error) 7 | } 8 | -------------------------------------------------------------------------------- /storage/gcs/client.go: -------------------------------------------------------------------------------- 1 | package gcs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | gcStorage "cloud.google.com/go/storage" 9 | ) 10 | 11 | // A minimal interface to mock behavior of GCS client. 12 | type Client interface { 13 | // Read an object from a GCS bucket. 14 | Read(ctx context.Context) ([]byte, error) 15 | 16 | // Write an object onto a GCS bucket. 17 | Write(ctx context.Context, p []byte) error 18 | } 19 | 20 | // An implementation of Client that delegates actual operation to gcsStorage.Client. 21 | type Adapter struct { 22 | // A config to specify which bucket and object we handle. 23 | config Config 24 | // A GCS client which is delegated actual operation. 25 | client *gcStorage.Client 26 | } 27 | 28 | func (a Adapter) Read(ctx context.Context) ([]byte, error) { 29 | r, err := a.client.Bucket(a.config.Bucket).Object(a.config.Name).NewReader(ctx) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer r.Close() 34 | 35 | body, err := io.ReadAll(r) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed reading from gcs://%s/%s: %w", a.config.Bucket, a.config.Name, err) 38 | } 39 | return body, nil 40 | } 41 | 42 | func (a Adapter) Write(ctx context.Context, p []byte) error { 43 | w := a.client.Bucket(a.config.Bucket).Object(a.config.Name).NewWriter(ctx) 44 | _, err := w.Write(p) 45 | 46 | if err != nil { 47 | return fmt.Errorf("failed writing to gcs://%s/%s: %w", a.config.Bucket, a.config.Name, err) 48 | } 49 | return w.Close() 50 | } 51 | 52 | // NewClient returns a new Client with given Context and Config. 53 | func NewClient(ctx context.Context, config Config) (Client, error) { 54 | c, err := gcStorage.NewClient(ctx) 55 | a := &Adapter{ 56 | config: config, 57 | client: c, 58 | } 59 | return a, err 60 | } 61 | -------------------------------------------------------------------------------- /storage/gcs/config.go: -------------------------------------------------------------------------------- 1 | package gcs 2 | 3 | import "github.com/minamijoyo/tfmigrate/storage" 4 | 5 | // Config is a config for Google Cloud Storage. 6 | // This is expected to have almost the same options as Terraform gcs backend. 7 | // https://www.terraform.io/language/settings/backends/gcs 8 | // However, it has many minor options and it's a pain to test all options from 9 | // first, so we added only options we need for now. 10 | type Config struct { 11 | // The name of the GCS bucket. 12 | Bucket string `hcl:"bucket"` 13 | // Path to the migration history file. 14 | Name string `hcl:"name"` 15 | } 16 | 17 | // Config implements a storage.Config. 18 | var _ storage.Config = (*Config)(nil) 19 | 20 | // NewStorage returns a new instance of storage.Storage. 21 | func (c *Config) NewStorage() (storage.Storage, error) { 22 | return NewStorage(c, nil) 23 | } 24 | -------------------------------------------------------------------------------- /storage/gcs/config_test.go: -------------------------------------------------------------------------------- 1 | package gcs 2 | 3 | import "testing" 4 | 5 | func TestConfigNewStorage(t *testing.T) { 6 | cases := []struct { 7 | desc string 8 | config *Config 9 | ok bool 10 | }{ 11 | { 12 | desc: "valid", 13 | config: &Config{ 14 | Bucket: "tfmigrate-test", 15 | Name: "tfmigrate/history.json", 16 | }, 17 | ok: true, 18 | }, 19 | } 20 | 21 | for _, tc := range cases { 22 | t.Run(tc.desc, func(t *testing.T) { 23 | got, err := tc.config.NewStorage() 24 | if tc.ok && err != nil { 25 | t.Fatalf("unexpected err: %s", err) 26 | } 27 | if !tc.ok && err == nil { 28 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 29 | } 30 | if tc.ok { 31 | _ = got.(*Storage) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /storage/gcs/storage.go: -------------------------------------------------------------------------------- 1 | package gcs 2 | 3 | import ( 4 | "context" 5 | 6 | gcStorage "cloud.google.com/go/storage" 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | ) 9 | 10 | // An implementation of [storage.Storage] interface. 11 | type Storage struct { 12 | // config is a storage config for GCS. 13 | config *Config 14 | // client is an instance of Client interface to call API. 15 | // It is intended to be replaced with a mock for testing. 16 | // https://pkg.go.dev/cloud.google.com/go/storage#Client 17 | client Client 18 | } 19 | 20 | var _ storage.Storage = (*Storage)(nil) 21 | 22 | // NewStorage returns a new instance of Storage. 23 | func NewStorage(config *Config, client Client) (*Storage, error) { 24 | s := &Storage{ 25 | config: config, 26 | client: client, 27 | } 28 | return s, nil 29 | } 30 | 31 | func (s *Storage) Write(ctx context.Context, b []byte) error { 32 | err := s.init(ctx) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return s.client.Write(ctx, b) 38 | } 39 | 40 | func (s *Storage) Read(ctx context.Context) ([]byte, error) { 41 | err := s.init(ctx) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | r, err := s.client.Read(ctx) 47 | if err == gcStorage.ErrObjectNotExist { 48 | return []byte{}, nil 49 | } else if err != nil { 50 | return nil, err 51 | } 52 | return r, nil 53 | } 54 | 55 | func (s *Storage) init(ctx context.Context) error { 56 | if s.client == nil { 57 | client, err := gcStorage.NewClient(ctx) 58 | if err != nil { 59 | return err 60 | } 61 | s.client = Adapter{ 62 | config: *s.config, 63 | client: client, 64 | } 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /storage/gcs/storage_test.go: -------------------------------------------------------------------------------- 1 | package gcs 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | gcStorage "cloud.google.com/go/storage" 8 | ) 9 | 10 | // mockClient is a mock implementation for testing. 11 | type mockClient struct { 12 | dataToRead []byte 13 | err error 14 | } 15 | 16 | func (c *mockClient) Read(_ context.Context) ([]byte, error) { 17 | return c.dataToRead, c.err 18 | } 19 | 20 | func (c *mockClient) Write(_ context.Context, _ []byte) error { 21 | return c.err 22 | } 23 | 24 | func TestStorageWrite(t *testing.T) { 25 | cases := []struct { 26 | desc string 27 | config *Config 28 | client Client 29 | contents []byte 30 | ok bool 31 | }{ 32 | { 33 | desc: "simple", 34 | config: &Config{ 35 | Bucket: "tfmigrate-test", 36 | Name: "tfmigrate/history.json", 37 | }, 38 | client: &mockClient{ 39 | err: nil, 40 | }, 41 | contents: []byte("foo"), 42 | ok: true, 43 | }, 44 | { 45 | desc: "bucket does not exist", 46 | config: &Config{ 47 | Bucket: "not-exist-bucket", 48 | Name: "tfmigrate/history.json", 49 | }, 50 | client: &mockClient{ 51 | err: gcStorage.ErrBucketNotExist, 52 | }, 53 | contents: []byte("foo"), 54 | ok: false, 55 | }, 56 | } 57 | 58 | for _, tc := range cases { 59 | t.Run(tc.desc, func(t *testing.T) { 60 | s, err := NewStorage(tc.config, tc.client) 61 | if err != nil { 62 | t.Fatalf("failed to NewStorage: %s", err) 63 | } 64 | err = s.Write(context.Background(), tc.contents) 65 | if tc.ok && err != nil { 66 | t.Fatalf("unexpected err: %s", err) 67 | } 68 | if !tc.ok && err == nil { 69 | t.Fatal("expected to return an error, but no error") 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestStorageRead(t *testing.T) { 76 | cases := []struct { 77 | desc string 78 | config *Config 79 | client Client 80 | contents []byte 81 | ok bool 82 | }{ 83 | { 84 | desc: "simple", 85 | config: &Config{ 86 | Bucket: "tfmigrate-test", 87 | Name: "tfmigrate/history.json", 88 | }, 89 | client: &mockClient{ 90 | dataToRead: []byte("foo"), 91 | err: nil, 92 | }, 93 | contents: []byte("foo"), 94 | ok: true, 95 | }, 96 | { 97 | desc: "bucket does not exist", 98 | config: &Config{ 99 | Bucket: "not-exist-bucket", 100 | Name: "tfmigrate/history.json", 101 | }, 102 | client: &mockClient{ 103 | dataToRead: nil, 104 | err: gcStorage.ErrBucketNotExist, 105 | }, 106 | contents: nil, 107 | ok: false, 108 | }, 109 | { 110 | desc: "object does not exist", 111 | config: &Config{ 112 | Bucket: "tfmigrate-test", 113 | Name: "not_exist.json", 114 | }, 115 | client: &mockClient{ 116 | err: gcStorage.ErrObjectNotExist, 117 | }, 118 | contents: []byte{}, 119 | ok: true, 120 | }, 121 | } 122 | 123 | for _, tc := range cases { 124 | t.Run(tc.desc, func(t *testing.T) { 125 | s, err := NewStorage(tc.config, tc.client) 126 | if err != nil { 127 | t.Fatalf("failed to NewStorage: %s", err) 128 | } 129 | got, err := s.Read(context.Background()) 130 | if tc.ok && err != nil { 131 | t.Fatalf("unexpected err: %s", err) 132 | } 133 | if !tc.ok && err == nil { 134 | t.Fatal("expected to return an error, but no error") 135 | } 136 | 137 | if tc.ok { 138 | if string(got) != string(tc.contents) { 139 | t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) 140 | } 141 | } 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /storage/local/config.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import "github.com/minamijoyo/tfmigrate/storage" 4 | 5 | // Config is a config for local storage. 6 | type Config struct { 7 | // Path to a migration history file. Relative to the current working directory. 8 | Path string `hcl:"path"` 9 | } 10 | 11 | // Config implements a storage.Config. 12 | var _ storage.Config = (*Config)(nil) 13 | 14 | // NewStorage returns a new instance of storage.Storage. 15 | func (c *Config) NewStorage() (storage.Storage, error) { 16 | return NewStorage(c) 17 | } 18 | -------------------------------------------------------------------------------- /storage/local/config_test.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import "testing" 4 | 5 | func TestConfigNewStorage(t *testing.T) { 6 | cases := []struct { 7 | desc string 8 | config *Config 9 | ok bool 10 | }{ 11 | { 12 | desc: "valid", 13 | config: &Config{ 14 | Path: "tmp/history.json", 15 | }, 16 | ok: true, 17 | }, 18 | } 19 | 20 | for _, tc := range cases { 21 | t.Run(tc.desc, func(t *testing.T) { 22 | got, err := tc.config.NewStorage() 23 | if tc.ok && err != nil { 24 | t.Fatalf("unexpected err: %s", err) 25 | } 26 | if !tc.ok && err == nil { 27 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 28 | } 29 | if tc.ok { 30 | _ = got.(*Storage) 31 | } 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /storage/local/storage.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | ) 9 | 10 | // Storage is a storage.Storage implementation for local file. 11 | // This was originally intended for debugging purposes, but it can also be used 12 | // as a workaround if Storage doesn't support your cloud provider. 13 | // That is, you can manually synchronize local output files to the remote. 14 | type Storage struct { 15 | // config is a storage config for local. 16 | config *Config 17 | } 18 | 19 | var _ storage.Storage = (*Storage)(nil) 20 | 21 | // NewStorage returns a new instance of Storage. 22 | func NewStorage(config *Config) (*Storage, error) { 23 | s := &Storage{ 24 | config: config, 25 | } 26 | return s, nil 27 | } 28 | 29 | // Write writes migration history data to storage. 30 | func (s *Storage) Write(_ context.Context, b []byte) error { 31 | // nolint gosec 32 | // G306: Expect WriteFile permissions to be 0600 or less 33 | // We ignore it because a history file doesn't contains sensitive data. 34 | // Note that changing a permission to 0600 is breaking change. 35 | return os.WriteFile(s.config.Path, b, 0644) 36 | } 37 | 38 | // Read reads migration history data from storage. 39 | // If the key does not exist, it is assumed to be uninitialized and returns 40 | // an empty array instead of an error. 41 | func (s *Storage) Read(_ context.Context) ([]byte, error) { 42 | if _, err := os.Stat(s.config.Path); os.IsNotExist(err) { 43 | // If the key does not exist 44 | return []byte{}, nil 45 | } 46 | return os.ReadFile(s.config.Path) 47 | } 48 | -------------------------------------------------------------------------------- /storage/local/storage_test.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestStorageWrite(t *testing.T) { 11 | cases := []struct { 12 | desc string 13 | config *Config 14 | contents []byte 15 | ok bool 16 | }{ 17 | { 18 | desc: "simple", 19 | config: &Config{ 20 | Path: "history.json", 21 | }, 22 | contents: []byte("foo"), 23 | ok: true, 24 | }, 25 | { 26 | desc: "dir does not exist", 27 | config: &Config{ 28 | Path: "not_exist/history.json", 29 | }, 30 | contents: []byte("foo"), 31 | ok: false, 32 | }, 33 | } 34 | 35 | for _, tc := range cases { 36 | t.Run(tc.desc, func(t *testing.T) { 37 | localDir, err := os.MkdirTemp("", "localDir") 38 | if err != nil { 39 | t.Fatalf("failed to craete temp dir: %s", err) 40 | } 41 | t.Cleanup(func() { os.RemoveAll(localDir) }) 42 | 43 | tc.config.Path = filepath.Join(localDir, tc.config.Path) 44 | s, err := NewStorage(tc.config) 45 | if err != nil { 46 | t.Fatalf("failed to NewStorage: %s", err) 47 | } 48 | err = s.Write(context.Background(), tc.contents) 49 | if tc.ok && err != nil { 50 | t.Fatalf("unexpected err: %s", err) 51 | } 52 | if !tc.ok && err == nil { 53 | t.Fatal("expected to return an error, but no error") 54 | } 55 | 56 | if tc.ok { 57 | got, err := os.ReadFile(tc.config.Path) 58 | if err != nil { 59 | t.Fatalf("failed to read contents: %s", err) 60 | } 61 | if string(got) != string(tc.contents) { 62 | t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) 63 | } 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestStorageRead(t *testing.T) { 70 | cases := []struct { 71 | desc string 72 | config *Config 73 | contents []byte 74 | ok bool 75 | }{ 76 | { 77 | desc: "simple", 78 | config: &Config{ 79 | Path: "history.json", 80 | }, 81 | contents: []byte("foo"), 82 | ok: true, 83 | }, 84 | { 85 | desc: "file does not exist", 86 | config: &Config{ 87 | Path: "not_exist.json", 88 | }, 89 | contents: []byte{}, 90 | ok: true, 91 | }, 92 | } 93 | 94 | for _, tc := range cases { 95 | t.Run(tc.desc, func(t *testing.T) { 96 | localDir, err := os.MkdirTemp("", "localDir") 97 | if err != nil { 98 | t.Fatalf("failed to craete temp dir: %s", err) 99 | } 100 | t.Cleanup(func() { os.RemoveAll(localDir) }) 101 | 102 | err = os.WriteFile(filepath.Join(localDir, "history.json"), tc.contents, 0600) 103 | if err != nil { 104 | t.Fatalf("failed to write contents: %s", err) 105 | } 106 | 107 | tc.config.Path = filepath.Join(localDir, tc.config.Path) 108 | s, err := NewStorage(tc.config) 109 | if err != nil { 110 | t.Fatalf("failed to NewStorage: %s", err) 111 | } 112 | got, err := s.Read(context.Background()) 113 | if tc.ok && err != nil { 114 | t.Fatalf("unexpected err: %#v", err) 115 | } 116 | if !tc.ok && err == nil { 117 | t.Fatal("expected to return an error, but no error") 118 | } 119 | 120 | if tc.ok { 121 | if string(got) != string(tc.contents) { 122 | t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) 123 | } 124 | } 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /storage/mock/config.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/minamijoyo/tfmigrate/storage" 4 | 5 | // Config is a config for mock storage. 6 | type Config struct { 7 | // Data stores a serialized data for history. 8 | Data string `hcl:"data"` 9 | // WriteError is a flag to return an error on Write(). 10 | WriteError bool `hcl:"write_error"` 11 | // ReadError is a flag to return an error on Read(). 12 | ReadError bool `hcl:"read_error"` 13 | 14 | // A reference to an instance of mock storage for testing. 15 | s *Storage 16 | } 17 | 18 | // Config implements a storage.Config. 19 | var _ storage.Config = (*Config)(nil) 20 | 21 | // NewStorage returns a new instance of storage.Storage. 22 | func (c *Config) NewStorage() (storage.Storage, error) { 23 | s, err := NewStorage(c) 24 | 25 | // store a reference for test assertion. 26 | c.s = s 27 | return s, err 28 | } 29 | 30 | // Storage returns a reference to mock storage for testing. 31 | func (c *Config) Storage() *Storage { 32 | return c.s 33 | } 34 | -------------------------------------------------------------------------------- /storage/mock/config_test.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "testing" 4 | 5 | func TestConfigNewStorage(t *testing.T) { 6 | cases := []struct { 7 | desc string 8 | config *Config 9 | ok bool 10 | }{ 11 | { 12 | desc: "valid", 13 | config: &Config{ 14 | Data: "foo", 15 | WriteError: true, 16 | ReadError: false, 17 | }, 18 | ok: true, 19 | }, 20 | } 21 | 22 | for _, tc := range cases { 23 | t.Run(tc.desc, func(t *testing.T) { 24 | got, err := tc.config.NewStorage() 25 | if tc.ok && err != nil { 26 | t.Fatalf("unexpected err: %s", err) 27 | } 28 | if !tc.ok && err == nil { 29 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 30 | } 31 | if tc.ok { 32 | _ = got.(*Storage) 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /storage/mock/storage.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/minamijoyo/tfmigrate/storage" 8 | ) 9 | 10 | // Storage is a storage.Storage implementation for mock. 11 | // It writes and reads data from memory. 12 | type Storage struct { 13 | // config is a storage config for mock 14 | config *Config 15 | // data stores a serialized data for history. 16 | data string 17 | } 18 | 19 | var _ storage.Storage = (*Storage)(nil) 20 | 21 | // NewStorage returns a new instance of Storage. 22 | func NewStorage(config *Config) (*Storage, error) { 23 | s := &Storage{ 24 | config: config, 25 | data: config.Data, 26 | } 27 | return s, nil 28 | } 29 | 30 | // Data returns a raw data in mock storage for testing. 31 | func (s *Storage) Data() string { 32 | return s.data 33 | } 34 | 35 | // Write writes migration history data to storage. 36 | func (s *Storage) Write(_ context.Context, b []byte) error { 37 | if s.config.WriteError { 38 | return fmt.Errorf("failed to write mock storage: writeError = %t", s.config.WriteError) 39 | } 40 | s.data = string(b) 41 | return nil 42 | } 43 | 44 | // Read reads migration history data from storage. 45 | func (s *Storage) Read(_ context.Context) ([]byte, error) { 46 | if s.config.ReadError { 47 | return nil, fmt.Errorf("failed to read mock storage: readError = %t", s.config.ReadError) 48 | } 49 | return []byte(s.data), nil 50 | } 51 | -------------------------------------------------------------------------------- /storage/mock/storage_test.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestStorageWrite(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | config *Config 12 | contents []byte 13 | ok bool 14 | }{ 15 | { 16 | desc: "simple", 17 | config: &Config{ 18 | Data: "", 19 | WriteError: false, 20 | ReadError: false, 21 | }, 22 | contents: []byte("foo"), 23 | ok: true, 24 | }, 25 | { 26 | desc: "write error", 27 | config: &Config{ 28 | Data: "", 29 | WriteError: true, 30 | ReadError: false, 31 | }, 32 | contents: []byte("foo"), 33 | ok: false, 34 | }, 35 | } 36 | 37 | for _, tc := range cases { 38 | t.Run(tc.desc, func(t *testing.T) { 39 | s, err := NewStorage(tc.config) 40 | if err != nil { 41 | t.Fatalf("failed to NewStorage: %s", err) 42 | } 43 | err = s.Write(context.Background(), tc.contents) 44 | if tc.ok && err != nil { 45 | t.Fatalf("unexpected err: %s", err) 46 | } 47 | if !tc.ok && err == nil { 48 | t.Fatal("expected to return an error, but no error") 49 | } 50 | 51 | if tc.ok { 52 | got := []byte(s.data) 53 | if err != nil { 54 | t.Fatalf("failed to read contents: %s", err) 55 | } 56 | if string(got) != string(tc.contents) { 57 | t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) 58 | } 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestStorageRead(t *testing.T) { 65 | cases := []struct { 66 | desc string 67 | config *Config 68 | contents []byte 69 | ok bool 70 | }{ 71 | { 72 | desc: "simple", 73 | config: &Config{ 74 | Data: "foo", 75 | WriteError: false, 76 | ReadError: false, 77 | }, 78 | contents: []byte("foo"), 79 | ok: true, 80 | }, 81 | { 82 | desc: "read error", 83 | config: &Config{ 84 | Data: "foo", 85 | WriteError: false, 86 | ReadError: true, 87 | }, 88 | contents: nil, 89 | ok: false, 90 | }, 91 | } 92 | 93 | for _, tc := range cases { 94 | t.Run(tc.desc, func(t *testing.T) { 95 | s, err := NewStorage(tc.config) 96 | if err != nil { 97 | t.Fatalf("failed to NewStorage: %s", err) 98 | } 99 | got, err := s.Read(context.Background()) 100 | if tc.ok && err != nil { 101 | t.Fatalf("unexpected err: %#v", err) 102 | } 103 | if !tc.ok && err == nil { 104 | t.Fatal("expected to return an error, but no error") 105 | } 106 | 107 | if tc.ok { 108 | if string(got) != string(tc.contents) { 109 | t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) 110 | } 111 | } 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /storage/s3/client.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" 9 | "github.com/aws/aws-sdk-go-v2/service/s3" 10 | awsbase "github.com/hashicorp/aws-sdk-go-base/v2" 11 | ) 12 | 13 | // Client is an abstraction layer for AWS S3 API. 14 | // It is intended to be replaced with a mock for testing. 15 | type Client interface { 16 | // PutObject puts a file to S3. 17 | PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) 18 | // GetObject gets a file from S3. 19 | GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) 20 | } 21 | 22 | // client is a real implementation of the Client. 23 | type client struct { 24 | s3Client *s3.Client 25 | awsConfig aws.Config 26 | } 27 | 28 | // newClient returns a new instance of Client. 29 | func newClient(config *Config) (Client, error) { 30 | cfg := &awsbase.Config{ 31 | AccessKey: config.AccessKey, 32 | Profile: config.Profile, 33 | Region: config.Region, 34 | SecretKey: config.SecretKey, 35 | SkipCredsValidation: config.SkipCredentialsValidation, 36 | } 37 | 38 | if config.RoleARN != "" { 39 | cfg.AssumeRole = &awsbase.AssumeRole{ 40 | RoleARN: config.RoleARN, 41 | } 42 | } 43 | 44 | if config.SkipMetadataAPICheck { 45 | cfg.EC2MetadataServiceEnableState = imds.ClientDisabled 46 | } else { 47 | cfg.EC2MetadataServiceEnableState = imds.ClientEnabled 48 | } 49 | 50 | ctx := context.Background() 51 | _, awsConfig, awsDiags := awsbase.GetAwsConfig(ctx, cfg) 52 | if awsDiags.HasError() { 53 | return nil, fmt.Errorf("failed to load aws config: %#v", awsDiags) 54 | } 55 | 56 | s3Client := s3.NewFromConfig(awsConfig, func(options *s3.Options) { 57 | if config.Endpoint != "" { 58 | options.BaseEndpoint = aws.String(config.Endpoint) 59 | } 60 | if config.ForcePathStyle { 61 | options.UsePathStyle = config.ForcePathStyle 62 | } 63 | }) 64 | 65 | return &client{ 66 | s3Client: s3Client, 67 | awsConfig: awsConfig, 68 | }, nil 69 | } 70 | 71 | // PutObject puts a file to S3. 72 | func (c *client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { 73 | return c.s3Client.PutObject(ctx, params, optFns...) 74 | } 75 | 76 | // GetObject gets a file from S3. 77 | func (c *client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { 78 | return c.s3Client.GetObject(ctx, params, optFns...) 79 | } 80 | -------------------------------------------------------------------------------- /storage/s3/config.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import "github.com/minamijoyo/tfmigrate/storage" 4 | 5 | // Config is a config for s3 storage. 6 | // This is expected to have almost the same options as Terraform s3 backend. 7 | // https://www.terraform.io/docs/backends/types/s3.html 8 | // However, it has many minor options and it's a pain to test all options from 9 | // first, so we added only options we need for now. 10 | type Config struct { 11 | // Name of the bucket. 12 | Bucket string `hcl:"bucket"` 13 | // Path to the migration history file. 14 | Key string `hcl:"key"` 15 | 16 | // AWS region. 17 | Region string `hcl:"region,optional"` 18 | // Custom endpoint for the AWS S3 API. 19 | Endpoint string `hcl:"endpoint,optional"` 20 | // AWS access key. 21 | AccessKey string `hcl:"access_key,optional"` 22 | // AWS secret key. 23 | SecretKey string `hcl:"secret_key,optional"` 24 | // Name of AWS profile in AWS shared credentials file. 25 | Profile string `hcl:"profile,optional"` 26 | // Amazon Resource Name (ARN) of the IAM Role to assume. 27 | RoleARN string `hcl:"role_arn,optional"` 28 | // Skip credentials validation via the STS API. 29 | SkipCredentialsValidation bool `hcl:"skip_credentials_validation,optional"` 30 | // Skip usage of EC2 Metadata API. 31 | SkipMetadataAPICheck bool `hcl:"skip_metadata_api_check,optional"` 32 | // Enable path-style S3 URLs (https:/// 33 | // instead of https://.). 34 | ForcePathStyle bool `hcl:"force_path_style,optional"` 35 | // SSE KMS Key Id for optional server-side encryption enablement 36 | KmsKeyID string `hcl:"kms_key_id,optional"` 37 | } 38 | 39 | // Config implements a storage.Config. 40 | var _ storage.Config = (*Config)(nil) 41 | 42 | // NewStorage returns a new instance of storage.Storage. 43 | func (c *Config) NewStorage() (storage.Storage, error) { 44 | return NewStorage(c, nil) 45 | } 46 | -------------------------------------------------------------------------------- /storage/s3/config_test.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import "testing" 4 | 5 | func TestConfigNewStorage(t *testing.T) { 6 | cases := []struct { 7 | desc string 8 | config *Config 9 | ok bool 10 | }{ 11 | { 12 | desc: "valid", 13 | config: &Config{ 14 | Bucket: "tfmigrate-test", 15 | Key: "tfmigrate/history.json", 16 | Region: "ap-northeast-1", 17 | Endpoint: "http://localstack:4566", 18 | AccessKey: "dummy", 19 | SecretKey: "dummy", 20 | // aws-sdk-go-v2 will cause an error if it reads an invalid profile, 21 | // so comment it out in the test. 22 | // Profile: "dev", 23 | SkipCredentialsValidation: true, 24 | SkipMetadataAPICheck: true, 25 | ForcePathStyle: true, 26 | }, 27 | ok: true, 28 | }, 29 | } 30 | 31 | for _, tc := range cases { 32 | t.Run(tc.desc, func(t *testing.T) { 33 | got, err := tc.config.NewStorage() 34 | if tc.ok && err != nil { 35 | t.Fatalf("unexpected err: %s", err) 36 | } 37 | if !tc.ok && err == nil { 38 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 39 | } 40 | if tc.ok { 41 | _ = got.(*Storage) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /storage/s3/storage.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/aws/aws-sdk-go-v2/service/s3" 10 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 11 | "github.com/minamijoyo/tfmigrate/storage" 12 | ) 13 | 14 | // Storage is a storage.Storage implementation for AWS S3. 15 | type Storage struct { 16 | // config is a storage config for s3. 17 | config *Config 18 | // client is an instance of S3Client interface to call API. 19 | // It is intended to be replaced with a mock for testing. 20 | client Client 21 | } 22 | 23 | var _ storage.Storage = (*Storage)(nil) 24 | 25 | // NewStorage returns a new instance of Storage. 26 | func NewStorage(config *Config, client Client) (*Storage, error) { 27 | if client == nil { 28 | var err error 29 | client, err = newClient(config) 30 | if err != nil { 31 | return nil, err 32 | } 33 | } 34 | 35 | s := &Storage{ 36 | config: config, 37 | client: client, 38 | } 39 | 40 | return s, nil 41 | } 42 | 43 | // Write writes migration history data to storage. 44 | func (s *Storage) Write(ctx context.Context, b []byte) error { 45 | input := &s3.PutObjectInput{ 46 | Bucket: aws.String(s.config.Bucket), 47 | Key: aws.String(s.config.Key), 48 | Body: bytes.NewReader(b), 49 | } 50 | if s.config.KmsKeyID != "" { 51 | input.SSEKMSKeyId = &s.config.KmsKeyID 52 | input.ServerSideEncryption = types.ServerSideEncryptionAwsKms 53 | } 54 | 55 | _, err := s.client.PutObject(ctx, input) 56 | 57 | return err 58 | } 59 | 60 | // Read reads migration history data from storage. 61 | // If the key does not exist, it is assumed to be uninitialized and returns 62 | // an empty array instead of an error. 63 | func (s *Storage) Read(ctx context.Context) ([]byte, error) { 64 | input := &s3.GetObjectInput{ 65 | Bucket: aws.String(s.config.Bucket), 66 | Key: aws.String(s.config.Key), 67 | } 68 | 69 | output, err := s.client.GetObject(ctx, input) 70 | if err != nil { 71 | var nsk *types.NoSuchKey 72 | if errors.As(err, &nsk) { 73 | // If the key does not exist 74 | return []byte{}, nil 75 | } 76 | // unexpected error 77 | return nil, err 78 | } 79 | 80 | defer output.Body.Close() 81 | 82 | buf := bytes.NewBuffer(nil) 83 | _, err = buf.ReadFrom(output.Body) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return buf.Bytes(), nil 89 | } 90 | -------------------------------------------------------------------------------- /storage/s3/storage_test.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/service/s3" 11 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 12 | ) 13 | 14 | // mockClient is a mock implementation for testing. 15 | type mockClient struct { 16 | putOutput *s3.PutObjectOutput 17 | getOutput *s3.GetObjectOutput 18 | err error 19 | } 20 | 21 | // PutObjectWithContext returns a mocked response. 22 | func (c *mockClient) PutObject(_ context.Context, _ *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { 23 | return c.putOutput, c.err 24 | } 25 | 26 | // GetObjectWithContext returns a mocked response. 27 | func (c *mockClient) GetObject(_ context.Context, _ *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { 28 | return c.getOutput, c.err 29 | } 30 | 31 | func TestStorageWrite(t *testing.T) { 32 | cases := []struct { 33 | desc string 34 | config *Config 35 | client Client 36 | contents []byte 37 | ok bool 38 | }{ 39 | { 40 | desc: "simple", 41 | config: &Config{ 42 | Bucket: "tfmigrate-test", 43 | Key: "tfmigrate/history.json", 44 | }, 45 | client: &mockClient{ 46 | putOutput: &s3.PutObjectOutput{}, 47 | err: nil, 48 | }, 49 | contents: []byte("foo"), 50 | ok: true, 51 | }, 52 | { 53 | desc: "bucket does not exist", 54 | config: &Config{ 55 | Bucket: "not-exist-bucket", 56 | Key: "tfmigrate/history.json", 57 | }, 58 | client: &mockClient{ 59 | putOutput: nil, 60 | err: &types.NoSuchBucket{Message: aws.String("The specified bucket does not exist.")}, 61 | }, 62 | contents: []byte("foo"), 63 | ok: false, 64 | }, 65 | } 66 | 67 | for _, tc := range cases { 68 | t.Run(tc.desc, func(t *testing.T) { 69 | s, err := NewStorage(tc.config, tc.client) 70 | if err != nil { 71 | t.Fatalf("failed to NewStorage: %s", err) 72 | } 73 | err = s.Write(context.Background(), tc.contents) 74 | if tc.ok && err != nil { 75 | t.Fatalf("unexpected err: %s", err) 76 | } 77 | if !tc.ok && err == nil { 78 | t.Fatal("expected to return an error, but no error") 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestStorageRead(t *testing.T) { 85 | cases := []struct { 86 | desc string 87 | config *Config 88 | client Client 89 | contents []byte 90 | ok bool 91 | }{ 92 | { 93 | desc: "simple", 94 | config: &Config{ 95 | Bucket: "tfmigrate-test", 96 | Key: "tfmigrate/history.json", 97 | }, 98 | client: &mockClient{ 99 | getOutput: &s3.GetObjectOutput{ 100 | Body: io.NopCloser(strings.NewReader("foo")), 101 | }, 102 | err: nil, 103 | }, 104 | contents: []byte("foo"), 105 | ok: true, 106 | }, 107 | { 108 | desc: "bucket does not exist", 109 | config: &Config{ 110 | Bucket: "not-exist-bucket", 111 | Key: "tfmigrate/history.json", 112 | }, 113 | client: &mockClient{ 114 | getOutput: nil, 115 | err: &types.NoSuchBucket{Message: aws.String("The specified bucket does not exist.")}, 116 | }, 117 | contents: nil, 118 | ok: false, 119 | }, 120 | { 121 | desc: "key does not exist", 122 | config: &Config{ 123 | Bucket: "tfmigrate-test", 124 | Key: "not_exist.json", 125 | }, 126 | client: &mockClient{ 127 | getOutput: nil, 128 | err: &types.NoSuchKey{Message: aws.String("The specified key does not exist.")}, 129 | }, 130 | contents: []byte{}, 131 | ok: true, 132 | }, 133 | } 134 | 135 | for _, tc := range cases { 136 | t.Run(tc.desc, func(t *testing.T) { 137 | s, err := NewStorage(tc.config, tc.client) 138 | if err != nil { 139 | t.Fatalf("failed to NewStorage: %s", err) 140 | } 141 | got, err := s.Read(context.Background()) 142 | if tc.ok && err != nil { 143 | t.Fatalf("unexpected err: %s", err) 144 | } 145 | if !tc.ok && err == nil { 146 | t.Fatal("expected to return an error, but no error") 147 | } 148 | 149 | if tc.ok { 150 | if string(got) != string(tc.contents) { 151 | t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) 152 | } 153 | } 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "context" 4 | 5 | // Storage is an abstraction layer for migration history data store. 6 | // As you know, this is the equivalent of Terraform's backend, but we have 7 | // implemented it by ourselves not to depend on Terraform internals directly. 8 | // To support multiple cloud storages, write and read operations are limited to 9 | // simple byte operations and a domain specific logic should not be included. 10 | type Storage interface { 11 | // Write writes migration history data to storage. 12 | Write(ctx context.Context, b []byte) error 13 | // Read reads migration history data from storage. 14 | // If the key does not exist, it is assumed to be uninitialized and returns 15 | // an empty array instead of an error. 16 | Read(ctx context.Context) ([]byte, error) 17 | } 18 | -------------------------------------------------------------------------------- /test-fixtures/backend_s3/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 | 19 | # https://www.terraform.io/docs/providers/aws/index.html 20 | # https://www.terraform.io/docs/providers/aws/guides/custom-service-endpoints.html#localstack 21 | provider "aws" { 22 | region = "ap-northeast-1" 23 | 24 | access_key = "dummy" 25 | secret_key = "dummy" 26 | skip_credentials_validation = true 27 | skip_metadata_api_check = true 28 | skip_region_validation = true 29 | skip_requesting_account_id = true 30 | s3_use_path_style = true 31 | 32 | # mock endpoints with localstack 33 | endpoints { 34 | s3 = "http://localstack:4566" 35 | ec2 = "http://localstack:4566" 36 | iam = "http://localstack:4566" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test-fixtures/backend_s3/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "foo" { 2 | name = "foo" 3 | } 4 | 5 | resource "aws_security_group" "bar" { 6 | name = "bar" 7 | } 8 | -------------------------------------------------------------------------------- /test-fixtures/fake-gcs-server/tfstate-test/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minamijoyo/tfmigrate/0c774937c63275506c7149e652b48bdf76a217fd/test-fixtures/fake-gcs-server/tfstate-test/.keep -------------------------------------------------------------------------------- /test-fixtures/legacy-tfstate/main.tf: -------------------------------------------------------------------------------- 1 | resource "null_resource" "foo" {} 2 | -------------------------------------------------------------------------------- /test-fixtures/legacy-tfstate/terraform.tfstate: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "0.12.31", 4 | "serial": 1, 5 | "lineage": "e80ec150-5474-9ca5-445f-bc55e224f303", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "null_resource", 11 | "name": "foo", 12 | "provider": "provider.null", 13 | "instances": [ 14 | { 15 | "schema_version": 0, 16 | "attributes": { 17 | "id": "859754710453181749", 18 | "triggers": null 19 | } 20 | } 21 | ] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test-fixtures/storage_gcs/.tfmigrate.hcl: -------------------------------------------------------------------------------- 1 | tfmigrate { 2 | migration_dir = "./tfmigrate" 3 | history { 4 | storage "gcs" { 5 | bucket = "tfstate-test" 6 | name = "tfmigrate/history.json" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test-fixtures/storage_gcs/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 | 19 | # https://www.terraform.io/docs/providers/aws/index.html 20 | # https://www.terraform.io/docs/providers/aws/guides/custom-service-endpoints.html#localstack 21 | provider "aws" { 22 | region = "ap-northeast-1" 23 | 24 | access_key = "dummy" 25 | secret_key = "dummy" 26 | skip_credentials_validation = true 27 | skip_metadata_api_check = true 28 | skip_region_validation = true 29 | skip_requesting_account_id = true 30 | s3_use_path_style = true 31 | 32 | # mock endpoints with localstack 33 | endpoints { 34 | s3 = "http://localstack:4566" 35 | ec2 = "http://localstack:4566" 36 | iam = "http://localstack:4566" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test-fixtures/storage_gcs/main.tf: -------------------------------------------------------------------------------- 1 | resource "null_resource" "foo1" {} 2 | resource "null_resource" "foo2" {} 3 | resource "null_resource" "foo3" {} 4 | resource "null_resource" "foo4" {} 5 | -------------------------------------------------------------------------------- /test-fixtures/storage_gcs/tfmigrate/mv_bar1.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "bar1" { 2 | actions = [ 3 | "mv null_resource.foo1 null_resource.bar1" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/storage_gcs/tfmigrate/mv_bar2.hcl.bk: -------------------------------------------------------------------------------- 1 | migration "state" "bar1" { 2 | actions = [ 3 | "mv null_resource.foo2 null_resource.bar2" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/storage_s3/.tfmigrate.hcl: -------------------------------------------------------------------------------- 1 | tfmigrate { 2 | migration_dir = "./tfmigrate" 3 | history { 4 | storage "s3" { 5 | bucket = "tfstate-test" 6 | key = "tfmigrate/history.json" 7 | region = "ap-northeast-1" 8 | 9 | endpoint = "http://localstack:4566" 10 | access_key = "dummy" 11 | secret_key = "dummy" 12 | skip_credentials_validation = true 13 | skip_metadata_api_check = true 14 | force_path_style = true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test-fixtures/storage_s3/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 | 19 | # https://www.terraform.io/docs/providers/aws/index.html 20 | # https://www.terraform.io/docs/providers/aws/guides/custom-service-endpoints.html#localstack 21 | provider "aws" { 22 | region = "ap-northeast-1" 23 | 24 | access_key = "dummy" 25 | secret_key = "dummy" 26 | skip_credentials_validation = true 27 | skip_metadata_api_check = true 28 | skip_region_validation = true 29 | skip_requesting_account_id = true 30 | s3_use_path_style = true 31 | 32 | # mock endpoints with localstack 33 | endpoints { 34 | s3 = "http://localstack:4566" 35 | ec2 = "http://localstack:4566" 36 | iam = "http://localstack:4566" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test-fixtures/storage_s3/main.tf: -------------------------------------------------------------------------------- 1 | resource "null_resource" "foo1" {} 2 | resource "null_resource" "foo2" {} 3 | resource "null_resource" "foo3" {} 4 | resource "null_resource" "foo4" {} 5 | -------------------------------------------------------------------------------- /test-fixtures/storage_s3/tfmigrate/mv_bar1.hcl: -------------------------------------------------------------------------------- 1 | migration "state" "bar1" { 2 | actions = [ 3 | "mv null_resource.foo1 null_resource.bar1" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test-fixtures/storage_s3/tfmigrate/mv_bar2.hcl.bk: -------------------------------------------------------------------------------- 1 | migration "state" "bar1" { 2 | actions = [ 3 | "mv null_resource.foo2 null_resource.bar2" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /tfexec/command.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | ) 7 | 8 | // Command is an interface for wrapping os/exec.Cmd. 9 | // Since the os/exec.Cmd is a struct, not an interface, 10 | // we cannot mock it for testing, so we wrap it here. 11 | // We implement only functions we need. 12 | type Command interface { 13 | // Run executes an arbitrary command. 14 | Run() error 15 | // Stdout returns outputs of stdout. 16 | Stdout() string 17 | // Stderr returns outputs of stderr. 18 | Stderr() string 19 | // Args returns args of the command. 20 | Args() []string 21 | } 22 | 23 | // command implements the Command interface. 24 | type command struct { 25 | // osExecCmd is an underlying object. 26 | osExecCmd *exec.Cmd 27 | // stdout is a buffer for stdout. 28 | stdout *bytes.Buffer 29 | // stderr is a buffer for stderr. 30 | stderr *bytes.Buffer 31 | } 32 | 33 | var _ Command = (*command)(nil) 34 | 35 | // Run executes an arbitrary command. 36 | func (c *command) Run() error { 37 | return c.osExecCmd.Run() 38 | } 39 | 40 | // Stdout returns outputs of stdout. 41 | func (c *command) Stdout() string { 42 | return c.stdout.String() 43 | } 44 | 45 | // Stderr returns outputs of stderr. 46 | func (c *command) Stderr() string { 47 | return c.stderr.String() 48 | } 49 | 50 | // Args returns args of the command. 51 | func (c *command) Args() []string { 52 | return c.osExecCmd.Args 53 | } 54 | -------------------------------------------------------------------------------- /tfexec/error.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | ) 8 | 9 | // ExitError is an interface for wrapping os/exec.ExitError. 10 | // We want to add helper methods we need. 11 | type ExitError interface { 12 | // String returns a string representation of the error. 13 | String() string 14 | // Error returns a string useful for displaying error messages. 15 | Error() string 16 | // ExitCode returns an exit status code of the command. 17 | ExitCode() int 18 | } 19 | 20 | // exitError implements the ExitError interface. 21 | type exitError struct { 22 | // osExecErr is an underlying object. 23 | osExecErr *exec.ExitError 24 | // cmd is an executed command. 25 | cmd Command 26 | } 27 | 28 | var _ ExitError = (*exitError)(nil) 29 | 30 | // String returns a string representation of the error. 31 | func (e *exitError) String() string { 32 | return e.osExecErr.String() 33 | } 34 | 35 | // Error returns a string useful for displaying error messages. 36 | func (e *exitError) Error() string { 37 | code := e.ExitCode() 38 | // args[0] contains the command name. 39 | args := strings.Join(e.cmd.Args(), " ") 40 | stdout := e.cmd.Stdout() 41 | stderr := e.cmd.Stderr() 42 | return fmt.Sprintf( 43 | "failed to run command (exited %d): %s\nstdout:\n%s\nstderr:\n%s", code, args, stdout, stderr, 44 | ) 45 | } 46 | 47 | // ExitCode returns an exit status code of the command. 48 | func (e *exitError) ExitCode() int { 49 | return e.osExecErr.ExitCode() 50 | } 51 | -------------------------------------------------------------------------------- /tfexec/executor.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/davecgh/go-spew/spew" 13 | ) 14 | 15 | // Executor abstracts the os command execution layer. 16 | type Executor interface { 17 | // NewCommandContext builds and returns an instance of Command. 18 | NewCommandContext(ctx context.Context, name string, args ...string) (Command, error) 19 | // Run executes a command. 20 | Run(cmd Command) error 21 | // Dir returns the current working directory. 22 | Dir() string 23 | // AppendEnv appends an environment variable. 24 | AppendEnv(key string, value string) 25 | } 26 | 27 | // executor implements the Executor interface. 28 | type executor struct { 29 | // outStream is the stdout stream. 30 | outStream io.Writer 31 | // errStream is the stderr stream. 32 | errStream io.Writer 33 | 34 | // a working directory where a command is executed. 35 | dir string 36 | // environment variables passed to a command. 37 | env []string 38 | } 39 | 40 | var _ Executor = (*executor)(nil) 41 | 42 | // NewExecutor returns a default executor for real environments. 43 | func NewExecutor(dir string, env []string) Executor { 44 | return &executor{ 45 | outStream: os.Stdout, 46 | errStream: os.Stderr, 47 | dir: dir, 48 | env: env, 49 | } 50 | } 51 | 52 | // NewCommandContext builds and returns an instance of Command. 53 | func (e *executor) NewCommandContext(ctx context.Context, name string, args ...string) (Command, error) { 54 | osExecCmd := exec.CommandContext(ctx, name, args...) 55 | stdout := &bytes.Buffer{} 56 | stderr := &bytes.Buffer{} 57 | osExecCmd.Stdout = stdout 58 | osExecCmd.Stderr = stderr 59 | osExecCmd.Dir = e.dir 60 | osExecCmd.Env = e.env 61 | 62 | return &command{ 63 | osExecCmd: osExecCmd, 64 | stdout: stdout, 65 | stderr: stderr, 66 | }, nil 67 | } 68 | 69 | // Run executes a command. 70 | func (e *executor) Run(cmd Command) error { 71 | log.Printf("[DEBUG] [executor@%s]$ %s", e.dir, strings.Join(cmd.Args(), " ")) 72 | err := cmd.Run() 73 | log.Printf("[TRACE] [executor@%s] cmd=%s ", e.dir, spew.Sdump(cmd)) 74 | if err != nil { 75 | log.Printf("[DEBUG] [executor@%s] failed to run command: %s", e.dir, spew.Sdump(err)) 76 | if osExecErr, ok := err.(*exec.ExitError); ok { 77 | return &exitError{ 78 | osExecErr: osExecErr, 79 | cmd: cmd, 80 | } 81 | } 82 | return err 83 | } 84 | return nil 85 | } 86 | 87 | // Dir returns the current working directory. 88 | func (e *executor) Dir() string { 89 | return e.dir 90 | } 91 | 92 | // AppendEnv appends an environment variable. 93 | func (e *executor) AppendEnv(key string, value string) { 94 | e.env = append(e.env, key+"="+value) 95 | } 96 | -------------------------------------------------------------------------------- /tfexec/terraform_apply.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "os" 6 | ) 7 | 8 | // Apply applies changes. 9 | // If a plan is given, use it for the input plan. 10 | func (c *terraformCLI) Apply(ctx context.Context, plan *Plan, opts ...string) error { 11 | args := []string{"apply"} 12 | args = append(args, opts...) 13 | 14 | if plan != nil { 15 | tmpPlan, err := writeTempFile(plan.Bytes()) 16 | defer os.Remove(tmpPlan.Name()) 17 | if err != nil { 18 | return err 19 | } 20 | args = append(args, tmpPlan.Name()) 21 | } 22 | 23 | _, _, err := c.Run(ctx, args...) 24 | 25 | return err 26 | } 27 | -------------------------------------------------------------------------------- /tfexec/terraform_apply_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "regexp" 7 | "testing" 8 | ) 9 | 10 | func TestTerraformCLIApply(t *testing.T) { 11 | plan := NewPlan([]byte("dummy plan")) 12 | 13 | cases := []struct { 14 | desc string 15 | mockCommands []*mockCommand 16 | plan *Plan 17 | opts []string 18 | ok bool 19 | }{ 20 | { 21 | desc: "no opts", 22 | mockCommands: []*mockCommand{ 23 | { 24 | args: []string{"terraform", "apply"}, 25 | exitCode: 0, 26 | }, 27 | }, 28 | ok: true, 29 | }, 30 | { 31 | desc: "failed to run terraform apply", 32 | mockCommands: []*mockCommand{ 33 | { 34 | args: []string{"terraform", "apply"}, 35 | exitCode: 1, 36 | }, 37 | }, 38 | ok: false, 39 | }, 40 | { 41 | desc: "with opts", 42 | mockCommands: []*mockCommand{ 43 | { 44 | args: []string{"terraform", "apply", "-input=false", "-no-color"}, 45 | exitCode: 0, 46 | }, 47 | }, 48 | opts: []string{"-input=false", "-no-color"}, 49 | ok: true, 50 | }, 51 | { 52 | desc: "with plan", 53 | mockCommands: []*mockCommand{ 54 | { 55 | args: []string{"terraform", "apply", "-input=false", "-no-color", "/path/to/planfile"}, 56 | argsRe: regexp.MustCompile(`^terraform apply -input=false -no-color \S+$`), 57 | exitCode: 0, 58 | }, 59 | }, 60 | plan: plan, 61 | opts: []string{"-input=false", "-no-color"}, 62 | ok: true, 63 | }, 64 | } 65 | 66 | for _, tc := range cases { 67 | t.Run(tc.desc, func(t *testing.T) { 68 | e := NewMockExecutor(tc.mockCommands) 69 | terraformCLI := NewTerraformCLI(e) 70 | terraformCLI.SetExecPath("terraform") 71 | err := terraformCLI.Apply(context.Background(), tc.plan, tc.opts...) 72 | if tc.ok && err != nil { 73 | t.Fatalf("unexpected err: %s", err) 74 | } 75 | if !tc.ok && err == nil { 76 | t.Fatal("expected to return an error, but no error") 77 | } 78 | }) 79 | } 80 | } 81 | 82 | func TestAccTerraformCLIApply(t *testing.T) { 83 | SkipUnlessAcceptanceTestEnabled(t) 84 | 85 | source := `resource "null_resource" "foo" {}` 86 | e := SetupTestAcc(t, source) 87 | terraformCLI := NewTerraformCLI(e) 88 | 89 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 90 | if err != nil { 91 | t.Fatalf("failed to run terraform init: %s", err) 92 | } 93 | 94 | plan, err := terraformCLI.Plan(context.Background(), nil, "-input=false", "-no-color") 95 | if err != nil { 96 | t.Fatalf("failed to run terraform plan: %s", err) 97 | } 98 | 99 | err = terraformCLI.Apply(context.Background(), plan, "-input=false", "-no-color") 100 | if err != nil { 101 | t.Fatalf("failed to run terraform apply: %s", err) 102 | } 103 | 104 | got, err := terraformCLI.StateList(context.Background(), nil, nil) 105 | if err != nil { 106 | t.Fatalf("failed to run terraform state list: %s", err) 107 | } 108 | 109 | want := []string{"null_resource.foo"} 110 | if !reflect.DeepEqual(got, want) { 111 | t.Errorf("got: %v, want: %v", got, want) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tfexec/terraform_destroy.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import "context" 4 | 5 | // Destroy destroys resources. 6 | func (c *terraformCLI) Destroy(ctx context.Context, opts ...string) error { 7 | args := []string{"destroy"} 8 | args = append(args, opts...) 9 | _, _, err := c.Run(ctx, args...) 10 | return err 11 | } 12 | -------------------------------------------------------------------------------- /tfexec/terraform_destroy_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestTerraformCLIDestroy(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | mockCommands []*mockCommand 12 | opts []string 13 | ok bool 14 | }{ 15 | { 16 | desc: "no opts", 17 | mockCommands: []*mockCommand{ 18 | { 19 | args: []string{"terraform", "destroy"}, 20 | exitCode: 0, 21 | }, 22 | }, 23 | ok: true, 24 | }, 25 | { 26 | desc: "failed to run terraform destroy", 27 | mockCommands: []*mockCommand{ 28 | { 29 | args: []string{"terraform", "destroy"}, 30 | exitCode: 1, 31 | }, 32 | }, 33 | ok: false, 34 | }, 35 | { 36 | desc: "with opts", 37 | mockCommands: []*mockCommand{ 38 | { 39 | args: []string{"terraform", "destroy", "-input=false", "-no-color"}, 40 | exitCode: 0, 41 | }, 42 | }, 43 | opts: []string{"-input=false", "-no-color"}, 44 | ok: true, 45 | }, 46 | } 47 | 48 | for _, tc := range cases { 49 | t.Run(tc.desc, func(t *testing.T) { 50 | e := NewMockExecutor(tc.mockCommands) 51 | terraformCLI := NewTerraformCLI(e) 52 | terraformCLI.SetExecPath("terraform") 53 | err := terraformCLI.Destroy(context.Background(), tc.opts...) 54 | if tc.ok && err != nil { 55 | t.Fatalf("unexpected err: %s", err) 56 | } 57 | if !tc.ok && err == nil { 58 | t.Fatal("expected to return an error, but no error") 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestAccTerraformCLIDestroy(t *testing.T) { 65 | SkipUnlessAcceptanceTestEnabled(t) 66 | 67 | source := `resource "null_resource" "foo" {}` 68 | e := SetupTestAcc(t, source) 69 | terraformCLI := NewTerraformCLI(e) 70 | 71 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 72 | if err != nil { 73 | t.Fatalf("failed to run terraform init: %s", err) 74 | } 75 | 76 | err = terraformCLI.Apply(context.Background(), nil, "-input=false", "-no-color", "-auto-approve") 77 | if err != nil { 78 | t.Fatalf("failed to run terraform apply: %s", err) 79 | } 80 | 81 | err = terraformCLI.Destroy(context.Background(), "-input=false", "-no-color", "-auto-approve") 82 | if err != nil { 83 | t.Fatalf("failed to run terraform destroy: %s", err) 84 | } 85 | 86 | got, err := terraformCLI.StateList(context.Background(), nil, nil) 87 | if err != nil { 88 | t.Fatalf("failed to run terraform state list: %s", err) 89 | } 90 | 91 | if len(got) != 0 { 92 | t.Errorf("expected no resources, but got: %v", got) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tfexec/terraform_import.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // Import imports an existing resource to state. 10 | // If a state is given, use it for the input state. 11 | func (c *terraformCLI) Import(ctx context.Context, state *State, address string, id string, opts ...string) (*State, error) { 12 | args := []string{"import"} 13 | 14 | if state != nil { 15 | if hasPrefixOptions(opts, "-state=") { 16 | return nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts) 17 | } 18 | tmpState, err := writeTempFile(state.Bytes()) 19 | defer os.Remove(tmpState.Name()) 20 | if err != nil { 21 | return nil, err 22 | } 23 | args = append(args, "-state="+tmpState.Name()) 24 | } 25 | 26 | // disallow -state-out option for writing a state file to a temporary file and load it to memory 27 | if hasPrefixOptions(opts, "-state-out=") { 28 | return nil, fmt.Errorf("failed to build options. The -state-out= option is not allowed. Read a return value: %v", opts) 29 | } 30 | 31 | tmpStateOut, err := os.CreateTemp("", "tfstate") 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to create temporary state out file: %s", err) 34 | } 35 | defer os.Remove(tmpStateOut.Name()) 36 | 37 | if err := tmpStateOut.Close(); err != nil { 38 | return nil, fmt.Errorf("failed to close temporary state out file: %s", err) 39 | } 40 | args = append(args, "-state-out="+tmpStateOut.Name()) 41 | 42 | args = append(args, opts...) 43 | args = append(args, address, id) 44 | 45 | _, _, err = c.Run(ctx, args...) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | stateOut, err := os.ReadFile(tmpStateOut.Name()) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return NewState(stateOut), nil 55 | } 56 | -------------------------------------------------------------------------------- /tfexec/terraform_init.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Init initializes the current work directory. 8 | func (c *terraformCLI) Init(ctx context.Context, opts ...string) error { 9 | args := []string{"init"} 10 | args = append(args, opts...) 11 | _, _, err := c.Run(ctx, args...) 12 | return err 13 | } 14 | -------------------------------------------------------------------------------- /tfexec/terraform_init_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestTerraformCLIInit(t *testing.T) { 11 | cases := []struct { 12 | desc string 13 | mockCommands []*mockCommand 14 | opts []string 15 | ok bool 16 | }{ 17 | { 18 | desc: "no opts", 19 | mockCommands: []*mockCommand{ 20 | { 21 | args: []string{"terraform", "init"}, 22 | exitCode: 0, 23 | }, 24 | }, 25 | ok: true, 26 | }, 27 | { 28 | desc: "failed to run terraform init", 29 | mockCommands: []*mockCommand{ 30 | { 31 | args: []string{"terraform", "init"}, 32 | exitCode: 1, 33 | }, 34 | }, 35 | ok: false, 36 | }, 37 | { 38 | desc: "with opts", 39 | mockCommands: []*mockCommand{ 40 | { 41 | args: []string{"terraform", "init", "-input=false", "-no-color"}, 42 | exitCode: 0, 43 | }, 44 | }, 45 | opts: []string{"-input=false", "-no-color"}, 46 | ok: true, 47 | }, 48 | } 49 | 50 | for _, tc := range cases { 51 | t.Run(tc.desc, func(t *testing.T) { 52 | e := NewMockExecutor(tc.mockCommands) 53 | terraformCLI := NewTerraformCLI(e) 54 | terraformCLI.SetExecPath("terraform") 55 | err := terraformCLI.Init(context.Background(), tc.opts...) 56 | if tc.ok && err != nil { 57 | t.Fatalf("unexpected err: %s", err) 58 | } 59 | if !tc.ok && err == nil { 60 | t.Fatal("expected to return an error, but no error") 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func TestAccTerraformCLIInit(t *testing.T) { 67 | SkipUnlessAcceptanceTestEnabled(t) 68 | 69 | source := `resource "null_resource" "foo" {}` 70 | e := SetupTestAcc(t, source) 71 | terraformCLI := NewTerraformCLI(e) 72 | 73 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 74 | if err != nil { 75 | t.Fatalf("failed to run terraform init: %s", err) 76 | } 77 | 78 | if _, err := os.Stat(filepath.Join(e.Dir(), ".terraform")); os.IsNotExist(err) { 79 | t.Fatalf("failed to find .terraform directory: %s", err) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tfexec/terraform_plan.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // Plan computes expected changes. 10 | // If a state is given, use it for the input state. 11 | func (c *terraformCLI) Plan(ctx context.Context, state *State, opts ...string) (*Plan, error) { 12 | args := []string{"plan"} 13 | 14 | if state != nil { 15 | if hasPrefixOptions(opts, "-state=") { 16 | return nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts) 17 | } 18 | tmpState, err := writeTempFile(state.Bytes()) 19 | defer os.Remove(tmpState.Name()) 20 | if err != nil { 21 | return nil, err 22 | } 23 | args = append(args, "-state="+tmpState.Name()) 24 | } 25 | 26 | // To return a plan file as a return value, we always use an -out option and load it to memory. 27 | // if the option exists just use it else create a temporary file. 28 | planOut := "" 29 | if hasPrefixOptions(opts, "-out=") { 30 | planOut = getOptionValue(opts, "-out=") 31 | } else { 32 | tmpPlan, err := os.CreateTemp("", "tfplan") 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to create temporary plan file: %s", err) 35 | } 36 | planOut = tmpPlan.Name() 37 | defer os.Remove(planOut) 38 | 39 | if err := tmpPlan.Close(); err != nil { 40 | return nil, fmt.Errorf("failed to close temporary plan file: %s", err) 41 | } 42 | args = append(args, "-out="+planOut) 43 | } 44 | 45 | args = append(args, opts...) 46 | 47 | _, _, err := c.Run(ctx, args...) 48 | 49 | // terraform plan -detailed-exitcode returns 2 if there is a diff. 50 | // So we intentionally ignore an error of read the plan file and returns the 51 | // original error of terraform plan command. 52 | plan, _ := os.ReadFile(planOut) 53 | return NewPlan(plan), err 54 | } 55 | -------------------------------------------------------------------------------- /tfexec/terraform_providers.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Providers prints out a tree of modules in the referenced configuration annotated with 8 | // their provider requirements. 9 | func (c *terraformCLI) Providers(ctx context.Context) (string, error) { 10 | args := []string{"providers"} 11 | 12 | stdout, _, err := c.Run(ctx, args...) 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | return stdout, nil 18 | } 19 | -------------------------------------------------------------------------------- /tfexec/terraform_providers_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | var legacyTerraformProvidersStdout = `. 10 | └── provider.null 11 | 12 | ` 13 | 14 | var terraformProvidersStdout = ` 15 | Providers required by configuration: 16 | . 17 | └── provider[registry.terraform.io/hashicorp/null] 18 | 19 | Providers required by state: 20 | 21 | provider[registry.terraform.io/hashicorp/null] 22 | 23 | ` 24 | 25 | var opentofuProvidersStdout = ` 26 | Providers required by configuration: 27 | . 28 | └── provider[registry.opentofu.org/hashicorp/null] 29 | 30 | Providers required by state: 31 | 32 | provider[registry.opentofu.org/hashicorp/null] 33 | 34 | ` 35 | 36 | func TestTerraformCLIProviders(t *testing.T) { 37 | cases := []struct { 38 | desc string 39 | mockCommands []*mockCommand 40 | addresses []string 41 | want string 42 | ok bool 43 | }{ 44 | { 45 | desc: "basic invocation", 46 | mockCommands: []*mockCommand{ 47 | { 48 | stdout: terraformProvidersStdout, 49 | exitCode: 0, 50 | }, 51 | }, 52 | want: terraformProvidersStdout, 53 | ok: true, 54 | }, 55 | { 56 | desc: "failed to run terraform providers", 57 | mockCommands: []*mockCommand{ 58 | { 59 | exitCode: 1, 60 | }, 61 | }, 62 | want: "", 63 | ok: false, 64 | }, 65 | } 66 | 67 | for _, tc := range cases { 68 | t.Run(tc.desc, func(t *testing.T) { 69 | tc.mockCommands[0].args = []string{"terraform", "providers"} 70 | e := NewMockExecutor(tc.mockCommands) 71 | terraformCLI := NewTerraformCLI(e) 72 | terraformCLI.SetExecPath("terraform") 73 | got, err := terraformCLI.Providers(context.Background()) 74 | if tc.ok && err != nil { 75 | t.Fatalf("unexpected err: %s", err) 76 | } 77 | if !tc.ok && err == nil { 78 | t.Fatal("expected to return an error, but no error") 79 | } 80 | if tc.ok && !reflect.DeepEqual(got, tc.want) { 81 | t.Errorf("got: %v, want: %v", got, tc.want) 82 | } 83 | }) 84 | } 85 | } 86 | 87 | func TestAccTerraformCLIProviders(t *testing.T) { 88 | SkipUnlessAcceptanceTestEnabled(t) 89 | 90 | source := ` 91 | resource "null_resource" "foo" {} 92 | resource "null_resource" "bar" {} 93 | ` 94 | e := SetupTestAcc(t, source) 95 | terraformCLI := NewTerraformCLI(e) 96 | 97 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 98 | if err != nil { 99 | t.Fatalf("failed to run terraform init: %s", err) 100 | } 101 | 102 | err = terraformCLI.Apply(context.Background(), nil, "-input=false", "-no-color", "-auto-approve") 103 | if err != nil { 104 | t.Fatalf("failed to run terraform apply: %s", err) 105 | } 106 | 107 | got, err := terraformCLI.Providers(context.Background()) 108 | if err != nil { 109 | t.Fatalf("failed to run terraform providers: %s", err) 110 | } 111 | 112 | supportsStateReplaceProvider, _, err := terraformCLI.SupportsStateReplaceProvider(context.Background()) 113 | if err != nil { 114 | t.Fatalf("failed to determine if Terraform version supports state replace-provider: %s", err) 115 | } 116 | 117 | execType, _, err := terraformCLI.Version(context.Background()) 118 | if err != nil { 119 | t.Fatalf("failed to detect execType: %s", err) 120 | } 121 | want := "" 122 | switch execType { 123 | case "terraform": 124 | if !supportsStateReplaceProvider { 125 | want = legacyTerraformProvidersStdout 126 | } else { 127 | want = terraformProvidersStdout 128 | } 129 | case "opentofu": 130 | want = opentofuProvidersStdout 131 | } 132 | 133 | if got != want { 134 | t.Errorf("got: %s, want: %s", got, want) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tfexec/terraform_state_list.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | // StateList shows a list of resources. 11 | // If a state is given, use it for the input state. 12 | func (c *terraformCLI) StateList(ctx context.Context, state *State, addresses []string, opts ...string) ([]string, error) { 13 | args := []string{"state", "list"} 14 | 15 | if state != nil { 16 | if hasPrefixOptions(opts, "-state=") { 17 | return nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts) 18 | } 19 | tmpState, err := writeTempFile(state.Bytes()) 20 | defer os.Remove(tmpState.Name()) 21 | if err != nil { 22 | return nil, err 23 | } 24 | args = append(args, "-state="+tmpState.Name()) 25 | } 26 | 27 | args = append(args, opts...) 28 | 29 | if len(addresses) > 0 { 30 | args = append(args, addresses...) 31 | } 32 | 33 | stdout, _, err := c.Run(ctx, args...) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | // we want to split stdout by '\n', but strings.Split returns []string{""} if stdout is empty. 39 | // we should remove empty strings from the list so that its length to be 0. 40 | resources := strings.FieldsFunc( 41 | strings.TrimRight(stdout, "\n"), 42 | func(c rune) bool { 43 | return c == '\n' 44 | }, 45 | ) 46 | return resources, nil 47 | } 48 | -------------------------------------------------------------------------------- /tfexec/terraform_state_mv.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // StateMv moves resources from source to destination address. 10 | // If a state argument is given, use it for the input state. 11 | // If a stateOut argument is given, move resources from state to stateOut. 12 | // It returns updated the given state and the stateOut. 13 | func (c *terraformCLI) StateMv(ctx context.Context, state *State, stateOut *State, source string, destination string, opts ...string) (*State, *State, error) { 14 | args := []string{"state", "mv"} 15 | 16 | var tmpState *os.File 17 | var tmpStateOut *os.File 18 | var err error 19 | 20 | if state != nil { 21 | if hasPrefixOptions(opts, "-state=") { 22 | return nil, nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts) 23 | } 24 | tmpState, err = writeTempFile(state.Bytes()) 25 | defer os.Remove(tmpState.Name()) 26 | if err != nil { 27 | return nil, nil, err 28 | } 29 | args = append(args, "-state="+tmpState.Name()) 30 | } 31 | 32 | if stateOut != nil { 33 | if hasPrefixOptions(opts, "-state-out=") { 34 | return nil, nil, fmt.Errorf("failed to build options. The stateOut argument (!= nil) and the -state-out= option cannot be set at the same time: stateOut=%v, opts=%v", stateOut, opts) 35 | } 36 | tmpStateOut, err = writeTempFile(stateOut.Bytes()) 37 | defer os.Remove(tmpStateOut.Name()) 38 | if err != nil { 39 | return nil, nil, err 40 | } 41 | args = append(args, "-state-out="+tmpStateOut.Name()) 42 | } 43 | 44 | args = append(args, opts...) 45 | args = append(args, source, destination) 46 | 47 | _, _, err = c.Run(ctx, args...) 48 | if err != nil { 49 | return nil, nil, err 50 | } 51 | 52 | // Read updated states 53 | var updatedState *State 54 | var updatedStateOut *State 55 | 56 | if state != nil { 57 | bytes, err := os.ReadFile(tmpState.Name()) 58 | if err != nil { 59 | return nil, nil, err 60 | } 61 | updatedState = NewState(bytes) 62 | } 63 | 64 | if stateOut != nil { 65 | bytes, err := os.ReadFile(tmpStateOut.Name()) 66 | if err != nil { 67 | return nil, nil, err 68 | } 69 | updatedStateOut = NewState(bytes) 70 | } 71 | 72 | return updatedState, updatedStateOut, nil 73 | } 74 | -------------------------------------------------------------------------------- /tfexec/terraform_state_pull.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import "context" 4 | 5 | // StatePull returns the current tfstate from remote. 6 | func (c *terraformCLI) StatePull(ctx context.Context, opts ...string) (*State, error) { 7 | args := []string{"state", "pull"} 8 | // At time of writing, there is no valid option in Terraform v0.12/v0.13. 9 | // It's a room for future extensions not to break the interface. 10 | args = append(args, opts...) 11 | 12 | stdout, _, err := c.Run(ctx, args...) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return NewState([]byte(stdout)), nil 18 | } 19 | -------------------------------------------------------------------------------- /tfexec/terraform_state_pull_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestTerraformCLIStatePull(t *testing.T) { 10 | stdout := "dummy state" 11 | cases := []struct { 12 | desc string 13 | mockCommands []*mockCommand 14 | opts []string 15 | want *State 16 | ok bool 17 | }{ 18 | { 19 | desc: "print tfstate to stdout", 20 | mockCommands: []*mockCommand{ 21 | { 22 | args: []string{"terraform", "state", "pull"}, 23 | stdout: stdout, 24 | exitCode: 0, 25 | }, 26 | }, 27 | want: NewState([]byte(stdout)), 28 | ok: true, 29 | }, 30 | { 31 | desc: "failed to run terraform state pull", 32 | mockCommands: []*mockCommand{ 33 | { 34 | args: []string{"terraform", "state", "pull"}, 35 | exitCode: 1, 36 | }, 37 | }, 38 | want: nil, 39 | ok: false, 40 | }, 41 | { 42 | desc: "with opts", // there is no valid option for now, just pass a dummy for testing. 43 | mockCommands: []*mockCommand{ 44 | { 45 | args: []string{"terraform", "state", "pull", "-foo"}, 46 | stdout: stdout, 47 | exitCode: 0, 48 | }, 49 | }, 50 | opts: []string{"-foo"}, 51 | want: NewState([]byte(stdout)), 52 | ok: true, 53 | }, 54 | } 55 | 56 | for _, tc := range cases { 57 | t.Run(tc.desc, func(t *testing.T) { 58 | e := NewMockExecutor(tc.mockCommands) 59 | terraformCLI := NewTerraformCLI(e) 60 | terraformCLI.SetExecPath("terraform") 61 | got, err := terraformCLI.StatePull(context.Background(), tc.opts...) 62 | if tc.ok && err != nil { 63 | t.Fatalf("unexpected err: %s", err) 64 | } 65 | if !tc.ok && err == nil { 66 | t.Fatalf("expected to return an error, but no error, got = %s", got) 67 | } 68 | if tc.ok && !reflect.DeepEqual(got.Bytes(), tc.want.Bytes()) { 69 | t.Errorf("got: %s, want: %s", got, tc.want) 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestAccTerraformCLIStatePull(t *testing.T) { 76 | SkipUnlessAcceptanceTestEnabled(t) 77 | 78 | source := `resource "null_resource" "foo" {}` 79 | e := SetupTestAcc(t, source) 80 | terraformCLI := NewTerraformCLI(e) 81 | 82 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 83 | if err != nil { 84 | t.Fatalf("failed to run terraform init: %s", err) 85 | } 86 | 87 | err = terraformCLI.Apply(context.Background(), nil, "-input=false", "-no-color", "-auto-approve") 88 | if err != nil { 89 | t.Fatalf("failed to run terraform apply: %s", err) 90 | } 91 | 92 | state, err := terraformCLI.StatePull(context.Background()) 93 | if err != nil { 94 | t.Fatalf("failed to run terraform state pull: %s", err) 95 | } 96 | 97 | got, err := terraformCLI.StateList(context.Background(), state, nil) 98 | if err != nil { 99 | t.Fatalf("failed to run terraform state list: %s", err) 100 | } 101 | 102 | want := []string{"null_resource.foo"} 103 | if !reflect.DeepEqual(got, want) { 104 | t.Errorf("got: %v, want: %v", got, want) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tfexec/terraform_state_push.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "os" 6 | ) 7 | 8 | // StatePush pushes a given State to remote. 9 | func (c *terraformCLI) StatePush(ctx context.Context, state *State, opts ...string) error { 10 | args := []string{"state", "push"} 11 | args = append(args, opts...) 12 | 13 | tmpState, err := writeTempFile(state.Bytes()) 14 | defer os.Remove(tmpState.Name()) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | args = append(args, tmpState.Name()) 20 | _, _, err = c.Run(ctx, args...) 21 | return err 22 | } 23 | -------------------------------------------------------------------------------- /tfexec/terraform_state_push_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | "regexp" 9 | "testing" 10 | ) 11 | 12 | func TestTerraformCLIStatePush(t *testing.T) { 13 | state := NewState([]byte("dummy state")) 14 | cases := []struct { 15 | desc string 16 | mockCommands []*mockCommand 17 | state *State 18 | opts []string 19 | ok bool 20 | }{ 21 | { 22 | desc: "state push from memory", 23 | mockCommands: []*mockCommand{ 24 | { 25 | args: []string{"terraform", "state", "push", "/path/to/tempfile"}, 26 | argsRe: regexp.MustCompile(`^terraform state push \S+$`), 27 | exitCode: 0, 28 | }, 29 | }, 30 | state: state, 31 | ok: true, 32 | }, 33 | { 34 | desc: "failed to run terraform state push", 35 | mockCommands: []*mockCommand{ 36 | { 37 | args: []string{"terraform", "state", "push", "/path/to/tempfile"}, 38 | argsRe: regexp.MustCompile(`^terraform state push \S+$`), 39 | exitCode: 1, 40 | }, 41 | }, 42 | state: state, 43 | ok: false, 44 | }, 45 | { 46 | desc: "with opts", 47 | mockCommands: []*mockCommand{ 48 | { 49 | args: []string{"terraform", "state", "push", "-force", "-lock=false", "/path/to/tempfile"}, 50 | argsRe: regexp.MustCompile(`^terraform state push -force -lock=false \S+$`), 51 | exitCode: 0, 52 | }, 53 | }, 54 | state: state, 55 | opts: []string{"-force", "-lock=false"}, 56 | ok: true, 57 | }, 58 | } 59 | 60 | for _, tc := range cases { 61 | t.Run(tc.desc, func(t *testing.T) { 62 | e := NewMockExecutor(tc.mockCommands) 63 | terraformCLI := NewTerraformCLI(e) 64 | terraformCLI.SetExecPath("terraform") 65 | err := terraformCLI.StatePush(context.Background(), tc.state, tc.opts...) 66 | if tc.ok && err != nil { 67 | t.Fatalf("unexpected err: %s", err) 68 | } 69 | if !tc.ok && err == nil { 70 | t.Fatalf("expected to return an error, but no error") 71 | } 72 | }) 73 | } 74 | } 75 | 76 | func TestAccTerraformCLIStatePush(t *testing.T) { 77 | SkipUnlessAcceptanceTestEnabled(t) 78 | 79 | source := `resource "null_resource" "foo" {}` 80 | e := SetupTestAcc(t, source) 81 | terraformCLI := NewTerraformCLI(e) 82 | 83 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 84 | if err != nil { 85 | t.Fatalf("failed to run terraform init: %s", err) 86 | } 87 | 88 | err = terraformCLI.Apply(context.Background(), nil, "-input=false", "-no-color", "-auto-approve") 89 | if err != nil { 90 | t.Fatalf("failed to run terraform apply: %s", err) 91 | } 92 | 93 | state, err := terraformCLI.StatePull(context.Background()) 94 | if err != nil { 95 | t.Fatalf("failed to run terraform state pull: %s", err) 96 | } 97 | 98 | // Normally, state push to remote, but we push to `local` here for testing. 99 | // So we remove the original local state before push. 100 | err = os.Remove(filepath.Join(e.Dir(), "terraform.tfstate")) 101 | if err != nil { 102 | t.Fatalf("failed to remove local tfstate before push: %s", err) 103 | } 104 | 105 | err = terraformCLI.StatePush(context.Background(), state) 106 | if err != nil { 107 | t.Fatalf("failed to run terraform state push: %s", err) 108 | } 109 | 110 | got, err := terraformCLI.StateList(context.Background(), nil, nil) 111 | if err != nil { 112 | t.Fatalf("failed to run terraform state list: %s", err) 113 | } 114 | 115 | want := []string{"null_resource.foo"} 116 | if !reflect.DeepEqual(got, want) { 117 | t.Errorf("got: %v, want: %v", got, want) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tfexec/terraform_state_replace_provider.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/hashicorp/go-version" 9 | ) 10 | 11 | const ( 12 | // MinimumTerraformVersionForStateReplaceProvider specifies the minimum 13 | // supported Terraform version for StateReplaceProvider. 14 | MinimumTerraformVersionForStateReplaceProvider = "0.13" 15 | 16 | // AcceptableLegacyStateInitError is the error message returned by `terraform 17 | // init` when a non-legacy Terraform CLI is used against a legacy Terraform state. 18 | // When invoking `state replace-provider`, it's necessary to first 19 | // invoke `terraform init`. However, when using a non-legacy Terraform CLI 20 | // against a legacy Terraform state, this error is expected. 21 | AcceptableLegacyStateInitError = "Error: Invalid legacy provider address" 22 | ) 23 | 24 | // SupportsStateReplaceProvider returns true if the terraform version is greater 25 | // than or equal to 0.13.0 and therefore supports the state replace-provider command. 26 | func (c *terraformCLI) SupportsStateReplaceProvider(ctx context.Context) (bool, version.Constraints, error) { 27 | constraints, err := version.NewConstraint(fmt.Sprintf(">= %s", MinimumTerraformVersionForStateReplaceProvider)) 28 | if err != nil { 29 | return false, constraints, err 30 | } 31 | 32 | _, v, err := c.Version(ctx) 33 | if err != nil { 34 | return false, constraints, err 35 | } 36 | 37 | ver, err := truncatePreReleaseVersion(v) 38 | if err != nil { 39 | return false, constraints, err 40 | } 41 | 42 | if !constraints.Check(ver) { 43 | return false, constraints, nil 44 | } 45 | 46 | return true, constraints, nil 47 | } 48 | 49 | // StateReplaceProvider replaces providers from source to destination address. 50 | // If a state argument is given, use it for the input state. 51 | // It returns the given state. 52 | func (c *terraformCLI) StateReplaceProvider(ctx context.Context, state *State, source string, destination string, opts ...string) (*State, error) { 53 | supports, constraints, err := c.SupportsStateReplaceProvider(ctx) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | if !supports { 59 | return nil, fmt.Errorf("replace-provider action requires Terraform version %s", constraints) 60 | } 61 | 62 | args := []string{"state", "replace-provider"} 63 | 64 | var tmpState *os.File 65 | 66 | if state != nil { 67 | if hasPrefixOptions(opts, "-state=") { 68 | return nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts) 69 | } 70 | tmpState, err = writeTempFile(state.Bytes()) 71 | defer os.Remove(tmpState.Name()) 72 | if err != nil { 73 | return nil, err 74 | } 75 | args = append(args, "-state="+tmpState.Name()) 76 | } 77 | 78 | args = append(args, opts...) 79 | args = append(args, source, destination) 80 | 81 | _, _, err = c.Run(ctx, args...) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | // Read updated states 87 | var updatedState *State 88 | 89 | if state != nil { 90 | bytes, err := os.ReadFile(tmpState.Name()) 91 | if err != nil { 92 | return nil, err 93 | } 94 | updatedState = NewState(bytes) 95 | } 96 | 97 | return updatedState, nil 98 | } 99 | -------------------------------------------------------------------------------- /tfexec/terraform_state_rm.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // StateRm removes resources from state. 10 | // If a state is given, use it for the input state and return a new state. 11 | // Note that if the input state is not given, always return nil state, 12 | // because the terraform state rm command doesn't have -state-out option. 13 | func (c *terraformCLI) StateRm(ctx context.Context, state *State, addresses []string, opts ...string) (*State, error) { 14 | args := []string{"state", "rm"} 15 | 16 | var tmpState *os.File 17 | var err error 18 | if state != nil { 19 | if hasPrefixOptions(opts, "-state=") { 20 | return nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts) 21 | } 22 | tmpState, err = writeTempFile(state.Bytes()) 23 | defer os.Remove(tmpState.Name()) 24 | if err != nil { 25 | return nil, err 26 | } 27 | args = append(args, "-state="+tmpState.Name()) 28 | } 29 | 30 | args = append(args, opts...) 31 | 32 | if len(addresses) > 0 { 33 | args = append(args, addresses...) 34 | } 35 | 36 | _, _, err = c.Run(ctx, args...) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // The terraform state rm command doesn't have -state-out option, 42 | // It updates the input state in-place, so we read the updated contents and return it. 43 | // The interface is a bit inconsistency against the terraform command, 44 | // but we prefer returning a new state to updating the argument in-place. 45 | if state != nil { 46 | stateOut, err := os.ReadFile(tmpState.Name()) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return NewState(stateOut), nil 51 | } 52 | // If state == nil, it updates the current default state, 53 | // we can read it with calling the state pull command, 54 | // but we avoid invoking it implicitly and just return nil. 55 | return nil, nil 56 | } 57 | -------------------------------------------------------------------------------- /tfexec/terraform_version.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/hashicorp/go-version" 10 | ) 11 | 12 | // tfVersionRe is a pattern to parse outputs from terraform version. 13 | var tfVersionRe = regexp.MustCompile(`^(Terraform|OpenTofu) v(.+)\s*\n`) 14 | 15 | // Version returns the Terraform execType and version number. 16 | // The execType can be either terraform or opentofu. 17 | func (c *terraformCLI) Version(ctx context.Context) (string, *version.Version, error) { 18 | stdout, _, err := c.Run(ctx, "version") 19 | if err != nil { 20 | return "", nil, err 21 | } 22 | 23 | matched := tfVersionRe.FindStringSubmatch(stdout) 24 | if len(matched) != 3 { 25 | return "", nil, fmt.Errorf("failed to parse terraform version: %s", stdout) 26 | } 27 | 28 | execType := "" 29 | switch matched[1] { 30 | case "Terraform": 31 | execType = "terraform" 32 | case "OpenTofu": 33 | execType = "opentofu" 34 | default: 35 | return "", nil, fmt.Errorf("unknown execType: %s", matched[1]) 36 | } 37 | 38 | version, err := version.NewVersion(matched[2]) 39 | if err != nil { 40 | return "", nil, err 41 | } 42 | 43 | return execType, version, nil 44 | } 45 | 46 | // truncatePreReleaseVersion is a helper function that removes 47 | // pre-release information. 48 | // The hashicorp/go-version returns false when comparing pre-releases, for 49 | // example 1.6.0-rc1 >= 0.13. This is counter-intuitive for determining the 50 | // presence or absence of a feature, so remove the pre-release information 51 | // before comparing. 52 | func truncatePreReleaseVersion(v *version.Version) (*version.Version, error) { 53 | if v.Prerelease() == "" { 54 | return v, nil 55 | } 56 | 57 | vs, _, _ := strings.Cut(v.String(), "-") 58 | 59 | ver, err := version.NewVersion(vs) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return ver, nil 65 | } 66 | -------------------------------------------------------------------------------- /tfexec/terraform_workspace_new.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // WorkspaceNew creates a new workspace 8 | func (c *terraformCLI) WorkspaceNew(ctx context.Context, workspace string, opts ...string) error { 9 | args := []string{"workspace", "new"} 10 | args = append(args, opts...) 11 | if len(workspace) > 0 { 12 | args = append(args, workspace) 13 | } 14 | _, _, err := c.Run(ctx, args...) 15 | return err 16 | } 17 | -------------------------------------------------------------------------------- /tfexec/terraform_workspace_new_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestTerraformCLIWorkspaceNew(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | mockCommands []*mockCommand 12 | workspace string 13 | opts []string 14 | ok bool 15 | }{ 16 | { 17 | desc: "no workspace, no opts", 18 | mockCommands: []*mockCommand{ 19 | { 20 | args: []string{"terraform", "workspace", "new"}, 21 | exitCode: 1, 22 | }, 23 | }, 24 | ok: false, 25 | }, 26 | { 27 | desc: "with workspace", 28 | mockCommands: []*mockCommand{ 29 | { 30 | args: []string{"terraform", "workspace", "new", "foo"}, 31 | exitCode: 0, 32 | }, 33 | }, 34 | workspace: "foo", 35 | ok: true, 36 | }, 37 | { 38 | desc: "with workspace and opts", 39 | mockCommands: []*mockCommand{ 40 | { 41 | args: []string{"terraform", "workspace", "new", "-lock=true", "-lock-timeout=0s", "foo"}, 42 | exitCode: 0, 43 | }, 44 | }, 45 | workspace: "foo", 46 | opts: []string{"-lock=true", "-lock-timeout=0s"}, 47 | ok: true, 48 | }, 49 | } 50 | for _, tc := range cases { 51 | t.Run(tc.desc, func(t *testing.T) { 52 | e := NewMockExecutor(tc.mockCommands) 53 | terraformCLI := NewTerraformCLI(e) 54 | terraformCLI.SetExecPath("terraform") 55 | err := terraformCLI.WorkspaceNew(context.Background(), tc.workspace, tc.opts...) 56 | if tc.ok && err != nil { 57 | t.Fatalf("unexpected err: %s", err) 58 | } 59 | if !tc.ok && err == nil { 60 | t.Fatal("expected to return an error, but no error") 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func TestAccTerraformCLIWorkspaceNew(t *testing.T) { 67 | SkipUnlessAcceptanceTestEnabled(t) 68 | 69 | source := `resource "null_resource" "foo" {}` 70 | e := SetupTestAcc(t, source) 71 | terraformCLI := NewTerraformCLI(e) 72 | 73 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 74 | if err != nil { 75 | t.Fatalf("failed to run terraform init: %s", err) 76 | } 77 | 78 | err = terraformCLI.WorkspaceNew(context.Background(), "myworkspace") 79 | if err != nil { 80 | t.Fatalf("failed to create a new workspace: %s", err) 81 | } 82 | 83 | got, err := terraformCLI.WorkspaceShow(context.Background()) 84 | if err != nil { 85 | t.Fatalf("failed to run terraform workspace show: %s", err) 86 | } 87 | 88 | if got != "myworkspace" { 89 | t.Error("The current workspace doesn't match the workspace that was just created") 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tfexec/terraform_workspace_select.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // WorkspaceSelect selects the workspace "workspace". The workspace needs to exist 8 | // in order for the switch to be successful 9 | func (c *terraformCLI) WorkspaceSelect(ctx context.Context, workspace string) error { 10 | args := []string{"workspace", "select"} 11 | if len(workspace) > 0 { 12 | args = append(args, workspace) 13 | } 14 | _, _, err := c.Run(ctx, args...) 15 | return err 16 | } 17 | -------------------------------------------------------------------------------- /tfexec/terraform_workspace_select_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestTerraformCLIWorkspaceSelect(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | mockCommands []*mockCommand 12 | workspace string 13 | ok bool 14 | }{ 15 | { 16 | desc: "no workspace", 17 | mockCommands: []*mockCommand{ 18 | { 19 | args: []string{"terraform", "workspace", "select"}, 20 | exitCode: 1, 21 | }, 22 | }, 23 | ok: false, 24 | }, 25 | { 26 | desc: "with existing workspace", 27 | mockCommands: []*mockCommand{ 28 | { 29 | args: []string{"terraform", "workspace", "select", "foo"}, 30 | exitCode: 0, 31 | }, 32 | }, 33 | workspace: "foo", 34 | ok: true, 35 | }, 36 | { 37 | desc: "failed to run terraform workspace select", 38 | mockCommands: []*mockCommand{ 39 | { 40 | args: []string{"terraform", "workspace", "select", "foo"}, 41 | exitCode: 1, 42 | }, 43 | }, 44 | workspace: "foo", 45 | ok: false, 46 | }, 47 | } 48 | for _, tc := range cases { 49 | t.Run(tc.desc, func(t *testing.T) { 50 | e := NewMockExecutor(tc.mockCommands) 51 | terraformCLI := NewTerraformCLI(e) 52 | terraformCLI.SetExecPath("terraform") 53 | err := terraformCLI.WorkspaceSelect(context.Background(), tc.workspace) 54 | if tc.ok && err != nil { 55 | t.Fatalf("unexpected err: %s", err) 56 | } 57 | if !tc.ok && err == nil { 58 | t.Fatal("expected to return an error, but no error") 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestAccTerraformCLIWorkspaceSelect(t *testing.T) { 65 | SkipUnlessAcceptanceTestEnabled(t) 66 | 67 | source := `` 68 | e := SetupTestAcc(t, source) 69 | terraformCLI := NewTerraformCLI(e) 70 | 71 | err := terraformCLI.Init(context.Background(), "-input=false", "-no-color") 72 | if err != nil { 73 | t.Fatalf("failed to run terraform init: %s", err) 74 | } 75 | 76 | err = terraformCLI.WorkspaceNew(context.Background(), "myworkspace") 77 | if err != nil { 78 | t.Fatalf("failed to create a new workspace: %s", err) 79 | } 80 | 81 | err = terraformCLI.WorkspaceSelect(context.Background(), "default") 82 | if err != nil { 83 | t.Fatalf("failed to switch back to default workspace: %s", err) 84 | } 85 | 86 | got, err := terraformCLI.WorkspaceShow(context.Background()) 87 | if err != nil { 88 | t.Fatalf("failed to run terraform workspace show: %s", err) 89 | } 90 | 91 | if got != "default" { 92 | t.Error("The current workspace doesn't match the workspace that was just selected") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tfexec/terraform_workspace_show.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | ) 7 | 8 | // WorkspaceShow returns the currently selected workspace 9 | func (c *terraformCLI) WorkspaceShow(ctx context.Context) (string, error) { 10 | args := []string{"workspace", "show"} 11 | stdout, _, err := c.Run(ctx, args...) 12 | if err != nil { 13 | return "", err 14 | } 15 | return strings.TrimRight(stdout, "\n"), nil 16 | } 17 | -------------------------------------------------------------------------------- /tfexec/terraform_workspace_show_test.go: -------------------------------------------------------------------------------- 1 | package tfexec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestTerraformCLIWorkspaceShow(t *testing.T) { 11 | cases := []struct { 12 | desc string 13 | mockCommands []*mockCommand 14 | want string 15 | ok bool 16 | }{ 17 | { 18 | desc: "parse output of terraform workspace show", 19 | mockCommands: []*mockCommand{ 20 | { 21 | args: []string{"terraform", "workspace", "show"}, 22 | stdout: "default", 23 | exitCode: 0, 24 | }, 25 | }, 26 | want: "default", 27 | ok: true, 28 | }, 29 | { 30 | desc: "with existing workspace", 31 | mockCommands: []*mockCommand{ 32 | { 33 | args: []string{"terraform", "workspace", "show"}, 34 | exitCode: 1, 35 | }, 36 | }, 37 | want: "", 38 | ok: false, 39 | }, 40 | } 41 | for _, tc := range cases { 42 | t.Run(tc.desc, func(t *testing.T) { 43 | e := NewMockExecutor(tc.mockCommands) 44 | terraformCLI := NewTerraformCLI(e) 45 | terraformCLI.SetExecPath("terraform") 46 | got, err := terraformCLI.WorkspaceShow(context.Background()) 47 | if tc.ok && err != nil { 48 | t.Fatalf("unexpected err: %s", err) 49 | } 50 | if !tc.ok && err == nil { 51 | t.Fatal("expected to return an error, but no error") 52 | } 53 | if tc.ok && got != tc.want { 54 | t.Errorf("got: %s, want: %s", got, tc.want) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestAccTerraformCLIWorkspaceShow(t *testing.T) { 61 | SkipUnlessAcceptanceTestEnabled(t) 62 | 63 | e := NewExecutor("", os.Environ()) 64 | terraformCLI := NewTerraformCLI(e) 65 | got, err := terraformCLI.WorkspaceShow(context.Background()) 66 | if err != nil { 67 | t.Fatalf("failed to run terraform workspace show: %s", err) 68 | } 69 | if got != "default" { 70 | t.Error("terraform workspace show should return the default workspace") 71 | } 72 | fmt.Printf("got = %s\n", got) 73 | } 74 | -------------------------------------------------------------------------------- /tfmigrate/config.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | // MigrationConfig is a config for a migration. 4 | type MigrationConfig struct { 5 | // Type is a type for migration. 6 | // Valid values are `state` and `multi_state`. 7 | Type string 8 | // Name is an arbitrary name for migration. 9 | Name string 10 | // Migrator is an interface of factory method for Migrator. 11 | Migrator MigratorConfig 12 | } 13 | 14 | // MigratorConfig is an interface of factory method for Migrator. 15 | type MigratorConfig interface { 16 | // NewMigrator returns a new instance of Migrator. 17 | NewMigrator(o *MigratorOption) (Migrator, error) 18 | } 19 | 20 | // MigratorOption customizes a behavior of Migrator. 21 | // It is used for shared settings across Migrator instances. 22 | type MigratorOption struct { 23 | // ExecPath is a string how terraform command is executed. Default to terraform. 24 | // It's intended to inject a wrapper command such as direnv. 25 | // e.g.) direnv exec . terraform 26 | // To use OpenTofu, set this to `tofu`. 27 | ExecPath string 28 | 29 | // PlanOut is a path to plan file to be saved. 30 | PlanOut string 31 | 32 | // IsBackendTerraformCloud is a boolean indicating if the remote backend is Terraform Cloud 33 | IsBackendTerraformCloud bool 34 | 35 | // BackendConfig is a -backend-config option for remote state 36 | BackendConfig []string 37 | } 38 | -------------------------------------------------------------------------------- /tfmigrate/migrator.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strings" 7 | 8 | "github.com/minamijoyo/tfmigrate/tfexec" 9 | ) 10 | 11 | // Migrator abstracts migration operations. 12 | type Migrator interface { 13 | // Plan computes a new state by applying state migration operations to a temporary state. 14 | // It will fail if terraform plan detects any diffs with the new state. 15 | Plan(ctx context.Context) error 16 | 17 | // Apply computes a new state and pushes it to remote state. 18 | // It will fail if terraform plan detects any diffs with the new state. 19 | // This is intended for solely state refactoring. 20 | // Any state migration operations should not break any real resources. 21 | Apply(ctx context.Context) error 22 | } 23 | 24 | // setupWorkDir is a common helper function to set up work dir and returns the 25 | // current state and a switch back function. 26 | func setupWorkDir(ctx context.Context, tf tfexec.TerraformCLI, workspace string, isBackendTerraformCloud bool, backendConfig []string, ignoreLegacyStateInitErr bool) (*tfexec.State, func() error, error) { 27 | // check if terraform command is available. 28 | execType, version, err := tf.Version(ctx) 29 | if err != nil { 30 | return nil, nil, err 31 | } 32 | log.Printf("[INFO] [migrator@%s] %s version: %s\n", tf.Dir(), execType, version) 33 | 34 | supportsStateReplaceProvider, constraints, err := tf.SupportsStateReplaceProvider(ctx) 35 | if err != nil { 36 | return nil, nil, err 37 | } 38 | 39 | // init folder 40 | log.Printf("[INFO] [migrator@%s] initialize work dir\n", tf.Dir()) 41 | err = tf.Init(ctx, "-input=false", "-no-color") 42 | if err != nil { 43 | if supportsStateReplaceProvider && ignoreLegacyStateInitErr && strings.Contains(err.Error(), tfexec.AcceptableLegacyStateInitError) { 44 | log.Printf("[INFO] [migrator@%s] ignoring error '%s' initilizing work dir; the error is expected when using Terraform %s with a legacy Terraform state\n", tf.Dir(), tfexec.AcceptableLegacyStateInitError, constraints) 45 | } else { 46 | return nil, nil, err 47 | } 48 | } 49 | 50 | // check current workspace 51 | currentWorkspace, err := tf.WorkspaceShow(ctx) 52 | if err != nil { 53 | return nil, nil, err 54 | } 55 | log.Printf("[DEBUG] [migrator@%s] currentWorkspace = %s, workspace = %s\n", tf.Dir(), currentWorkspace, workspace) 56 | if currentWorkspace != workspace { 57 | // switch to workspace 58 | log.Printf("[INFO] [migrator@%s] switch to remote workspace %s\n", tf.Dir(), workspace) 59 | err = tf.WorkspaceSelect(ctx, workspace) 60 | if err != nil { 61 | return nil, nil, err 62 | } 63 | } 64 | 65 | // get the current remote state. 66 | log.Printf("[INFO] [migrator@%s] get the current remote state\n", tf.Dir()) 67 | currentState, err := tf.StatePull(ctx) 68 | if err != nil { 69 | return nil, nil, err 70 | } 71 | // override backend to local 72 | log.Printf("[INFO] [migrator@%s] override backend to local\n", tf.Dir()) 73 | switchBackToRemoteFunc, err := tf.OverrideBackendToLocal(ctx, "_tfmigrate_override.tf", workspace, isBackendTerraformCloud, backendConfig, ignoreLegacyStateInitErr) 74 | if err != nil { 75 | return nil, nil, err 76 | } 77 | return currentState, switchBackToRemoteFunc, nil 78 | } 79 | -------------------------------------------------------------------------------- /tfmigrate/mock_migrator.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/minamijoyo/tfmigrate/tfexec" 9 | ) 10 | 11 | // MockMigratorConfig is a config for MockMigrator. 12 | type MockMigratorConfig struct { 13 | // PlanError is a flag to return an error on Plan(). 14 | PlanError bool `hcl:"plan_error"` 15 | // ApplyError is a flag to return an error on Apply(). 16 | ApplyError bool `hcl:"apply_error"` 17 | } 18 | 19 | // MockMigratorConfig implements a MigratorConfig. 20 | var _ MigratorConfig = (*MockMigratorConfig)(nil) 21 | 22 | // NewMigrator returns a new instance of MockMigrator. 23 | func (c *MockMigratorConfig) NewMigrator(_ *MigratorOption) (Migrator, error) { 24 | return NewMockMigrator(c.PlanError, c.ApplyError), nil 25 | } 26 | 27 | // MockMigrator implements the Migrator interface for testing. 28 | // It does nothing, but can return an error. 29 | type MockMigrator struct { 30 | // planError is a flag to return an error on Plan(). 31 | planError bool 32 | // applyError is a flag to return an error on Apply(). 33 | applyError bool 34 | } 35 | 36 | var _ Migrator = (*MockMigrator)(nil) 37 | 38 | // NewMockMigrator returns a new MockMigrator instance. 39 | func NewMockMigrator(planError bool, applyError bool) *MockMigrator { 40 | return &MockMigrator{ 41 | planError: planError, 42 | applyError: applyError, 43 | } 44 | } 45 | 46 | // plan computes a new state by applying state migration operations to a temporary state. 47 | // It does nothing, but can return an error. 48 | func (m *MockMigrator) plan(_ context.Context) (*tfexec.State, error) { 49 | if m.planError { 50 | return nil, fmt.Errorf("failed to plan mock migrator: planError = %t", m.planError) 51 | } 52 | return nil, nil 53 | } 54 | 55 | // Plan computes a new state by applying state migration operations to a temporary state. 56 | // It does nothing, but can return an error. 57 | func (m *MockMigrator) Plan(ctx context.Context) error { 58 | log.Printf("[INFO] [migrator] start state migrator plan\n") 59 | _, err := m.plan(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | log.Printf("[INFO] [migrator] state migrator plan success!\n") 64 | return nil 65 | } 66 | 67 | // Apply computes a new state and pushes it to remote state. 68 | // It does nothing, but can return an error. 69 | func (m *MockMigrator) Apply(ctx context.Context) error { 70 | log.Printf("[INFO] [migrator] start state migrator plan phase for apply\n") 71 | _, err := m.plan(ctx) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | log.Printf("[INFO] [migrator] start state migrator apply phase\n") 77 | if m.applyError { 78 | return fmt.Errorf("failed to apply mock migrator: applyError = %t", m.applyError) 79 | } 80 | log.Printf("[INFO] [migrator] state migrator apply success!\n") 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /tfmigrate/mock_migrator_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestMockMigratorConfigNewMigrator(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | config *MockMigratorConfig 12 | o *MigratorOption 13 | ok bool 14 | }{ 15 | { 16 | desc: "valid", 17 | config: &MockMigratorConfig{ 18 | PlanError: true, 19 | ApplyError: false, 20 | }, 21 | o: &MigratorOption{ 22 | ExecPath: "direnv exec . terraform", 23 | }, 24 | ok: true, 25 | }, 26 | } 27 | 28 | for _, tc := range cases { 29 | t.Run(tc.desc, func(t *testing.T) { 30 | got, err := tc.config.NewMigrator(tc.o) 31 | if tc.ok && err != nil { 32 | t.Fatalf("unexpected err: %s", err) 33 | } 34 | if !tc.ok && err == nil { 35 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 36 | } 37 | if tc.ok { 38 | _ = got.(*MockMigrator) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestMockMigratorPlan(t *testing.T) { 45 | cases := []struct { 46 | desc string 47 | m *MockMigrator 48 | ok bool 49 | }{ 50 | { 51 | desc: "no error", 52 | m: &MockMigrator{ 53 | planError: false, 54 | applyError: false, 55 | }, 56 | ok: true, 57 | }, 58 | { 59 | desc: "plan error", 60 | m: &MockMigrator{ 61 | planError: true, 62 | applyError: false, 63 | }, 64 | ok: false, 65 | }, 66 | } 67 | 68 | for _, tc := range cases { 69 | t.Run(tc.desc, func(t *testing.T) { 70 | err := tc.m.Plan(context.Background()) 71 | if tc.ok && err != nil { 72 | t.Fatalf("unexpected err: %s", err) 73 | } 74 | if !tc.ok && err == nil { 75 | t.Fatal("expected to return an error, but no error") 76 | } 77 | }) 78 | } 79 | } 80 | 81 | func TestMockMigratorApply(t *testing.T) { 82 | cases := []struct { 83 | desc string 84 | m *MockMigrator 85 | ok bool 86 | }{ 87 | { 88 | desc: "no error", 89 | m: &MockMigrator{ 90 | planError: false, 91 | applyError: false, 92 | }, 93 | ok: true, 94 | }, 95 | { 96 | desc: "plan error", 97 | m: &MockMigrator{ 98 | planError: true, 99 | applyError: false, 100 | }, 101 | ok: false, 102 | }, 103 | { 104 | desc: "apply error", 105 | m: &MockMigrator{ 106 | planError: false, 107 | applyError: true, 108 | }, 109 | ok: false, 110 | }, 111 | } 112 | 113 | for _, tc := range cases { 114 | t.Run(tc.desc, func(t *testing.T) { 115 | err := tc.m.Apply(context.Background()) 116 | if tc.ok && err != nil { 117 | t.Fatalf("unexpected err: %s", err) 118 | } 119 | if !tc.ok && err == nil { 120 | t.Fatal("expected to return an error, but no error") 121 | } 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tfmigrate/multi_state_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | // MultiStateAction abstracts multi state migration operations. 11 | // It's used for moving resources from one state to another. 12 | type MultiStateAction interface { 13 | // MultiStateUpdate updates given two states and returns new two states. 14 | MultiStateUpdate(ctx context.Context, fromTf tfexec.TerraformCLI, toTf tfexec.TerraformCLI, fromState *tfexec.State, toState *tfexec.State) (*tfexec.State, *tfexec.State, error) 15 | } 16 | 17 | // NewMultiStateActionFromString is a factory method which returns a new 18 | // MultiStateAction from a given string. 19 | // cmdStr is a plain text for state operation. 20 | // This method is useful to build an action from terraform state command. 21 | // Valid formats are the following. 22 | // "mv " 23 | // "xmv " 24 | func NewMultiStateActionFromString(cmdStr string) (MultiStateAction, error) { 25 | args, err := splitStateAction(cmdStr) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to parse action: %s, err: %s", cmdStr, err) 28 | } 29 | 30 | if len(args) == 0 { 31 | return nil, fmt.Errorf("multi state action is empty: %s", cmdStr) 32 | } 33 | actionType := args[0] 34 | 35 | // switch by action type and parse arguments and build an action. 36 | var action MultiStateAction 37 | switch actionType { 38 | case "mv": 39 | if len(args) != 3 { 40 | return nil, fmt.Errorf("multi state mv action is invalid: %s", cmdStr) 41 | } 42 | src := args[1] 43 | dst := args[2] 44 | action = NewMultiStateMvAction(src, dst) 45 | 46 | case "xmv": 47 | if len(args) != 3 { 48 | return nil, fmt.Errorf("multi state xmv action is invalid: %s", cmdStr) 49 | } 50 | src := args[1] 51 | dst := args[2] 52 | action = NewMultiStateXmvAction(src, dst) 53 | 54 | default: 55 | return nil, fmt.Errorf("unknown multi state action type: %s", cmdStr) 56 | } 57 | 58 | return action, nil 59 | } 60 | -------------------------------------------------------------------------------- /tfmigrate/multi_state_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestNewMultiStateActionFromString(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | cmdStr string 12 | want MultiStateAction 13 | ok bool 14 | }{ 15 | { 16 | desc: "mv action (valid)", 17 | cmdStr: "mv null_resource.foo null_resource.foo2", 18 | want: &MultiStateMvAction{ 19 | source: "null_resource.foo", 20 | destination: "null_resource.foo2", 21 | }, 22 | ok: true, 23 | }, 24 | { 25 | desc: "mv action (no args)", 26 | cmdStr: "mv", 27 | want: nil, 28 | ok: false, 29 | }, 30 | { 31 | desc: "mv action (1 arg)", 32 | cmdStr: "mv null_resource.foo", 33 | want: nil, 34 | ok: false, 35 | }, 36 | { 37 | desc: "mv action (3 args)", 38 | cmdStr: "mv null_resource.foo null_resource.foo2 null_resource.foo3", 39 | want: nil, 40 | ok: false, 41 | }, 42 | { 43 | desc: "xmv action (valid)", 44 | cmdStr: "xmv null_resource.* null_resource.$1", 45 | want: &MultiStateXmvAction{ 46 | source: "null_resource.*", 47 | destination: "null_resource.$1", 48 | }, 49 | ok: true, 50 | }, 51 | { 52 | desc: "xmv action (no args)", 53 | cmdStr: "xmv", 54 | want: nil, 55 | ok: false, 56 | }, 57 | { 58 | desc: "xmv action (1 arg)", 59 | cmdStr: "xmv null_resource.foo", 60 | want: nil, 61 | ok: false, 62 | }, 63 | { 64 | desc: "xmv action (3 args)", 65 | cmdStr: "xmv null_resource.foo null_resource.foo2 null_resource.foo3", 66 | want: nil, 67 | ok: false, 68 | }, 69 | { 70 | desc: "duplicated white spaces", 71 | cmdStr: " mv null_resource.foo null_resource.foo2 ", 72 | want: &MultiStateMvAction{ 73 | source: "null_resource.foo", 74 | destination: "null_resource.foo2", 75 | }, 76 | ok: true, 77 | }, 78 | { 79 | desc: "unknown type", 80 | cmdStr: "foo bar baz", 81 | want: nil, 82 | ok: false, 83 | }, 84 | } 85 | 86 | for _, tc := range cases { 87 | t.Run(tc.desc, func(t *testing.T) { 88 | got, err := NewMultiStateActionFromString(tc.cmdStr) 89 | if tc.ok && err != nil { 90 | t.Fatalf("unexpected err: %s", err) 91 | } 92 | if !tc.ok && err == nil { 93 | t.Fatalf("expected to return an error, but no error, got: %#v", got) 94 | } 95 | if !reflect.DeepEqual(got, tc.want) { 96 | t.Errorf("got: %#v, want: %#v", got, tc.want) 97 | } 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tfmigrate/multi_state_mv_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // MultiStateMvAction implements the MultiStateAction interface. 10 | // MultiStateMvAction moves a resource from a dir to another. 11 | // It also can rename an address of resource. 12 | type MultiStateMvAction struct { 13 | // source is an address of resource or module to be moved. 14 | source string 15 | // destination is a new address of resource or module to move. 16 | destination string 17 | } 18 | 19 | var _ MultiStateAction = (*MultiStateMvAction)(nil) 20 | 21 | // NewMultiStateMvAction returns a new MultiStateMvAction instance. 22 | func NewMultiStateMvAction(source string, destination string) *MultiStateMvAction { 23 | return &MultiStateMvAction{ 24 | source: source, 25 | destination: destination, 26 | } 27 | } 28 | 29 | // MultiStateUpdate updates given two states and returns new two states. 30 | // It moves a resource from a dir to another. 31 | // It also can rename an address of resource. 32 | func (a *MultiStateMvAction) MultiStateUpdate(ctx context.Context, fromTf tfexec.TerraformCLI, toTf tfexec.TerraformCLI, fromState *tfexec.State, toState *tfexec.State) (*tfexec.State, *tfexec.State, error) { 33 | // move a resource from fromState to a temporary diffState. 34 | diffState := tfexec.NewState([]byte{}) 35 | fromNewState, diffNewState, err := fromTf.StateMv(ctx, fromState, diffState, a.source, a.source, "-backup=/dev/null") 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | 40 | // move the resource from the diffState to toState. 41 | _, toNewState, err := toTf.StateMv(ctx, diffNewState, toState, a.source, a.destination, "-backup=/dev/null") 42 | if err != nil { 43 | return nil, nil, err 44 | } 45 | 46 | return fromNewState, toNewState, nil 47 | } 48 | -------------------------------------------------------------------------------- /tfmigrate/multi_state_mv_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | func TestAccMultiStateMvAction(t *testing.T) { 11 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 12 | ctx := context.Background() 13 | 14 | // setup the initial files and states 15 | fromBackend := tfexec.GetTestAccBackendS3Config(t.Name() + "/fromDir") 16 | fromSource := ` 17 | resource "null_resource" "foo" {} 18 | resource "null_resource" "bar" {} 19 | resource "null_resource" "baz" {} 20 | ` 21 | fromWorkspace := "default" 22 | fromTf := tfexec.SetupTestAccWithApply(t, fromWorkspace, fromBackend+fromSource) 23 | 24 | toBackend := tfexec.GetTestAccBackendS3Config(t.Name() + "/toDir") 25 | toSource := ` 26 | resource "null_resource" "qux" {} 27 | ` 28 | toWorkspace := "default" 29 | toTf := tfexec.SetupTestAccWithApply(t, toWorkspace, toBackend+toSource) 30 | 31 | // update terraform resource files for migration 32 | fromUpdatedSource := ` 33 | resource "null_resource" "baz" {} 34 | ` 35 | tfexec.UpdateTestAccSource(t, fromTf, fromBackend+fromUpdatedSource) 36 | 37 | toUpdatedSource := ` 38 | resource "null_resource" "foo" {} 39 | resource "null_resource" "bar2" {} 40 | resource "null_resource" "qux" {} 41 | ` 42 | tfexec.UpdateTestAccSource(t, toTf, toBackend+toUpdatedSource) 43 | 44 | fromChanged, err := fromTf.PlanHasChange(ctx, nil) 45 | if err != nil { 46 | t.Fatalf("failed to run PlanHasChange in fromDir: %s", err) 47 | } 48 | if !fromChanged { 49 | t.Fatalf("expect to have changes in fromDir") 50 | } 51 | 52 | toChanged, err := toTf.PlanHasChange(ctx, nil) 53 | if err != nil { 54 | t.Fatalf("failed to run PlanHasChange in toDir: %s", err) 55 | } 56 | if !toChanged { 57 | t.Fatalf("expect to have changes in toDir") 58 | } 59 | 60 | // perform state migration 61 | actions := []MultiStateAction{ 62 | NewMultiStateMvAction("null_resource.foo", "null_resource.foo"), 63 | NewMultiStateMvAction("null_resource.bar", "null_resource.bar2"), 64 | } 65 | o := &MigratorOption{} 66 | force := false 67 | m := NewMultiStateMigrator(fromTf.Dir(), toTf.Dir(), fromWorkspace, toWorkspace, actions, o, force, false, false) 68 | err = m.Plan(ctx) 69 | if err != nil { 70 | t.Fatalf("failed to run migrator plan: %s", err) 71 | } 72 | 73 | err = m.Apply(ctx) 74 | if err != nil { 75 | t.Fatalf("failed to run migrator apply: %s", err) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tfmigrate/multi_state_xmv_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // MultiStateXmvAction implements the MultiStateAction interface. 10 | // MultiStateXmvAction is an extended version of MultiStateMvAction. 11 | // It allows you to move multiple resouces with wildcard matching. 12 | type MultiStateXmvAction struct { 13 | // source is a address of resource or module to be moved which can contain wildcards. 14 | source string 15 | // destination is a new address of resource or module to move which can contain placeholders. 16 | destination string 17 | } 18 | 19 | var _ MultiStateAction = (*MultiStateXmvAction)(nil) 20 | 21 | // NewMultiStateXmvAction returns a new MultiStateXmvAction instance. 22 | func NewMultiStateXmvAction(source string, destination string) *MultiStateXmvAction { 23 | return &MultiStateXmvAction{ 24 | source: source, 25 | destination: destination, 26 | } 27 | } 28 | 29 | // MultiStateUpdate updates given two states and returns new two states. 30 | // It moves a resource from a dir to another. 31 | // It also can rename an address of resource. 32 | func (a *MultiStateXmvAction) MultiStateUpdate(ctx context.Context, fromTf tfexec.TerraformCLI, toTf tfexec.TerraformCLI, fromState *tfexec.State, toState *tfexec.State) (*tfexec.State, *tfexec.State, error) { 33 | multiStateMvActions, err := a.generateMvActions(ctx, fromTf, fromState) 34 | if err != nil { 35 | return nil, nil, err 36 | } 37 | 38 | for _, action := range multiStateMvActions { 39 | fromState, toState, err = action.MultiStateUpdate(ctx, fromTf, toTf, fromState, toState) 40 | if err != nil { 41 | return nil, nil, err 42 | } 43 | } 44 | return fromState, toState, nil 45 | } 46 | 47 | // generateMvActions uses an xmv and use the state to determine the corresponding mv actions. 48 | func (a *MultiStateXmvAction) generateMvActions(ctx context.Context, fromTf tfexec.TerraformCLI, fromState *tfexec.State) ([]*MultiStateMvAction, error) { 49 | stateList, err := fromTf.StateList(ctx, fromState, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | // create a temporary single state mv actions. 55 | // It may look a bit strange as a type. 56 | // This is only because sharing the logic while maintaining consistency. 57 | stateXmv := NewStateXmvAction(a.source, a.destination) 58 | 59 | e := newXmvExpander(stateXmv) 60 | stateMvActions, err := e.expand(stateList) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | // convert StateMvAction to MultiStateMvAction. 66 | multiStateMvActions := []*MultiStateMvAction{} 67 | for _, action := range stateMvActions { 68 | multiStateMvActions = append(multiStateMvActions, NewMultiStateMvAction(action.source, action.destination)) 69 | } 70 | 71 | return multiStateMvActions, nil 72 | } 73 | -------------------------------------------------------------------------------- /tfmigrate/multi_state_xmv_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | func TestAccMultiStateXmvAction(t *testing.T) { 11 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 12 | ctx := context.Background() 13 | 14 | // setup the initial files and states 15 | fromBackend := tfexec.GetTestAccBackendS3Config(t.Name() + "/fromDir") 16 | fromSource := ` 17 | resource "null_resource" "foo" {} 18 | resource "null_resource" "bar" {} 19 | resource "time_static" "foo" {} 20 | ` 21 | fromWorkspace := "default" 22 | fromTf := tfexec.SetupTestAccWithApply(t, fromWorkspace, fromBackend+fromSource) 23 | 24 | toBackend := tfexec.GetTestAccBackendS3Config(t.Name() + "/toDir") 25 | toSource := ` 26 | resource "null_resource" "qux" {} 27 | ` 28 | toWorkspace := "default" 29 | toTf := tfexec.SetupTestAccWithApply(t, toWorkspace, toBackend+toSource) 30 | 31 | // update terraform resource files for migration 32 | fromUpdatedSource := ` 33 | resource "time_static" "foo" {} 34 | ` 35 | tfexec.UpdateTestAccSource(t, fromTf, fromBackend+fromUpdatedSource) 36 | 37 | toUpdatedSource := ` 38 | resource "null_resource" "foo2" {} 39 | resource "null_resource" "bar2" {} 40 | resource "null_resource" "qux" {} 41 | ` 42 | tfexec.UpdateTestAccSource(t, toTf, toBackend+toUpdatedSource) 43 | 44 | fromChanged, err := fromTf.PlanHasChange(ctx, nil) 45 | if err != nil { 46 | t.Fatalf("failed to run PlanHasChange in fromDir: %s", err) 47 | } 48 | if !fromChanged { 49 | t.Fatalf("expect to have changes in fromDir") 50 | } 51 | 52 | toChanged, err := toTf.PlanHasChange(ctx, nil) 53 | if err != nil { 54 | t.Fatalf("failed to run PlanHasChange in toDir: %s", err) 55 | } 56 | if !toChanged { 57 | t.Fatalf("expect to have changes in toDir") 58 | } 59 | 60 | // perform state migration 61 | actions := []MultiStateAction{ 62 | NewMultiStateXmvAction("null_resource.*", "null_resource.${1}2"), 63 | } 64 | o := &MigratorOption{} 65 | force := false 66 | m := NewMultiStateMigrator(fromTf.Dir(), toTf.Dir(), fromWorkspace, toWorkspace, actions, o, force, false, false) 67 | err = m.Plan(ctx) 68 | if err != nil { 69 | t.Fatalf("failed to run migrator plan: %s", err) 70 | } 71 | 72 | err = m.Apply(ctx) 73 | if err != nil { 74 | t.Fatalf("failed to run migrator plan: %s", err) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tfmigrate/state_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/mattn/go-shellwords" 8 | "github.com/minamijoyo/tfmigrate/tfexec" 9 | ) 10 | 11 | // StateAction abstracts state migration operations. 12 | type StateAction interface { 13 | // StateUpdate updates a given state and returns a new state. 14 | StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) 15 | } 16 | 17 | // NewStateActionFromString is a factory method which returns a new StateAction 18 | // from a given string. 19 | // cmdStr is a plain text for state operation. 20 | // This method is useful to build an action from terraform state command. 21 | // Valid formats are the following. 22 | // "mv " 23 | // "rm ... 24 | // "import
" 25 | // "xmv " 26 | func NewStateActionFromString(cmdStr string) (StateAction, error) { 27 | args, err := splitStateAction(cmdStr) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to parse action: %s, err: %s", cmdStr, err) 30 | } 31 | 32 | if len(args) == 0 { 33 | return nil, fmt.Errorf("state action is empty: %s", cmdStr) 34 | } 35 | actionType := args[0] 36 | 37 | // switch by action type and parse arguments and build an action. 38 | var action StateAction 39 | switch actionType { 40 | case "mv": 41 | if len(args) != 3 { 42 | return nil, fmt.Errorf("state mv action is invalid: %s", cmdStr) 43 | } 44 | src := args[1] 45 | dst := args[2] 46 | action = NewStateMvAction(src, dst) 47 | 48 | case "replace-provider": 49 | if len(args) != 3 { 50 | return nil, fmt.Errorf("state replace-provider action is invalid: %s", cmdStr) 51 | } 52 | src := args[1] 53 | dst := args[2] 54 | action = NewStateReplaceProviderAction(src, dst) 55 | 56 | case "xmv": 57 | if len(args) != 3 { 58 | return nil, fmt.Errorf("state xmv action is invalid: %s", cmdStr) 59 | } 60 | src := args[1] 61 | dst := args[2] 62 | action = NewStateXmvAction(src, dst) 63 | 64 | case "rm": 65 | if len(args) < 2 { 66 | return nil, fmt.Errorf("state rm action is invalid: %s", cmdStr) 67 | } 68 | addrs := args[1:] 69 | action = NewStateRmAction(addrs) 70 | 71 | case "import": 72 | if len(args) != 3 { 73 | return nil, fmt.Errorf("state import action is invalid: %s", cmdStr) 74 | } 75 | addr := args[1] 76 | id := args[2] 77 | action = NewStateImportAction(addr, id) 78 | 79 | default: 80 | return nil, fmt.Errorf("unknown state action type: %s", cmdStr) 81 | } 82 | 83 | return action, nil 84 | } 85 | 86 | // splitStateAction splits a given string like a shell. 87 | func splitStateAction(cmdStr string) ([]string, error) { 88 | // Note that we cannot simply split it by space because the address of resource can contain spaces. 89 | return shellwords.Parse(cmdStr) 90 | } 91 | -------------------------------------------------------------------------------- /tfmigrate/state_import_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // StateImportAction implements the StateAction interface. 10 | // StateImportAction imports an existing resource to state. 11 | // Note that the Terraform has terraform import command, not terraform state import command. 12 | // According to the help of import command, this is because a future version 13 | // Terraform will not only import state, but also generate configuration. 14 | // We intentionally use term "StateImportAction" to clarify it imports state only. 15 | type StateImportAction struct { 16 | // address is an address to import resource to. 17 | address string 18 | // id is a resource identifier to be imported. 19 | id string 20 | } 21 | 22 | var _ StateAction = (*StateImportAction)(nil) 23 | 24 | // NewStateImportAction returns a new StateImportAction instance. 25 | func NewStateImportAction(address string, id string) *StateImportAction { 26 | return &StateImportAction{ 27 | address: address, 28 | id: id, 29 | } 30 | } 31 | 32 | // StateUpdate updates a given state and returns a new state. 33 | // It imports an existing resource to state. 34 | func (a *StateImportAction) StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) { 35 | // Disable unnecessary state backup here, 36 | // because we never restore state from the backup generated by each state action. 37 | return tf.Import(ctx, state, a.address, a.id, "-input=false", "-no-color", "-backup=/dev/null") 38 | } 39 | -------------------------------------------------------------------------------- /tfmigrate/state_import_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | func TestAccStateImportAction(t *testing.T) { 11 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 12 | 13 | backend := tfexec.GetTestAccBackendS3Config(t.Name()) 14 | 15 | source := ` 16 | resource "time_static" "foo" { triggers = {} } 17 | resource "time_static" "bar" { triggers = {} } 18 | resource "time_static" "baz" { triggers = {} } 19 | ` 20 | 21 | workspace := "default" 22 | tf := tfexec.SetupTestAccWithApply(t, workspace, backend+source) 23 | ctx := context.Background() 24 | 25 | _, err := tf.StateRm(ctx, nil, []string{"time_static.foo", "time_static.baz"}) 26 | if err != nil { 27 | t.Fatalf("failed to run terraform state rm: %s", err) 28 | } 29 | 30 | changed, err := tf.PlanHasChange(ctx, nil) 31 | if err != nil { 32 | t.Fatalf("failed to run PlanHasChange: %s", err) 33 | } 34 | if !changed { 35 | t.Fatalf("expect to have changes") 36 | } 37 | 38 | actions := []StateAction{ 39 | NewStateImportAction("time_static.foo", "2006-01-02T15:04:05Z"), 40 | NewStateImportAction("time_static.baz", "2006-01-02T15:04:05Z"), 41 | } 42 | 43 | m := NewStateMigrator(tf.Dir(), workspace, actions, &MigratorOption{}, false, false) 44 | err = m.Plan(ctx) 45 | if err != nil { 46 | t.Fatalf("failed to run migrator plan: %s", err) 47 | } 48 | 49 | err = m.Apply(ctx) 50 | if err != nil { 51 | t.Fatalf("failed to run migrator apply: %s", err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tfmigrate/state_mv_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // StateMvAction implements the StateAction interface. 10 | // StateMvAction moves a resource from source address to destination address in 11 | // the same tfstate file. 12 | type StateMvAction struct { 13 | // source is a address of resource or module to be moved. 14 | source string 15 | // // destination is a new address of resource or module to move. 16 | destination string 17 | } 18 | 19 | var _ StateAction = (*StateMvAction)(nil) 20 | 21 | // NewStateMvAction returns a new StateMvAction instance. 22 | func NewStateMvAction(source string, destination string) *StateMvAction { 23 | return &StateMvAction{ 24 | source: source, 25 | destination: destination, 26 | } 27 | } 28 | 29 | // StateUpdate updates a given state and returns a new state. 30 | // It moves a resource from source address to destination address in the same tfstate file. 31 | func (a *StateMvAction) StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) { 32 | // Disable unnecessary state backup here, 33 | // because we never restore state from the backup generated by each state action. 34 | // The state mv command doesn't provide a way to disable it, so we backup to /dev/null. 35 | newState, _, err := tf.StateMv(ctx, state, nil, a.source, a.destination, "-backup=/dev/null") 36 | return newState, err 37 | } 38 | -------------------------------------------------------------------------------- /tfmigrate/state_mv_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | func TestAccStateMvAction(t *testing.T) { 11 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 12 | 13 | backend := tfexec.GetTestAccBackendS3Config(t.Name()) 14 | 15 | source := ` 16 | resource "null_resource" "foo" {} 17 | resource "null_resource" "bar" {} 18 | resource "null_resource" "baz" {} 19 | ` 20 | 21 | workspace := "default" 22 | tf := tfexec.SetupTestAccWithApply(t, workspace, backend+source) 23 | ctx := context.Background() 24 | 25 | updatedSource := ` 26 | resource "null_resource" "foo2" {} 27 | resource "null_resource" "bar2" {} 28 | resource "null_resource" "baz" {} 29 | ` 30 | 31 | tfexec.UpdateTestAccSource(t, tf, backend+updatedSource) 32 | 33 | changed, err := tf.PlanHasChange(ctx, nil) 34 | if err != nil { 35 | t.Fatalf("failed to run PlanHasChange: %s", err) 36 | } 37 | if !changed { 38 | t.Fatalf("expect to have changes") 39 | } 40 | 41 | actions := []StateAction{ 42 | NewStateMvAction("null_resource.foo", "null_resource.foo2"), 43 | NewStateMvAction("null_resource.bar", "null_resource.bar2"), 44 | } 45 | 46 | m := NewStateMigrator(tf.Dir(), workspace, actions, &MigratorOption{}, false, false) 47 | err = m.Plan(ctx) 48 | if err != nil { 49 | t.Fatalf("failed to run migrator plan: %s", err) 50 | } 51 | 52 | err = m.Apply(ctx) 53 | if err != nil { 54 | t.Fatalf("failed to run migrator apply: %s", err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tfmigrate/state_replace_provider_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // StateReplaceProviderAction implements the StateAction interface. 10 | // StateReplaceProviderAction replaces a provider from source address to destination address in 11 | // the same tfstate file. 12 | type StateReplaceProviderAction struct { 13 | // source is the provider address to be replaced. 14 | source string 15 | // destination is the new provider address. 16 | destination string 17 | } 18 | 19 | var _ StateAction = (*StateReplaceProviderAction)(nil) 20 | 21 | // NewStateReplaceProviderAction returns a new StateReplaceProviderAction instance. 22 | func NewStateReplaceProviderAction(source string, destination string) *StateReplaceProviderAction { 23 | return &StateReplaceProviderAction{ 24 | source: source, 25 | destination: destination, 26 | } 27 | } 28 | 29 | // StateUpdate updates a given state and returns a new state. 30 | // It moves a provider from source address to destination address in the same tfstate file. 31 | func (a *StateReplaceProviderAction) StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) { 32 | // Disable unnecessary state backup here, 33 | // because we never restore state from the backup generated by each state action. 34 | // The state replace-provider command doesn't provide a way to disable it, so we backup to /dev/null. 35 | return tf.StateReplaceProvider(ctx, state, a.source, a.destination, "-backup=/dev/null", "-auto-approve") 36 | } 37 | -------------------------------------------------------------------------------- /tfmigrate/state_replace_provider_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/credentials" 12 | "github.com/aws/aws-sdk-go-v2/feature/s3/manager" 13 | "github.com/aws/aws-sdk-go-v2/service/s3" 14 | "github.com/minamijoyo/tfmigrate/tfexec" 15 | ) 16 | 17 | func TestAccStateReplaceProviderActionUsingLegacyTerraform(t *testing.T) { 18 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 19 | 20 | tfVersion := os.Getenv("TERRAFORM_VERSION") 21 | if tfVersion != tfexec.LegacyTerraformVersion { 22 | t.Skipf("skip %s acceptance test for non-legacy Terraform version %s", t.Name(), tfVersion) 23 | } 24 | 25 | backend := tfexec.GetTestAccBackendS3Config(t.Name()) 26 | 27 | source := ` 28 | resource "null_resource" "foo" {} 29 | ` 30 | 31 | workspace := "default" 32 | tf := tfexec.SetupTestAccWithApply(t, workspace, backend+source) 33 | ctx := context.Background() 34 | 35 | actions := []StateAction{ 36 | NewStateReplaceProviderAction("registry.terraform.io/-/null", "registry.terraform.io/hashicorp/null"), 37 | } 38 | 39 | expected := "replace-provider action requires Terraform version >= 0.13.0" 40 | m := NewStateMigrator(tf.Dir(), workspace, actions, &MigratorOption{}, false, false) 41 | err := m.Plan(ctx) 42 | if err == nil || strings.Contains(err.Error(), expected) { 43 | t.Fatalf("expected to receive '%s' error using legacy Terraform; got: %s", expected, err) 44 | } 45 | } 46 | 47 | func TestAccStateReplaceProviderAction(t *testing.T) { 48 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 49 | 50 | tfVersion := os.Getenv("TERRAFORM_VERSION") 51 | if tfVersion == tfexec.LegacyTerraformVersion { 52 | t.Skipf("skip %s acceptance test for legacy Terraform version %s", t.Name(), tfVersion) 53 | } 54 | 55 | backend := tfexec.GetTestAccBackendS3Config(t.Name()) 56 | 57 | // To test the use of a non-legacy Terraform CLI version with a legacy 58 | // Terraform state version, it's necessary to use a state file that was 59 | // created with a legacy Terraform CLI version, as provided via the 60 | // test-fixtures/legacy-tfstate directory. 61 | tfConf, err := os.ReadFile("../test-fixtures/legacy-tfstate/main.tf") 62 | if err != nil { 63 | t.Fatalf("error reading test fixture terraform configuration: %s", err) 64 | } 65 | 66 | source := string(tfConf) 67 | 68 | stateFile, err := os.Open("../test-fixtures/legacy-tfstate/terraform.tfstate") 69 | if err != nil { 70 | t.Fatalf("error opening tfstate fixture: %s", err) 71 | } 72 | defer stateFile.Close() 73 | ctx := context.Background() 74 | 75 | cfg, err := config.LoadDefaultConfig( 76 | ctx, 77 | config.WithRegion(tfexec.TestS3Region), 78 | config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(tfexec.TestS3AccessKey, tfexec.TestS3SecretKey, "")), 79 | ) 80 | if err != nil { 81 | t.Fatalf("failed to load aws config: %s", err) 82 | } 83 | s3Client := s3.NewFromConfig(cfg, func(options *s3.Options) { 84 | options.BaseEndpoint = aws.String(tfexec.GetTestAccS3Endpoint()) 85 | options.UsePathStyle = true 86 | }) 87 | 88 | // Upload the legacy state file to S3 to pre-seed the backend S3 bucket. 89 | uploader := manager.NewUploader(s3Client) 90 | _, err = uploader.Upload(ctx, &s3.PutObjectInput{ 91 | Bucket: aws.String(tfexec.TestS3Bucket), 92 | Key: aws.String(tfexec.GetTestAccBackendS3Key(t.Name())), 93 | Body: stateFile, 94 | }) 95 | if err != nil { 96 | t.Fatalf("failed to upload legacy state file: %s", err) 97 | } 98 | 99 | workspace := "default" 100 | tf := tfexec.SetupTestAccForStateReplaceProvider(t, workspace, backend+source) 101 | 102 | registry := "registry.terraform.io" 103 | tfExecType, _, err := tf.Version(ctx) 104 | if err != nil { 105 | t.Fatalf("failed to get tfExecType: %s", err) 106 | } 107 | if tfExecType == "opentofu" { 108 | registry = "registry.opentofu.org" 109 | } 110 | 111 | actions := []StateAction{ 112 | NewStateReplaceProviderAction(registry+"/-/null", registry+"/hashicorp/null"), 113 | } 114 | 115 | m := NewStateMigrator(tf.Dir(), workspace, actions, &MigratorOption{}, false, false) 116 | err = m.Plan(ctx) 117 | if err != nil { 118 | t.Fatalf("failed to run migrator plan: %s", err) 119 | } 120 | 121 | err = m.Apply(ctx) 122 | if err != nil { 123 | t.Fatalf("failed to run migrator apply: %s", err) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tfmigrate/state_rm_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // StateRmAction implements the StateAction interface. 10 | // StateRmAction removes resources from state at given addresses. 11 | type StateRmAction struct { 12 | // addresses is a list of address to be removed from state. 13 | addresses []string 14 | } 15 | 16 | var _ StateAction = (*StateRmAction)(nil) 17 | 18 | // NewStateRmAction returns a new StateRmAction instance. 19 | func NewStateRmAction(addresses []string) *StateRmAction { 20 | return &StateRmAction{ 21 | addresses: addresses, 22 | } 23 | } 24 | 25 | // StateUpdate updates a given state and returns a new state. 26 | // It removes resources from state at given addresses. 27 | func (a *StateRmAction) StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) { 28 | // Disable unnecessary state backup here, 29 | // because we never restore state from the backup generated by each state action. 30 | // The state rm command doesn't provide a way to disable it, so we backup to /dev/null. 31 | return tf.StateRm(ctx, state, a.addresses, "-backup=/dev/null") 32 | } 33 | -------------------------------------------------------------------------------- /tfmigrate/state_rm_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | func TestAccStateRmAction(t *testing.T) { 11 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 12 | 13 | backend := tfexec.GetTestAccBackendS3Config(t.Name()) 14 | 15 | source := ` 16 | resource "null_resource" "foo" {} 17 | resource "null_resource" "bar" {} 18 | resource "null_resource" "baz" {} 19 | resource "null_resource" "qux" {} 20 | ` 21 | 22 | workspace := "default" 23 | tf := tfexec.SetupTestAccWithApply(t, workspace, backend+source) 24 | ctx := context.Background() 25 | 26 | updatedSource := ` 27 | resource "null_resource" "baz" {} 28 | ` 29 | 30 | tfexec.UpdateTestAccSource(t, tf, backend+updatedSource) 31 | 32 | changed, err := tf.PlanHasChange(ctx, nil) 33 | if err != nil { 34 | t.Fatalf("failed to run PlanHasChange: %s", err) 35 | } 36 | if !changed { 37 | t.Fatalf("expect to have changes") 38 | } 39 | 40 | actions := []StateAction{ 41 | NewStateRmAction([]string{"null_resource.foo", "null_resource.bar"}), 42 | NewStateRmAction([]string{"null_resource.qux"}), 43 | } 44 | 45 | m := NewStateMigrator(tf.Dir(), workspace, actions, &MigratorOption{}, false, false) 46 | err = m.Plan(ctx) 47 | if err != nil { 48 | t.Fatalf("failed to run migrator plan: %s", err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tfmigrate/state_xmv_action.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/minamijoyo/tfmigrate/tfexec" 7 | ) 8 | 9 | // StateXmvAction implements the StateAction interface. 10 | // StateXmvAction is an extended version of StateMvAction. 11 | // It allows you to move multiple resouces with wildcard matching. 12 | type StateXmvAction struct { 13 | // source is a address of resource or module to be moved which can contain wildcards. 14 | source string 15 | // destination is a new address of resource or module to move which can contain placeholders. 16 | destination string 17 | } 18 | 19 | var _ StateAction = (*StateXmvAction)(nil) 20 | 21 | // NewStateXmvAction returns a new StateXmvAction instance. 22 | func NewStateXmvAction(source string, destination string) *StateXmvAction { 23 | return &StateXmvAction{ 24 | source: source, 25 | destination: destination, 26 | } 27 | } 28 | 29 | // StateUpdate updates a given state and returns a new state. 30 | // Source resources have wildcards which should be matched against the tf state. 31 | // Each occurrence will generate a move command. 32 | func (a *StateXmvAction) StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) { 33 | stateMvActions, err := a.generateMvActions(ctx, tf, state) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | for _, action := range stateMvActions { 39 | state, err = action.StateUpdate(ctx, tf, state) 40 | if err != nil { 41 | return nil, err 42 | } 43 | } 44 | return state, err 45 | } 46 | 47 | // generateMvActions uses an xmv and use the state to determine the corresponding mv actions. 48 | func (a *StateXmvAction) generateMvActions(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) ([]*StateMvAction, error) { 49 | stateList, err := tf.StateList(ctx, state, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | e := newXmvExpander(a) 55 | return e.expand(stateList) 56 | } 57 | -------------------------------------------------------------------------------- /tfmigrate/state_xmv_action_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/minamijoyo/tfmigrate/tfexec" 8 | ) 9 | 10 | func TestAccStateXmvAction(t *testing.T) { 11 | tfexec.SkipUnlessAcceptanceTestEnabled(t) 12 | 13 | backend := tfexec.GetTestAccBackendS3Config(t.Name()) 14 | 15 | source := ` 16 | resource "null_resource" "foo" {} 17 | resource "null_resource" "bar" {} 18 | ` 19 | 20 | workspace := "default" 21 | tf := tfexec.SetupTestAccWithApply(t, workspace, backend+source) 22 | ctx := context.Background() 23 | 24 | updatedSource := ` 25 | resource "null_resource" "foo2" {} 26 | resource "null_resource" "bar2" {} 27 | ` 28 | tfexec.UpdateTestAccSource(t, tf, backend+updatedSource) 29 | 30 | changed, err := tf.PlanHasChange(ctx, nil) 31 | if err != nil { 32 | t.Fatalf("failed to run PlanHasChange: %s", err) 33 | } 34 | if !changed { 35 | t.Fatalf("expect to have changes") 36 | } 37 | 38 | actions := []StateAction{ 39 | NewStateXmvAction("null_resource.*", "null_resource.${1}2"), 40 | } 41 | 42 | m := NewStateMigrator(tf.Dir(), workspace, actions, &MigratorOption{}, false, false) 43 | err = m.Plan(ctx) 44 | if err != nil { 45 | t.Fatalf("failed to run migrator plan: %s", err) 46 | } 47 | 48 | err = m.Apply(ctx) 49 | if err != nil { 50 | t.Fatalf("failed to run migrator apply: %s", err) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tfmigrate/test_helper.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import "regexp" 4 | 5 | // SwitchBackToRemoteFuncError tests verify error messages, but the 6 | // error message for missing bucket key in the s3 backend differs 7 | // depending on the Terraform version and OpenTofu version. 8 | // Define a helper function to hide the difference. 9 | const testBucketRequiredErrorLegacyTerraform = `Error: "bucket": required field is not set` 10 | const testBucketRequiredErrorTerraform16 = `The attribute "bucket" is required by the backend` 11 | const testBucketRequiredErrorOpenTofu16 = `The "bucket" attribute value must not be empty` 12 | 13 | var testBucketRequiredErrorRE = regexp.MustCompile( 14 | testBucketRequiredErrorLegacyTerraform + `|` + 15 | testBucketRequiredErrorTerraform16 + `|` + 16 | testBucketRequiredErrorOpenTofu16) 17 | 18 | func containsBucketRequiredError(err error) bool { 19 | return testBucketRequiredErrorRE.MatchString(err.Error()) 20 | } 21 | -------------------------------------------------------------------------------- /tfmigrate/test_helper_test.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestContainsBucketRequiredError(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | msg string 12 | want bool 13 | }{ 14 | { 15 | desc: "terraform v1.5", 16 | msg: `Error: "bucket": required field is not set`, 17 | want: true, 18 | }, 19 | { 20 | desc: "terraform v1.6", 21 | msg: ` 22 | Error: Missing Required Value 23 | 24 | on main.tf line 4, in terraform: 25 | 4: backend "s3" { 26 | 27 | The attribute "bucket" is required by the backend. 28 | 29 | Refer to the backend documentation for additional information which 30 | attributes are required. 31 | 32 | `, 33 | want: true, 34 | }, 35 | { 36 | desc: "unknown", 37 | msg: `Error: unknown`, 38 | want: false, 39 | }, 40 | } 41 | 42 | for _, tc := range cases { 43 | t.Run(tc.desc, func(t *testing.T) { 44 | got := containsBucketRequiredError(errors.New(tc.msg)) 45 | if got != tc.want { 46 | t.Errorf("got: %t, want: %t", got, tc.want) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tfmigrate/xmv_expander.go: -------------------------------------------------------------------------------- 1 | package tfmigrate 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // xmvExpander is a helper object for implementing wildcard expansion for xmv actions. 10 | type xmvExpander struct { 11 | // xmv action to be expanded 12 | action *StateXmvAction 13 | } 14 | 15 | // newXmvExpander returns a new xmvExpander instance. 16 | func newXmvExpander(action *StateXmvAction) *xmvExpander { 17 | return &xmvExpander{ 18 | action: action, 19 | } 20 | } 21 | 22 | // A wildcardChar will greedy match with any character in the resource path. 23 | const matchWildcardRegex = "(.*)" 24 | const wildcardChar = "*" 25 | 26 | // makeSourceMatchPattern returns regex pattern that matches the wildcard 27 | // source and make sure characters are not treated as special meta characters. 28 | func makeSourceMatchPattern(s string) string { 29 | safeString := regexp.QuoteMeta(s) 30 | quotedWildCardChar := regexp.QuoteMeta(wildcardChar) 31 | return strings.ReplaceAll(safeString, quotedWildCardChar, matchWildcardRegex) 32 | } 33 | 34 | // makeSrcRegex returns a regex that will do matching based on the wildcard 35 | // source that was given. 36 | func makeSrcRegex(source string) (*regexp.Regexp, error) { 37 | regPattern := makeSourceMatchPattern(source) 38 | regExpression, err := regexp.Compile(regPattern) 39 | if err != nil { 40 | return nil, fmt.Errorf("could not make pattern out of %s (%s) due to %s", source, regPattern, err) 41 | } 42 | return regExpression, nil 43 | } 44 | 45 | // expand returns actions matching wildcard move actions based on the list of resources. 46 | func (e *xmvExpander) expand(stateList []string) ([]*StateMvAction, error) { 47 | if e.nrOfWildcards() == 0 { 48 | staticActionAsList := make([]*StateMvAction, 1) 49 | staticActionAsList[0] = NewStateMvAction(e.action.source, e.action.destination) 50 | return staticActionAsList, nil 51 | } 52 | matchingSources, err := e.getMatchingSourcesFromState(stateList) 53 | if err != nil { 54 | return nil, err 55 | } 56 | matchingActions := make([]*StateMvAction, len(matchingSources)) 57 | for i, matchingSource := range matchingSources { 58 | destination, e2 := e.getDestinationForStateSrc(matchingSource) 59 | if e2 != nil { 60 | return nil, e2 61 | } 62 | matchingActions[i] = NewStateMvAction(matchingSource, destination) 63 | } 64 | return matchingActions, nil 65 | } 66 | 67 | // nrOfWildcards counts a number of wildcard characters. 68 | func (e *xmvExpander) nrOfWildcards() int { 69 | return strings.Count(e.action.source, wildcardChar) 70 | } 71 | 72 | // getMatchingSourcesFromState looks into the state and find sources that match 73 | // pattern with wildcards. 74 | func (e *xmvExpander) getMatchingSourcesFromState(stateList []string) ([]string, error) { 75 | re, err := makeSrcRegex(e.action.source) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | var matchingStateSources []string 81 | 82 | for _, s := range stateList { 83 | match := re.FindString(s) 84 | if match != "" { 85 | matchingStateSources = append(matchingStateSources, match) 86 | } 87 | } 88 | return matchingStateSources, err 89 | } 90 | 91 | // getDestinationForStateSrc returns the destination for a source. 92 | func (e *xmvExpander) getDestinationForStateSrc(stateSource string) (string, error) { 93 | re, err := makeSrcRegex(e.action.source) 94 | if err != nil { 95 | return "", err 96 | } 97 | destination := re.ReplaceAllString(stateSource, e.action.destination) 98 | return destination, err 99 | } 100 | --------------------------------------------------------------------------------