├── .dockerignore ├── .envrc.sample ├── .github └── workflows │ ├── docker.yaml │ ├── lint.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .go-version ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── Makefile ├── README.md ├── command ├── env.go ├── lock.go ├── meta.go ├── module.go ├── opentofu.go ├── provider.go ├── release.go ├── release_latest.go ├── release_list.go └── terraform.go ├── compose.yaml ├── go.mod ├── go.sum ├── lock ├── hash.go ├── hash_test.go ├── index.go ├── index_test.go ├── mock.go ├── provider_downloader.go ├── provider_downloader_test.go ├── provider_version.go └── provider_version_test.go ├── main.go ├── release ├── github.go ├── github_test.go ├── gitlab.go ├── gitlab_test.go ├── release.go ├── release_test.go ├── tfregistry.go ├── tfregistry_test.go ├── version.go └── version_test.go ├── scripts └── testacc │ ├── all.sh │ └── lock.sh ├── test-fixtures └── lock │ └── simple │ ├── dir1 │ └── main.tf │ ├── dir2 │ └── main.tf │ └── main.tf ├── tfregistry ├── client.go ├── client_test.go ├── mock.go ├── module.go ├── module_versions.go ├── module_versions_test.go ├── provider.go ├── provider_package_metadata.go ├── provider_package_metadata_test.go ├── provider_versions.go └── provider_versions_test.go └── tfupdate ├── context.go ├── context_test.go ├── file.go ├── file_test.go ├── hclwrite.go ├── hclwrite_test.go ├── lock.go ├── lock_test.go ├── module.go ├── module_test.go ├── opentofu.go ├── opentofu_test.go ├── option.go ├── option_test.go ├── provider.go ├── provider_test.go ├── terraform.go ├── terraform_test.go ├── update.go └── update_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | bin/ 3 | tmp/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.envrc.sample: -------------------------------------------------------------------------------- 1 | export TERRAFORM_VERSION=1.5.2 2 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: docker 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | - "docker-build-*" 10 | tags: 11 | - "v[0-9]+.*" 12 | 13 | jobs: 14 | docker: 15 | timeout-minutes: 20 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0 22 | - name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4.6.0 25 | with: 26 | images: ${{ github.repository }} 27 | tags: | 28 | type=ref,event=branch 29 | type=ref,event=pr 30 | type=semver,pattern={{version}} 31 | type=semver,pattern={{major}}.{{minor}} 32 | - name: Login to DockerHub 33 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0 34 | with: 35 | username: ${{ secrets.DOCKERHUB_USERNAME }} 36 | password: ${{ secrets.DOCKERHUB_TOKEN }} 37 | - name: Build and push 38 | id: docker_build 39 | uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1 40 | with: 41 | push: ${{ github.event_name != 'pull_request' }} 42 | tags: ${{ steps.meta.outputs.tags }} 43 | labels: ${{ steps.meta.outputs.labels }} 44 | platforms: linux/amd64,linux/arm64 45 | - name: Image digest 46 | run: echo ${{ steps.docker_build.outputs.digest }} 47 | - name: docker run 48 | if: ${{ github.event_name != 'pull_request' }} 49 | run: docker run --rm ${{ github.repository }}:${{ steps.meta.outputs.version }} --version 50 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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-tfupdate 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 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | timeout-minutes: 5 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, macOS-latest] 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 23 | with: 24 | go-version-file: '.go-version' 25 | - name: test 26 | run: make test 27 | testacc_terraform: 28 | runs-on: ubuntu-latest 29 | timeout-minutes: 5 30 | strategy: 31 | matrix: 32 | terraform: 33 | - 1.11.3 34 | - 1.10.5 35 | - 0.14.11 36 | env: 37 | TERRAFORM_VERSION: ${{ matrix.terraform }} 38 | TFUPDATE_EXEC_PATH: terraform 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - name: docker build 42 | run: docker compose build 43 | - name: terraform --version 44 | run: docker compose run --rm tfupdate terraform --version 45 | - name: testacc 46 | run: docker compose run --rm tfupdate make testacc 47 | testacc_opentofu: 48 | runs-on: ubuntu-latest 49 | timeout-minutes: 5 50 | strategy: 51 | matrix: 52 | opentofu: 53 | - 1.9.1 54 | - 1.6.3 55 | env: 56 | OPENTOFU_VERSION: ${{ matrix.opentofu }} 57 | TFUPDATE_EXEC_PATH: tofu 58 | TFREGISTRY_BASE_URL: https://registry.opentofu.org/ 59 | steps: 60 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 61 | - name: docker build 62 | run: docker compose build 63 | - name: tofu --version 64 | run: docker compose run --rm tfupdate tofu --version 65 | - name: testacc 66 | run: docker compose run --rm tfupdate make testacc 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .vscode 3 | bin/ 4 | tmp/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - binary: tfupdate 4 | goos: 5 | - darwin 6 | - linux 7 | - windows 8 | goarch: 9 | - amd64 10 | - arm64 11 | env: 12 | - CGO_ENABLED=0 13 | release: 14 | prerelease: auto 15 | changelog: 16 | filters: 17 | exclude: 18 | - Merge pull request 19 | - Merge branch 20 | - Update README 21 | - Update CHANGELOG 22 | brews: 23 | - repository: 24 | owner: minamijoyo 25 | name: homebrew-tfupdate 26 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 27 | commit_author: 28 | name: "Masayuki Morita" 29 | email: minamijoyo@gmail.com 30 | homepage: https://github.com/minamijoyo/tfupdate 31 | description: "Update version constraints in your Terraform / OpenTofu configurations" 32 | skip_upload: auto 33 | test: | 34 | system "#{bin}/tfupdate --version" 35 | install: | 36 | bin.install "tfupdate" 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # tfupdate 2 | FROM golang:1.24-alpine3.21 AS tfupdate 3 | RUN apk --no-cache add make git 4 | WORKDIR /work 5 | 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . . 10 | RUN make build 11 | 12 | # hub 13 | # The linux binary for hub can not run on alpine. 14 | # So we need to build it from source. 15 | # https://github.com/github/hub/issues/1818 16 | FROM golang:1.24-alpine3.21 AS hub 17 | RUN apk add --no-cache bash git 18 | RUN git clone https://github.com/github/hub /work 19 | WORKDIR /work 20 | RUN ./script/build -o bin/hub 21 | 22 | # runtime 23 | # Note: Required Tools for Primary Containers on CircleCI 24 | # https://circleci.com/docs/2.0/custom-images/#required-tools-for-primary-containers 25 | FROM alpine:3.21 26 | RUN apk --no-cache add bash git openssh-client tar gzip ca-certificates jq openssl curl 27 | COPY --from=tfupdate /work/bin/tfupdate /usr/local/bin/ 28 | COPY --from=hub /work/bin/hub /usr/local/bin/ 29 | ENTRYPOINT ["tfupdate"] 30 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 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 | # tfupdate 12 | FROM golang:1.24-alpine3.21 AS tfupdate 13 | RUN apk --no-cache add make git 14 | 15 | # A workaround for a permission issue of git. 16 | # Since UIDs are different between host and container, 17 | # the .git directory is untrusted by default. 18 | # We need to allow it explicitly. 19 | # https://github.com/actions/checkout/issues/760 20 | RUN git config --global --add safe.directory /work 21 | 22 | # for testing 23 | RUN apk add --no-cache bash 24 | COPY --from=terraform /bin/terraform /usr/local/bin/ 25 | COPY --from=opentofu /usr/local/bin/tofu /usr/local/bin/ 26 | 27 | WORKDIR /work 28 | 29 | COPY go.mod go.sum ./ 30 | RUN go mod download 31 | 32 | COPY . . 33 | RUN make install 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 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 := tfupdate 2 | 3 | .DEFAULT_GOAL := build 4 | 5 | .PHONY: deps 6 | deps: 7 | go mod download 8 | 9 | .PHONY: build 10 | build: deps 11 | go build -o bin/$(NAME) 12 | 13 | .PHONY: install 14 | install: deps 15 | go install 16 | 17 | .PHONY: lint 18 | lint: 19 | golangci-lint run ./... 20 | 21 | .PHONY: test 22 | test: build 23 | go test ./... 24 | 25 | .PHONY: testacc 26 | testacc: install testacc-lock-simple 27 | 28 | .PHONY: testacc-lock-simple 29 | testacc-lock-simple: install 30 | scripts/testacc/lock.sh run simple 31 | 32 | .PHONY: testacc-lock-debug 33 | testacc-lock-debug: install 34 | scripts/testacc/lock.sh $(ARG) 35 | 36 | .PHONY: testacc-all 37 | testacc-all: install 38 | scripts/testacc/all.sh 39 | 40 | .PHONY: check 41 | check: lint test 42 | -------------------------------------------------------------------------------- /command/env.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | // Env is a set of configurations read from environment variables. 4 | type Env struct { 5 | // GitHubBaseURL is a base URL for GtiHub API requests. 6 | // Defaults to the public GitHub API. 7 | GitHubBaseURL string `envconfig:"GITHUB_BASE_URL" default:"https://api.github.com/"` 8 | // GitHubToken is a personal access token for GitHub. 9 | // This allows access to a private repository. 10 | GitHubToken string `envconfig:"GITHUB_TOKEN"` 11 | // GitLabBaseURL is a base URL for GitLab API requests. 12 | // Defaults to the public GitLab API. 13 | GitLabBaseURL string `envconfig:"GITLAB_BASE_URL" default:"https://gitlab.com/api/v4/"` 14 | // GitLabToken is a personal access token for GitLab. 15 | // This is needed for public and private projects on all instances. 16 | GitLabToken string `envconfig:"GITLAB_TOKEN"` 17 | // TFRegistryBaseURL is a base URL for Terraform registry. 18 | // Defaults to the public Terraform registry. 19 | // To use the public OpenTofu registry, set this to `https://registry.opentofu.org/`. 20 | TFRegistryBaseURL string `envconfig:"TFREGISTRY_BASE_URL" default:"https://registry.terraform.io/"` 21 | } 22 | -------------------------------------------------------------------------------- /command/lock.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/kelseyhightower/envconfig" 11 | "github.com/minamijoyo/tfupdate/tfregistry" 12 | "github.com/minamijoyo/tfupdate/tfupdate" 13 | flag "github.com/spf13/pflag" 14 | ) 15 | 16 | // LockCommand is a command which update dependency lock files. 17 | type LockCommand struct { 18 | Meta 19 | platforms []string 20 | path string 21 | recursive bool 22 | ignorePaths []string 23 | } 24 | 25 | // Run runs the procedure of this command. 26 | func (c *LockCommand) Run(args []string) int { 27 | cmdFlags := flag.NewFlagSet("lock", flag.ContinueOnError) 28 | cmdFlags.StringArrayVar(&c.platforms, "platform", []string{}, "A target platform for dependecy lock file") 29 | cmdFlags.BoolVarP(&c.recursive, "recursive", "r", false, "Check a directory recursively") 30 | cmdFlags.StringArrayVarP(&c.ignorePaths, "ignore-path", "i", []string{}, "A regular expression for path to ignore") 31 | 32 | if err := cmdFlags.Parse(args); err != nil { 33 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 34 | return 1 35 | } 36 | 37 | if len(cmdFlags.Args()) != 1 { 38 | c.UI.Error(fmt.Sprintf("The command expects 1 arguments, but got %d", len(cmdFlags.Args()))) 39 | c.UI.Error(c.Help()) 40 | return 1 41 | } 42 | 43 | c.path = cmdFlags.Arg(0) 44 | 45 | if filepath.IsAbs(c.path) { 46 | c.UI.Error("The PATH argument should be a relative path, not an absolute path") 47 | c.UI.Error(c.Help()) 48 | return 1 49 | } 50 | 51 | if len(c.platforms) == 0 { 52 | c.UI.Error("The --platform flag is required") 53 | c.UI.Error(c.Help()) 54 | return 1 55 | } 56 | 57 | log.Println("[INFO] Update dependency lock files") 58 | 59 | // Fetch environment variables 60 | var env Env 61 | err := envconfig.Process("", &env) 62 | if err != nil { 63 | c.UI.Error(fmt.Sprintf("failed to fetch environment variables: %s", err)) 64 | return 1 65 | } 66 | 67 | // Create tfregistry.Config 68 | tfregistryConfig := tfregistry.Config{ 69 | BaseURL: env.TFRegistryBaseURL, 70 | } 71 | 72 | option, err := tfupdate.NewOption("lock", "", "", c.platforms, c.recursive, c.ignorePaths, "", tfregistryConfig) 73 | if err != nil { 74 | c.UI.Error(err.Error()) 75 | return 1 76 | } 77 | 78 | gc, err := tfupdate.NewGlobalContext(c.Fs, option) 79 | if err != nil { 80 | c.UI.Error(err.Error()) 81 | return 1 82 | } 83 | 84 | err = tfupdate.UpdateFileOrDir(context.Background(), gc, c.path) 85 | if err != nil { 86 | c.UI.Error(err.Error()) 87 | return 1 88 | } 89 | 90 | return 0 91 | } 92 | 93 | // Help returns long-form help text. 94 | func (c *LockCommand) Help() string { 95 | helpText := ` 96 | Usage: tfupdate lock [options] 97 | 98 | Arguments 99 | PATH A relative path of directory to update 100 | 101 | Options: 102 | --platform Specify a platform to update dependency lock files. 103 | At least one or more --platform flags must be specified. 104 | Use this option multiple times to include checksums for multiple target systems. 105 | Target platform names consist of an operating system and a CPU architecture. 106 | (e.g. linux_amd64, darwin_amd64, darwin_arm64) 107 | -r --recursive Check a directory recursively (default: false) 108 | -i --ignore-path A regular expression for path to ignore 109 | If you want to ignore multiple directories, set the flag multiple times. 110 | ` 111 | return strings.TrimSpace(helpText) 112 | } 113 | 114 | // Synopsis returns one-line help text. 115 | func (c *LockCommand) Synopsis() string { 116 | return "Update dependency lock files" 117 | } 118 | -------------------------------------------------------------------------------- /command/meta.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kelseyhightower/envconfig" 7 | "github.com/minamijoyo/tfupdate/release" 8 | "github.com/minamijoyo/tfupdate/tfregistry" 9 | "github.com/mitchellh/cli" 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | // Meta are the meta-options that are available on all or most commands. 14 | type Meta struct { 15 | // UI is a user interface representing input and output. 16 | UI cli.Ui 17 | 18 | // Fs is an afero filesystem. 19 | Fs afero.Fs 20 | } 21 | 22 | // newRelease is a factory method which returns an Release implementation. 23 | func newRelease(sourceType string, source string) (release.Release, error) { 24 | var env Env 25 | err := envconfig.Process("", &env) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to fetch environment variables: %s", err) 28 | } 29 | 30 | switch sourceType { 31 | case "github": 32 | config := release.GitHubConfig{ 33 | BaseURL: env.GitHubBaseURL, 34 | Token: env.GitHubToken, 35 | } 36 | return release.NewGitHubRelease(source, config) 37 | case "gitlab": 38 | config := release.GitLabConfig{ 39 | BaseURL: env.GitLabBaseURL, 40 | Token: env.GitLabToken, 41 | } 42 | return release.NewGitLabRelease(source, config) 43 | case "tfregistryModule": 44 | config := tfregistry.Config{ 45 | BaseURL: env.TFRegistryBaseURL, 46 | } 47 | return release.NewTFRegistryModuleRelease(source, config) 48 | case "tfregistryProvider": 49 | config := tfregistry.Config{ 50 | BaseURL: env.TFRegistryBaseURL, 51 | } 52 | return release.NewTFRegistryProviderRelease(source, config) 53 | default: 54 | return nil, fmt.Errorf("failed to new release data source. unknown type: %s", sourceType) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /command/module.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/minamijoyo/tfupdate/tfregistry" 10 | "github.com/minamijoyo/tfupdate/tfupdate" 11 | flag "github.com/spf13/pflag" 12 | ) 13 | 14 | // ModuleCommand is a command which update version constraints for module. 15 | type ModuleCommand struct { 16 | Meta 17 | name string 18 | version string 19 | path string 20 | recursive bool 21 | ignorePaths []string 22 | sourceMatchType string 23 | } 24 | 25 | // Run runs the procedure of this command. 26 | func (c *ModuleCommand) Run(args []string) int { 27 | cmdFlags := flag.NewFlagSet("module", flag.ContinueOnError) 28 | cmdFlags.StringVarP(&c.version, "version", "v", "", "A new version constraint") 29 | cmdFlags.BoolVarP(&c.recursive, "recursive", "r", false, "Check a directory recursively") 30 | cmdFlags.StringArrayVarP(&c.ignorePaths, "ignore-path", "i", []string{}, "A regular expression for path to ignore") 31 | cmdFlags.StringVar(&c.sourceMatchType, "source-match-type", "full", "Define how to match module source URLs. Valid values are \"full\" or \"regex\".") 32 | 33 | if err := cmdFlags.Parse(args); err != nil { 34 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 35 | return 1 36 | } 37 | 38 | if len(cmdFlags.Args()) != 2 { 39 | c.UI.Error(fmt.Sprintf("The command expects 2 arguments, but got %d", len(cmdFlags.Args()))) 40 | c.UI.Error(c.Help()) 41 | return 1 42 | } 43 | 44 | c.name = cmdFlags.Arg(0) 45 | c.path = cmdFlags.Arg(1) 46 | 47 | v := c.version 48 | if len(v) == 0 { 49 | // For modules, automatic latest version resolution is not simple. 50 | // To implement, we will probably need to get information from the Terraform Registry. 51 | c.UI.Error("A new version constraint is required. Automatic latest version resolution is not currently supported for modules.") 52 | return 1 53 | } 54 | 55 | log.Printf("[INFO] Update module %s to %s", c.name, v) 56 | option, err := tfupdate.NewOption("module", c.name, v, []string{}, c.recursive, c.ignorePaths, c.sourceMatchType, tfregistry.Config{}) 57 | if err != nil { 58 | c.UI.Error(err.Error()) 59 | return 1 60 | } 61 | 62 | gc, err := tfupdate.NewGlobalContext(c.Fs, option) 63 | if err != nil { 64 | c.UI.Error(err.Error()) 65 | return 1 66 | } 67 | 68 | err = tfupdate.UpdateFileOrDir(context.Background(), gc, c.path) 69 | if err != nil { 70 | c.UI.Error(err.Error()) 71 | return 1 72 | } 73 | 74 | return 0 75 | } 76 | 77 | // Help returns long-form help text. 78 | func (c *ModuleCommand) Help() string { 79 | helpText := ` 80 | Usage: tfupdate module [options] 81 | 82 | Arguments 83 | MODULE_NAME A name of module or a regular expression in RE2 syntax 84 | e.g. 85 | terraform-aws-modules/vpc/aws 86 | git::https://example.com/vpc.git 87 | git::https://example\.com/.+ 88 | PATH A path of file or directory to update 89 | 90 | Options: 91 | -v --version A new version constraint (required) 92 | Automatic latest version resolution is not currently supported for modules. 93 | -r --recursive Check a directory recursively (default: false) 94 | -i --ignore-path A regular expression for path to ignore 95 | If you want to ignore multiple directories, set the flag multiple times. 96 | --source-match-type Define how to match MODULE_NAME to the module source URLs. Valid values are "full" or "regex". (default: full) 97 | ` 98 | return strings.TrimSpace(helpText) 99 | } 100 | 101 | // Synopsis returns one-line help text. 102 | func (c *ModuleCommand) Synopsis() string { 103 | return "Update version constraints for module" 104 | } 105 | -------------------------------------------------------------------------------- /command/opentofu.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/minamijoyo/tfupdate/release" 10 | "github.com/minamijoyo/tfupdate/tfregistry" 11 | "github.com/minamijoyo/tfupdate/tfupdate" 12 | flag "github.com/spf13/pflag" 13 | ) 14 | 15 | // OpenTofuCommand is a command which update version constraints for OpenTofu. 16 | type OpenTofuCommand struct { 17 | Meta 18 | version string 19 | path string 20 | recursive bool 21 | ignorePaths []string 22 | } 23 | 24 | // Run runs the procedure of this command. 25 | func (c *OpenTofuCommand) Run(args []string) int { 26 | cmdFlags := flag.NewFlagSet("opentofu", flag.ContinueOnError) 27 | cmdFlags.StringVarP(&c.version, "version", "v", "latest", "A new version constraint") 28 | cmdFlags.BoolVarP(&c.recursive, "recursive", "r", false, "Check a directory recursively") 29 | cmdFlags.StringArrayVarP(&c.ignorePaths, "ignore-path", "i", []string{}, "A regular expression for path to ignore") 30 | 31 | if err := cmdFlags.Parse(args); err != nil { 32 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 33 | return 1 34 | } 35 | 36 | if len(cmdFlags.Args()) != 1 { 37 | c.UI.Error(fmt.Sprintf("The command expects 1 argument, but got %d", len(cmdFlags.Args()))) 38 | c.UI.Error(c.Help()) 39 | return 1 40 | } 41 | 42 | c.path = cmdFlags.Arg(0) 43 | 44 | v := c.version 45 | if v == "latest" { 46 | r, err := newRelease("github", "opentofu/opentofu") 47 | if err != nil { 48 | c.UI.Error(err.Error()) 49 | return 1 50 | } 51 | 52 | v, err = release.Latest(context.Background(), r) 53 | if err != nil { 54 | c.UI.Error(err.Error()) 55 | return 1 56 | } 57 | } 58 | 59 | log.Printf("[INFO] Update opentofu to %s", v) 60 | option, err := tfupdate.NewOption("opentofu", "", v, []string{}, c.recursive, c.ignorePaths, "", tfregistry.Config{}) 61 | if err != nil { 62 | c.UI.Error(err.Error()) 63 | return 1 64 | } 65 | 66 | gc, err := tfupdate.NewGlobalContext(c.Fs, option) 67 | if err != nil { 68 | c.UI.Error(err.Error()) 69 | return 1 70 | } 71 | 72 | err = tfupdate.UpdateFileOrDir(context.Background(), gc, c.path) 73 | if err != nil { 74 | c.UI.Error(err.Error()) 75 | return 1 76 | } 77 | 78 | return 0 79 | } 80 | 81 | // Help returns long-form help text. 82 | func (c *OpenTofuCommand) Help() string { 83 | helpText := ` 84 | Usage: tfupdate opentofu [options] 85 | 86 | Arguments 87 | PATH A path of file or directory to update 88 | 89 | Options: 90 | -v --version A new version constraint (default: latest) 91 | If the version is omitted, the latest version is automatically checked and set. 92 | -r --recursive Check a directory recursively (default: false) 93 | -i --ignore-path A regular expression for path to ignore 94 | If you want to ignore multiple directories, set the flag multiple times. 95 | ` 96 | return strings.TrimSpace(helpText) 97 | } 98 | 99 | // Synopsis returns one-line help text. 100 | func (c *OpenTofuCommand) Synopsis() string { 101 | return "Update version constraints for opentofu" 102 | } 103 | -------------------------------------------------------------------------------- /command/provider.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/minamijoyo/tfupdate/release" 10 | "github.com/minamijoyo/tfupdate/tfregistry" 11 | "github.com/minamijoyo/tfupdate/tfupdate" 12 | flag "github.com/spf13/pflag" 13 | ) 14 | 15 | // ProviderCommand is a command which update version constraints for provider. 16 | type ProviderCommand struct { 17 | Meta 18 | name string 19 | version string 20 | path string 21 | recursive bool 22 | ignorePaths []string 23 | } 24 | 25 | // Run runs the procedure of this command. 26 | func (c *ProviderCommand) Run(args []string) int { 27 | cmdFlags := flag.NewFlagSet("provider", flag.ContinueOnError) 28 | cmdFlags.StringVarP(&c.version, "version", "v", "latest", "A new version constraint") 29 | cmdFlags.BoolVarP(&c.recursive, "recursive", "r", false, "Check a directory recursively") 30 | cmdFlags.StringArrayVarP(&c.ignorePaths, "ignore-path", "i", []string{}, "A regular expression for path to ignore") 31 | 32 | if err := cmdFlags.Parse(args); err != nil { 33 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 34 | return 1 35 | } 36 | 37 | if len(cmdFlags.Args()) != 2 { 38 | c.UI.Error(fmt.Sprintf("The command expects 2 arguments, but got %d", len(cmdFlags.Args()))) 39 | c.UI.Error(c.Help()) 40 | return 1 41 | } 42 | 43 | c.name = cmdFlags.Arg(0) 44 | c.path = cmdFlags.Arg(1) 45 | 46 | v := c.version 47 | if v == "latest" { 48 | source := "" 49 | if strings.Contains(c.name, "/") { 50 | namespace, name, _ := strings.Cut(c.name, "/") 51 | source = fmt.Sprintf("%s/terraform-provider-%s", namespace, name) 52 | } else { 53 | source = fmt.Sprintf("hashicorp/terraform-provider-%s", c.name) 54 | } 55 | r, err := newRelease("github", source) 56 | if err != nil { 57 | c.UI.Error(err.Error()) 58 | return 1 59 | } 60 | 61 | v, err = release.Latest(context.Background(), r) 62 | if err != nil { 63 | c.UI.Error(err.Error()) 64 | return 1 65 | } 66 | } 67 | 68 | log.Printf("[INFO] Update provider %s to %s", c.name, v) 69 | option, err := tfupdate.NewOption("provider", c.name, v, []string{}, c.recursive, c.ignorePaths, "", tfregistry.Config{}) 70 | if err != nil { 71 | c.UI.Error(err.Error()) 72 | return 1 73 | } 74 | 75 | gc, err := tfupdate.NewGlobalContext(c.Fs, option) 76 | if err != nil { 77 | c.UI.Error(err.Error()) 78 | return 1 79 | } 80 | 81 | err = tfupdate.UpdateFileOrDir(context.Background(), gc, c.path) 82 | if err != nil { 83 | c.UI.Error(err.Error()) 84 | return 1 85 | } 86 | 87 | return 0 88 | } 89 | 90 | // Help returns long-form help text. 91 | func (c *ProviderCommand) Help() string { 92 | helpText := ` 93 | Usage: tfupdate provider [options] 94 | 95 | Arguments 96 | PROVIDER_NAME A name of provider (e.g. aws or integrations/github) 97 | PATH A path of file or directory to update 98 | 99 | Options: 100 | -v --version A new version constraint (default: latest) 101 | If the version is omitted, the latest version is automatically checked and set. 102 | -r --recursive Check a directory recursively (default: false) 103 | -i --ignore-path A regular expression for path to ignore 104 | If you want to ignore multiple directories, set the flag multiple times. 105 | ` 106 | return strings.TrimSpace(helpText) 107 | } 108 | 109 | // Synopsis returns one-line help text. 110 | func (c *ProviderCommand) Synopsis() string { 111 | return "Update version constraints for provider" 112 | } 113 | -------------------------------------------------------------------------------- /command/release.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/mitchellh/cli" 7 | ) 8 | 9 | // ReleaseCommand is a command which just shows help for subcommands. 10 | type ReleaseCommand struct { 11 | Meta 12 | } 13 | 14 | // Run runs the procedure of this command. 15 | func (c *ReleaseCommand) Run(args []string) int { // nolint revive unused-parameter 16 | return cli.RunResultHelp 17 | } 18 | 19 | // Help returns long-form help text. 20 | func (c *ReleaseCommand) Help() string { 21 | helpText := ` 22 | Usage: tfupdate release [options] [args] 23 | 24 | This command has subcommands for release version information. 25 | ` 26 | return strings.TrimSpace(helpText) 27 | } 28 | 29 | // Synopsis returns one-line help text. 30 | func (c *ReleaseCommand) Synopsis() string { 31 | return "Get release version information" 32 | } 33 | -------------------------------------------------------------------------------- /command/release_latest.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/minamijoyo/tfupdate/release" 9 | flag "github.com/spf13/pflag" 10 | ) 11 | 12 | // ReleaseLatestCommand is a command which gets the latest release version. 13 | type ReleaseLatestCommand struct { 14 | Meta 15 | sourceType string 16 | source string 17 | } 18 | 19 | // Run runs the procedure of this command. 20 | func (c *ReleaseLatestCommand) Run(args []string) int { 21 | cmdFlags := flag.NewFlagSet("release latest", flag.ContinueOnError) 22 | cmdFlags.StringVarP(&c.sourceType, "source-type", "s", "github", "A type of release data source") 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 | if len(cmdFlags.Args()) != 1 { 30 | c.UI.Error(fmt.Sprintf("The command expects 1 argument, but got %d", len(cmdFlags.Args()))) 31 | c.UI.Error(c.Help()) 32 | return 1 33 | } 34 | 35 | c.source = cmdFlags.Arg(0) 36 | 37 | r, err := newRelease(c.sourceType, c.source) 38 | if err != nil { 39 | c.UI.Error(err.Error()) 40 | return 1 41 | } 42 | 43 | v, err := release.Latest(context.Background(), r) 44 | if err != nil { 45 | c.UI.Error(err.Error()) 46 | return 1 47 | } 48 | 49 | c.UI.Output(v) 50 | return 0 51 | } 52 | 53 | // Help returns long-form help text. 54 | func (c *ReleaseLatestCommand) Help() string { 55 | helpText := ` 56 | Usage: tfupdate release latest [options] 57 | 58 | Arguments 59 | SOURCE A path of release data source. 60 | Valid format depends on --source-type option. 61 | - github or gitlab: 62 | owner/repo 63 | e.g. terraform-providers/terraform-provider-aws 64 | - tfregistryModule: 65 | namespace/name/provider 66 | e.g. terraform-aws-modules/vpc/aws 67 | - tfregistryProvider: 68 | namespace/type 69 | e.g. hashicorp/aws 70 | 71 | Options: 72 | -s --source-type A type of release data source. 73 | Valid values are 74 | - github (default) 75 | - gitlab 76 | - tfregistryModule 77 | - tfregistryProvider 78 | ` 79 | return strings.TrimSpace(helpText) 80 | } 81 | 82 | // Synopsis returns one-line help text. 83 | func (c *ReleaseLatestCommand) Synopsis() string { 84 | return "Get the latest release version" 85 | } 86 | -------------------------------------------------------------------------------- /command/release_list.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/minamijoyo/tfupdate/release" 9 | flag "github.com/spf13/pflag" 10 | ) 11 | 12 | // ReleaseListCommand is a command which gets a list of release versions. 13 | type ReleaseListCommand struct { 14 | Meta 15 | maxLength int 16 | preRelease bool 17 | sourceType string 18 | source string 19 | } 20 | 21 | // Run runs the procedure of this command. 22 | func (c *ReleaseListCommand) Run(args []string) int { 23 | cmdFlags := flag.NewFlagSet("release list", flag.ContinueOnError) 24 | cmdFlags.IntVarP(&c.maxLength, "max-length", "n", 10, "the maximum length of list") 25 | cmdFlags.BoolVar(&c.preRelease, "pre-release", false, "show pre-releases") 26 | cmdFlags.StringVarP(&c.sourceType, "source-type", "s", "github", "A type of release data source") 27 | 28 | if err := cmdFlags.Parse(args); err != nil { 29 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 30 | return 1 31 | } 32 | 33 | if len(cmdFlags.Args()) != 1 { 34 | c.UI.Error(fmt.Sprintf("The command expects 1 argument, but got %d", len(cmdFlags.Args()))) 35 | c.UI.Error(c.Help()) 36 | return 1 37 | } 38 | 39 | c.source = cmdFlags.Arg(0) 40 | 41 | r, err := newRelease(c.sourceType, c.source) 42 | if err != nil { 43 | c.UI.Error(err.Error()) 44 | return 1 45 | } 46 | 47 | versions, err := release.List(context.Background(), r, c.maxLength, c.preRelease) 48 | if err != nil { 49 | c.UI.Error(err.Error()) 50 | return 1 51 | } 52 | 53 | c.UI.Output(strings.Join(versions, "\n")) 54 | return 0 55 | } 56 | 57 | // Help returns long-form help text. 58 | func (c *ReleaseListCommand) Help() string { 59 | helpText := ` 60 | Usage: tfupdate release list [options] 61 | 62 | Arguments 63 | SOURCE A path of release data source. 64 | Valid format depends on --source-type option. 65 | - github or gitlab: 66 | owner/repo 67 | e.g. terraform-providers/terraform-provider-aws 68 | - tfregistryModule: 69 | namespace/name/provider 70 | e.g. terraform-aws-modules/vpc/aws 71 | - tfregistryProvider: 72 | namespace/type 73 | e.g. hashicorp/aws 74 | 75 | Options: 76 | -s --source-type A type of release data source. 77 | Valid values are 78 | - github (default) 79 | - gitlab 80 | - tfregistryModule 81 | - tfregistryProvider 82 | -n --max-length The maximum length of list. 83 | --pre-release Show pre-releases. (default: false) 84 | ` 85 | return strings.TrimSpace(helpText) 86 | } 87 | 88 | // Synopsis returns one-line help text. 89 | func (c *ReleaseListCommand) Synopsis() string { 90 | return "Get a list of release versions" 91 | } 92 | -------------------------------------------------------------------------------- /command/terraform.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/minamijoyo/tfupdate/release" 10 | "github.com/minamijoyo/tfupdate/tfregistry" 11 | "github.com/minamijoyo/tfupdate/tfupdate" 12 | flag "github.com/spf13/pflag" 13 | ) 14 | 15 | // TerraformCommand is a command which update version constraints for terraform. 16 | type TerraformCommand struct { 17 | Meta 18 | version string 19 | path string 20 | recursive bool 21 | ignorePaths []string 22 | } 23 | 24 | // Run runs the procedure of this command. 25 | func (c *TerraformCommand) Run(args []string) int { 26 | cmdFlags := flag.NewFlagSet("terraform", flag.ContinueOnError) 27 | cmdFlags.StringVarP(&c.version, "version", "v", "latest", "A new version constraint") 28 | cmdFlags.BoolVarP(&c.recursive, "recursive", "r", false, "Check a directory recursively") 29 | cmdFlags.StringArrayVarP(&c.ignorePaths, "ignore-path", "i", []string{}, "A regular expression for path to ignore") 30 | 31 | if err := cmdFlags.Parse(args); err != nil { 32 | c.UI.Error(fmt.Sprintf("failed to parse arguments: %s", err)) 33 | return 1 34 | } 35 | 36 | if len(cmdFlags.Args()) != 1 { 37 | c.UI.Error(fmt.Sprintf("The command expects 1 argument, but got %d", len(cmdFlags.Args()))) 38 | c.UI.Error(c.Help()) 39 | return 1 40 | } 41 | 42 | c.path = cmdFlags.Arg(0) 43 | 44 | v := c.version 45 | if v == "latest" { 46 | r, err := newRelease("github", "hashicorp/terraform") 47 | if err != nil { 48 | c.UI.Error(err.Error()) 49 | return 1 50 | } 51 | 52 | v, err = release.Latest(context.Background(), r) 53 | if err != nil { 54 | c.UI.Error(err.Error()) 55 | return 1 56 | } 57 | } 58 | 59 | log.Printf("[INFO] Update terraform to %s", v) 60 | option, err := tfupdate.NewOption("terraform", "", v, []string{}, c.recursive, c.ignorePaths, "", tfregistry.Config{}) 61 | if err != nil { 62 | c.UI.Error(err.Error()) 63 | return 1 64 | } 65 | 66 | gc, err := tfupdate.NewGlobalContext(c.Fs, option) 67 | if err != nil { 68 | c.UI.Error(err.Error()) 69 | return 1 70 | } 71 | 72 | err = tfupdate.UpdateFileOrDir(context.Background(), gc, c.path) 73 | if err != nil { 74 | c.UI.Error(err.Error()) 75 | return 1 76 | } 77 | 78 | return 0 79 | } 80 | 81 | // Help returns long-form help text. 82 | func (c *TerraformCommand) Help() string { 83 | helpText := ` 84 | Usage: tfupdate terraform [options] 85 | 86 | Arguments 87 | PATH A path of file or directory to update 88 | 89 | Options: 90 | -v --version A new version constraint (default: latest) 91 | If the version is omitted, the latest version is automatically checked and set. 92 | -r --recursive Check a directory recursively (default: false) 93 | -i --ignore-path A regular expression for path to ignore 94 | If you want to ignore multiple directories, set the flag multiple times. 95 | ` 96 | return strings.TrimSpace(helpText) 97 | } 98 | 99 | // Synopsis returns one-line help text. 100 | func (c *TerraformCommand) Synopsis() string { 101 | return "Update version constraints for terraform" 102 | } 103 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | tfupdate: 3 | build: 4 | context: . 5 | dockerfile: ./Dockerfile.dev 6 | args: 7 | TERRAFORM_VERSION: ${TERRAFORM_VERSION:-latest} 8 | OPENTOFU_VERSION: ${OPENTOFU_VERSION:-latest} 9 | volumes: 10 | - ".:/work" 11 | environment: 12 | CGO_ENABLED: 0 # disable cgo for go test 13 | TFUPDATE_EXEC_PATH: 14 | TFREGISTRY_BASE_URL: 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/minamijoyo/tfupdate 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 7 | github.com/google/go-cmp v0.6.0 8 | github.com/google/go-github/v28 v28.1.1 9 | github.com/hashicorp/go-version v1.6.0 10 | github.com/hashicorp/hcl/v2 v2.23.0 11 | github.com/hashicorp/logutils v1.0.0 12 | github.com/hashicorp/terraform-registry-address v0.2.0 13 | github.com/hashicorp/terraform-svchost v0.0.1 14 | github.com/kelseyhightower/envconfig v1.4.0 15 | github.com/minamijoyo/terraform-config-inspect v0.0.0-20250505010908-6ad6eb27d3c9 16 | github.com/mitchellh/cli v1.0.0 17 | github.com/pkg/errors v0.9.1 18 | github.com/spf13/afero v1.9.5 19 | github.com/spf13/pflag v1.0.5 20 | github.com/xanzy/go-gitlab v0.20.1 21 | github.com/zclconf/go-cty v1.14.4 22 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 23 | golang.org/x/mod v0.8.0 24 | golang.org/x/oauth2 v0.4.0 25 | ) 26 | 27 | require ( 28 | github.com/agext/levenshtein v1.2.2 // indirect 29 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 30 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 // indirect 31 | github.com/bgentry/speakeasy v0.1.0 // indirect 32 | github.com/fatih/color v1.7.0 // indirect 33 | github.com/golang/protobuf v1.5.2 // indirect 34 | github.com/google/go-querystring v1.0.0 // indirect 35 | github.com/hashicorp/errwrap v1.0.0 // indirect 36 | github.com/hashicorp/go-multierror v1.0.0 // indirect 37 | github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f // indirect 38 | github.com/mattn/go-colorable v0.0.9 // indirect 39 | github.com/mattn/go-isatty v0.0.3 // indirect 40 | github.com/mitchellh/go-wordwrap v1.0.0 // indirect 41 | github.com/posener/complete v1.1.1 // indirect 42 | golang.org/x/crypto v0.1.0 // indirect 43 | golang.org/x/net v0.6.0 // indirect 44 | golang.org/x/sys v0.5.0 // indirect 45 | golang.org/x/text v0.11.0 // indirect 46 | golang.org/x/tools v0.6.0 // indirect 47 | google.golang.org/appengine v1.6.7 // indirect 48 | google.golang.org/protobuf v1.28.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /lock/hash.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "golang.org/x/mod/sumdb/dirhash" 9 | ) 10 | 11 | // zipDataToH1Hash is a helper function that calculates the h1 hash value from 12 | // bytes sequence of the provider's zip archive. 13 | func zipDataToH1Hash(zipData []byte) (string, error) { 14 | tmpZipfile, err := writeTempFile(zipData) 15 | if err != nil { 16 | return "", err 17 | } 18 | defer os.Remove(tmpZipfile.Name()) 19 | 20 | // The h1 hash value in .terraform.lock.hcl uses the same hash function as go.sum. 21 | hash, err := dirhash.HashZip(tmpZipfile.Name(), dirhash.Hash1) 22 | if err != nil { 23 | return "", fmt.Errorf("failed to calculate h1 hash: %s", err) 24 | } 25 | 26 | return hash, nil 27 | } 28 | 29 | // writeTempFile writes content to a temporary file and return its file. 30 | func writeTempFile(content []byte) (*os.File, error) { 31 | tmpfile, err := os.CreateTemp("", "tmp") 32 | if err != nil { 33 | return tmpfile, fmt.Errorf("failed to create temporary file: %s", err) 34 | } 35 | 36 | if _, err := tmpfile.Write(content); err != nil { 37 | return tmpfile, fmt.Errorf("failed to write temporary file: %s", err) 38 | } 39 | 40 | if err := tmpfile.Close(); err != nil { 41 | return tmpfile, fmt.Errorf("failed to close temporary file: %s", err) 42 | } 43 | 44 | return tmpfile, nil 45 | } 46 | 47 | // shaSumsDataToZhHash is a helper function for parsing zh hash values from 48 | // bytes sequence of the shaSumsData document. 49 | func shaSumsDataToZhHash(shaSumsData []byte) (map[string]string, error) { 50 | document := string(shaSumsData) 51 | zh := make(map[string]string) 52 | // Read an entry per line. 53 | for _, line := range strings.Split(document, "\n") { 54 | // We expect that blank lines are not normally included, but to make the 55 | // test data easier to read, ignore blank lines. 56 | if len(line) == 0 { 57 | continue 58 | } 59 | 60 | // Split rows into columns with spaces, but note that there are two spaces between the columns. 61 | // e4453fbebf90c53ca3323a92e7ca0f9961427d2f0ce0d2b65523cc04d5d999c2 terraform-provider-null_3.2.1_darwin_arm64.zip 62 | 63 | fields := strings.Fields(line) 64 | if len(fields) != 2 { 65 | return nil, fmt.Errorf("failed to parse hash in shaSumsData: %s", document) 66 | } 67 | hash := fields[0] 68 | 69 | // Initially, we thought of using the key of the zh hash as the platform, 70 | // but we found out that it also includes metadata such as manifest.json, 71 | // so we decided to use the filename as it is. 72 | filename := fields[1] 73 | 74 | // As the implementation of the h1 hash includes a prefix for the "h1:" 75 | // scheme, zh also includes the "zh:" prefix for consistency. 76 | zh[filename] = "zh:" + hash 77 | } 78 | 79 | return zh, nil 80 | } 81 | -------------------------------------------------------------------------------- /lock/hash_test.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/davecgh/go-spew/spew" 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestZipDataToH1Hash(t *testing.T) { 11 | filename := "terraform-provider-dummy_v3.2.1_x5" 12 | cases := []struct { 13 | desc string 14 | makeZip bool 15 | filename string 16 | // Actually it's a binary of the provider's executable, but here we'll use dummy data for testing. 17 | contents string 18 | want string 19 | ok bool 20 | }{ 21 | { 22 | desc: "darwin_arm64", 23 | makeZip: true, 24 | contents: "dummy_3.2.1_darwin_arm64", 25 | want: "h1:3323G20HW9PA9ONrL6CdQCdCFe6y94kXeOTprq+Zu+w=", 26 | ok: true, 27 | }, 28 | { 29 | desc: "darwin_amd64", 30 | makeZip: true, 31 | contents: "dummy_3.2.1_darwin_amd64", 32 | want: "h1:63My0EuWIYHWVwWOxmxWwgrfx+58Tz+nTduelaCCAfs=", 33 | ok: true, 34 | }, 35 | { 36 | desc: "linux_amd64", 37 | makeZip: true, 38 | contents: "dummy_3.2.1_linux_amd64", 39 | want: "h1:2zotrPRAjGZZMkjJGBGLnIbG+sqhQN30sbwqSDECQFQ=", 40 | ok: true, 41 | }, 42 | { 43 | desc: "invalid zip format", 44 | makeZip: false, 45 | contents: "dummy_3.2.1_linux_amd64", 46 | want: "", 47 | ok: false, 48 | }, 49 | } 50 | 51 | for _, tc := range cases { 52 | t.Run(tc.desc, func(t *testing.T) { 53 | var zipData []byte 54 | var err error 55 | if tc.makeZip { 56 | // create a zip file in memory. 57 | zipData, err = newMockZipData(filename, tc.contents) 58 | if err != nil { 59 | t.Fatalf("failed to create a zip file in memory: err = %s", err) 60 | } 61 | } else { 62 | // invalid zip format 63 | zipData = []byte(tc.contents) 64 | } 65 | 66 | got, err := zipDataToH1Hash(zipData) 67 | 68 | if tc.ok && err != nil { 69 | t.Fatalf("failed to call zipDataToH1Hash: err = %s", err) 70 | } 71 | 72 | if !tc.ok && err == nil { 73 | t.Fatalf("expected to fail, but success: got = %s", got) 74 | } 75 | 76 | if got != tc.want { 77 | t.Errorf("got=%s, but want=%s", got, tc.want) 78 | } 79 | }) 80 | } 81 | } 82 | 83 | func TestShaSumsDataToZhHash(t *testing.T) { 84 | // create a valid dummy shaSumsData. 85 | platforms := []string{"darwin_arm64", "darwin_amd64", "linux_amd64", "windows_amd64"} 86 | shaSumsData, err := newMockShaSumsData("dummy", "3.2.1", platforms) 87 | if err != nil { 88 | t.Fatalf("failed to create a shaSumsData: err = %s", err) 89 | } 90 | 91 | // To update the following static test case, uncomment out here. 92 | // t.Logf("%s", string(shaSumsData)) 93 | 94 | cases := []struct { 95 | desc string 96 | shaSumsData []byte 97 | want map[string]string 98 | ok bool 99 | }{ 100 | { 101 | desc: "static", 102 | // The input shaSumsData should be the same as the following dynamic 103 | // case, but the output of newMockShaSumsData is pasted into the test 104 | // case for test case readability. 105 | shaSumsData: []byte(` 106 | 5622a0fd03420ed1fa83a1a6e90b65fbe34bc74c251b3b47048f14217e93b086 terraform-provider-dummy_3.2.1_darwin_arm64.zip 107 | 8b75ff41191a7fe6c5d9129ed19a01eacde5a3797b48b738eefa21f5330c081e terraform-provider-dummy_3.2.1_windows_amd64.zip 108 | c5f0a44e3a3795cb3ee0abb0076097c738294c241f74c145dfb50f2b9fd71fd2 terraform-provider-dummy_3.2.1_linux_amd64.zip 109 | fc5bbdd0a1bd6715b9afddf3aba6acc494425d77015c19579b9a9fa950e532b2 terraform-provider-dummy_3.2.1_darwin_amd64.zip 110 | `), 111 | want: map[string]string{ 112 | "terraform-provider-dummy_3.2.1_darwin_arm64.zip": "zh:5622a0fd03420ed1fa83a1a6e90b65fbe34bc74c251b3b47048f14217e93b086", 113 | "terraform-provider-dummy_3.2.1_darwin_amd64.zip": "zh:fc5bbdd0a1bd6715b9afddf3aba6acc494425d77015c19579b9a9fa950e532b2", 114 | "terraform-provider-dummy_3.2.1_linux_amd64.zip": "zh:c5f0a44e3a3795cb3ee0abb0076097c738294c241f74c145dfb50f2b9fd71fd2", 115 | "terraform-provider-dummy_3.2.1_windows_amd64.zip": "zh:8b75ff41191a7fe6c5d9129ed19a01eacde5a3797b48b738eefa21f5330c081e", 116 | }, 117 | ok: true, 118 | }, 119 | { 120 | desc: "dynamic", 121 | shaSumsData: shaSumsData, 122 | want: map[string]string{ 123 | "terraform-provider-dummy_3.2.1_darwin_arm64.zip": "zh:5622a0fd03420ed1fa83a1a6e90b65fbe34bc74c251b3b47048f14217e93b086", 124 | "terraform-provider-dummy_3.2.1_darwin_amd64.zip": "zh:fc5bbdd0a1bd6715b9afddf3aba6acc494425d77015c19579b9a9fa950e532b2", 125 | "terraform-provider-dummy_3.2.1_linux_amd64.zip": "zh:c5f0a44e3a3795cb3ee0abb0076097c738294c241f74c145dfb50f2b9fd71fd2", 126 | "terraform-provider-dummy_3.2.1_windows_amd64.zip": "zh:8b75ff41191a7fe6c5d9129ed19a01eacde5a3797b48b738eefa21f5330c081e", 127 | }, 128 | ok: true, 129 | }, 130 | { 131 | desc: "empty", 132 | shaSumsData: []byte(""), 133 | want: map[string]string{}, 134 | ok: true, 135 | }, 136 | { 137 | desc: "parse hash error", 138 | shaSumsData: []byte("aaa"), 139 | want: nil, 140 | ok: false, 141 | }, 142 | } 143 | 144 | for _, tc := range cases { 145 | t.Run(tc.desc, func(t *testing.T) { 146 | 147 | got, err := shaSumsDataToZhHash(tc.shaSumsData) 148 | 149 | if tc.ok && err != nil { 150 | t.Fatalf("failed to call shaSumsDataToZhHash: err = %s", err) 151 | } 152 | 153 | if !tc.ok && err == nil { 154 | t.Fatalf("expected to fail, but success: got = %s", spew.Sdump(got)) 155 | } 156 | 157 | if diff := cmp.Diff(got, tc.want); diff != "" { 158 | t.Errorf("got: %s, want = %s, diff = %s", spew.Sdump(got), spew.Sdump(tc.want), diff) 159 | } 160 | }) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lock/index.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | tfaddr "github.com/hashicorp/terraform-registry-address" 10 | "github.com/minamijoyo/tfupdate/tfregistry" 11 | ) 12 | 13 | // Index is an in-memory data store for caching provider hash values. 14 | type Index interface { 15 | // GetOrCreateProviderVersion returns a cached provider version if available, 16 | // otherwise creates it. 17 | // address is a provider address such as hashicorp/null. 18 | // version is a version number such as 3.2.1. 19 | // platforms is a list of target platforms to generate hash values. 20 | // Target platform names consist of an operating system and a CPU architecture such as darwin_arm64. 21 | GetOrCreateProviderVersion(ctx context.Context, address string, version string, platforms []string) (*ProviderVersion, error) 22 | } 23 | 24 | // index is an implementation for Index interface. 25 | type index struct { 26 | // providers is a dictionary of providerIndex. 27 | // The key is a provider address such as hashicorp/null. 28 | providers map[string]*providerIndex 29 | 30 | // papi is a ProviderDownloaderAPI interface implementation used for downloading provider. 31 | papi ProviderDownloaderAPI 32 | } 33 | 34 | // NewIndexFromConfig returns a new instance of Index with the given registry config. 35 | func NewIndexFromConfig(config tfregistry.Config) (Index, error) { 36 | client, err := NewProviderDownloaderClient(config) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | index := NewIndex(client) 42 | 43 | return index, nil 44 | } 45 | 46 | // NewIndex returns a new instance of Index with the given provider downloader API. 47 | func NewIndex(papi ProviderDownloaderAPI) Index { 48 | providers := make(map[string]*providerIndex) 49 | return &index{ 50 | providers: providers, 51 | papi: papi, 52 | } 53 | } 54 | 55 | // GetOrCreateProviderVersion returns a cached provider version if available, 56 | // otherwise creates it. 57 | func (i *index) GetOrCreateProviderVersion(ctx context.Context, address string, version string, platforms []string) (*ProviderVersion, error) { 58 | pi, ok := i.providers[address] 59 | if !ok { 60 | // cache miss 61 | pi = newProviderIndex(address, i.papi) 62 | i.providers[address] = pi 63 | } 64 | // Delegate to ProviderIndex. 65 | return pi.getOrCreateProviderVersion(ctx, version, platforms) 66 | } 67 | 68 | // The providerIndex holds multiple version data for a specific provider. 69 | type providerIndex struct { 70 | // address is a provider address such as hashicorp/null. 71 | address string 72 | 73 | // versions is a dictionary of ProviderVersion. 74 | // The key is a version number such as 3.2.1. 75 | versions map[string]*ProviderVersion 76 | 77 | // papi is a ProviderDownloaderAPI interface implementation used for downloading provider. 78 | papi ProviderDownloaderAPI 79 | } 80 | 81 | // newProviderIndex returns a new instance of providerIndex. 82 | func newProviderIndex(address string, papi ProviderDownloaderAPI) *providerIndex { 83 | versions := make(map[string]*ProviderVersion) 84 | return &providerIndex{ 85 | address: address, 86 | versions: versions, 87 | papi: papi, 88 | } 89 | } 90 | 91 | // getOrCreateProviderVersion returns a cached provider version if available, 92 | // otherwise creates it. 93 | func (pi *providerIndex) getOrCreateProviderVersion(ctx context.Context, version string, platforms []string) (*ProviderVersion, error) { 94 | pv, ok := pi.versions[version] 95 | if !ok { 96 | // cache miss 97 | var err error 98 | pv, err = pi.createProviderVersion(ctx, version, platforms) 99 | if err != nil { 100 | return nil, err 101 | } 102 | pi.versions[version] = pv 103 | } 104 | return pv, nil 105 | } 106 | 107 | // createProviderVersion downloads the specified provider, calculates the hash 108 | // value and returns an instance of the ProviderVersion. 109 | func (pi *providerIndex) createProviderVersion(ctx context.Context, version string, platforms []string) (*ProviderVersion, error) { 110 | ret := newEmptyProviderVersion(pi.address, version) 111 | 112 | for _, platform := range platforms { 113 | req, err := newProviderDownloadRequest(pi.address, version, platform) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | // Download a given provider from registry. 119 | log.Printf("[DEBUG] providerIndex.createProviderVersion: %s, %s, %s", pi.address, version, platform) 120 | res, err := pi.papi.ProviderDownload(ctx, req) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | // Currently the Terraform Registry returns the zh hash for all platforms, 126 | // but not the h1 hash, so the h1 hash has to be calculated separately. 127 | // We need to calculate the values for each platform and merge the results. 128 | pv, err := buildProviderVersion(pi.address, version, platform, res) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | err = ret.Merge(pv) 134 | if err != nil { 135 | return nil, err 136 | } 137 | } 138 | 139 | return ret, nil 140 | } 141 | 142 | // newProviderDownloadRequest is a helper function for building the parameters for downloading provider. 143 | // address is a provider address such as hashicorp/null. 144 | // version is a version number such as 3.2.1. 145 | // platform is a target platform name such as darwin_arm64. 146 | func newProviderDownloadRequest(address string, version string, platform string) (*ProviderDownloadRequest, error) { 147 | // We parse an provider address by using the terraform-registry-address 148 | // library to support fully qualified addresses such as 149 | // registry.terraform.io/hashicorp/null in the future, but note that the 150 | // current ProviderDownloaderClient implementation only supports the public 151 | // standard registry (registry.terraform.io). 152 | pAddr, err := tfaddr.ParseProviderSource(address) 153 | if err != nil { 154 | return nil, fmt.Errorf("failed to parse provider aaddress: %s", address) 155 | } 156 | 157 | // Since .terraform.lock.hcl was introduced from v0.14, we assume that 158 | // provider address is qualified with namespaces at least. We won't support 159 | // implicit legacy things. 160 | if !pAddr.HasKnownNamespace() { 161 | return nil, fmt.Errorf("failed to parse unknown provider aaddress: %s", address) 162 | } 163 | if pAddr.IsLegacy() { 164 | return nil, fmt.Errorf("failed to parse legacy provider aaddress: %s", address) 165 | } 166 | 167 | pf := strings.Split(platform, "_") 168 | if len(pf) != 2 { 169 | return nil, fmt.Errorf("failed to parse platform: %s", platform) 170 | } 171 | os := pf[0] 172 | arch := pf[1] 173 | 174 | req := &ProviderDownloadRequest{ 175 | Namespace: pAddr.Namespace, 176 | Type: pAddr.Type, 177 | Version: version, 178 | OS: os, 179 | Arch: arch, 180 | } 181 | 182 | return req, nil 183 | } 184 | 185 | // buildProviderVersion calculates hash values from the ProviderDownloadResponse 186 | // and returns an instance of the ProviderVersion. 187 | func buildProviderVersion(address string, version string, platform string, res *ProviderDownloadResponse) (*ProviderVersion, error) { 188 | h1Hashes := make(map[string]string) 189 | 190 | h1, err := zipDataToH1Hash(res.zipData) 191 | if err != nil { 192 | return nil, err 193 | } 194 | h1Hashes[res.filename] = h1 195 | 196 | zhHashes, err := shaSumsDataToZhHash(res.shaSumsData) 197 | if err != nil { 198 | return nil, err 199 | } 200 | 201 | pv := &ProviderVersion{ 202 | address: address, 203 | version: version, 204 | platforms: []string{platform}, 205 | h1Hashes: h1Hashes, 206 | zhHashes: zhHashes, 207 | } 208 | 209 | return pv, nil 210 | } 211 | -------------------------------------------------------------------------------- /lock/mock.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | 13 | tfaddr "github.com/hashicorp/terraform-registry-address" 14 | "github.com/minamijoyo/tfupdate/tfregistry" 15 | "golang.org/x/exp/slices" 16 | ) 17 | 18 | // mockTFRegistryClient is a mock implementation of tfregistry.API 19 | type mockTFRegistryClient struct { 20 | metadataRes *tfregistry.ProviderPackageMetadataResponse 21 | err error 22 | } 23 | 24 | var _ tfregistry.API = (*mockTFRegistryClient)(nil) 25 | 26 | func (c *mockTFRegistryClient) ProviderPackageMetadata(_ context.Context, _ *tfregistry.ProviderPackageMetadataRequest) (*tfregistry.ProviderPackageMetadataResponse, error) { 27 | return c.metadataRes, c.err 28 | } 29 | 30 | func (c *mockTFRegistryClient) ListModuleVersions(_ context.Context, _ *tfregistry.ListModuleVersionsRequest) (*tfregistry.ListModuleVersionsResponse, error) { 31 | return nil, nil // dummy implementation as it's not used in tests 32 | } 33 | 34 | func (c *mockTFRegistryClient) ListProviderVersions(_ context.Context, _ *tfregistry.ListProviderVersionsRequest) (*tfregistry.ListProviderVersionsResponse, error) { 35 | return nil, nil // dummy implementation as it's not used in tests 36 | } 37 | 38 | // newMockServer returns a new mock server for testing. 39 | func newMockServer() (*http.ServeMux, *url.URL) { 40 | mux := http.NewServeMux() 41 | server := httptest.NewServer(mux) 42 | mockServerURL, _ := url.Parse(server.URL) 43 | return mux, mockServerURL 44 | } 45 | 46 | // newTestClient returns a new client for testing. 47 | func newTestClient(mockServerURL *url.URL, config tfregistry.Config) *ProviderDownloaderClient { 48 | config.BaseURL = mockServerURL.String() 49 | c, _ := NewProviderDownloaderClient(config) 50 | return c 51 | } 52 | 53 | // newMockZipData returns a new zip format data for testing. 54 | func newMockZipData(filename string, contents string) ([]byte, error) { 55 | // create a zip file in memory 56 | var buf bytes.Buffer 57 | zw := zip.NewWriter(&buf) 58 | 59 | // create a file in the zip file 60 | w, err := zw.Create(filename) 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to create a file in zip: err = %s", err) 63 | } 64 | _, err = w.Write([]byte(contents)) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to write contents to a file: err = %s", err) 67 | } 68 | 69 | // zip 70 | err = zw.Close() 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to flush a zip file: err = %s", err) 73 | } 74 | 75 | return buf.Bytes(), nil 76 | } 77 | 78 | // newMockShaSumsData returns a new shaSumsData for testing. 79 | // To ensure that the dummy data can be re-used in other test cases, the 80 | // function really creates a zip file in memory and calculates its sha256sum. 81 | func newMockShaSumsData(name string, version string, platforms []string) ([]byte, error) { 82 | // terraform-provider-dummy_v3.2.1_x5 83 | filename := fmt.Sprintf("terraform-provider-%s_v%s_x5", name, version) 84 | lines := []string{} 85 | for _, platform := range platforms { 86 | // dummy_3.2.1_darwin_arm64 87 | contents := fmt.Sprintf("%s_%s_%s", name, version, platform) 88 | 89 | // create a zip file in memory. 90 | zipData, err := newMockZipData(filename, contents) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to create a zip file in memory: err = %s", err) 93 | } 94 | zh := sha256sumAsHexString(zipData) 95 | zipFilename := "terraform-provider-" + contents + ".zip" 96 | line := fmt.Sprintf("%s %s", zh, zipFilename) 97 | lines = append(lines, line) 98 | } 99 | 100 | slices.Sort(lines) 101 | document := strings.Join(lines, "\n") 102 | return []byte(document), nil 103 | } 104 | 105 | // newMockProviderDownloadResponse returns a new ProviderDownloadResponse for testing. 106 | func newMockProviderDownloadResponse(address string, version string, targetPlatform string, allPlatforms []string) (*ProviderDownloadResponse, error) { 107 | pAddr, err := tfaddr.ParseProviderSource(address) 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to parse provider aaddress: %s", address) 110 | } 111 | name := pAddr.Type 112 | // create a zip file in memory. 113 | zipDataFilename := fmt.Sprintf("terraform-provider-%s_v%s_x5", name, version) 114 | zipDataContents := fmt.Sprintf("%s_%s_%s", name, version, targetPlatform) 115 | zipData, err := newMockZipData(zipDataFilename, zipDataContents) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to create a zip file in memory: err = %s", err) 118 | } 119 | // create a valid dummy shaSumsData. 120 | shaSumsData, err := newMockShaSumsData(name, version, allPlatforms) 121 | if err != nil { 122 | return nil, fmt.Errorf("failed to create a shaSumsData: err = %s", err) 123 | } 124 | filename := fmt.Sprintf("terraform-provider-%s_%s_%s.zip", name, version, targetPlatform) 125 | return &ProviderDownloadResponse{ 126 | filename: filename, 127 | zipData: zipData, 128 | shaSumsData: shaSumsData, 129 | }, nil 130 | } 131 | 132 | // newMockProviderDownloadResponses returns a new list of ProviderDownloadResponse for testing. 133 | func newMockProviderDownloadResponses(address string, version string, targetPlatforms []string, allPlatforms []string) ([]*ProviderDownloadResponse, error) { 134 | responses := []*ProviderDownloadResponse{} 135 | for _, targetPlatform := range targetPlatforms { 136 | res, err := newMockProviderDownloadResponse(address, version, targetPlatform, allPlatforms) 137 | if err != nil { 138 | return nil, err 139 | } 140 | responses = append(responses, res) 141 | } 142 | 143 | return responses, nil 144 | } 145 | 146 | // NewMockIndex does not call the real API but returns preset mock provider version metadata. 147 | func NewMockIndex(pvs []*ProviderVersion) Index { 148 | i := &index{ 149 | providers: make(map[string]*providerIndex), 150 | papi: nil, 151 | } 152 | for _, pv := range pvs { 153 | pi, ok := i.providers[pv.address] 154 | if !ok { 155 | pi = newProviderIndex(pv.address, i.papi) 156 | i.providers[pv.address] = pi 157 | } 158 | pi.versions[pv.version] = pv 159 | } 160 | 161 | return i 162 | } 163 | 164 | // NewMockProviderVersion returns a mocked ProviderVersion for testing. 165 | // This is actually a setter to all private fields, but should not be used 166 | // except for generating test data from outside the package. 167 | func NewMockProviderVersion(address string, version string, platforms []string, h1Hashes map[string]string, zhHashes map[string]string) *ProviderVersion { 168 | return &ProviderVersion{ 169 | address: address, 170 | version: version, 171 | platforms: platforms, 172 | h1Hashes: h1Hashes, 173 | zhHashes: zhHashes, 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lock/provider_downloader.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/minamijoyo/tfupdate/tfregistry" 14 | ) 15 | 16 | // PackageDownloaderAPI is an interface for downloading provider package. 17 | // Provider packages are downloaded from the HashiCorp release server, 18 | // GitHub release page or somewhere else. 19 | // Therefore we distinct this API from the Terraform Registry API. 20 | // The API specification is not documented. 21 | type ProviderDownloaderAPI interface { 22 | // ProviderDownload downloads a provider package. 23 | ProviderDownload(ctx context.Context, req *ProviderDownloadRequest) (*ProviderDownloadResponse, error) 24 | } 25 | 26 | // ProviderDownloaderClient implements the ProviderDownloaderAPI interface 27 | type ProviderDownloaderClient struct { 28 | // api is an instance of tfregistry.API interface. 29 | // It can be replaced for testing. 30 | api tfregistry.API 31 | 32 | // httpClient is a http client which communicates with the ProviderDownloaderAPI. 33 | httpClient *http.Client 34 | } 35 | 36 | // NewProviderDownloaderClient is a factory method which returns a ProviderDownloaderClient instance. 37 | func NewProviderDownloaderClient(config tfregistry.Config) (*ProviderDownloaderClient, error) { 38 | api, err := tfregistry.NewClient(config) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | httpClient := config.HTTPClient 44 | if httpClient == nil { 45 | httpClient = &http.Client{} 46 | } 47 | 48 | return &ProviderDownloaderClient{ 49 | api: api, 50 | httpClient: httpClient, 51 | }, nil 52 | } 53 | 54 | // ProviderDownloadRequest is a request type for ProviderDownload. 55 | type ProviderDownloadRequest struct { 56 | // (required): the namespace portion of the address of the requested provider. 57 | Namespace string `json:"namespace"` 58 | // (required): the type portion of the address of the requested provider. 59 | Type string `json:"type"` 60 | // (required): the version selected to download. 61 | Version string `json:"version"` 62 | // (required): a keyword identifying the operating system that the returned package should be compatible with, like "linux" or "darwin". 63 | OS string `json:"os"` 64 | // (required): a keyword identifying the CPU architecture that the returned package should be compatible with, like "amd64" or "arm". 65 | Arch string `json:"arch"` 66 | } 67 | 68 | // ProviderDownloadResponse is a response type for ProviderDownload. 69 | type ProviderDownloadResponse struct { 70 | // filename is the filename for zipData. 71 | filename string 72 | 73 | // zipData is the raw byte sequence of the provider package. 74 | zipData []byte 75 | 76 | // shaSumsData is the raw byte sequence of the provider shasum file. 77 | shaSumsData []byte 78 | } 79 | 80 | // ProviderDownload downloads a provider package. 81 | func (c *ProviderDownloaderClient) ProviderDownload(ctx context.Context, req *ProviderDownloadRequest) (*ProviderDownloadResponse, error) { 82 | metadataReq := &tfregistry.ProviderPackageMetadataRequest{ 83 | Namespace: req.Namespace, 84 | Type: req.Type, 85 | Version: req.Version, 86 | OS: req.OS, 87 | Arch: req.Arch, 88 | } 89 | 90 | metadataRes, err := c.api.ProviderPackageMetadata(ctx, metadataReq) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | downloadURL := metadataRes.DownloadURL 96 | zipData, err := c.download(ctx, downloadURL) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | err = validateSHA256Sum(zipData, metadataRes.SHASum) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | shaSumsURL := metadataRes.SHASumsURL 107 | shaSumsData, err := c.download(ctx, shaSumsURL) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | err = validateSHASumsData(shaSumsData, metadataRes.Filename, metadataRes.SHASum) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | ret := &ProviderDownloadResponse{ 118 | filename: metadataRes.Filename, 119 | zipData: zipData, 120 | shaSumsData: shaSumsData, 121 | } 122 | 123 | return ret, nil 124 | } 125 | 126 | // download is a helper function that downloads contents from a given url. 127 | func (c *ProviderDownloaderClient) download(ctx context.Context, url string) ([]byte, error) { 128 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 129 | if err != nil { 130 | return nil, fmt.Errorf("failed to build http request: err = %s, url = %s", err, url) 131 | } 132 | 133 | log.Printf("[DEBUG] ProviderDownloaderClient.download: GET %s", url) 134 | res, err := c.httpClient.Do(req) 135 | if err != nil { 136 | return nil, fmt.Errorf("failed to request: err = %s, url = %s", err, url) 137 | } 138 | defer res.Body.Close() 139 | 140 | if res.StatusCode != http.StatusOK { 141 | return nil, fmt.Errorf("unexpected status code %s: %s", res.Status, url) 142 | } 143 | 144 | data, err := io.ReadAll(res.Body) 145 | if err != nil { 146 | return nil, fmt.Errorf("failed to read body: err = %s, url = %s", err, url) 147 | } 148 | 149 | return data, nil 150 | } 151 | 152 | // validateSHA256Sum calculates the sha256 sum of the given byte sequence and 153 | // checks whether it matches the expected hash value. 154 | // The hash value is specified as a hexadecimal string. 155 | func validateSHA256Sum(b []byte, sha256sum string) error { 156 | got := sha256sumAsHexString(b) 157 | if got != sha256sum { 158 | return fmt.Errorf("checksum missmatch error. got = %s, expected = %s", got, sha256sum) 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // sha256sumAsHexString calculates the sha256 sum of the given byte sequence and 165 | // returns it as a hexadecimal string. 166 | func sha256sumAsHexString(b []byte) string { 167 | h := sha256.New() 168 | h.Write(b) 169 | return hex.EncodeToString(h.Sum(nil)) 170 | } 171 | 172 | // validateSHASumsData checks whether the SHA256Sum document contains a matching hash value for a given filename. 173 | func validateSHASumsData(b []byte, filename string, sha256sum string) error { 174 | document := string(b) 175 | for _, line := range strings.Split(document, "\n") { 176 | // We expect that blank lines are not normally included, but to make the 177 | // test data easier to read, ignore blank lines. 178 | if len(line) == 0 { 179 | continue 180 | } 181 | 182 | // Split rows into columns with spaces, but note that there are two spaces between the columns. 183 | // e4453fbebf90c53ca3323a92e7ca0f9961427d2f0ce0d2b65523cc04d5d999c2 terraform-provider-null_3.2.1_darwin_arm64.zip 184 | fields := strings.Fields(line) 185 | if len(fields) != 2 { 186 | return fmt.Errorf("checksum parse error: %s", document) 187 | } 188 | if fields[1] == filename { 189 | if fields[0] != sha256sum { 190 | return fmt.Errorf("checksum missmatch error. got = %s, expected = %s", fields[0], sha256sum) 191 | } 192 | return nil // ok 193 | } 194 | } 195 | 196 | // not found 197 | return fmt.Errorf("checksum not found error: %s", document) 198 | } 199 | -------------------------------------------------------------------------------- /lock/provider_version.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "golang.org/x/exp/maps" 8 | "golang.org/x/exp/slices" 9 | ) 10 | 11 | // ProviderVersion is a data structure that holds hash values of a specific 12 | // version of a particular provider. It corresponds to one provider block in 13 | // the dependency lock file (.terraform.lock.hcl). 14 | // https://developer.hashicorp.com/terraform/language/files/dependency-lock 15 | type ProviderVersion struct { 16 | // address is a provider address such as hashicorp/null. 17 | address string 18 | 19 | // version is a version number such as 3.2.1. 20 | version string 21 | 22 | // platforms is a list of target platforms to generate hash values. 23 | // Target platform names consist of an operating system and a CPU architecture such as darwin_arm64. 24 | // The actual lock file does not distinguish which platform the hash values 25 | // belong to, but we keep them distinct in memory for easy debugging in case 26 | // of checksum mismatches. 27 | platforms []string 28 | 29 | // h1Hashes is a dictionary of hash values calculated with the h1 scheme. 30 | // The key is the filename. 31 | h1Hashes map[string]string 32 | 33 | // zhHashes is a dictionary of hash values calculated with the zh scheme. 34 | // The key is the filename. 35 | zhHashes map[string]string 36 | } 37 | 38 | // newEmptyProviderVersion returns a new empty ProviderVersion, which is 39 | // intended to be used as a variable to store merge results. 40 | func newEmptyProviderVersion(address string, version string) *ProviderVersion { 41 | return &ProviderVersion{ 42 | address: address, 43 | version: version, 44 | platforms: make([]string, 0), 45 | h1Hashes: make(map[string]string, 0), 46 | zhHashes: make(map[string]string, 0), 47 | } 48 | } 49 | 50 | // Merge takes another ProviderVersion and merges it. It returns an error if 51 | // the argument is incompatible the current object. 52 | func (pv *ProviderVersion) Merge(rhs *ProviderVersion) error { 53 | if pv.address != rhs.address { 54 | return fmt.Errorf("failed to merge ProviderVersion.address: %s != %s", pv.address, rhs.address) 55 | } 56 | if pv.version != rhs.version { 57 | return fmt.Errorf("failed to merge ProviderVersion.version: %s != %s", pv.version, rhs.version) 58 | } 59 | 60 | pv.platforms = append(pv.platforms, rhs.platforms...) 61 | maps.Copy(pv.h1Hashes, rhs.h1Hashes) 62 | 63 | if len(pv.zhHashes) != 0 { 64 | if !reflect.DeepEqual(pv.zhHashes, rhs.zhHashes) { 65 | // should not happen 66 | return fmt.Errorf("failed to merge ProviderVersion.zhHashes: %#v != %#v", pv.zhHashes, rhs.zhHashes) 67 | } 68 | } else { 69 | pv.zhHashes = rhs.zhHashes 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // AllHashes returns an array of strings containing all hash values. It is 76 | // intended to be used as the value of hashes in a dependency lock file. 77 | // The result is sorted alphabetically. 78 | func (pv *ProviderVersion) AllHashes() []string { 79 | h1 := maps.Values(pv.h1Hashes) 80 | zh := maps.Values(pv.zhHashes) 81 | hashes := append(h1, zh...) 82 | slices.Sort(hashes) 83 | return hashes 84 | } 85 | -------------------------------------------------------------------------------- /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/tfupdate/command" 12 | "github.com/mitchellh/cli" 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | // Version is a version number. 17 | var version = "0.9.1" 18 | 19 | // UI is a user interface which is a global variable for mocking. 20 | var UI cli.Ui 21 | 22 | func init() { 23 | UI = &cli.BasicUi{ 24 | Writer: os.Stdout, 25 | } 26 | } 27 | 28 | func main() { 29 | log.SetOutput(logOutput()) 30 | log.Printf("[INFO] CLI args: %#v", os.Args) 31 | 32 | commands := initCommands() 33 | 34 | args := os.Args[1:] 35 | 36 | c := &cli.CLI{ 37 | Name: "tfupdate", 38 | Version: version, 39 | Args: args, 40 | Commands: commands, 41 | HelpWriter: os.Stdout, 42 | Autocomplete: true, 43 | AutocompleteInstall: "install-autocomplete", 44 | AutocompleteUninstall: "uninstall-autocomplete", 45 | } 46 | 47 | exitStatus, err := c.Run() 48 | if err != nil { 49 | UI.Error(fmt.Sprintf("Failed to execute CLI: %s", err)) 50 | } 51 | 52 | os.Exit(exitStatus) 53 | } 54 | 55 | func logOutput() io.Writer { 56 | levels := []logutils.LogLevel{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} 57 | minLevel := os.Getenv("TFUPDATE_LOG") 58 | 59 | // default log writer is null device. 60 | writer := io.Discard 61 | if minLevel != "" { 62 | writer = os.Stderr 63 | } 64 | 65 | filter := &logutils.LevelFilter{ 66 | Levels: levels, 67 | MinLevel: logutils.LogLevel(strings.ToUpper(minLevel)), 68 | Writer: writer, 69 | } 70 | 71 | return filter 72 | } 73 | 74 | func initCommands() map[string]cli.CommandFactory { 75 | meta := command.Meta{ 76 | UI: UI, 77 | Fs: afero.NewOsFs(), 78 | } 79 | 80 | commands := map[string]cli.CommandFactory{ 81 | "terraform": func() (cli.Command, error) { 82 | return &command.TerraformCommand{ 83 | Meta: meta, 84 | }, nil 85 | }, 86 | "opentofu": func() (cli.Command, error) { 87 | return &command.OpenTofuCommand{ 88 | Meta: meta, 89 | }, nil 90 | }, 91 | "provider": func() (cli.Command, error) { 92 | return &command.ProviderCommand{ 93 | Meta: meta, 94 | }, nil 95 | }, 96 | "module": func() (cli.Command, error) { 97 | return &command.ModuleCommand{ 98 | Meta: meta, 99 | }, nil 100 | }, 101 | "lock": func() (cli.Command, error) { 102 | return &command.LockCommand{ 103 | Meta: meta, 104 | }, nil 105 | }, 106 | "release": func() (cli.Command, error) { 107 | return &command.ReleaseCommand{ 108 | Meta: meta, 109 | }, nil 110 | }, 111 | "release latest": func() (cli.Command, error) { 112 | return &command.ReleaseLatestCommand{ 113 | Meta: meta, 114 | }, nil 115 | }, 116 | "release list": func() (cli.Command, error) { 117 | return &command.ReleaseListCommand{ 118 | Meta: meta, 119 | }, nil 120 | }, 121 | } 122 | 123 | return commands 124 | } 125 | -------------------------------------------------------------------------------- /release/github.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/google/go-github/v28/github" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // GitHubAPI is an interface which calls GitHub API. 15 | // This abstraction layer is needed for testing with mock. 16 | type GitHubAPI interface { 17 | // RepositoriesListReleases lists the releases for a repository. 18 | // GitHub API docs: https://developer.github.com/v3/repos/releases/#list-releases-for-a-repository 19 | RepositoriesListReleases(ctx context.Context, owner, repo string, opt *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error) 20 | } 21 | 22 | // GitHubConfig is a set of configurations for GitHubRelease. 23 | type GitHubConfig struct { 24 | // api is an instance of GitHubAPI interface. 25 | // It can be replaced for testing. 26 | api GitHubAPI 27 | 28 | // BaseURL is a URL for GitHub API requests. 29 | // Defaults to the public GitHub API. 30 | // This looks like the GitHub Enterprise support, but currently for testing purposes only. 31 | // The GitHub Enterprise is not supported yet. 32 | // BaseURL should always be specified with a trailing slash. 33 | BaseURL string 34 | 35 | // Token is a personal access token for GitHub. 36 | // This allows access to a private repository. 37 | Token string 38 | } 39 | 40 | // GitHubClient is a real GitHubAPI implementation. 41 | type GitHubClient struct { 42 | client *github.Client 43 | } 44 | 45 | var _ GitHubAPI = (*GitHubClient)(nil) 46 | 47 | // NewGitHubClient returns a real GitHubClient instance. 48 | func NewGitHubClient(config GitHubConfig) (*GitHubClient, error) { 49 | var hc *http.Client 50 | if len(config.Token) != 0 { 51 | hc = newOAuth2Client(config.Token) 52 | } 53 | c := github.NewClient(hc) 54 | 55 | if len(config.BaseURL) != 0 { 56 | baseURL, err := url.Parse(config.BaseURL) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to parse github base url: %s", err) 59 | } 60 | c.BaseURL = baseURL 61 | } 62 | 63 | return &GitHubClient{ 64 | client: c, 65 | }, nil 66 | } 67 | 68 | // newOAuth2Client returns a *http.Client which sets a given token to the Authorization header. 69 | // This allows access to a private repository. 70 | func newOAuth2Client(token string) *http.Client { 71 | t := &oauth2.Token{ 72 | AccessToken: token, 73 | } 74 | ts := oauth2.StaticTokenSource(t) 75 | 76 | return oauth2.NewClient(context.Background(), ts) 77 | } 78 | 79 | // RepositoriesListReleases lists the releases for a repository. 80 | func (c *GitHubClient) RepositoriesListReleases(ctx context.Context, owner, repo string, opt *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error) { 81 | return c.client.Repositories.ListReleases(ctx, owner, repo, opt) 82 | } 83 | 84 | // GitHubRelease is a release implementation which provides version information with GitHub Release. 85 | type GitHubRelease struct { 86 | // api is an instance of GitHubAPI interface. 87 | // It can be replaced for testing. 88 | api GitHubAPI 89 | 90 | // owner is a namespace of repository. 91 | owner string 92 | 93 | // repo is a name of repository. 94 | repo string 95 | } 96 | 97 | var _ Release = (*GitHubRelease)(nil) 98 | 99 | // NewGitHubRelease is a factory method which returns an GitHubRelease instance. 100 | func NewGitHubRelease(source string, config GitHubConfig) (Release, error) { 101 | s := strings.SplitN(source, "/", 2) 102 | if len(s) != 2 { 103 | return nil, fmt.Errorf("failed to parse source: %s", source) 104 | } 105 | 106 | // If config.api is not set, create a default GitHubClient 107 | var api GitHubAPI 108 | if config.api == nil { 109 | var err error 110 | api, err = NewGitHubClient(config) 111 | if err != nil { 112 | return nil, err 113 | } 114 | } else { 115 | api = config.api 116 | } 117 | 118 | return &GitHubRelease{ 119 | api: api, 120 | owner: s[0], 121 | repo: s[1], 122 | }, nil 123 | } 124 | 125 | // ListReleases returns a list of unsorted all releases including pre-release. 126 | func (r *GitHubRelease) ListReleases(ctx context.Context) ([]string, error) { 127 | versions := []string{} 128 | opt := &github.ListOptions{ 129 | PerPage: 100, // max 130 | } 131 | 132 | for { 133 | releases, resp, err := r.api.RepositoriesListReleases(ctx, r.owner, r.repo, opt) 134 | 135 | if err != nil { 136 | return nil, fmt.Errorf("failed to list releases for %s/%s: %s", r.owner, r.repo, err) 137 | } 138 | 139 | for _, release := range releases { 140 | v := tagNameToVersion(*release.TagName) 141 | versions = append(versions, v) 142 | } 143 | if resp.NextPage == 0 { 144 | break 145 | } 146 | opt.Page = resp.NextPage 147 | } 148 | 149 | return versions, nil 150 | } 151 | -------------------------------------------------------------------------------- /release/github_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/davecgh/go-spew/spew" 10 | "github.com/google/go-github/v28/github" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // mockGitHubClient is a mock GitHubAPI implementation. 15 | type mockGitHubClient struct { 16 | repositoryReleases []*github.RepositoryRelease 17 | response *github.Response 18 | err error 19 | } 20 | 21 | var _ GitHubAPI = (*mockGitHubClient)(nil) 22 | 23 | func (c *mockGitHubClient) RepositoriesListReleases(ctx context.Context, owner, repo string, opt *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error) { // nolint revive unused-parameter 24 | return c.repositoryReleases, c.response, c.err 25 | } 26 | 27 | func TestNewGitHubClient(t *testing.T) { 28 | cases := []struct { 29 | baseURL string 30 | want string 31 | ok bool 32 | }{ 33 | { 34 | baseURL: "", 35 | want: "https://api.github.com/", 36 | ok: true, 37 | }, 38 | { 39 | baseURL: "https://api.github.com/", 40 | want: "https://api.github.com/", 41 | ok: true, 42 | }, 43 | { 44 | baseURL: "http://localhost/", 45 | want: "http://localhost/", 46 | ok: true, 47 | }, 48 | { 49 | baseURL: `https://api\.github.om/`, 50 | want: "", 51 | ok: false, 52 | }, 53 | } 54 | 55 | for _, tc := range cases { 56 | config := GitHubConfig{ 57 | BaseURL: tc.baseURL, 58 | } 59 | got, err := NewGitHubClient(config) 60 | 61 | if tc.ok && err != nil { 62 | t.Errorf("NewGitHubClient() with baseURL = %s returns unexpected err: %s", tc.baseURL, err) 63 | } 64 | 65 | if !tc.ok && err == nil { 66 | t.Errorf("NewGitHubClient() with baseURL = %s expects to return an error, but no error", tc.baseURL) 67 | } 68 | 69 | if tc.ok { 70 | if got.client.BaseURL.String() != tc.want { 71 | t.Errorf("NewGitHubClient() with baseURL = %s returns %s, but want %s", tc.baseURL, got.client.BaseURL.String(), tc.want) 72 | } 73 | } 74 | } 75 | } 76 | 77 | func TestNewOAuth2Client(t *testing.T) { 78 | cases := []struct { 79 | token string 80 | }{ 81 | { 82 | token: "hoge", 83 | }, 84 | } 85 | 86 | for _, tc := range cases { 87 | c := newOAuth2Client(tc.token) 88 | trans := c.Transport.(*oauth2.Transport) 89 | got, err := trans.Source.Token() 90 | if err != nil { 91 | t.Fatalf("failed to get a token from OAuth2 client: %s", err) 92 | } 93 | if got.AccessToken != tc.token { 94 | t.Errorf("newOAuth2Client() expects to set a token = %s, but got = %s", tc.token, got.AccessToken) 95 | } 96 | } 97 | } 98 | 99 | func TestNewGitHubRelease(t *testing.T) { 100 | cases := []struct { 101 | source string 102 | api GitHubAPI 103 | owner string 104 | repo string 105 | ok bool 106 | }{ 107 | { 108 | source: "hoge/fuga", 109 | api: &mockGitHubClient{}, 110 | owner: "hoge", 111 | repo: "fuga", 112 | ok: true, 113 | }, 114 | { 115 | source: "hoge", 116 | api: &mockGitHubClient{}, 117 | owner: "", 118 | repo: "", 119 | ok: false, 120 | }, 121 | } 122 | 123 | for _, tc := range cases { 124 | config := GitHubConfig{ 125 | api: tc.api, 126 | } 127 | got, err := NewGitHubRelease(tc.source, config) 128 | 129 | if tc.ok && err != nil { 130 | t.Errorf("NewGitHubRelease() with source = %s, api = %#v returns unexpected err: %s", tc.source, tc.api, err) 131 | } 132 | 133 | if !tc.ok && err == nil { 134 | t.Errorf("NewGitHubRelease() with source = %s, api = %#v expects to return an error, but no error", tc.source, tc.api) 135 | } 136 | 137 | if tc.ok { 138 | r := got.(*GitHubRelease) 139 | 140 | if r.api != tc.api { 141 | t.Errorf("NewGitHubRelease() with source = %s, api = %#v sets api = %#v, but want %s", tc.source, tc.api, r.api, tc.api) 142 | } 143 | 144 | if !(r.owner == tc.owner && r.repo == tc.repo) { 145 | t.Errorf("NewGitHubRelease() with source = %s, api = %#v returns (%s, %s), but want (%s, %s)", tc.source, tc.api, r.owner, r.repo, tc.owner, tc.repo) 146 | } 147 | } 148 | } 149 | } 150 | 151 | func TestGitHubReleaseListReleases(t *testing.T) { 152 | tagv := []string{"v0.3.0", "v0.2.0", "v0.1.0"} 153 | cases := []struct { 154 | client *mockGitHubClient 155 | want []string 156 | ok bool 157 | }{ 158 | { 159 | client: &mockGitHubClient{ 160 | repositoryReleases: []*github.RepositoryRelease{ 161 | {TagName: &tagv[0]}, 162 | {TagName: &tagv[1]}, 163 | {TagName: &tagv[2]}, 164 | }, 165 | response: &github.Response{}, 166 | err: nil, 167 | }, 168 | want: []string{"0.3.0", "0.2.0", "0.1.0"}, 169 | ok: true, 170 | }, 171 | { 172 | client: &mockGitHubClient{ 173 | repositoryReleases: nil, 174 | response: &github.Response{}, 175 | // Actual error response type is *github.ErrorResponse, 176 | // but we are not interested in the internal structure. 177 | err: errors.New(`GET https://api.github.com/repos/hoge/fuga/releases: 404 Not Found []`), 178 | }, 179 | want: nil, 180 | ok: false, 181 | }, 182 | } 183 | 184 | source := "hoge/fuga" 185 | for _, tc := range cases { 186 | // Set a mock client 187 | config := GitHubConfig{ 188 | api: tc.client, 189 | } 190 | r, err := NewGitHubRelease(source, config) 191 | if err != nil { 192 | t.Fatalf("failed to NewGitHubRelease(%s, %#v): %s", source, config, err) 193 | } 194 | 195 | got, err := r.ListReleases(context.Background()) 196 | 197 | if tc.ok && err != nil { 198 | t.Errorf("(*GitHubRelease).ListReleases() with r = %s returns unexpected err: %+v", spew.Sdump(r), err) 199 | } 200 | 201 | if !tc.ok && err == nil { 202 | t.Errorf("(*GitHubRelease).ListReleases() with r = %s expects to return an error, but no error", spew.Sdump(r)) 203 | } 204 | 205 | if !reflect.DeepEqual(got, tc.want) { 206 | t.Errorf("(*GitHubRelease).ListReleases() with r = %s returns %s, but want = %s", spew.Sdump(r), got, tc.want) 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /release/gitlab.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/xanzy/go-gitlab" 10 | ) 11 | 12 | // GitLabAPI is an interface which calls GitLab API. 13 | // This abstraction layer is needed for testing with mock. 14 | type GitLabAPI interface { 15 | // ProjectListReleases gets a pagenated of releases accessible by the authenticated user. 16 | ProjectListReleases(ctx context.Context, owner, project string, opt *gitlab.ListReleasesOptions) ([]*gitlab.Release, *gitlab.Response, error) 17 | } 18 | 19 | // GitLabConfig is a set of configurations for GitLabRelease.. 20 | type GitLabConfig struct { 21 | // api is an instance of GitLabAPI interface. 22 | // It can be replaced for testing. 23 | api GitLabAPI 24 | 25 | // BaseURL is a URL for GitLab API requests. 26 | // Defaults to the public GitLab API. 27 | // BaseURL should always be specified with a trailing slash. 28 | BaseURL string 29 | 30 | // Token is a personal access token for GitLab, needed to use the api. 31 | Token string 32 | } 33 | 34 | // GitLabClient is a real GitLabAPI implementation. 35 | type GitLabClient struct { 36 | client *gitlab.Client 37 | } 38 | 39 | var _ GitLabAPI = (*GitLabClient)(nil) 40 | 41 | // NewGitLabClient returns a real GitLab instance. 42 | func NewGitLabClient(config GitLabConfig) (*GitLabClient, error) { 43 | if len(config.Token) == 0 { 44 | return nil, fmt.Errorf("failed to get personal access token (env: GITLAB_TOKEN)") 45 | } 46 | c := gitlab.NewClient(nil, config.Token) 47 | 48 | if len(config.BaseURL) != 0 { 49 | baseURL, err := url.Parse(config.BaseURL) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to parse gitlab base url: %s", err) 52 | } 53 | if err = c.SetBaseURL(baseURL.String()); err != nil { 54 | return nil, err 55 | } 56 | } 57 | 58 | return &GitLabClient{ 59 | client: c, 60 | }, nil 61 | } 62 | 63 | // ProjectListReleases gets a pagenated of releases accessible by the authenticated user. 64 | func (c *GitLabClient) ProjectListReleases(ctx context.Context, owner, project string, opt *gitlab.ListReleasesOptions) ([]*gitlab.Release, *gitlab.Response, error) { 65 | return c.client.Releases.ListReleases(owner+"/"+project, opt, gitlab.WithContext(ctx)) 66 | } 67 | 68 | // GitLabRelease is a release implementation which provides version information with GitLab Release. 69 | type GitLabRelease struct { 70 | // api is an instance of GitLabAPI interface. 71 | // It can be replaced for testing. 72 | api GitLabAPI 73 | 74 | // owner is a namespace of project. 75 | // limited to one level (group or personal - not sub-groups?) 76 | owner string 77 | 78 | // project is a name of project (repository). 79 | project string 80 | } 81 | 82 | var _ Release = (*GitLabRelease)(nil) 83 | 84 | // NewGitLabRelease is a factory method which returns an GitLabRelease instance. 85 | func NewGitLabRelease(source string, config GitLabConfig) (*GitLabRelease, error) { 86 | s := strings.SplitN(source, "/", 2) 87 | if len(s) != 2 { 88 | return nil, fmt.Errorf("failed to parse source: %s", source) 89 | } 90 | 91 | // If config.api is not set, create a default GitLabClient 92 | var api GitLabAPI 93 | if config.api == nil { 94 | var err error 95 | api, err = NewGitLabClient(config) 96 | if err != nil { 97 | return nil, err 98 | } 99 | } else { 100 | api = config.api 101 | } 102 | 103 | return &GitLabRelease{ 104 | api: api, 105 | owner: s[0], 106 | project: s[1], 107 | }, nil 108 | } 109 | 110 | // ListReleases returns a list of unsorted all releases including pre-release. 111 | func (r *GitLabRelease) ListReleases(ctx context.Context) ([]string, error) { 112 | versions := []string{} 113 | opt := &gitlab.ListReleasesOptions{ 114 | PerPage: 100, // max 115 | } 116 | 117 | for { 118 | releases, resp, err := r.api.ProjectListReleases(ctx, r.owner, r.project, opt) 119 | 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to list releases for %s/%s: %s", r.owner, r.project, err) 122 | } 123 | 124 | for _, release := range releases { 125 | v := tagNameToVersion(release.TagName) 126 | versions = append(versions, v) 127 | } 128 | if resp.NextPage == 0 { 129 | break 130 | } 131 | opt.Page = resp.NextPage 132 | } 133 | 134 | return versions, nil 135 | } 136 | -------------------------------------------------------------------------------- /release/gitlab_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/davecgh/go-spew/spew" 10 | "github.com/xanzy/go-gitlab" 11 | ) 12 | 13 | // mockGitLabClient is a mock GitLabAPI implementation. 14 | type mockGitLabClient struct { 15 | projectReleases []*gitlab.Release 16 | response *gitlab.Response 17 | err error 18 | } 19 | 20 | var _ GitLabAPI = (*mockGitLabClient)(nil) 21 | 22 | // ProjectListReleases returns a list of releases for the mockGitLabClient. 23 | func (c *mockGitLabClient) ProjectListReleases(ctx context.Context, owner, repo string, opt *gitlab.ListReleasesOptions) ([]*gitlab.Release, *gitlab.Response, error) { // nolint revive unused-parameter 24 | return c.projectReleases, c.response, c.err 25 | } 26 | 27 | // Test of NewGitLabClient(config GitLabConfig) 28 | func TestNewGitLabClient(t *testing.T) { 29 | cases := []struct { 30 | baseURL string 31 | want string 32 | ok bool 33 | }{ // test default value 34 | { 35 | baseURL: "", 36 | want: "https://gitlab.com/api/v4/", 37 | ok: true, 38 | }, 39 | // test custom value 40 | { 41 | baseURL: "https://gitlab.com/api/v4/", 42 | want: "https://gitlab.com/api/v4/", 43 | ok: true, 44 | }, 45 | // test custom value 46 | { 47 | baseURL: "http://localhost/api/v4/", 48 | want: "http://localhost/api/v4/", 49 | ok: true, 50 | }, 51 | // test unparsable URL 52 | { 53 | baseURL: `https://gitlab\.com/api/v4/`, 54 | want: "", 55 | ok: false, 56 | }, 57 | } 58 | 59 | for _, tc := range cases { 60 | config := GitLabConfig{ 61 | BaseURL: tc.baseURL, 62 | Token: "dummy_token", 63 | } 64 | got, err := NewGitLabClient(config) 65 | 66 | if tc.ok && err != nil { 67 | t.Errorf("NewGitLabClient() with baseURL = %s returns unexpected err: %s", tc.baseURL, err) 68 | } 69 | 70 | if !tc.ok && err == nil { 71 | t.Errorf("NewGitLabClient() with baseURL = %s expects to return an error, but no error", tc.baseURL) 72 | } 73 | 74 | if tc.ok { 75 | if got.client.BaseURL().String() != tc.want { 76 | t.Errorf("NewGitLabClient() with baseURL = %s returns %s, but want %s", tc.baseURL, got.client.BaseURL().String(), tc.want) 77 | } 78 | } 79 | } 80 | } 81 | 82 | // Test of NewGitLabRelease(source string, config GitLabConfig) 83 | func TestNewGitLabRelease(t *testing.T) { 84 | cases := []struct { 85 | source string 86 | api GitLabAPI 87 | owner string 88 | project string 89 | ok bool 90 | }{ // test complete config 91 | { 92 | source: "gitlab-org/gitlab", 93 | api: &mockGitLabClient{}, 94 | owner: "gitlab-org", 95 | project: "gitlab", 96 | ok: true, 97 | }, 98 | // test release without owner or project 99 | { 100 | source: "gitlab", 101 | api: &mockGitLabClient{}, 102 | owner: "", 103 | project: "", 104 | ok: false, 105 | }, 106 | // test release with missing api 107 | { 108 | source: "gitlab-org/gitlab", 109 | api: nil, 110 | owner: "gitlab-org", 111 | project: "gitlab", 112 | ok: false, 113 | }, 114 | } 115 | 116 | for _, tc := range cases { 117 | config := GitLabConfig{ 118 | api: tc.api, 119 | } 120 | got, err := NewGitLabRelease(tc.source, config) 121 | 122 | if tc.ok && err != nil { 123 | t.Errorf("NewGitLabRelease() with source = %s, api = %#v returns unexpected err: %s", tc.source, tc.api, err) 124 | } 125 | 126 | if !tc.ok && err == nil { 127 | t.Errorf("NewGitLabRelease() with source = %s, api = %#v expects to return an error, but no error", tc.source, tc.api) 128 | } 129 | 130 | if tc.ok { 131 | r := got 132 | 133 | if r.api != tc.api { 134 | t.Errorf("NewGitLabRelease() with source = %s, api = %#v sets api = %#v, but want %s", tc.source, tc.api, r.api, tc.api) 135 | } 136 | 137 | if !(r.owner == tc.owner && r.project == tc.project) { 138 | t.Errorf("NewGitLabRelease() with source = %s, api = %#v returns (%s, %s), but want (%s, %s)", tc.source, tc.api, r.owner, r.project, tc.owner, tc.project) 139 | } 140 | } 141 | } 142 | } 143 | 144 | // Test of GitLabRelease.List(ctx context.Context, maxLength int) 145 | func TestGitLabReleaseListReleases(t *testing.T) { 146 | tagv := []string{"v0.3.0", "v0.2.0", "v0.1.0"} 147 | cases := []struct { 148 | client *mockGitLabClient 149 | want []string 150 | ok bool 151 | }{ 152 | { 153 | client: &mockGitLabClient{ 154 | projectReleases: []*gitlab.Release{ 155 | {TagName: tagv[0]}, 156 | {TagName: tagv[1]}, 157 | {TagName: tagv[2]}, 158 | }, 159 | response: &gitlab.Response{}, 160 | err: nil, 161 | }, 162 | want: []string{"0.3.0", "0.2.0", "0.1.0"}, 163 | ok: true, 164 | }, 165 | { 166 | client: &mockGitLabClient{ 167 | projectReleases: nil, 168 | response: &gitlab.Response{}, 169 | // Actual error response type is *gitlab.ErrorResponse, 170 | // but we are not interested in the internal structure. 171 | err: errors.New(`GET https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/releases: 404 Not Found []`), 172 | }, 173 | want: nil, 174 | ok: false, 175 | }, 176 | } 177 | 178 | source := "gitlab-org/gitlab" 179 | for _, tc := range cases { 180 | // Set a mock client 181 | config := GitLabConfig{ 182 | api: tc.client, 183 | } 184 | r, err := NewGitLabRelease(source, config) 185 | if err != nil { 186 | t.Fatalf("failed to NewGitLabRelease(%s, %#v): %s", source, config, err) 187 | } 188 | 189 | got, err := r.ListReleases(context.Background()) 190 | 191 | if tc.ok && err != nil { 192 | t.Errorf("(*GitLabRelease).ListReleases() with r = %s returns unexpected err: %+v", spew.Sdump(r), err) 193 | } 194 | 195 | if !tc.ok && err == nil { 196 | t.Errorf("(*GitLabRelease).ListReleases() with r = %s expects to return an error, but no error", spew.Sdump(r)) 197 | } 198 | 199 | if !reflect.DeepEqual(got, tc.want) { 200 | t.Errorf("(*GitLabRelease).ListReleases() with r = %s returns %s, but want = %s", spew.Sdump(r), got, tc.want) 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /release/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // Release is an interface which provides version information of a module or provider. 9 | type Release interface { 10 | // ListReleases returns a list of unsorted all releases including pre-release. 11 | ListReleases(ctx context.Context) ([]string, error) 12 | } 13 | 14 | // Latest returns the latest release. 15 | // Note that GetLatestRelease API in GitHub and GitLab returns the most recent 16 | // release, which doesn't means the latest stable release. I'm not sure it also 17 | // affects Terraform Registry but I think we should use the same strategy for 18 | // consistency. So we sort versions in semver order and find the latest non 19 | // pre-release. 20 | func Latest(ctx context.Context, r Release) (string, error) { 21 | versions, err := List(ctx, r, 1, false) 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | if len(versions) == 0 { 27 | return "", errors.New("no releases found") 28 | } 29 | 30 | return versions[0], nil 31 | } 32 | 33 | // List returns a list of releases in semver order. 34 | // If preRelease is set to false, the result doesn't contain pre-releases. 35 | func List(ctx context.Context, r Release, maxLength int, preRelease bool) ([]string, error) { 36 | res, err := r.ListReleases(ctx) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | versions := toVersions(res) 42 | sorted := sortVersions(versions) 43 | rels := sorted 44 | 45 | if !preRelease { 46 | rels = excludePreReleases(sorted) 47 | } 48 | 49 | releases := fromVersions(rels) 50 | start := len(releases) - minInt(maxLength, len(releases)) 51 | return releases[start:], nil 52 | } 53 | -------------------------------------------------------------------------------- /release/release_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | type mockRelease struct { 11 | versions []string 12 | err error 13 | } 14 | 15 | var _ Release = (*mockRelease)(nil) 16 | 17 | func (r *mockRelease) ListReleases(ctx context.Context) ([]string, error) { // nolint revive unused-parameter 18 | return r.versions, r.err 19 | } 20 | 21 | func TestLatest(t *testing.T) { 22 | cases := []struct { 23 | desc string 24 | r Release 25 | want string 26 | ok bool 27 | }{ 28 | { 29 | desc: "sort", 30 | r: &mockRelease{ 31 | versions: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1"}, 32 | err: nil, 33 | }, 34 | want: "0.3.0", 35 | ok: true, 36 | }, 37 | { 38 | desc: "pre-release", 39 | r: &mockRelease{ 40 | versions: []string{"0.1.0", "0.2.0", "0.1.1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-alpha1", "0.3.0-rc"}, 41 | err: nil, 42 | }, 43 | want: "0.2.0", 44 | ok: true, 45 | }, 46 | { 47 | desc: "no release", 48 | r: &mockRelease{ 49 | versions: []string{}, 50 | err: nil, 51 | }, 52 | want: "", 53 | ok: false, 54 | }, 55 | { 56 | desc: "api error", 57 | r: &mockRelease{ 58 | versions: nil, 59 | err: errors.New("mocked error"), 60 | }, 61 | want: "", 62 | ok: false, 63 | }, 64 | { 65 | desc: "parse error", 66 | r: &mockRelease{ 67 | versions: []string{"foo", "0.3.0", "0.2.0", "0.1.0", "0.1.1"}, 68 | err: nil, 69 | }, 70 | want: "0.3.0", 71 | ok: true, 72 | }, 73 | } 74 | 75 | for _, tc := range cases { 76 | t.Run(tc.desc, func(t *testing.T) { 77 | got, err := Latest(context.Background(), tc.r) 78 | 79 | if tc.ok && err != nil { 80 | t.Fatalf("unexpected err: %#v", err) 81 | } 82 | 83 | if !tc.ok && err == nil { 84 | t.Fatalf("expects to return an error, but no error. got = %#v", got) 85 | } 86 | 87 | if !reflect.DeepEqual(got, tc.want) { 88 | t.Errorf("got = %#v, but want = %#v", got, tc.want) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestList(t *testing.T) { 95 | cases := []struct { 96 | desc string 97 | r Release 98 | maxLength int 99 | preRelease bool 100 | want []string 101 | ok bool 102 | }{ 103 | { 104 | desc: "sort", 105 | r: &mockRelease{ 106 | versions: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1"}, 107 | err: nil, 108 | }, 109 | maxLength: 5, 110 | preRelease: true, 111 | want: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0"}, 112 | ok: true, 113 | }, 114 | { 115 | desc: "maxLength", 116 | r: &mockRelease{ 117 | versions: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1"}, 118 | err: nil, 119 | }, 120 | maxLength: 3, 121 | preRelease: true, 122 | want: []string{"0.1.1", "0.2.0", "0.3.0"}, 123 | ok: true, 124 | }, 125 | { 126 | desc: "include pre-release", 127 | r: &mockRelease{ 128 | versions: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-alpha1", "0.3.0-rc"}, 129 | err: nil, 130 | }, 131 | maxLength: 3, 132 | preRelease: true, 133 | want: []string{"0.3.0-beta2", "0.3.0-rc", "0.3.0"}, 134 | ok: true, 135 | }, 136 | { 137 | desc: "exclude pre-release", 138 | r: &mockRelease{ 139 | versions: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-alpha1", "0.3.0-rc"}, 140 | err: nil, 141 | }, 142 | maxLength: 3, 143 | preRelease: false, 144 | want: []string{"0.1.1", "0.2.0", "0.3.0"}, 145 | ok: true, 146 | }, 147 | { 148 | desc: "empty", 149 | r: &mockRelease{ 150 | versions: []string{}, 151 | err: nil, 152 | }, 153 | maxLength: 3, 154 | preRelease: true, 155 | want: []string{}, 156 | ok: true, 157 | }, 158 | { 159 | desc: "api error", 160 | r: &mockRelease{ 161 | versions: nil, 162 | err: errors.New("mocked error"), 163 | }, 164 | maxLength: 3, 165 | preRelease: true, 166 | want: nil, 167 | ok: false, 168 | }, 169 | } 170 | 171 | for _, tc := range cases { 172 | t.Run(tc.desc, func(t *testing.T) { 173 | got, err := List(context.Background(), tc.r, tc.maxLength, tc.preRelease) 174 | 175 | if tc.ok && err != nil { 176 | t.Fatalf("unexpected err: %#v", err) 177 | } 178 | 179 | if !tc.ok && err == nil { 180 | t.Fatalf("expects to return an error, but no error. got = %#v", got) 181 | } 182 | 183 | if !reflect.DeepEqual(got, tc.want) { 184 | t.Errorf("got = %#v, but want = %#v", got, tc.want) 185 | } 186 | }) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /release/tfregistry.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/minamijoyo/tfupdate/tfregistry" 9 | ) 10 | 11 | // TFRegistryModuleRelease is a release implementation which provides version information with TFRegistryModule Release. 12 | type TFRegistryModuleRelease struct { 13 | // api is an instance of tfregistry.API interface. 14 | // It can be replaced for testing. 15 | api tfregistry.API 16 | 17 | // namespace is a user name which owns the module. 18 | namespace string 19 | 20 | // name is a name of the module. 21 | name string 22 | 23 | // provider is a name of the provider. 24 | provider string 25 | } 26 | 27 | var _ Release = (*TFRegistryModuleRelease)(nil) 28 | 29 | // NewTFRegistryModuleRelease is a factory method which returns an TFRegistryModuleRelease instance. 30 | func NewTFRegistryModuleRelease(source string, config tfregistry.Config) (Release, error) { 31 | s := strings.SplitN(source, "/", 3) 32 | if len(s) != 3 { 33 | return nil, fmt.Errorf("failed to parse source: %s", source) 34 | } 35 | 36 | client, err := tfregistry.NewClient(config) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return &TFRegistryModuleRelease{ 42 | api: client, 43 | namespace: s[0], 44 | name: s[1], 45 | provider: s[2], 46 | }, nil 47 | } 48 | 49 | // ListReleases returns a list of unsorted all releases including pre-release. 50 | func (r *TFRegistryModuleRelease) ListReleases(ctx context.Context) ([]string, error) { 51 | req := &tfregistry.ListModuleVersionsRequest{ 52 | Namespace: r.namespace, 53 | Name: r.name, 54 | Provider: r.provider, 55 | } 56 | 57 | response, err := r.api.ListModuleVersions(ctx, req) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to get a list of versions for %s/%s/%s: %s", r.namespace, r.name, r.provider, err) 60 | } 61 | 62 | // Extract versions from the response 63 | if len(response.Modules) == 0 { 64 | return []string{}, nil 65 | } 66 | 67 | versions := []string{} 68 | for _, version := range response.Modules[0].Versions { 69 | versions = append(versions, version.Version) 70 | } 71 | 72 | return versions, nil 73 | } 74 | 75 | // TFRegistryProviderRelease is a release implementation which provides version information with TFRegistryProvider Release. 76 | type TFRegistryProviderRelease struct { 77 | // api is an instance of tfregistry.API interface. 78 | // It can be replaced for testing. 79 | api tfregistry.API 80 | 81 | // The user or organization the provider is owned by. 82 | namespace string 83 | 84 | // The type name of the provider. 85 | providerType string 86 | } 87 | 88 | var _ Release = (*TFRegistryProviderRelease)(nil) 89 | 90 | // NewTFRegistryProviderRelease is a factory method which returns an TFRegistryProviderRelease instance. 91 | func NewTFRegistryProviderRelease(source string, config tfregistry.Config) (Release, error) { 92 | s := strings.SplitN(source, "/", 2) 93 | if len(s) != 2 { 94 | return nil, fmt.Errorf("failed to parse source: %s", source) 95 | } 96 | 97 | client, err := tfregistry.NewClient(config) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return &TFRegistryProviderRelease{ 103 | api: client, 104 | namespace: s[0], 105 | providerType: s[1], 106 | }, nil 107 | } 108 | 109 | // ListReleases returns a list of unsorted all releases including pre-release. 110 | func (r *TFRegistryProviderRelease) ListReleases(ctx context.Context) ([]string, error) { 111 | req := &tfregistry.ListProviderVersionsRequest{ 112 | Namespace: r.namespace, 113 | Type: r.providerType, 114 | } 115 | 116 | response, err := r.api.ListProviderVersions(ctx, req) 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to get a list of versions for %s/%s: %s", r.namespace, r.providerType, err) 119 | } 120 | 121 | // Extract versions from the response 122 | versions := []string{} 123 | for _, version := range response.Versions { 124 | versions = append(versions, version.Version) 125 | } 126 | 127 | return versions, nil 128 | } 129 | -------------------------------------------------------------------------------- /release/tfregistry_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/davecgh/go-spew/spew" 10 | "github.com/minamijoyo/tfupdate/tfregistry" 11 | ) 12 | 13 | // mockTFRegistryClient is a mock implementation of tfregistry.API 14 | type mockTFRegistryClient struct { 15 | moduleRes *tfregistry.ListModuleVersionsResponse 16 | providerRes *tfregistry.ListProviderVersionsResponse 17 | err error 18 | } 19 | 20 | var _ tfregistry.API = (*mockTFRegistryClient)(nil) 21 | 22 | func (c *mockTFRegistryClient) ListModuleVersions(_ context.Context, _ *tfregistry.ListModuleVersionsRequest) (*tfregistry.ListModuleVersionsResponse, error) { 23 | return c.moduleRes, c.err 24 | } 25 | 26 | func (c *mockTFRegistryClient) ListProviderVersions(_ context.Context, _ *tfregistry.ListProviderVersionsRequest) (*tfregistry.ListProviderVersionsResponse, error) { 27 | return c.providerRes, c.err 28 | } 29 | 30 | func (c *mockTFRegistryClient) ProviderPackageMetadata(_ context.Context, _ *tfregistry.ProviderPackageMetadataRequest) (*tfregistry.ProviderPackageMetadataResponse, error) { 31 | return nil, nil // dummy implementation as it's not used in tests 32 | } 33 | 34 | func TestNewTFRegistryModuleRelease(t *testing.T) { 35 | cases := []struct { 36 | source string 37 | namespace string 38 | name string 39 | provider string 40 | ok bool 41 | }{ 42 | { 43 | source: "hoge/fuga/piyo", 44 | namespace: "hoge", 45 | name: "fuga", 46 | provider: "piyo", 47 | ok: true, 48 | }, 49 | { 50 | source: "hoge", 51 | namespace: "", 52 | name: "", 53 | provider: "", 54 | ok: false, 55 | }, 56 | } 57 | 58 | for _, tc := range cases { 59 | config := tfregistry.Config{} 60 | got, err := NewTFRegistryModuleRelease(tc.source, config) 61 | 62 | if tc.ok && err != nil { 63 | t.Errorf("NewTFRegistryModuleRelease() with source = %s returns unexpected err: %s", tc.source, err) 64 | } 65 | 66 | if !tc.ok && err == nil { 67 | t.Errorf("NewTFRegistryModuleRelease() with source = %s expects to return an error, but no error", tc.source) 68 | } 69 | 70 | if tc.ok { 71 | r := got.(*TFRegistryModuleRelease) 72 | 73 | if !(r.namespace == tc.namespace && r.name == tc.name && r.provider == tc.provider) { 74 | t.Errorf("NewTFRegistryModuleRelease() with source = %s returns (%s, %s, %s), but want (%s, %s, %s)", tc.source, r.namespace, r.name, r.provider, tc.namespace, tc.name, tc.provider) 75 | } 76 | } 77 | } 78 | } 79 | 80 | func TestNewTFRegistryProviderRelease(t *testing.T) { 81 | cases := []struct { 82 | source string 83 | namespace string 84 | providerType string 85 | ok bool 86 | }{ 87 | { 88 | source: "hoge/piyo", 89 | namespace: "hoge", 90 | providerType: "piyo", 91 | ok: true, 92 | }, 93 | { 94 | source: "hoge", 95 | namespace: "", 96 | providerType: "", 97 | ok: false, 98 | }, 99 | } 100 | 101 | for _, tc := range cases { 102 | config := tfregistry.Config{} 103 | got, err := NewTFRegistryProviderRelease(tc.source, config) 104 | 105 | if tc.ok && err != nil { 106 | t.Errorf("NewTFRegistryProviderRelease() with source = %s returns unexpected err: %s", tc.source, err) 107 | } 108 | 109 | if !tc.ok && err == nil { 110 | t.Errorf("NewTFRegistryProviderRelease() with source = %s expects to return an error, but no error", tc.source) 111 | } 112 | 113 | if tc.ok { 114 | r := got.(*TFRegistryProviderRelease) 115 | 116 | if !(r.namespace == tc.namespace && r.providerType == tc.providerType) { 117 | t.Errorf("NewTFRegistryProviderRelease() with source = %s returns (%s, %s), but want (%s, %s)", tc.source, r.namespace, r.providerType, tc.namespace, tc.providerType) 118 | } 119 | } 120 | } 121 | } 122 | 123 | func TestTFRegistryModuleReleaseListReleases(t *testing.T) { 124 | cases := []struct { 125 | client *mockTFRegistryClient 126 | want []string 127 | ok bool 128 | }{ 129 | { 130 | client: &mockTFRegistryClient{ 131 | moduleRes: &tfregistry.ListModuleVersionsResponse{ 132 | Modules: []tfregistry.ModuleVersions{ 133 | { 134 | Versions: []tfregistry.ModuleVersion{ 135 | {Version: "0.3.0"}, 136 | {Version: "0.2.0"}, 137 | {Version: "0.1.0"}, 138 | }, 139 | }, 140 | }, 141 | }, 142 | err: nil, 143 | }, 144 | want: []string{"0.3.0", "0.2.0", "0.1.0"}, 145 | ok: true, 146 | }, 147 | { 148 | client: &mockTFRegistryClient{ 149 | moduleRes: nil, 150 | err: errors.New(`unexpected HTTP Status Code: 404`), 151 | }, 152 | want: nil, 153 | ok: false, 154 | }, 155 | } 156 | 157 | source := "hoge/fuga/piyo" 158 | for _, tc := range cases { 159 | config := tfregistry.Config{} 160 | r, err := NewTFRegistryModuleRelease(source, config) 161 | if err != nil { 162 | t.Fatalf("failed to NewTFRegistryModuleRelease(%s, %#v): %s", source, config, err) 163 | } 164 | r.(*TFRegistryModuleRelease).api = tc.client 165 | 166 | got, err := r.ListReleases(context.Background()) 167 | 168 | if tc.ok && err != nil { 169 | t.Errorf("(*TFRegistryModuleRelease).ListReleases() with r = %s returns unexpected err: %+v", spew.Sdump(r), err) 170 | } 171 | 172 | if !tc.ok && err == nil { 173 | t.Errorf("(*TFRegistryModuleRelease).ListReleases() with r = %s expects to return an error, but no error", spew.Sdump(r)) 174 | } 175 | 176 | if !reflect.DeepEqual(got, tc.want) { 177 | t.Errorf("(*TFRegistryModuleRelease).ListReleases() with r = %s returns %s, but want = %s", spew.Sdump(r), got, tc.want) 178 | } 179 | } 180 | } 181 | 182 | func TestTFRegistryProviderReleaseListReleases(t *testing.T) { 183 | cases := []struct { 184 | client *mockTFRegistryClient 185 | want []string 186 | ok bool 187 | }{ 188 | { 189 | client: &mockTFRegistryClient{ 190 | providerRes: &tfregistry.ListProviderVersionsResponse{ 191 | Versions: []tfregistry.ProviderVersion{ 192 | {Version: "0.3.0"}, 193 | {Version: "0.2.0"}, 194 | {Version: "0.1.0"}, 195 | }, 196 | }, 197 | err: nil, 198 | }, 199 | want: []string{"0.3.0", "0.2.0", "0.1.0"}, 200 | ok: true, 201 | }, 202 | { 203 | client: &mockTFRegistryClient{ 204 | providerRes: nil, 205 | err: errors.New(`unexpected HTTP Status Code: 404`), 206 | }, 207 | want: nil, 208 | ok: false, 209 | }, 210 | } 211 | 212 | source := "hoge/piyo" 213 | for _, tc := range cases { 214 | config := tfregistry.Config{} 215 | r, err := NewTFRegistryProviderRelease(source, config) 216 | if err != nil { 217 | t.Fatalf("failed to NewTFRegistryProviderRelease(%s, %#v): %s", source, config, err) 218 | } 219 | r.(*TFRegistryProviderRelease).api = tc.client 220 | 221 | got, err := r.ListReleases(context.Background()) 222 | 223 | if tc.ok && err != nil { 224 | t.Errorf("(*NewTFRegistryProviderRelease).ListReleases() with r = %s returns unexpected err: %+v", spew.Sdump(r), err) 225 | } 226 | 227 | if !tc.ok && err == nil { 228 | t.Errorf("(*NewTFRegistryProviderRelease).ListReleases() with r = %s expects to return an error, but no error", spew.Sdump(r)) 229 | } 230 | 231 | if !reflect.DeepEqual(got, tc.want) { 232 | t.Errorf("(*NewTFRegistryProviderRelease).ListReleases() with r = %s returns %s, but want = %s", spew.Sdump(r), got, tc.want) 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /release/version.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "sort" 5 | 6 | version "github.com/hashicorp/go-version" 7 | ) 8 | 9 | func tagNameToVersion(tagName string) string { 10 | // if a tagName starts with `v`, remove it. 11 | if tagName[0] == 'v' { 12 | return tagName[1:] 13 | } 14 | 15 | return tagName 16 | } 17 | 18 | func reverseStringSlice(s []string) []string { 19 | r := []string{} 20 | // apparently inefficient but simple way 21 | for i := len(s) - 1; i >= 0; i-- { 22 | r = append(r, s[i]) 23 | } 24 | return r 25 | } 26 | 27 | func minInt(a, b int) int { 28 | if a < b { 29 | return a 30 | } 31 | return b 32 | } 33 | 34 | // toVersions converts []string to []*version.Version. 35 | // Ignore if parse error. 36 | func toVersions(versionsRaw []string) []*version.Version { 37 | versions := []*version.Version{} 38 | for _, raw := range versionsRaw { 39 | v, err := version.NewVersion(raw) 40 | if err != nil { 41 | continue 42 | } 43 | versions = append(versions, v) 44 | } 45 | return versions 46 | } 47 | 48 | // fromVersions converts []*version.Version to []string. 49 | func fromVersions(versions []*version.Version) []string { 50 | versionsRaw := make([]string, len(versions)) 51 | for i, v := range versions { 52 | raw := v.String() 53 | versionsRaw[i] = raw 54 | } 55 | return versionsRaw 56 | } 57 | 58 | // sortVersions sort a list of versions in semver order. 59 | func sortVersions(versions []*version.Version) []*version.Version { 60 | sort.Sort(version.Collection(versions)) 61 | return versions 62 | } 63 | 64 | // excludePreReleases excludes pre-releases such as alpha, beta, rc. 65 | func excludePreReleases(versions []*version.Version) []*version.Version { 66 | // exclude pre-release 67 | filtered := []*version.Version{} 68 | for _, v := range versions { 69 | if len(v.Prerelease()) == 0 { 70 | filtered = append(filtered, v) 71 | } 72 | } 73 | 74 | return filtered 75 | } 76 | -------------------------------------------------------------------------------- /release/version_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestSortVersions(t *testing.T) { 9 | cases := []struct { 10 | desc string 11 | versionsRaw []string 12 | want []string 13 | }{ 14 | { 15 | desc: "simple", 16 | versionsRaw: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1"}, 17 | want: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0"}, 18 | }, 19 | { 20 | desc: "empty", 21 | versionsRaw: []string{}, 22 | want: []string{}, 23 | }, 24 | { 25 | desc: "pre-release", 26 | versionsRaw: []string{"0.3.0", "0.2.0", "0.1.0", "0.1.1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-alpha1", "0.3.0-rc"}, 27 | want: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0-alpha1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-rc", "0.3.0"}, 28 | }, 29 | } 30 | 31 | for _, tc := range cases { 32 | t.Run(tc.desc, func(t *testing.T) { 33 | got := fromVersions(sortVersions(toVersions(tc.versionsRaw))) 34 | if !reflect.DeepEqual(got, tc.want) { 35 | t.Errorf("got = %#v, but want = %#v", got, tc.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestExcludePreReleases(t *testing.T) { 42 | cases := []struct { 43 | desc string 44 | versionsRaw []string 45 | want []string 46 | }{ 47 | { 48 | desc: "simple", 49 | versionsRaw: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0-alpha1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-rc", "0.3.0"}, 50 | want: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0"}, 51 | }, 52 | { 53 | desc: "no pre-relases", 54 | versionsRaw: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0"}, 55 | want: []string{"0.1.0", "0.1.1", "0.2.0", "0.3.0"}, 56 | }, 57 | { 58 | desc: "no stable relases", 59 | versionsRaw: []string{"0.3.0-alpha1", "0.3.0-beta1", "0.3.0-beta2", "0.3.0-rc"}, 60 | want: []string{}, 61 | }, 62 | { 63 | desc: "empty", 64 | versionsRaw: []string{}, 65 | want: []string{}, 66 | }, 67 | } 68 | 69 | for _, tc := range cases { 70 | t.Run(tc.desc, func(t *testing.T) { 71 | got := fromVersions(excludePreReleases(toVersions(tc.versionsRaw))) 72 | if !reflect.DeepEqual(got, tc.want) { 73 | t.Errorf("got = %#v, but want = %#v", got, tc.want) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /scripts/testacc/all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | script_full_path=$(dirname "$0") 6 | 7 | # test simple 8 | bash "$script_full_path/lock.sh" run simple 9 | 10 | # test all 11 | repo_root_dir="$(git rev-parse --show-toplevel)" 12 | fixturesdir="$repo_root_dir/test-fixtures/lock/" 13 | 14 | fixtures=$(find "$fixturesdir" -type d -mindepth 1 -maxdepth 1 -exec basename {} \; | sort) 15 | 16 | for fixture in ${fixtures} 17 | do 18 | echo "$fixture" 19 | bash "$script_full_path/lock.sh" run "$fixture" 20 | done 21 | -------------------------------------------------------------------------------- /scripts/testacc/lock.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | usage() 6 | { 7 | cat << EOF 8 | Usage: $(basename "$0") 9 | 10 | Arguments: 11 | command: A name of step to run. Valid values are: 12 | run | setup | provider | lock | cleanup 13 | fixture: A name of fixture in test-fixtures/lock/ 14 | EOF 15 | } 16 | 17 | setup() 18 | { 19 | cp -prT "$FIXTUREDIR" ./ 20 | ALL_DIRS=$(find . -type f -print0 -name '*.tf' | xargs -0 -I {} dirname {} | sort | uniq | grep -v 'modules/') 21 | for dir in ${ALL_DIRS} 22 | do 23 | pushd "$dir" 24 | 25 | # always create a new lock 26 | rm -f .terraform.lock.hcl 27 | "$TFUPDATE_EXEC_PATH" providers lock -platform=linux_amd64 -platform=darwin_amd64 -platform=darwin_arm64 28 | cat .terraform.lock.hcl 29 | rm -rf .terraform 30 | 31 | popd 32 | done 33 | } 34 | 35 | provider() 36 | { 37 | TFUPDATE_LOG=DEBUG tfupdate provider null -v 3.2.1 -r ./ 38 | } 39 | 40 | lock() 41 | { 42 | TFUPDATE_LOG=DEBUG tfupdate lock --platform=linux_amd64 --platform=darwin_amd64 --platform=darwin_arm64 -r ./ 43 | 44 | ALL_DIRS=$(find . -type f -print0 -name '*.tf' | xargs -0 -I {} dirname {} | sort | uniq | grep -v 'modules/') 45 | for dir in ${ALL_DIRS} 46 | do 47 | pushd "$dir" 48 | 49 | # got 50 | mv .terraform.lock.hcl .terraform.lock.hcl.got 51 | 52 | # want 53 | "$TFUPDATE_EXEC_PATH" providers lock -platform=linux_amd64 -platform=darwin_amd64 -platform=darwin_arm64 54 | 55 | # assert the result 56 | cat .terraform.lock.hcl 57 | cat .terraform.lock.hcl.got 58 | diff -u .terraform.lock.hcl .terraform.lock.hcl.got 59 | 60 | popd 61 | done 62 | } 63 | 64 | cleanup() 65 | { 66 | find ./ -mindepth 1 -delete 67 | } 68 | 69 | run() 70 | { 71 | setup 72 | provider 73 | lock 74 | cleanup 75 | } 76 | 77 | # main 78 | if [[ $# -ne 2 ]]; then 79 | usage 80 | exit 1 81 | fi 82 | 83 | set -x 84 | 85 | COMMAND=$1 86 | FIXTURE=$2 87 | 88 | REPO_ROOT_DIR="$(git rev-parse --show-toplevel)" 89 | WORKDIR="$REPO_ROOT_DIR/tmp/testacc/lock/$FIXTURE" 90 | FIXTUREDIR="$REPO_ROOT_DIR/test-fixtures/lock/$FIXTURE/" 91 | mkdir -p "$WORKDIR" 92 | pushd "$WORKDIR" 93 | 94 | case "$COMMAND" in 95 | run | setup | provider | lock | cleanup ) 96 | "$COMMAND" 97 | RET=$? 98 | ;; 99 | *) 100 | usage 101 | RET=1 102 | ;; 103 | esac 104 | 105 | popd 106 | exit $RET 107 | -------------------------------------------------------------------------------- /test-fixtures/lock/simple/dir1/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | source = "hashicorp/null" 5 | version = "3.1.1" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test-fixtures/lock/simple/dir2/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | source = "hashicorp/null" 5 | version = "3.1.1" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test-fixtures/lock/simple/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | source = "hashicorp/null" 5 | version = "3.1.1" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tfregistry/client.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | ) 12 | 13 | // To avoid depending on a specific version of Terraform, 14 | // we implement a pure Terraform Registry API client. 15 | // https://www.terraform.io/docs/registry/api.html 16 | // 17 | // The public Terraform and OpenTofu registries are supported. 18 | // There are other APIs and request/response fields, 19 | // but we define only the ones we need here to keep it simple. 20 | 21 | const ( 22 | // The public Terraform Registry API endpoint. 23 | defaultBaseURL = "https://registry.terraform.io/" 24 | ) 25 | 26 | // API is an interface which calls Terraform Registry API. 27 | // This works for both Terraform and OpenTofu registries. 28 | // This abstraction layer is needed for testing with mock. 29 | type API interface { 30 | ModuleV1API 31 | ProviderV1API 32 | } 33 | 34 | // Config is a set of configurations for TFRegistry client. 35 | type Config struct { 36 | // HTTPClient is a http client which communicates with the API. 37 | // If nil, a default client will be used. 38 | HTTPClient *http.Client 39 | 40 | // BaseURL is a URL for Terraform Registry API requests. 41 | // Defaults to the public Terraform Registry API. 42 | // We have not yet implemented registry authentication, 43 | // so private registries such as HCP Terraform are not yet supported. 44 | // BaseURL should always be specified with a trailing slash. 45 | BaseURL string 46 | } 47 | 48 | // Client manages communication with the Terraform Registry API. 49 | type Client struct { 50 | // httpClient is a http client which communicates with the API. 51 | httpClient *http.Client 52 | // BaseURL is a base url for API requests. Defaults to the public Terraform Registry API. 53 | BaseURL *url.URL 54 | } 55 | 56 | // Ensure Client implements API interface 57 | var _ API = (*Client)(nil) 58 | 59 | // NewClient returns a new Client instance. 60 | func NewClient(config Config) (*Client, error) { 61 | httpClient := config.HTTPClient 62 | if httpClient == nil { 63 | httpClient = &http.Client{} 64 | } 65 | 66 | var baseURL *url.URL 67 | var err error 68 | if config.BaseURL != "" { 69 | baseURL, err = url.Parse(config.BaseURL) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to parse base URL: %s", err) 72 | } 73 | } else { 74 | baseURL, _ = url.Parse(defaultBaseURL) 75 | } 76 | 77 | c := &Client{httpClient: httpClient, BaseURL: baseURL} 78 | return c, nil 79 | } 80 | 81 | // newRequest builds a http Request instance. 82 | func (c *Client) newRequest(ctx context.Context, method string, subPath string, body io.Reader) (*http.Request, error) { 83 | endpointURL := *c.BaseURL 84 | endpointURL.Path = path.Join(c.BaseURL.Path, subPath) 85 | 86 | req, err := http.NewRequest(method, endpointURL.String(), body) 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to build HTTP request: err = %s, method = %s, endpointURL = %s, body = %#v", err, method, endpointURL.String(), body) 89 | } 90 | 91 | req = req.WithContext(ctx) 92 | 93 | req.Header.Set("Content-Type", "application/json") 94 | 95 | return req, nil 96 | } 97 | 98 | // decodeBody decodes a raw body data into a specific response type structure. 99 | func decodeBody(resp *http.Response, out interface{}) error { 100 | defer resp.Body.Close() 101 | decoder := json.NewDecoder(resp.Body) 102 | err := decoder.Decode(out) 103 | if err != nil { 104 | return fmt.Errorf("failed to decode response: err = %s, resp = %#v", err, resp) 105 | } 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /tfregistry/client_test.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import "testing" 4 | 5 | func TestNewClient(t *testing.T) { 6 | cases := []struct { 7 | baseURL string 8 | want string 9 | ok bool 10 | }{ 11 | { 12 | baseURL: "", 13 | want: "https://registry.terraform.io/", 14 | ok: true, 15 | }, 16 | { 17 | baseURL: "https://registry.terraform.io/", 18 | want: "https://registry.terraform.io/", 19 | ok: true, 20 | }, 21 | { 22 | baseURL: "http://localhost/", 23 | want: "http://localhost/", 24 | ok: true, 25 | }, 26 | { 27 | baseURL: `https://registry\.terraform.io/`, 28 | want: "", 29 | ok: false, 30 | }, 31 | } 32 | 33 | for _, tc := range cases { 34 | config := Config{ 35 | BaseURL: tc.baseURL, 36 | } 37 | got, err := NewClient(config) 38 | 39 | if tc.ok && err != nil { 40 | t.Errorf("NewClient() with baseURL = %s returns unexpected err: %s", tc.baseURL, err) 41 | } 42 | 43 | if !tc.ok && err == nil { 44 | t.Errorf("NewClient() with baseURL = %s expects to return an error, but no error", tc.baseURL) 45 | } 46 | 47 | if tc.ok { 48 | if got.BaseURL.String() != tc.want { 49 | t.Errorf("NewClient() with baseURL = %s returns %s, but want %s", tc.baseURL, got.BaseURL.String(), tc.want) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tfregistry/mock.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | ) 8 | 9 | // newMockServer returns a new mock server for testing. 10 | func newMockServer() (*http.ServeMux, *url.URL) { 11 | mux := http.NewServeMux() 12 | server := httptest.NewServer(mux) 13 | mockServerURL, _ := url.Parse(server.URL) 14 | return mux, mockServerURL 15 | } 16 | 17 | // newTestClient returns a new client for testing. 18 | func newTestClient(mockServerURL *url.URL) *Client { 19 | config := Config{ 20 | HTTPClient: &http.Client{}, 21 | } 22 | c, _ := NewClient(config) 23 | c.BaseURL = mockServerURL 24 | return c 25 | } 26 | -------------------------------------------------------------------------------- /tfregistry/module.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | const ( 8 | // moduleV1Service is a sub path of module v1 service endpoint. 9 | // The service discovery protocol is not implemented for now. 10 | // https://www.terraform.io/docs/internals/remote-service-discovery.html 11 | // 12 | // Include slashes for later implementation of service discovery. 13 | // curl https://registry.terraform.io/.well-known/terraform.json 14 | // {"modules.v1":"/v1/modules/","providers.v1":"/v1/providers/"} 15 | moduleV1Service = "/v1/modules/" 16 | ) 17 | 18 | // ModuleV1API is an interface for the module v1 service. 19 | type ModuleV1API interface { 20 | // ListModuleVersions returns all versions of a module for a single provider. 21 | // This works for both Terraform and OpenTofu registries. 22 | // https://developer.hashicorp.com/terraform/registry/api-docs#list-available-versions-for-a-specific-module 23 | // https://opentofu.org/docs/internals/module-registry-protocol/#list-available-versions-for-a-specific-module 24 | ListModuleVersions(ctx context.Context, req *ListModuleVersionsRequest) (*ListModuleVersionsResponse, error) 25 | } 26 | -------------------------------------------------------------------------------- /tfregistry/module_versions.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ListModuleVersionsRequest is a request parameter for the ListModuleVersions API. 9 | type ListModuleVersionsRequest struct { 10 | // The user or organization the module is owned by. 11 | Namespace string `json:"namespace"` 12 | // The name of the module. 13 | Name string `json:"name"` 14 | // The name of the provider. 15 | Provider string `json:"provider"` 16 | } 17 | 18 | // ListModuleVersionsResponse is a response data for the ListModuleVersions API. 19 | type ListModuleVersionsResponse struct { 20 | // Modules is an array containing module information. 21 | // The first element contains the requested module. 22 | Modules []ModuleVersions `json:"modules"` 23 | } 24 | 25 | // ModuleVersions represents version information for a module. 26 | type ModuleVersions struct { 27 | // Versions is a list of available versions. 28 | Versions []ModuleVersion `json:"versions"` 29 | } 30 | 31 | // ModuleVersion represents a single version of a module. 32 | type ModuleVersion struct { 33 | // Version is the version string. 34 | Version string `json:"version"` 35 | } 36 | 37 | // ListModuleVersions returns all versions of a module for a single provider. 38 | // This works for both Terraform and OpenTofu registries. 39 | func (c *Client) ListModuleVersions(ctx context.Context, req *ListModuleVersionsRequest) (*ListModuleVersionsResponse, error) { 40 | if len(req.Namespace) == 0 { 41 | return nil, fmt.Errorf("Invalid request. Namespace is required. req = %#v", req) 42 | } 43 | if len(req.Name) == 0 { 44 | return nil, fmt.Errorf("Invalid request. Name is required. req = %#v", req) 45 | } 46 | if len(req.Provider) == 0 { 47 | return nil, fmt.Errorf("Invalid request. Provider is required. req = %#v", req) 48 | } 49 | 50 | subPath := fmt.Sprintf("%s%s/%s/%s/versions", moduleV1Service, req.Namespace, req.Name, req.Provider) 51 | 52 | httpRequest, err := c.newRequest(ctx, "GET", subPath, nil) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | httpResponse, err := c.httpClient.Do(httpRequest) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to HTTP Request: err = %s, req = %#v", err, httpRequest) 60 | } 61 | 62 | if httpResponse.StatusCode != 200 { 63 | return nil, fmt.Errorf("unexpected HTTP Status Code: %d", httpResponse.StatusCode) 64 | } 65 | 66 | var res ListModuleVersionsResponse 67 | if err := decodeBody(httpResponse, &res); err != nil { 68 | return nil, err 69 | } 70 | 71 | return &res, nil 72 | } 73 | -------------------------------------------------------------------------------- /tfregistry/module_versions_test.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestListModuleVersions(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | req *ListModuleVersionsRequest 15 | ok bool 16 | code int 17 | res string 18 | want *ListModuleVersionsResponse 19 | }{ 20 | { 21 | desc: "simple", 22 | req: &ListModuleVersionsRequest{ 23 | Namespace: "terraform-aws-modules", 24 | Name: "vpc", 25 | Provider: "aws", 26 | }, 27 | ok: true, 28 | code: 200, 29 | res: `{"modules": [{"versions": [{"version": "2.22.0"}, {"version": "2.23.0"}, {"version": "2.24.0"}]}]}`, 30 | want: &ListModuleVersionsResponse{ 31 | Modules: []ModuleVersions{ 32 | { 33 | Versions: []ModuleVersion{ 34 | {Version: "2.22.0"}, 35 | {Version: "2.23.0"}, 36 | {Version: "2.24.0"}, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | { 43 | desc: "not found", 44 | req: &ListModuleVersionsRequest{ 45 | Namespace: "hoge", 46 | Name: "fuga", 47 | Provider: "piyo", 48 | }, 49 | ok: false, 50 | code: 404, 51 | res: `{"errors":["Not Found"]}`, 52 | want: nil, 53 | }, 54 | { 55 | desc: "invalid request (Namespace)", 56 | req: &ListModuleVersionsRequest{ 57 | Namespace: "", 58 | Name: "fuga", 59 | Provider: "piyo", 60 | }, 61 | ok: false, 62 | code: 0, 63 | res: "", 64 | want: nil, 65 | }, 66 | { 67 | desc: "invalid request (Name)", 68 | req: &ListModuleVersionsRequest{ 69 | Namespace: "hoge", 70 | Name: "", 71 | Provider: "piyo", 72 | }, 73 | ok: false, 74 | code: 0, 75 | res: "", 76 | want: nil, 77 | }, 78 | { 79 | desc: "invalid request (Provider)", 80 | req: &ListModuleVersionsRequest{ 81 | Namespace: "hoge", 82 | Name: "fuga", 83 | Provider: "", 84 | }, 85 | ok: false, 86 | code: 0, 87 | res: "", 88 | want: nil, 89 | }, 90 | } 91 | 92 | for _, tc := range cases { 93 | t.Run(tc.desc, func(t *testing.T) { 94 | mux, mockServerURL := newMockServer() 95 | client := newTestClient(mockServerURL) 96 | subPath := fmt.Sprintf("%s%s/%s/%s/versions", moduleV1Service, tc.req.Namespace, tc.req.Name, tc.req.Provider) 97 | mux.HandleFunc(subPath, func(w http.ResponseWriter, _ *http.Request) { 98 | w.WriteHeader(tc.code) 99 | fmt.Fprint(w, tc.res) 100 | }) 101 | 102 | got, err := client.ListModuleVersions(context.Background(), tc.req) 103 | 104 | if tc.ok && err != nil { 105 | t.Fatalf("failed to call ListModuleVersions: err = %s, req = %#v", err, tc.req) 106 | } 107 | 108 | if !tc.ok && err == nil { 109 | t.Fatalf("expected to fail, but success: req = %#v, got = %#v", tc.req, got) 110 | } 111 | 112 | if !reflect.DeepEqual(got, tc.want) { 113 | t.Errorf("got=%#v, but want=%#v", got, tc.want) 114 | } 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tfregistry/provider.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | const ( 8 | // providerV1Service is a sub path of provider v1 service endpoint. 9 | // The service discovery protocol is not implemented for now. 10 | // https://www.terraform.io/docs/internals/provider-registry-protocol.html#service-discovery 11 | // 12 | // Include slashes for later implementation of service discovery. 13 | // curl https://registry.terraform.io/.well-known/terraform.json 14 | // {"modules.v1":"/v1/modules/","providers.v1":"/v1/providers/"} 15 | providerV1Service = "/v1/providers/" 16 | ) 17 | 18 | // ProviderV1API is an interface for the provider v1 service. 19 | type ProviderV1API interface { 20 | // ListProviderVersions returns all versions of a provider. 21 | // This works for both Terraform and OpenTofu registries. 22 | // https://developer.hashicorp.com/terraform/internals/provider-registry-protocol#list-available-versions 23 | // https://opentofu.org/docs/internals/provider-registry-protocol/#list-available-versions 24 | ListProviderVersions(ctx context.Context, req *ListProviderVersionsRequest) (*ListProviderVersionsResponse, error) 25 | 26 | // ProviderPackageMetadata returns a package metadata of a provider. 27 | // https://developer.hashicorp.com/terraform/internals/provider-registry-protocol#find-a-provider-package 28 | // https://opentofu.org/docs/internals/provider-registry-protocol/#find-a-provider-package 29 | ProviderPackageMetadata(ctx context.Context, req *ProviderPackageMetadataRequest) (*ProviderPackageMetadataResponse, error) 30 | } 31 | -------------------------------------------------------------------------------- /tfregistry/provider_package_metadata.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | ) 8 | 9 | // ProviderPackageMetadataRequest is a request parameter for ProviderPackageMetadata(). 10 | // https://developer.hashicorp.com/terraform/internals/provider-registry-protocol#find-a-provider-package 11 | type ProviderPackageMetadataRequest struct { 12 | // (required): the namespace portion of the address of the requested provider. 13 | Namespace string `json:"namespace"` 14 | // (required): the type portion of the address of the requested provider. 15 | Type string `json:"type"` 16 | // (required): the version selected to download. 17 | Version string `json:"version"` 18 | // (required): a keyword identifying the operating system that the returned package should be compatible with, like "linux" or "darwin". 19 | OS string `json:"os"` 20 | // (required): a keyword identifying the CPU architecture that the returned package should be compatible with, like "amd64" or "arm". 21 | Arch string `json:"arch"` 22 | } 23 | 24 | // ProviderPackageMetadataResponse is a response data for ProviderPackageMetadata(). 25 | // There are other response fields, but we define only those we need here. 26 | type ProviderPackageMetadataResponse struct { 27 | // (required): the filename for this provider's zip archive as recorded in the "shasums" document, so that Terraform CLI can determine which of the given checksums should be used for this specific package. 28 | Filename string `json:"filename"` 29 | // (required): a URL from which Terraform can retrieve the provider's zip archive. If this is a relative URL then it will be resolved relative to the URL that returned the containing JSON object. 30 | DownloadURL string `json:"download_url"` 31 | // (required): the SHA256 checksum for this provider's zip archive as recorded in the shasums document. 32 | SHASum string `json:"shasum"` 33 | // (required): a URL from which Terraform can retrieve a text document recording expected SHA256 checksums for this package and possibly other packages for the same provider version on other platforms. 34 | SHASumsURL string `json:"shasums_url"` 35 | } 36 | 37 | // ProviderPackageMetadata returns a package metadata of a provider. 38 | // https://developer.hashicorp.com/terraform/internals/provider-registry-protocol#find-a-provider-package 39 | func (c *Client) ProviderPackageMetadata(ctx context.Context, req *ProviderPackageMetadataRequest) (*ProviderPackageMetadataResponse, error) { 40 | if len(req.Namespace) == 0 { 41 | return nil, fmt.Errorf("Invalid request. Namespace is required. req = %#v", req) 42 | } 43 | if len(req.Type) == 0 { 44 | return nil, fmt.Errorf("Invalid request. Type is required. req = %#v", req) 45 | } 46 | if len(req.Version) == 0 { 47 | return nil, fmt.Errorf("Invalid request. Version is required. req = %#v", req) 48 | } 49 | if len(req.OS) == 0 { 50 | return nil, fmt.Errorf("Invalid request. OS is required. req = %#v", req) 51 | } 52 | if len(req.Arch) == 0 { 53 | return nil, fmt.Errorf("Invalid request. Arch is required. req = %#v", req) 54 | } 55 | 56 | subPath := fmt.Sprintf("%s%s/%s/%s/download/%s/%s", providerV1Service, req.Namespace, req.Type, req.Version, req.OS, req.Arch) 57 | 58 | httpRequest, err := c.newRequest(ctx, "GET", subPath, nil) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | log.Printf("[DEBUG] Client.ProviderPackageMetadata: GET %s", httpRequest.URL) 64 | httpResponse, err := c.httpClient.Do(httpRequest) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to HTTP Request: err = %s, req = %#v", err, httpRequest) 67 | } 68 | 69 | if httpResponse.StatusCode != 200 { 70 | return nil, fmt.Errorf("unexpected HTTP Status Code: %d", httpResponse.StatusCode) 71 | } 72 | 73 | var res ProviderPackageMetadataResponse 74 | if err := decodeBody(httpResponse, &res); err != nil { 75 | return nil, err 76 | } 77 | 78 | return &res, nil 79 | } 80 | -------------------------------------------------------------------------------- /tfregistry/provider_versions.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ListProviderVersionsRequest is a request parameter for ListProviderVersions API. 9 | type ListProviderVersionsRequest struct { 10 | // The user or organization the provider is owned by. 11 | Namespace string `json:"namespace"` 12 | // The type name of the provider. 13 | Type string `json:"type"` 14 | } 15 | 16 | // ListProviderVersionsResponse is a response data for ListProviderVersions API. 17 | type ListProviderVersionsResponse struct { 18 | // Versions is a list of available versions. 19 | Versions []ProviderVersion `json:"versions"` 20 | } 21 | 22 | // ProviderVersion represents a single version of a provider. 23 | type ProviderVersion struct { 24 | // Version is the version string. 25 | Version string `json:"version"` 26 | // Protocols is a list of supported protocol versions. 27 | Protocols []string `json:"protocols,omitempty"` 28 | // Platforms is a list of supported platforms. 29 | Platforms []ProviderPlatform `json:"platforms,omitempty"` 30 | } 31 | 32 | // ProviderPlatform represents a platform supported by a provider version. 33 | type ProviderPlatform struct { 34 | // OS is the operating system. 35 | OS string `json:"os"` 36 | // Arch is the architecture. 37 | Arch string `json:"arch"` 38 | } 39 | 40 | // ListProviderVersions returns all versions of a provider. 41 | // This works for both Terraform and OpenTofu registries. 42 | func (c *Client) ListProviderVersions(ctx context.Context, req *ListProviderVersionsRequest) (*ListProviderVersionsResponse, error) { 43 | if len(req.Namespace) == 0 { 44 | return nil, fmt.Errorf("Invalid request. Namespace is required. req = %#v", req) 45 | } 46 | if len(req.Type) == 0 { 47 | return nil, fmt.Errorf("Invalid request. Type is required. req = %#v", req) 48 | } 49 | 50 | subPath := fmt.Sprintf("%s%s/%s/versions", providerV1Service, req.Namespace, req.Type) 51 | 52 | httpRequest, err := c.newRequest(ctx, "GET", subPath, nil) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | httpResponse, err := c.httpClient.Do(httpRequest) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to HTTP Request: err = %s, req = %#v", err, httpRequest) 60 | } 61 | 62 | if httpResponse.StatusCode != 200 { 63 | return nil, fmt.Errorf("unexpected HTTP Status Code: %d", httpResponse.StatusCode) 64 | } 65 | 66 | var res ListProviderVersionsResponse 67 | if err := decodeBody(httpResponse, &res); err != nil { 68 | return nil, err 69 | } 70 | 71 | return &res, nil 72 | } 73 | -------------------------------------------------------------------------------- /tfregistry/provider_versions_test.go: -------------------------------------------------------------------------------- 1 | package tfregistry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestListProviderVersions(t *testing.T) { 12 | cases := []struct { 13 | desc string 14 | req *ListProviderVersionsRequest 15 | ok bool 16 | code int 17 | res string 18 | want *ListProviderVersionsResponse 19 | }{ 20 | { 21 | desc: "simple", 22 | req: &ListProviderVersionsRequest{ 23 | Namespace: "hashicorp", 24 | Type: "aws", 25 | }, 26 | ok: true, 27 | code: 200, 28 | res: `{"versions": [{"version": "3.5.0"}, {"version": "3.6.0"}, {"version": "3.7.0"}]}`, 29 | want: &ListProviderVersionsResponse{ 30 | Versions: []ProviderVersion{ 31 | {Version: "3.5.0"}, 32 | {Version: "3.6.0"}, 33 | {Version: "3.7.0"}, 34 | }, 35 | }, 36 | }, 37 | { 38 | desc: "with protocols and platforms", 39 | req: &ListProviderVersionsRequest{ 40 | Namespace: "hashicorp", 41 | Type: "aws", 42 | }, 43 | ok: true, 44 | code: 200, 45 | res: `{"versions": [{"version": "3.7.0", "protocols": ["4.0", "5.1"], "platforms": [{"os": "linux", "arch": "amd64"}, {"os": "darwin", "arch": "amd64"}]}]}`, 46 | want: &ListProviderVersionsResponse{ 47 | Versions: []ProviderVersion{ 48 | { 49 | Version: "3.7.0", 50 | Protocols: []string{"4.0", "5.1"}, 51 | Platforms: []ProviderPlatform{ 52 | {OS: "linux", Arch: "amd64"}, 53 | {OS: "darwin", Arch: "amd64"}, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | { 60 | desc: "not found", 61 | req: &ListProviderVersionsRequest{ 62 | Namespace: "hoge", 63 | Type: "piyo", 64 | }, 65 | ok: false, 66 | code: 404, 67 | res: `{"errors":["Not Found"]}`, 68 | want: nil, 69 | }, 70 | { 71 | desc: "invalid request (Namespace)", 72 | req: &ListProviderVersionsRequest{ 73 | Namespace: "", 74 | Type: "piyo", 75 | }, 76 | ok: false, 77 | code: 0, 78 | res: "", 79 | want: nil, 80 | }, 81 | { 82 | desc: "invalid request (Type)", 83 | req: &ListProviderVersionsRequest{ 84 | Namespace: "hoge", 85 | Type: "", 86 | }, 87 | ok: false, 88 | code: 0, 89 | res: "", 90 | want: nil, 91 | }, 92 | } 93 | 94 | for _, tc := range cases { 95 | t.Run(tc.desc, func(t *testing.T) { 96 | mux, mockServerURL := newMockServer() 97 | client := newTestClient(mockServerURL) 98 | subPath := fmt.Sprintf("%s%s/%s/versions", providerV1Service, tc.req.Namespace, tc.req.Type) 99 | mux.HandleFunc(subPath, func(w http.ResponseWriter, _ *http.Request) { 100 | w.WriteHeader(tc.code) 101 | fmt.Fprint(w, tc.res) 102 | }) 103 | 104 | got, err := client.ListProviderVersions(context.Background(), tc.req) 105 | 106 | if tc.ok && err != nil { 107 | t.Fatalf("failed to call ListProviderVersions: err = %s, req = %#v", err, tc.req) 108 | } 109 | 110 | if !tc.ok && err == nil { 111 | t.Fatalf("expected to fail, but success: req = %#v, got = %#v", tc.req, got) 112 | } 113 | 114 | if !reflect.DeepEqual(got, tc.want) { 115 | t.Errorf("got=%#v, but want=%#v", got, tc.want) 116 | } 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tfupdate/context.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "log" 5 | 6 | version "github.com/hashicorp/go-version" 7 | "github.com/minamijoyo/terraform-config-inspect/tfconfig" 8 | "github.com/spf13/afero" 9 | "golang.org/x/exp/maps" 10 | "golang.org/x/exp/slices" 11 | ) 12 | 13 | // GlobalContext is information that is shared over the lifetime of the process. 14 | type GlobalContext struct { 15 | // fs is an afero filesystem for testing. 16 | fs afero.Fs 17 | 18 | // updater is an interface to rewriting rule implementations. 19 | updater Updater 20 | 21 | // option is a set of global parameters. 22 | option Option 23 | } 24 | 25 | // NewGlobalContext returns a new instance of NewGlobalContext. 26 | func NewGlobalContext(fs afero.Fs, o Option) (*GlobalContext, error) { 27 | updater, err := NewUpdater(o) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | gc := &GlobalContext{ 33 | fs: fs, 34 | updater: updater, 35 | option: o, 36 | } 37 | 38 | return gc, nil 39 | } 40 | 41 | // ModuleContext is information shared across files within a directory. 42 | type ModuleContext struct { 43 | // gc is a pointer to delegate some implementations to GlobalContext. 44 | gc *GlobalContext 45 | 46 | // dir is a relative path to the module from the current working directory. 47 | dir string 48 | 49 | // requiredProviders is version constraints of Terraform providers. 50 | // This is the result of parsing terraform-config-inspect and may contain 51 | // multiple constraints. The meaning depends on the use case and is therefore 52 | // lazily evaluated. 53 | requiredProviders map[string]*tfconfig.ProviderRequirement 54 | } 55 | 56 | // SelectedProvider is the source address and version of the provider, as 57 | // inferred from the version constraint. 58 | type SelectedProvider struct { 59 | // source is a source address of the provider. 60 | Source string 61 | 62 | // version is a version of the provider. 63 | Version string 64 | } 65 | 66 | // aferoToTfconfigFS converts afero.Fs to tfconfig.FS. 67 | // The filesystem has been replaced for testing purposes, but due to historical 68 | // reasons, we use afero.Fs instead of standard io/fs.FS introduced in Go 1.16. 69 | // On the other hand, the tfconfig uses its own tfconfig.FS, which is also 70 | // incompatible the standard one. Fortunately, both have adaptors for 71 | // converting the interface to the standard one. Converting afero.Fs to 72 | // io/fs.FS and then to tfconfig.FS makes the types match. 73 | // Note that the standard io/fs.FS doesn't support any write operations and 74 | // afero.IOFS doesn't support absolute paths at the time of writing. 75 | // It might be better to use the native OS filesystem for testing without 76 | // relying on afero. 77 | func aferoToTfconfigFS(afs afero.Fs) tfconfig.FS { 78 | return tfconfig.WrapFS(afero.NewIOFS(afs)) 79 | } 80 | 81 | // NewModuleContext parses a given module and returns a new ModuleContext. 82 | // The dir is a relative path to the module from the current working directory. 83 | func NewModuleContext(dir string, gc *GlobalContext) (*ModuleContext, error) { 84 | requiredProviders := make(map[string]*tfconfig.ProviderRequirement) 85 | m, diags := tfconfig.LoadModuleFromFilesystem(aferoToTfconfigFS(gc.fs), dir) 86 | if diags.HasErrors() { 87 | // There is a known issue passing absolute paths to afero.IOFS results in 88 | // an error, but as the result of module inspection is not essential for 89 | // all use cases now, we intentionally ignore the error here. 90 | // https://github.com/minamijoyo/tfupdate/issues/93 91 | log.Printf("[DEBUG] failed to load module: dir = %s, err = %s", dir, diags) 92 | } else { 93 | requiredProviders = m.RequiredProviders 94 | } 95 | 96 | c := &ModuleContext{ 97 | gc: gc, 98 | dir: dir, 99 | requiredProviders: requiredProviders, 100 | } 101 | 102 | return c, nil 103 | } 104 | 105 | // GlobalContext returns an instance of the global context. 106 | func (mc *ModuleContext) GlobalContext() *GlobalContext { 107 | return mc.gc 108 | } 109 | 110 | // FS returns an instance of afero filesystem 111 | func (mc *ModuleContext) FS() afero.Fs { 112 | return mc.gc.fs 113 | } 114 | 115 | // Updater returns an instance of Updater. 116 | func (mc *ModuleContext) Updater() Updater { 117 | return mc.gc.updater 118 | } 119 | 120 | // Option returns an instance of Option. 121 | func (mc *ModuleContext) Option() Option { 122 | return mc.gc.option 123 | } 124 | 125 | // SelectedProviders returns a list of providers inferred from version constraints. 126 | // The result is sorted alphabetically by source address. 127 | // Version constraints only support simple constants and not comparison 128 | // operators. Ignore what cannot be interpreted. 129 | func (mc *ModuleContext) SelecetedProviders() []SelectedProvider { 130 | selected := make(map[string]string) 131 | for _, p := range mc.requiredProviders { 132 | if p.Source == "" { 133 | // A source address with an empty string implies an unknown namespace prior to 134 | // Terraform v0.13, but since this is already a deprecated usage, we don't 135 | // implicitly complement the official hashicorp namespace and is not included 136 | // in the results. 137 | log.Printf("[DEBUG] ModuleContext.SelecetedProviders: ignore legacy provider address notation: %s", p.Source) 138 | continue 139 | } 140 | 141 | v := selectVersion(p.VersionConstraints) 142 | 143 | if v == "" { 144 | // Ignore if no version is specified. 145 | log.Printf("[DEBUG] ModuleContext.SelecetedProviders: ignore no version selected: %s", p.Source) 146 | continue 147 | } 148 | 149 | // It is not possible to mix multiple provider versions in one module, so 150 | // simply overwrite without taking duplicates into account 151 | selected[p.Source] = v 152 | } 153 | 154 | // Sort to get stable results 155 | keys := maps.Keys(selected) 156 | slices.Sort(keys) 157 | 158 | ret := []SelectedProvider{} 159 | for _, k := range keys { 160 | s := SelectedProvider{Source: k, Version: selected[k]} 161 | ret = append(ret, s) 162 | } 163 | return ret 164 | } 165 | 166 | // selectVersion resolves version constraints and returns the version. 167 | // Note that it does not actually re-implement the resolution of version 168 | // constraints in terraform init. It is very simplified for the use we need. 169 | // Version constraints only support simple constants and not comparison 170 | // operators. Ignore what cannot be interpreted. 171 | func selectVersion(constraints []string) string { 172 | for _, c := range constraints { 173 | v, err := version.NewVersion(c) 174 | if err != nil { 175 | // Ignore parse error 176 | log.Printf("[DEBUG] selectVersion: ignore version parse error: constaraints = %#v, err = %s", constraints, err) 177 | continue 178 | } 179 | // return the first one found 180 | return v.String() 181 | } 182 | return "" 183 | } 184 | 185 | // ResolveProviderShortNameFromSource is a helper function to resolve provider 186 | // short names from the source address. 187 | // If not found, return an empty string. 188 | func (mc *ModuleContext) ResolveProviderShortNameFromSource(source string) string { 189 | for k, v := range mc.requiredProviders { 190 | if v.Source == source { 191 | return k 192 | } 193 | } 194 | 195 | return "" 196 | } 197 | -------------------------------------------------------------------------------- /tfupdate/file.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/hashicorp/hcl/v2/hclwrite" 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | // UpdateFile updates version constraints in a single file. 17 | // We use an afero filesystem here for testing. 18 | func UpdateFile(ctx context.Context, mc *ModuleContext, filename string) error { 19 | log.Printf("[DEBUG] check file: %s", filename) 20 | r, err := mc.FS().Open(filename) 21 | if err != nil { 22 | return fmt.Errorf("failed to open file: %s", err) 23 | } 24 | defer r.Close() 25 | 26 | w := &bytes.Buffer{} 27 | isUpdated, err := UpdateHCL(ctx, mc, r, w, filename) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | // Write contents back to source file if changed. 33 | if isUpdated { 34 | log.Printf("[INFO] update file: %s", filename) 35 | updated := w.Bytes() 36 | // We should be able to choose whether to format output or not. 37 | // However, the current implementation of (*hclwrite.Body).SetAttributeValue() 38 | // does not seem to preserve an original SpaceBefore value of attribute. 39 | // So, we need to format output here. 40 | result := hclwrite.Format(updated) 41 | if err = afero.WriteFile(mc.FS(), filename, result, os.ModePerm); err != nil { 42 | return fmt.Errorf("failed to write file: %s", err) 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // UpdateDir updates version constraints for files in a given directory. 50 | // If a recursive flag is true, it checks and updates recursively. 51 | // skip hidden directories such as .terraform or .git. 52 | // It also skips unsupported file type. 53 | func UpdateDir(ctx context.Context, current *ModuleContext, dirname string) error { 54 | log.Printf("[DEBUG] check dir: %s", dirname) 55 | option := current.Option() 56 | dir, err := afero.ReadDir(current.FS(), dirname) 57 | if err != nil { 58 | return fmt.Errorf("failed to open dir: %s", err) 59 | } 60 | 61 | for _, entry := range dir { 62 | path := filepath.Join(dirname, entry.Name()) 63 | 64 | // if a path of entry matches ignorePaths, skip it. 65 | if option.MatchIgnorePaths(path) { 66 | log.Printf("[DEBUG] ignore: %s", path) 67 | continue 68 | } 69 | 70 | if entry.IsDir() { 71 | // if an entry is a directory 72 | if !option.recursive { 73 | // skip directory if a recursive flag is false 74 | continue 75 | } 76 | if strings.HasPrefix(entry.Name(), ".") { 77 | // skip hidden directories such as .terraform or .git 78 | continue 79 | } 80 | 81 | child, err := NewModuleContext(path, current.GlobalContext()) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | err = UpdateDir(ctx, child, path) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | continue 92 | } 93 | 94 | // if an entry is a file 95 | if !(filepath.Ext(entry.Name()) == ".tf" || filepath.Ext(entry.Name()) == ".tofu" || entry.Name() == ".terraform.lock.hcl") { 96 | // skip unsupported file type 97 | continue 98 | } 99 | 100 | err := UpdateFile(ctx, current, path) 101 | if err != nil { 102 | return err 103 | } 104 | } 105 | return nil 106 | } 107 | 108 | // UpdateFileOrDir updates version constraints in a given file or directory. 109 | func UpdateFileOrDir(ctx context.Context, gc *GlobalContext, path string) error { 110 | isDir, err := afero.IsDir(gc.fs, path) 111 | if err != nil { 112 | return fmt.Errorf("failed to open path: %s", err) 113 | } 114 | 115 | if isDir { 116 | // if an entry is a directory 117 | mc, err := NewModuleContext(path, gc) 118 | if err != nil { 119 | return err 120 | } 121 | return UpdateDir(ctx, mc, path) 122 | } 123 | 124 | // if an entry is a file 125 | // Note that even if only the filename is specified, the directory containing 126 | // it is read for module context analysis. 127 | dir := filepath.Dir(path) 128 | mc, err := NewModuleContext(dir, gc) 129 | if err != nil { 130 | return err 131 | } 132 | // When the filename is intentionally specified, 133 | // we should not ignore it by its extension as much as possible. 134 | return UpdateFile(ctx, mc, path) 135 | } 136 | -------------------------------------------------------------------------------- /tfupdate/hclwrite.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclparse" 10 | "github.com/hashicorp/hcl/v2/hclsyntax" 11 | "github.com/hashicorp/hcl/v2/hclwrite" 12 | "github.com/zclconf/go-cty/cty" 13 | ) 14 | 15 | // allMatchingBlocks returns all matching blocks from the body that have the 16 | // given name and labels or returns an empty list if there is currently no 17 | // matching block. 18 | func allMatchingBlocks(b *hclwrite.Body, typeName string, labels []string) []*hclwrite.Block { 19 | var matched []*hclwrite.Block 20 | for _, block := range b.Blocks() { 21 | if typeName == block.Type() { 22 | labelNames := block.Labels() 23 | if len(labels) == 0 && len(labelNames) == 0 { 24 | matched = append(matched, block) 25 | continue 26 | } 27 | if reflect.DeepEqual(labels, labelNames) { 28 | matched = append(matched, block) 29 | } 30 | } 31 | } 32 | 33 | return matched 34 | } 35 | 36 | // allMatchingBlocksByType returns all matching blocks from the body that have the 37 | // given name or returns an empty list if there is currently no matching block. 38 | // This method is useful when you want to ignore label differences. 39 | func allMatchingBlocksByType(b *hclwrite.Body, typeName string) []*hclwrite.Block { 40 | var matched []*hclwrite.Block 41 | for _, block := range b.Blocks() { 42 | if typeName == block.Type() { 43 | matched = append(matched, block) 44 | } 45 | } 46 | 47 | return matched 48 | } 49 | 50 | // getHCLNativeAttribute gets hclwrite.Attribute as a native hcl.Attribute. 51 | // At the time of writing, there is no way to do with the hclwrite AST, 52 | // so we build low-level byte sequences and parse an attribute as a 53 | // hcl.Attribute on memory. 54 | // If not found, returns nil without an error. 55 | func getHCLNativeAttribute(body *hclwrite.Body, name string) (*hcl.Attribute, error) { 56 | attr := body.GetAttribute(name) 57 | if attr == nil { 58 | return nil, nil 59 | } 60 | 61 | // build low-level byte sequences 62 | attrAsBytes := attr.Expr().BuildTokens(nil).Bytes() 63 | src := append([]byte(name+" = "), attrAsBytes...) 64 | 65 | // parse an expression as a hcl.File. 66 | // Note that an attribute may contains references, which are defined outside the file. 67 | // So we cannot simply use hclsyntax.ParseExpression or hclsyntax.ParseConfig here. 68 | // We need to use a loe-level parser not to resolve all references. 69 | parser := hclparse.NewParser() 70 | file, diags := parser.ParseHCL(src, "generated_by_getHCLNativeAttribute") 71 | if diags.HasErrors() { 72 | return nil, fmt.Errorf("failed to parse expression: %s", diags) 73 | } 74 | 75 | attrs, diags := file.Body.JustAttributes() 76 | if diags.HasErrors() { 77 | return nil, fmt.Errorf("failed to get attributes: %s", diags) 78 | } 79 | 80 | hclAttr, ok := attrs[name] 81 | if !ok { 82 | return nil, fmt.Errorf("attribute not found: %s", src) 83 | } 84 | 85 | return hclAttr, nil 86 | } 87 | 88 | // getAttributeValueAsString returns a value of Attribute as string. 89 | // There is no way to get value as string directly, 90 | // so we parses tokens of Attribute and build string representation. 91 | // The returned value is unquoted. 92 | func getAttributeValueAsUnquotedString(attr *hclwrite.Attribute) string { 93 | // find TokenEqual 94 | expr := attr.Expr() 95 | exprTokens := expr.BuildTokens(nil) 96 | 97 | // TokenIdent records SpaceBefore, but we should ignore it here. 98 | quotedValue := strings.TrimSpace(string(exprTokens.Bytes())) 99 | 100 | // unquote 101 | value := strings.Trim(quotedValue, "\"") 102 | 103 | return value 104 | } 105 | 106 | // tokensForListPerLine builds a hclwrite.Tokens for a given list, but breaks the line for each element. 107 | func tokensForListPerLine(list []string) hclwrite.Tokens { 108 | // The original TokensForValue implementation does not break line by line for list, 109 | // so we build a token sequence by ourselves. 110 | tokens := hclwrite.Tokens{} 111 | tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenOBrack, Bytes: []byte{'['}}) 112 | tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}}) 113 | 114 | for _, i := range list { 115 | ts := hclwrite.TokensForValue(cty.StringVal(i)) 116 | tokens = append(tokens, ts...) 117 | tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte{','}}) 118 | tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}}) 119 | } 120 | 121 | tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte{']'}}) 122 | 123 | return tokens 124 | } 125 | -------------------------------------------------------------------------------- /tfupdate/hclwrite_test.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/hashicorp/hcl/v2" 11 | "github.com/hashicorp/hcl/v2/hclsyntax" 12 | "github.com/hashicorp/hcl/v2/hclwrite" 13 | ) 14 | 15 | func TestAllMatchingBlocks(t *testing.T) { 16 | src := `a = "b" 17 | service { 18 | attr0 = "val0" 19 | } 20 | service { 21 | attr1 = "val1" 22 | } 23 | service "label1" "label2" { 24 | attr2 = "val2" 25 | } 26 | service "label1" "label2" { 27 | attr3 = "val3" 28 | } 29 | ` 30 | 31 | tests := []struct { 32 | src string 33 | typeName string 34 | labels []string 35 | want string 36 | }{ 37 | { 38 | src, 39 | "service", 40 | []string{}, 41 | `service { 42 | attr0 = "val0" 43 | } 44 | service { 45 | attr1 = "val1" 46 | } 47 | `, 48 | }, 49 | { 50 | src, 51 | "service", 52 | []string{"label1", "label2"}, 53 | `service "label1" "label2" { 54 | attr2 = "val2" 55 | } 56 | service "label1" "label2" { 57 | attr3 = "val3" 58 | } 59 | `, 60 | }, 61 | { 62 | src, 63 | "hoge", 64 | []string{}, 65 | "", 66 | }, 67 | } 68 | 69 | for _, test := range tests { 70 | t.Run(fmt.Sprintf("%s %s", test.typeName, strings.Join(test.labels, " ")), func(t *testing.T) { 71 | f, diags := hclwrite.ParseConfig([]byte(test.src), "", hcl.Pos{Line: 1, Column: 1}) 72 | if len(diags) != 0 { 73 | for _, diag := range diags { 74 | t.Logf("- %s", diag.Error()) 75 | } 76 | t.Fatalf("unexpected diagnostics") 77 | } 78 | 79 | blocks := allMatchingBlocks(f.Body(), test.typeName, test.labels) 80 | if len(blocks) == 0 { 81 | if test.want != "" { 82 | t.Fatal("block not found, but want it to exist") 83 | } 84 | } else { 85 | if test.want == "" { 86 | t.Fatal("block found, but expecting not found") 87 | } 88 | 89 | got := "" 90 | for _, block := range blocks { 91 | got += string(block.BuildTokens(nil).Bytes()) 92 | } 93 | if got != test.want { 94 | t.Errorf("wrong result\ngot: %s\nwant: %s", got, test.want) 95 | } 96 | } 97 | }) 98 | } 99 | } 100 | 101 | func TestAllMatchingBlocksByType(t *testing.T) { 102 | src := `a = "b" 103 | service { 104 | attr0 = "val0" 105 | } 106 | resource { 107 | attr1 = "val1" 108 | } 109 | service "label1" { 110 | attr2 = "val2" 111 | } 112 | ` 113 | 114 | tests := []struct { 115 | src string 116 | typeName string 117 | want string 118 | }{ 119 | { 120 | src, 121 | "service", 122 | `service { 123 | attr0 = "val0" 124 | } 125 | service "label1" { 126 | attr2 = "val2" 127 | } 128 | `, 129 | }, 130 | } 131 | 132 | for _, test := range tests { 133 | t.Run(test.typeName, func(t *testing.T) { 134 | f, diags := hclwrite.ParseConfig([]byte(test.src), "", hcl.Pos{Line: 1, Column: 1}) 135 | if len(diags) != 0 { 136 | for _, diag := range diags { 137 | t.Logf("- %s", diag.Error()) 138 | } 139 | t.Fatalf("unexpected diagnostics") 140 | } 141 | 142 | blocks := allMatchingBlocksByType(f.Body(), test.typeName) 143 | if len(blocks) == 0 { 144 | if test.want != "" { 145 | t.Fatal("block not found, but want it to exist") 146 | } 147 | } else { 148 | if test.want == "" { 149 | t.Fatal("block found, but expecting not found") 150 | } 151 | 152 | got := "" 153 | for _, block := range blocks { 154 | got += string(block.BuildTokens(nil).Bytes()) 155 | } 156 | if got != test.want { 157 | t.Errorf("wrong result\ngot: %s\nwant: %s", got, test.want) 158 | } 159 | } 160 | }) 161 | } 162 | } 163 | 164 | func TestGetHCLNativeAttributeValue(t *testing.T) { 165 | cases := []struct { 166 | desc string 167 | src string 168 | name string 169 | wantExprType hcl.Expression 170 | ok bool 171 | }{ 172 | { 173 | desc: "string literal", 174 | src: ` 175 | foo = "123" 176 | `, 177 | name: "foo", 178 | wantExprType: &hclsyntax.TemplateExpr{}, 179 | ok: true, 180 | }, 181 | { 182 | desc: "object literal", 183 | src: ` 184 | foo = { 185 | bar = "123" 186 | baz = "BAZ" 187 | } 188 | `, 189 | name: "foo", 190 | wantExprType: &hclsyntax.ObjectConsExpr{}, 191 | ok: true, 192 | }, 193 | { 194 | desc: "object with references", 195 | src: ` 196 | foo = { 197 | bar = "123" 198 | baz = "BAZ" 199 | 200 | items = [ 201 | var.aaa, 202 | var.bbb, 203 | ] 204 | } 205 | `, 206 | name: "foo", 207 | wantExprType: &hclsyntax.ObjectConsExpr{}, 208 | ok: true, 209 | }, 210 | { 211 | desc: "not found", 212 | src: ` 213 | foo = "123" 214 | `, 215 | name: "bar", 216 | wantExprType: nil, 217 | ok: true, 218 | }, 219 | } 220 | 221 | for _, tc := range cases { 222 | t.Run(tc.desc, func(t *testing.T) { 223 | f, diags := hclwrite.ParseConfig([]byte(tc.src), "", hcl.Pos{Line: 1, Column: 1}) 224 | if len(diags) != 0 { 225 | for _, diag := range diags { 226 | t.Logf("- %s", diag.Error()) 227 | } 228 | t.Fatalf("unexpected diagnostics") 229 | } 230 | 231 | got, err := getHCLNativeAttribute(f.Body(), tc.name) 232 | if tc.ok && err != nil { 233 | t.Errorf("unexpected err: %#v", err) 234 | } 235 | 236 | if !tc.ok && err == nil { 237 | t.Errorf("expects to return an error, but no error. got = %#v", got) 238 | } 239 | 240 | if tc.ok && got != nil { 241 | // An expression is a complicated object and hard to build from literal. 242 | // So we simply compare it by type. 243 | if reflect.TypeOf(got.Expr) != reflect.TypeOf(tc.wantExprType) { 244 | t.Errorf("got = %#v, but want = %#v", got.Expr, tc.wantExprType) 245 | } 246 | } 247 | }) 248 | } 249 | } 250 | 251 | func TestGetAttributeValueAsUnquotedString(t *testing.T) { 252 | cases := []struct { 253 | desc string 254 | src string 255 | want string 256 | }{ 257 | { 258 | desc: "simple", 259 | src: ` 260 | foo = "123" 261 | `, 262 | want: "123", 263 | }, 264 | } 265 | 266 | for _, tc := range cases { 267 | t.Run(tc.desc, func(t *testing.T) { 268 | f, diags := hclwrite.ParseConfig([]byte(tc.src), "", hcl.Pos{Line: 1, Column: 1}) 269 | if len(diags) != 0 { 270 | for _, diag := range diags { 271 | t.Logf("- %s", diag.Error()) 272 | } 273 | t.Fatalf("unexpected diagnostics") 274 | } 275 | 276 | attr := f.Body().GetAttribute("foo") 277 | got := getAttributeValueAsUnquotedString(attr) 278 | 279 | if got != tc.want { 280 | t.Errorf("got = %s, but want = %s", got, tc.want) 281 | } 282 | }) 283 | } 284 | } 285 | 286 | func TestTokensForListPerLine(t *testing.T) { 287 | cases := []struct { 288 | desc string 289 | list []string 290 | want string 291 | }{ 292 | { 293 | desc: "simple", 294 | list: []string{"aaa", "bbb"}, 295 | want: `foo = [ 296 | "aaa", 297 | "bbb", 298 | ] 299 | `, 300 | }, 301 | } 302 | 303 | for _, tc := range cases { 304 | t.Run(tc.desc, func(t *testing.T) { 305 | f := hclwrite.NewEmptyFile() 306 | f.Body().SetAttributeRaw("foo", tokensForListPerLine(tc.list)) 307 | 308 | got := string(hclwrite.Format(f.BuildTokens(nil).Bytes())) 309 | 310 | if diff := cmp.Diff(got, tc.want); diff != "" { 311 | t.Errorf("got: %s, want = %s, diff = %s", got, tc.want, diff) 312 | } 313 | }) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /tfupdate/lock.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "path/filepath" 9 | 10 | "github.com/hashicorp/hcl/v2/hclwrite" 11 | tfaddr "github.com/hashicorp/terraform-registry-address" 12 | svchost "github.com/hashicorp/terraform-svchost" 13 | "github.com/minamijoyo/tfupdate/lock" 14 | "github.com/minamijoyo/tfupdate/tfregistry" 15 | "github.com/zclconf/go-cty/cty" 16 | ) 17 | 18 | // LockUpdater is a updater implementation which updates the dependency lock file. 19 | type LockUpdater struct { 20 | platforms []string 21 | 22 | // index is a cached index for updating dependency lock files. 23 | index lock.Index 24 | 25 | // tfregistryConfig is a configuration for Terraform Registry API. 26 | tfregistryConfig tfregistry.Config 27 | } 28 | 29 | // NewLockUpdater is a factory method which returns an LockUpdater instance. 30 | func NewLockUpdater(platforms []string, tfregistryConfig tfregistry.Config) (Updater, error) { 31 | // Create a new index with the provided registry config 32 | index, err := lock.NewIndexFromConfig(tfregistryConfig) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return &LockUpdater{ 38 | platforms: platforms, 39 | index: index, 40 | tfregistryConfig: tfregistryConfig, 41 | }, nil 42 | } 43 | 44 | // Update updates the dependency lock file. 45 | // Note that this method will rewrite the AST passed as an argument. 46 | func (u *LockUpdater) Update(ctx context.Context, mc *ModuleContext, filename string, f *hclwrite.File) error { 47 | if filepath.Base(filename) != ".terraform.lock.hcl" { 48 | // Skip other than the lock file. 49 | return nil 50 | } 51 | 52 | return u.updateLockfile(ctx, mc, f) 53 | } 54 | 55 | // updateLockfile updates the dependency lock file. 56 | func (u *LockUpdater) updateLockfile(ctx context.Context, mc *ModuleContext, f *hclwrite.File) error { 57 | for _, p := range mc.SelecetedProviders() { 58 | pAddr, err := u.fullyQualifiedProviderAddress(p.Source) 59 | if err != nil { 60 | // Unsupported formats, such as legacy abbreviated notation, will result 61 | // in parse errors, but should be ignored without returning an error if 62 | // possible. 63 | log.Printf("[DEBUG] LockUpdater.updateLockfile: ignore legacy provider address notation: %s", p.Source) 64 | continue 65 | } 66 | 67 | pBlock := f.Body().FirstMatchingBlock("provider", []string{pAddr}) 68 | if pBlock != nil { 69 | // update the existing provider block 70 | err := u.updateProviderBlock(ctx, pBlock, p) 71 | if err != nil { 72 | return err 73 | } 74 | } else { 75 | // create a new provider block 76 | f.Body().AppendNewline() 77 | pBlock = f.Body().AppendNewBlock("provider", []string{pAddr}) 78 | 79 | err := u.updateProviderBlock(ctx, pBlock, p) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // updateProviderBlock updates the provider block in the dependency lock file. 90 | func (u *LockUpdater) updateProviderBlock(ctx context.Context, pBlock *hclwrite.Block, p SelectedProvider) error { 91 | vAttr := pBlock.Body().GetAttribute("version") 92 | if vAttr != nil { 93 | // a version attribute found 94 | vVal := getAttributeValueAsUnquotedString(vAttr) 95 | log.Printf("[DEBUG] check provider version in lock file: address = %s, lock = %s, config = %s", p.Source, vVal, p.Version) 96 | if vVal == p.Version { 97 | // Avoid unnecessary recalculations if no version change 98 | return nil 99 | } 100 | } 101 | 102 | pBlock.Body().SetAttributeValue("version", cty.StringVal(p.Version)) 103 | 104 | //Strictly speaking, constraints can contain multiple constraint expressions, 105 | //including comparison operators, but in the tfupdate use case, we assume 106 | //that the required_providers are pinned to a specific version to detect the 107 | //required version without terraform init, so we can simply specify the 108 | //constraints attribute as the same as the version. This may differ from what 109 | //terraform generates, but we expect that it doesn't matter in practice. 110 | pBlock.Body().SetAttributeValue("constraints", cty.StringVal(p.Version)) 111 | 112 | // Calculate the hash value of the provider. 113 | // Note that the provider will be downloaded if cache miss. 114 | pv, err := u.index.GetOrCreateProviderVersion(ctx, p.Source, p.Version, u.platforms) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | hashes := pv.AllHashes() 120 | pBlock.Body().SetAttributeRaw("hashes", tokensForListPerLine(hashes)) 121 | 122 | return nil 123 | } 124 | 125 | // fullyQualifiedProviderAddress converts the short form of the provider 126 | // address into the fully qualified form. 127 | // Example: hashicorp/null => registry.terraform.io/hashicorp/null 128 | // If BaseURL is set (e.g., https://registry.opentofu.org/), it will use its hostname 129 | // instead of the default one (e.g., hashicorp/null => registry.opentofu.org/hashicorp/null). 130 | func (u *LockUpdater) fullyQualifiedProviderAddress(address string) (string, error) { 131 | pAddr, err := tfaddr.ParseProviderSource(address) 132 | if err != nil { 133 | return "", fmt.Errorf("failed to parse provider address: %s", address) 134 | } 135 | 136 | // Since .terraform.lock.hcl was introduced from v0.14, we assume that 137 | // provider address is qualified with namespaces at least. We won't support 138 | // implicit legacy things. 139 | if !pAddr.HasKnownNamespace() { 140 | return "", fmt.Errorf("failed to parse unknown provider address: %s", address) 141 | } 142 | if pAddr.IsLegacy() { 143 | return "", fmt.Errorf("failed to parse legacy provider address: %s", address) 144 | } 145 | 146 | // If BaseURL is set, use its hostname 147 | if u.tfregistryConfig.BaseURL != "" { 148 | baseURL, err := url.Parse(u.tfregistryConfig.BaseURL) 149 | if err == nil && baseURL.Hostname() != "" { 150 | // Use the hostname from BaseURL with type casting to svchost.Hostname 151 | pAddr.Hostname = svchost.Hostname(baseURL.Hostname()) 152 | } 153 | } 154 | 155 | return pAddr.String(), nil 156 | } 157 | -------------------------------------------------------------------------------- /tfupdate/module.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "regexp" 7 | 8 | "github.com/hashicorp/hcl/v2/hclsyntax" 9 | "github.com/hashicorp/hcl/v2/hclwrite" 10 | "github.com/pkg/errors" 11 | "github.com/zclconf/go-cty/cty" 12 | ) 13 | 14 | // moduleSourceRegexp is a regular expression for module source. 15 | // This is not a complete module source definition, but it is sufficient to 16 | // parse version. Note that a git reference can be branch name, so we need to 17 | // check if it seems to be a version number. 18 | // https://www.terraform.io/docs/modules/sources.html 19 | var moduleSourceRegexp = regexp.MustCompile(`(.+)\?ref=v([0-9]+(\.[0-9]+)*(-.*)*)`) 20 | 21 | // ModuleUpdater is a updater implementation which updates the module version constraint. 22 | type ModuleUpdater struct { 23 | name string 24 | nameRegex *regexp.Regexp 25 | version string 26 | } 27 | 28 | // NewModuleUpdater is a factory method which returns an ModuleUpdater instance. 29 | func NewModuleUpdater(name string, version string, nameRegex *regexp.Regexp) (Updater, error) { 30 | if len(name) == 0 { 31 | return nil, errors.Errorf("failed to new module updater. name is required") 32 | } 33 | 34 | if len(version) == 0 { 35 | return nil, errors.Errorf("failed to new module updater. version is required") 36 | } 37 | 38 | return &ModuleUpdater{ 39 | name: name, 40 | nameRegex: nameRegex, 41 | version: version, 42 | }, nil 43 | } 44 | 45 | // Update updates the module version constraint. 46 | // Note that this method will rewrite the AST passed as an argument. 47 | func (u *ModuleUpdater) Update(_ context.Context, _ *ModuleContext, filename string, f *hclwrite.File) error { 48 | if filepath.Base(filename) == ".terraform.lock.hcl" { 49 | // skip a lock file. 50 | return nil 51 | } 52 | 53 | return u.updateModuleBlock(f) 54 | } 55 | 56 | func (u *ModuleUpdater) match(name string) bool { 57 | if u.nameRegex == nil { 58 | return u.name == name 59 | } 60 | return u.nameRegex.MatchString(name) 61 | } 62 | 63 | func (u *ModuleUpdater) updateModuleBlock(f *hclwrite.File) error { 64 | for _, m := range allMatchingBlocksByType(f.Body(), "module") { 65 | if s := m.Body().GetAttribute("source"); s != nil { 66 | name, version := parseModuleSource(s) 67 | // If this module is a target module 68 | if u.match(name) { 69 | if len(version) == 0 { 70 | // The source attribute doesn't have a version number. 71 | // Set a version to attribute value only if the version key exists. 72 | if m.Body().GetAttribute("version") != nil { 73 | m.Body().SetAttributeValue("version", cty.StringVal(u.version)) 74 | } 75 | continue 76 | } 77 | // The source attribute has a version number. 78 | // Update a version reference in the source value. 79 | newSourceValue := name + `?ref=v` + u.version 80 | m.Body().SetAttributeValue("source", cty.StringVal(newSourceValue)) 81 | } 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // parseModuleSource parses module source and returns module name and version. 89 | func parseModuleSource(a *hclwrite.Attribute) (string, string) { 90 | tokens := a.Expr().BuildTokens(nil) 91 | if len(tokens) == 3 && 92 | tokens[0].Type == hclsyntax.TokenOQuote && 93 | tokens[1].Type == hclsyntax.TokenQuotedLit && 94 | tokens[2].Type == hclsyntax.TokenCQuote { 95 | source := string(tokens[1].Bytes) 96 | matched := moduleSourceRegexp.FindStringSubmatch(source) 97 | if len(matched) == 0 { 98 | // no version number 99 | return source, "" 100 | } 101 | name := matched[1] 102 | version := matched[2] 103 | return name, version 104 | } 105 | return "", "" 106 | } 107 | -------------------------------------------------------------------------------- /tfupdate/module_test.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "regexp" 7 | "testing" 8 | 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclwrite" 11 | ) 12 | 13 | func TestNewModuleUpdater(t *testing.T) { 14 | cases := []struct { 15 | name string 16 | sourceMatchType string 17 | version string 18 | want Updater 19 | ok bool 20 | }{ 21 | { 22 | name: "terraform-aws-modules/vpc/aws", 23 | sourceMatchType: "full", 24 | version: "2.17.0", 25 | want: &ModuleUpdater{ 26 | name: "terraform-aws-modules/vpc/aws", 27 | nameRegex: nil, 28 | version: "2.17.0", 29 | }, 30 | ok: true, 31 | }, 32 | { 33 | name: "", 34 | sourceMatchType: "full", 35 | version: "2.17.0", 36 | want: nil, 37 | ok: false, 38 | }, 39 | { 40 | name: "terraform-aws-modules/vpc/aws", 41 | sourceMatchType: "full", 42 | version: "", 43 | want: nil, 44 | ok: false, 45 | }, 46 | } 47 | 48 | for _, tc := range cases { 49 | got, err := NewModuleUpdater(tc.name, tc.version, nil) 50 | if tc.ok && err != nil { 51 | t.Errorf("NewModuleUpdater() with name = %s, version = %s returns unexpected err: %+v", tc.name, tc.version, err) 52 | } 53 | 54 | if !tc.ok && err == nil { 55 | t.Errorf("NewModuleUpdater() with name = %s, version = %s expects to return an error, but no error", tc.name, tc.version) 56 | } 57 | 58 | if !reflect.DeepEqual(got, tc.want) { 59 | t.Errorf("NewModuleUpdater() with name = %s, version = %s returns %#v, but want = %#v", tc.name, tc.version, got, tc.want) 60 | } 61 | } 62 | } 63 | 64 | func TestUpdateModule(t *testing.T) { 65 | cases := []struct { 66 | filename string 67 | src string 68 | name string 69 | sourceMatchType string 70 | version string 71 | want string 72 | ok bool 73 | }{ 74 | { 75 | filename: "main.tf", 76 | src: ` 77 | module "vpc" { 78 | source = "terraform-aws-modules/vpc/aws" 79 | version = "2.17.0" 80 | } 81 | `, 82 | name: "terraform-aws-modules/vpc/aws", 83 | version: "2.18.0", 84 | sourceMatchType: "full", 85 | want: ` 86 | module "vpc" { 87 | source = "terraform-aws-modules/vpc/aws" 88 | version = "2.18.0" 89 | } 90 | `, 91 | ok: true, 92 | }, 93 | { 94 | filename: "main.tf", 95 | src: ` 96 | module "vpc1" { 97 | source = "terraform-aws-modules/vpc/aws" 98 | version = "2.17.0" 99 | } 100 | module "vpc2" { 101 | source = "terraform-aws-modules/vpc/aws" 102 | version = "2.17.0" 103 | } 104 | `, 105 | name: "terraform-aws-modules/vpc/aws", 106 | version: "2.18.0", 107 | sourceMatchType: "full", 108 | want: ` 109 | module "vpc1" { 110 | source = "terraform-aws-modules/vpc/aws" 111 | version = "2.18.0" 112 | } 113 | module "vpc2" { 114 | source = "terraform-aws-modules/vpc/aws" 115 | version = "2.18.0" 116 | } 117 | `, 118 | ok: true, 119 | }, 120 | { 121 | filename: "main.tf", 122 | src: ` 123 | module "vpc" { 124 | source = "terraform-aws-modules/vpc/aws" 125 | version = "2.17.0" 126 | } 127 | `, 128 | name: "terraform-aws-modules/hoge/aws", 129 | sourceMatchType: "full", 130 | version: "2.18.0", 131 | want: ` 132 | module "vpc" { 133 | source = "terraform-aws-modules/vpc/aws" 134 | version = "2.17.0" 135 | } 136 | `, 137 | ok: true, 138 | }, 139 | { 140 | filename: "main.tf", 141 | src: ` 142 | module "vpc" { 143 | source = "terraform-aws-modules/vpc/aws" 144 | } 145 | `, 146 | name: "terraform-aws-modules/vpc/aws", 147 | sourceMatchType: "full", 148 | version: "2.18.0", 149 | want: ` 150 | module "vpc" { 151 | source = "terraform-aws-modules/vpc/aws" 152 | } 153 | `, 154 | ok: true, 155 | }, 156 | { 157 | filename: "main.tf", 158 | src: ` 159 | module "vpc" { 160 | source = "git::https://example.com/vpc.git?ref=v1.2.0" 161 | } 162 | `, 163 | name: "git::https://example.com/vpc.git", 164 | sourceMatchType: "full", 165 | version: "1.3.0", 166 | want: ` 167 | module "vpc" { 168 | source = "git::https://example.com/vpc.git?ref=v1.3.0" 169 | } 170 | `, 171 | ok: true, 172 | }, 173 | { 174 | filename: "main.tf", 175 | src: ` 176 | module "vpc1" { 177 | source = "terraform-aws-modules.git/vpc/aws1" 178 | version = "2.17.0" 179 | } 180 | module "vpc2" { 181 | source = "terraform-aws-modules.git/vpc/aws2" 182 | version = "2.17.0" 183 | } 184 | `, 185 | name: "terraform-aws-modules.git/", 186 | version: "2.18.0", 187 | sourceMatchType: "regex", 188 | want: ` 189 | module "vpc1" { 190 | source = "terraform-aws-modules.git/vpc/aws1" 191 | version = "2.18.0" 192 | } 193 | module "vpc2" { 194 | source = "terraform-aws-modules.git/vpc/aws2" 195 | version = "2.18.0" 196 | } 197 | `, 198 | ok: true, 199 | }, 200 | { 201 | filename: "main.tf", 202 | src: ` 203 | module "vpc1" { 204 | source = "terraform-aws-modules.git/vpc/aws1" 205 | version = "2.17.0" 206 | } 207 | module "vpc2" { 208 | source = "terraform-aws-modules.git/vpc/aws2" 209 | version = "2.17.0" 210 | } 211 | `, 212 | name: "terraform-aws-modules\\.git/.+", 213 | version: "2.18.0", 214 | sourceMatchType: "regex", 215 | want: ` 216 | module "vpc1" { 217 | source = "terraform-aws-modules.git/vpc/aws1" 218 | version = "2.18.0" 219 | } 220 | module "vpc2" { 221 | source = "terraform-aws-modules.git/vpc/aws2" 222 | version = "2.18.0" 223 | } 224 | `, 225 | ok: true, 226 | }, 227 | } 228 | 229 | for _, tc := range cases { 230 | u := &ModuleUpdater{ 231 | name: tc.name, 232 | nameRegex: func() *regexp.Regexp { 233 | if tc.sourceMatchType == "regex" { 234 | return regexp.MustCompile(tc.name) 235 | } 236 | return nil 237 | }(), 238 | version: tc.version, 239 | } 240 | f, diags := hclwrite.ParseConfig([]byte(tc.src), tc.filename, hcl.Pos{Line: 1, Column: 1}) 241 | if diags.HasErrors() { 242 | t.Fatalf("unexpected diagnostics: %s", diags) 243 | } 244 | 245 | err := u.Update(context.Background(), nil, tc.filename, f) 246 | if tc.ok && err != nil { 247 | t.Errorf("Update() with src = %s, name = %s, version = %s returns unexpected err: %+v", tc.src, tc.name, tc.version, err) 248 | } 249 | if !tc.ok && err == nil { 250 | t.Errorf("Update() with src = %s, name = %s, version = %s expects to return an error, but no error", tc.src, tc.name, tc.version) 251 | } 252 | 253 | got := string(hclwrite.Format(f.BuildTokens(nil).Bytes())) 254 | if got != tc.want { 255 | t.Errorf("Update() with src = %s, name = %s, version = %s returns %s, but want = %s", tc.src, tc.name, tc.version, got, tc.want) 256 | } 257 | } 258 | } 259 | 260 | func TestParseModuleSource(t *testing.T) { 261 | cases := []struct { 262 | src string 263 | name string 264 | version string 265 | }{ 266 | { 267 | src: ` 268 | module "vpc" { 269 | source = "git::https://example.com/vpc.git" 270 | } 271 | `, 272 | name: "git::https://example.com/vpc.git", 273 | version: "", 274 | }, 275 | { 276 | src: ` 277 | module "vpc" { 278 | source = "git::https://example.com/vpc.git?ref=v1" 279 | } 280 | `, 281 | name: "git::https://example.com/vpc.git", 282 | version: "1", 283 | }, 284 | { 285 | src: ` 286 | module "vpc" { 287 | source = "git::https://example.com/vpc.git?ref=v1.2" 288 | } 289 | `, 290 | name: "git::https://example.com/vpc.git", 291 | version: "1.2", 292 | }, 293 | { 294 | src: ` 295 | module "vpc" { 296 | source = "git::https://example.com/vpc.git?ref=v1.2.0" 297 | } 298 | `, 299 | name: "git::https://example.com/vpc.git", 300 | version: "1.2.0", 301 | }, 302 | { 303 | src: ` 304 | module "vpc" { 305 | source = "git::https://example.com/vpc.git?ref=v1.2.0-rc1" 306 | } 307 | `, 308 | name: "git::https://example.com/vpc.git", 309 | version: "1.2.0-rc1", 310 | }, 311 | { 312 | src: ` 313 | module "vpc" { 314 | source = "git::https://example.com/vpc.git?ref=vhoge" 315 | } 316 | `, 317 | name: "git::https://example.com/vpc.git?ref=vhoge", 318 | version: "", 319 | }, 320 | } 321 | 322 | for _, tc := range cases { 323 | f, diags := hclwrite.ParseConfig([]byte(tc.src), "", hcl.Pos{Line: 1, Column: 1}) 324 | if diags.HasErrors() { 325 | t.Fatalf("unexpected diagnostics: %s", diags) 326 | } 327 | 328 | m := allMatchingBlocksByType(f.Body(), "module") 329 | if len(m) != 1 { 330 | t.Fatalf("failed to get module block: %s", tc.src) 331 | } 332 | s := m[0].Body().GetAttribute("source") 333 | if s == nil { 334 | t.Fatalf("failed to get module source attribute: %s", tc.src) 335 | } 336 | name, version := parseModuleSource(s) 337 | 338 | if !(name == tc.name && version == tc.version) { 339 | t.Errorf("parseModuleSource() with src = %s returns (%s, %s), but want = (%s, %s)", tc.src, name, version, tc.name, tc.version) 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /tfupdate/opentofu.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | 7 | "github.com/hashicorp/hcl/v2/hclwrite" 8 | "github.com/pkg/errors" 9 | "github.com/zclconf/go-cty/cty" 10 | ) 11 | 12 | // OpenTofuUpdater is a updater implementation which updates the OpenTofu version constraint. 13 | type OpenTofuUpdater struct { 14 | version string 15 | } 16 | 17 | // NewOpenTofuUpdater is a factory method which returns an OpenTofuUpdater instance. 18 | func NewOpenTofuUpdater(version string) (Updater, error) { 19 | if len(version) == 0 { 20 | return nil, errors.Errorf("failed to new opentofu updater. version is required") 21 | } 22 | 23 | return &OpenTofuUpdater{ 24 | version: version, 25 | }, nil 26 | } 27 | 28 | // Update updates the OpenTofu version constraint. 29 | // Note that this method will rewrite the AST passed as an argument. 30 | func (u *OpenTofuUpdater) Update(_ context.Context, _ *ModuleContext, filename string, f *hclwrite.File) error { 31 | if filepath.Base(filename) == ".terraform.lock.hcl" { 32 | // skip a lock file. 33 | return nil 34 | } 35 | 36 | for _, tf := range allMatchingBlocks(f.Body(), "terraform", []string{}) { 37 | // set a version to attribute value only if the key exists 38 | if tf.Body().GetAttribute("required_version") != nil { 39 | tf.Body().SetAttributeValue("required_version", cty.StringVal(u.version)) 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /tfupdate/opentofu_test.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclwrite" 10 | ) 11 | 12 | func TestNewOpenTofuUpdater(t *testing.T) { 13 | cases := []struct { 14 | version string 15 | want Updater 16 | ok bool 17 | }{ 18 | { 19 | version: "1.9.0", 20 | want: &OpenTofuUpdater{ 21 | version: "1.9.0", 22 | }, 23 | ok: true, 24 | }, 25 | { 26 | version: "", 27 | want: nil, 28 | ok: false, 29 | }, 30 | } 31 | 32 | for _, tc := range cases { 33 | got, err := NewOpenTofuUpdater(tc.version) 34 | if tc.ok && err != nil { 35 | t.Errorf("NewOpenTofuUpdater() with version = %s returns unexpected err: %+v", tc.version, err) 36 | } 37 | 38 | if !tc.ok && err == nil { 39 | t.Errorf("NewOpenTofuUpdater() with version = %s expects to return an error, but no error", tc.version) 40 | } 41 | 42 | if !reflect.DeepEqual(got, tc.want) { 43 | t.Errorf("NewOpenTofuUpdater() with version = %s returns %#v, but want = %#v", tc.version, got, tc.want) 44 | } 45 | } 46 | } 47 | 48 | func TestUpdateOpenTofu(t *testing.T) { 49 | cases := []struct { 50 | filename string 51 | src string 52 | version string 53 | want string 54 | ok bool 55 | }{ 56 | { 57 | filename: "main.tf", 58 | src: ` 59 | terraform { 60 | required_version = "1.8.0" 61 | } 62 | `, 63 | version: "1.9.0", 64 | want: ` 65 | terraform { 66 | required_version = "1.9.0" 67 | } 68 | `, 69 | ok: true, 70 | }, 71 | { 72 | filename: "main.tf", 73 | src: ` 74 | terraform { 75 | required_providers { 76 | null = "2.1.1" 77 | } 78 | } 79 | `, 80 | version: "1.9.0", 81 | want: ` 82 | terraform { 83 | required_providers { 84 | null = "2.1.1" 85 | } 86 | } 87 | `, 88 | ok: true, 89 | }, 90 | { 91 | filename: "main.tf", 92 | src: ` 93 | provider "aws" { 94 | version = "2.11.0" 95 | region = "ap-northeast-1" 96 | } 97 | `, 98 | version: "1.9.0", 99 | want: ` 100 | provider "aws" { 101 | version = "2.11.0" 102 | region = "ap-northeast-1" 103 | } 104 | `, 105 | ok: true, 106 | }, 107 | { 108 | filename: "main.tf", 109 | src: `terraform { 110 | backend "s3" { 111 | region = "ap-northeast-1" 112 | bucket = "hoge" 113 | key = "terraform.tfstate" 114 | } 115 | } 116 | terraform { 117 | required_version = "1.8.0" 118 | } 119 | `, 120 | version: "1.9.0", 121 | want: `terraform { 122 | backend "s3" { 123 | region = "ap-northeast-1" 124 | bucket = "hoge" 125 | key = "terraform.tfstate" 126 | } 127 | } 128 | terraform { 129 | required_version = "1.9.0" 130 | } 131 | `, 132 | ok: true, 133 | }, 134 | } 135 | 136 | for _, tc := range cases { 137 | u := &OpenTofuUpdater{ 138 | version: tc.version, 139 | } 140 | f, diags := hclwrite.ParseConfig([]byte(tc.src), tc.filename, hcl.Pos{Line: 1, Column: 1}) 141 | if diags.HasErrors() { 142 | t.Fatalf("unexpected diagnostics: %s", diags) 143 | } 144 | 145 | err := u.Update(context.Background(), nil, tc.filename, f) 146 | if tc.ok && err != nil { 147 | t.Errorf("Update() with src = %s, version = %s returns unexpected err: %+v", tc.src, tc.version, err) 148 | } 149 | if !tc.ok && err == nil { 150 | t.Errorf("Update() with src = %s, version = %s expects to return an error, but no error", tc.src, tc.version) 151 | } 152 | 153 | got := string(hclwrite.Format(f.BuildTokens(nil).Bytes())) 154 | if got != tc.want { 155 | t.Errorf("Update() with src = %s, version = %s returns %s, but want = %s", tc.src, tc.version, got, tc.want) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /tfupdate/option.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/minamijoyo/tfupdate/tfregistry" 9 | "golang.org/x/exp/slices" 10 | ) 11 | 12 | // Option is a set of parameters to update. 13 | type Option struct { 14 | // A type of updater. Valid values are as follows: 15 | // - terraform 16 | // - provider 17 | // - module 18 | // - lock 19 | updateType string 20 | 21 | // If an updateType is terraform, there is no meaning. 22 | // If an updateType is provider or module, Set a name of provider or module. 23 | name string 24 | 25 | // a new version constraint 26 | version string 27 | 28 | // platforms is a list of target platforms to generate hash values. 29 | // Target platform names consist of an operating system and a CPU 30 | // architecture such as darwin_arm64. 31 | platforms []string 32 | 33 | // If a recursive flag is true, it checks and updates directories recursively. 34 | recursive bool 35 | 36 | // An array of regular expression for paths to ignore. 37 | ignorePaths []*regexp.Regexp 38 | 39 | // This field stores the compiled RE2 regex from the provide name parameter. 40 | // In case the sourceMatchType is set to regex this field is used to match the name. 41 | // In case the provided sourceMatchType is full this field is nil. 42 | nameRegex *regexp.Regexp 43 | 44 | // tfregistryConfig is a configuration for Terraform Registry API. 45 | tfregistryConfig tfregistry.Config 46 | } 47 | 48 | // NewOption returns an option. 49 | func NewOption(updateType string, name string, version string, platforms []string, recursive bool, ignorePaths []string, sourceMatchType string, tfregistryConfig tfregistry.Config) (Option, error) { 50 | regexps := make([]*regexp.Regexp, 0, len(ignorePaths)) 51 | for _, ignorePath := range ignorePaths { 52 | if len(ignorePath) == 0 { 53 | continue 54 | } 55 | 56 | r, err := regexp.Compile(ignorePath) 57 | if err != nil { 58 | return Option{}, fmt.Errorf("failed to compile regexp for ignorePath: %s", err) 59 | } 60 | regexps = append(regexps, r) 61 | } 62 | 63 | nameRegex, err := nameRegex(updateType, name, sourceMatchType) 64 | if err != nil { 65 | return Option{}, err 66 | } 67 | 68 | return Option{ 69 | updateType: updateType, 70 | name: name, 71 | version: version, 72 | platforms: platforms, 73 | recursive: recursive, 74 | ignorePaths: regexps, 75 | nameRegex: nameRegex, 76 | tfregistryConfig: tfregistryConfig, 77 | }, nil 78 | } 79 | 80 | func nameRegex(updateType string, name string, sourceMatchType string) (*regexp.Regexp, error) { 81 | if updateType == "module" { 82 | validSourceMatchTypes := []string{"full", "regex"} 83 | 84 | if !slices.Contains[string](validSourceMatchTypes, sourceMatchType) { 85 | return nil, fmt.Errorf("invalid sourceMatchType: %s valid options [%s]", sourceMatchType, strings.Join(validSourceMatchTypes, ",")) 86 | } else if sourceMatchType == "regex" { 87 | if len(name) == 0 { 88 | return nil, fmt.Errorf("name is required when sourceMatchType is regex") 89 | } 90 | 91 | r, err := regexp.Compile(name) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to compile regexp for name: %s with error: %s", name, err) 94 | } 95 | return r, nil 96 | } 97 | } 98 | return nil, nil 99 | } 100 | 101 | // MatchIgnorePaths returns whether any of the ignore conditions are met. 102 | func (o *Option) MatchIgnorePaths(path string) bool { 103 | for _, r := range o.ignorePaths { 104 | if r.MatchString(path) { 105 | return true 106 | } 107 | } 108 | 109 | return false 110 | } 111 | -------------------------------------------------------------------------------- /tfupdate/provider.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclsyntax" 11 | "github.com/hashicorp/hcl/v2/hclwrite" 12 | "github.com/pkg/errors" 13 | "github.com/zclconf/go-cty/cty" 14 | ) 15 | 16 | // ProviderUpdater is a updater implementation which updates the provider version constraint. 17 | type ProviderUpdater struct { 18 | name string 19 | version string 20 | } 21 | 22 | // NewProviderUpdater is a factory method which returns an ProviderUpdater instance. 23 | func NewProviderUpdater(name string, version string) (Updater, error) { 24 | if len(name) == 0 { 25 | return nil, errors.Errorf("failed to new provider updater. name is required") 26 | } 27 | 28 | if len(version) == 0 { 29 | return nil, errors.Errorf("failed to new provider updater. version is required") 30 | } 31 | 32 | return &ProviderUpdater{ 33 | name: name, 34 | version: version, 35 | }, nil 36 | } 37 | 38 | // Update updates the provider version constraint. 39 | // Note that this method will rewrite the AST passed as an argument. 40 | func (u *ProviderUpdater) Update(_ context.Context, mc *ModuleContext, filename string, f *hclwrite.File) error { 41 | if filepath.Base(filename) == ".terraform.lock.hcl" { 42 | // skip a lock file. 43 | return nil 44 | } 45 | 46 | if err := u.updateTerraformBlock(mc, f); err != nil { 47 | return err 48 | } 49 | 50 | return u.updateProviderBlock(f) 51 | } 52 | 53 | func (u *ProviderUpdater) updateTerraformBlock(mc *ModuleContext, f *hclwrite.File) error { 54 | for _, tf := range allMatchingBlocks(f.Body(), "terraform", []string{}) { 55 | p := tf.Body().FirstMatchingBlock("required_providers", []string{}) 56 | if p == nil { 57 | continue 58 | } 59 | 60 | name := u.name 61 | // If the name contains /, assume that a namespace is intended and check the source. 62 | if strings.Contains(u.name, "/") { 63 | name = mc.ResolveProviderShortNameFromSource(u.name) 64 | if name == "" { 65 | continue 66 | } 67 | } 68 | 69 | // The hclwrite.Attribute doesn't have enough AST for object type to check. 70 | // Get the attribute as a native hcl.Attribute as a compromise. 71 | hclAttr, err := getHCLNativeAttribute(p.Body(), name) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | if hclAttr != nil { 77 | // There are some variations on the syntax of required_providers. 78 | // So we check a type of the value and switch implementations. 79 | // If the expression can be parsed as a static expression and it's type is a primitive, 80 | // then it's a legacy string syntax. 81 | if expr, err := hclAttr.Expr.Value(nil); err == nil && expr.Type().IsPrimitiveType() { 82 | u.updateTerraformRequiredProvidersBlockAsString(p) 83 | } else { 84 | // Otherwise, it's an object syntax. 85 | if err := u.updateTerraformRequiredProvidersBlockAsObject(p, name, hclAttr); err != nil { 86 | return err 87 | } 88 | } 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (u *ProviderUpdater) updateTerraformRequiredProvidersBlockAsObject(p *hclwrite.Block, name string, hclAttr *hcl.Attribute) error { 96 | // terraform { 97 | // required_providers { 98 | // aws = { 99 | // source = "hashicorp/aws" 100 | // version = "2.65.0" 101 | // 102 | // configuration_aliases = [ 103 | // aws.primary, 104 | // aws.secondary, 105 | // ] 106 | // } 107 | // } 108 | // } 109 | 110 | oldVersion, err := detectVersionInObject(hclAttr) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | if len(oldVersion) == 0 { 116 | // If the version key is missing, just ignore it. 117 | return nil 118 | } 119 | 120 | // Updating the whole object loses original sort order and comments. 121 | // At the time of writing, there is no way to update a value inside an 122 | // object directly while preserving original tokens. 123 | // 124 | // Since we fully understand the valid syntax, we compromise and read the 125 | // tokens in order, updating the bytes directly. 126 | // It's apparently a fragile dirty hack, but I didn't come up with the better 127 | // way to do this. 128 | attr := p.Body().GetAttribute(name) 129 | tokens := attr.Expr().BuildTokens(nil) 130 | 131 | i := 0 132 | // find key of version 133 | // Although not explicitly stated in the required_providers documentation, 134 | // a TokenQuotedLit is also valid token. Strict speaking there are more 135 | // variants because the left hand side of object key accepts an expression in 136 | // HCL. For accurate implementation, it should be implemented using the 137 | // original parser. 138 | for !((tokens[i].Type == hclsyntax.TokenIdent || tokens[i].Type == hclsyntax.TokenQuotedLit) && 139 | string(tokens[i].Bytes) == "version") { 140 | i++ 141 | } 142 | 143 | // find = 144 | for tokens[i].Type != hclsyntax.TokenEqual { 145 | i++ 146 | } 147 | 148 | // find value of old version 149 | for !(tokens[i].Type == hclsyntax.TokenQuotedLit && string(tokens[i].Bytes) == oldVersion) { 150 | i++ 151 | } 152 | 153 | // Since I've checked for the existence of the version key in advance, 154 | // if we reach here, we found the token to be updated. 155 | // So we now update bytes of the token in place. 156 | tokens[i].Bytes = []byte(u.version) 157 | 158 | return nil 159 | } 160 | 161 | // detectVersionInObject parses an object expression and detects a value for 162 | // the "version" key. 163 | // If the version key is missing, just returns an empty string without an error. 164 | func detectVersionInObject(hclAttr *hcl.Attribute) (string, error) { 165 | // The configuration_aliases syntax isn't directly related version updateing, 166 | // but it contains provider references and causes an parse error without an EvalContext. 167 | // So we treat the expression as a hcl.ExprMap to avoid fully decoding the object. 168 | kvs, diags := hcl.ExprMap(hclAttr.Expr) 169 | if diags.HasErrors() { 170 | return "", fmt.Errorf("failed to parse expr as hcl.ExprMap: %s", diags) 171 | } 172 | 173 | oldVersion := "" 174 | for _, kv := range kvs { 175 | key, diags := kv.Key.Value(nil) 176 | if diags.HasErrors() { 177 | return "", fmt.Errorf("failed to get key: %s", diags) 178 | } 179 | if key.AsString() == "version" { 180 | value, diags := kv.Value.Value(nil) 181 | if diags.HasErrors() { 182 | return "", fmt.Errorf("failed to get value: %s", diags) 183 | } 184 | oldVersion = value.AsString() 185 | } 186 | } 187 | 188 | return oldVersion, nil 189 | } 190 | 191 | func (u *ProviderUpdater) updateTerraformRequiredProvidersBlockAsString(p *hclwrite.Block) { 192 | // terraform { 193 | // required_providers { 194 | // aws = "2.65.0" 195 | // } 196 | // } 197 | p.Body().SetAttributeValue(u.name, cty.StringVal(u.version)) 198 | } 199 | 200 | func (u *ProviderUpdater) updateProviderBlock(f *hclwrite.File) error { 201 | for _, p := range allMatchingBlocks(f.Body(), "provider", []string{u.name}) { 202 | // set a version to attribute value only if the key exists 203 | if p.Body().GetAttribute("version") != nil { 204 | p.Body().SetAttributeValue("version", cty.StringVal(u.version)) 205 | } 206 | } 207 | 208 | return nil 209 | } 210 | -------------------------------------------------------------------------------- /tfupdate/terraform.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | 7 | "github.com/hashicorp/hcl/v2/hclwrite" 8 | "github.com/pkg/errors" 9 | "github.com/zclconf/go-cty/cty" 10 | ) 11 | 12 | // TerraformUpdater is a updater implementation which updates the terraform version constraint. 13 | type TerraformUpdater struct { 14 | version string 15 | } 16 | 17 | // NewTerraformUpdater is a factory method which returns an TerraformUpdater instance. 18 | func NewTerraformUpdater(version string) (Updater, error) { 19 | if len(version) == 0 { 20 | return nil, errors.Errorf("failed to new terraform updater. version is required") 21 | } 22 | 23 | return &TerraformUpdater{ 24 | version: version, 25 | }, nil 26 | } 27 | 28 | // Update updates the terraform version constraint. 29 | // Note that this method will rewrite the AST passed as an argument. 30 | func (u *TerraformUpdater) Update(_ context.Context, _ *ModuleContext, filename string, f *hclwrite.File) error { 31 | if filepath.Base(filename) == ".terraform.lock.hcl" { 32 | // skip a lock file. 33 | return nil 34 | } 35 | 36 | for _, tf := range allMatchingBlocks(f.Body(), "terraform", []string{}) { 37 | // set a version to attribute value only if the key exists 38 | if tf.Body().GetAttribute("required_version") != nil { 39 | tf.Body().SetAttributeValue("required_version", cty.StringVal(u.version)) 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /tfupdate/terraform_test.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/hashicorp/hcl/v2" 9 | "github.com/hashicorp/hcl/v2/hclwrite" 10 | ) 11 | 12 | func TestNewTerraformUpdater(t *testing.T) { 13 | cases := []struct { 14 | version string 15 | want Updater 16 | ok bool 17 | }{ 18 | { 19 | version: "0.12.7", 20 | want: &TerraformUpdater{ 21 | version: "0.12.7", 22 | }, 23 | ok: true, 24 | }, 25 | { 26 | version: "", 27 | want: nil, 28 | ok: false, 29 | }, 30 | } 31 | 32 | for _, tc := range cases { 33 | got, err := NewTerraformUpdater(tc.version) 34 | if tc.ok && err != nil { 35 | t.Errorf("NewTerraformUpdater() with version = %s returns unexpected err: %+v", tc.version, err) 36 | } 37 | 38 | if !tc.ok && err == nil { 39 | t.Errorf("NewTerraformUpdater() with version = %s expects to return an error, but no error", tc.version) 40 | } 41 | 42 | if !reflect.DeepEqual(got, tc.want) { 43 | t.Errorf("NewTerraformUpdater() with version = %s returns %#v, but want = %#v", tc.version, got, tc.want) 44 | } 45 | } 46 | } 47 | 48 | func TestUpdateTerraform(t *testing.T) { 49 | cases := []struct { 50 | filename string 51 | src string 52 | version string 53 | want string 54 | ok bool 55 | }{ 56 | { 57 | filename: "main.tf", 58 | src: ` 59 | terraform { 60 | required_version = "0.12.6" 61 | } 62 | `, 63 | version: "0.12.7", 64 | want: ` 65 | terraform { 66 | required_version = "0.12.7" 67 | } 68 | `, 69 | ok: true, 70 | }, 71 | { 72 | filename: "main.tf", 73 | src: ` 74 | terraform { 75 | required_providers { 76 | null = "2.1.1" 77 | } 78 | } 79 | `, 80 | version: "0.12.7", 81 | want: ` 82 | terraform { 83 | required_providers { 84 | null = "2.1.1" 85 | } 86 | } 87 | `, 88 | ok: true, 89 | }, 90 | { 91 | filename: "main.tf", 92 | src: ` 93 | provider "aws" { 94 | version = "2.11.0" 95 | region = "ap-northeast-1" 96 | } 97 | `, 98 | version: "0.12.7", 99 | want: ` 100 | provider "aws" { 101 | version = "2.11.0" 102 | region = "ap-northeast-1" 103 | } 104 | `, 105 | ok: true, 106 | }, 107 | { 108 | filename: "main.tf", 109 | src: `terraform { 110 | backend "s3" { 111 | region = "ap-northeast-1" 112 | bucket = "hoge" 113 | key = "terraform.tfstate" 114 | } 115 | } 116 | terraform { 117 | required_version = "0.12.6" 118 | } 119 | `, 120 | version: "0.12.7", 121 | want: `terraform { 122 | backend "s3" { 123 | region = "ap-northeast-1" 124 | bucket = "hoge" 125 | key = "terraform.tfstate" 126 | } 127 | } 128 | terraform { 129 | required_version = "0.12.7" 130 | } 131 | `, 132 | ok: true, 133 | }, 134 | } 135 | 136 | for _, tc := range cases { 137 | u := &TerraformUpdater{ 138 | version: tc.version, 139 | } 140 | f, diags := hclwrite.ParseConfig([]byte(tc.src), tc.filename, hcl.Pos{Line: 1, Column: 1}) 141 | if diags.HasErrors() { 142 | t.Fatalf("unexpected diagnostics: %s", diags) 143 | } 144 | 145 | err := u.Update(context.Background(), nil, tc.filename, f) 146 | if tc.ok && err != nil { 147 | t.Errorf("Update() with src = %s, version = %s returns unexpected err: %+v", tc.src, tc.version, err) 148 | } 149 | if !tc.ok && err == nil { 150 | t.Errorf("Update() with src = %s, version = %s expects to return an error, but no error", tc.src, tc.version) 151 | } 152 | 153 | got := string(hclwrite.Format(f.BuildTokens(nil).Bytes())) 154 | if got != tc.want { 155 | t.Errorf("Update() with src = %s, version = %s returns %s, but want = %s", tc.src, tc.version, got, tc.want) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /tfupdate/update.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "log" 9 | "runtime/debug" 10 | 11 | "github.com/hashicorp/hcl/v2" 12 | "github.com/hashicorp/hcl/v2/hclwrite" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // Updater is an interface which updates a version constraint in HCL. 17 | type Updater interface { 18 | // Update updates a version constraint. 19 | // Note that this method will rewrite the AST passed as an argument. 20 | Update(ctx context.Context, mc *ModuleContext, filename string, f *hclwrite.File) error 21 | } 22 | 23 | // NewUpdater is a factory method which returns an Updater implementation. 24 | func NewUpdater(o Option) (Updater, error) { 25 | switch o.updateType { 26 | case "terraform": 27 | return NewTerraformUpdater(o.version) 28 | case "opentofu": 29 | return NewOpenTofuUpdater(o.version) 30 | case "provider": 31 | return NewProviderUpdater(o.name, o.version) 32 | case "module": 33 | return NewModuleUpdater(o.name, o.version, o.nameRegex) 34 | case "lock": 35 | return NewLockUpdater(o.platforms, o.tfregistryConfig) 36 | default: 37 | return nil, errors.Errorf("failed to new updater. unknown type: %s", o.updateType) 38 | } 39 | } 40 | 41 | // UpdateHCL reads HCL from io.Reader, updates version constraints 42 | // and writes updated contents to io.Writer. 43 | // If contents changed successfully, it returns true, or otherwise returns false. 44 | // If an error occurs, Nothing is written to the output stream. 45 | func UpdateHCL(ctx context.Context, mc *ModuleContext, r io.Reader, w io.Writer, filename string) (bool, error) { 46 | input, err := io.ReadAll(r) 47 | if err != nil { 48 | return false, fmt.Errorf("failed to read input: %s", err) 49 | } 50 | 51 | f, err := safeParseConfig(input, filename, hcl.Pos{Line: 1, Column: 1}) 52 | if err != nil { 53 | return false, err 54 | } 55 | 56 | u := mc.Updater() 57 | if err = u.Update(ctx, mc, filename, f); err != nil { 58 | return false, err 59 | } 60 | 61 | output := f.BuildTokens(nil).Bytes() 62 | 63 | if _, err := w.Write(output); err != nil { 64 | return false, fmt.Errorf("failed to write output: %s", err) 65 | } 66 | 67 | isUpdated := !bytes.Equal(input, output) 68 | return isUpdated, nil 69 | } 70 | 71 | // safeParseConfig parses config and recovers if panic occurs. 72 | // The current hclwrite implementation is no perfect and will panic if 73 | // unparseable input is given. We just treat it as a parse error so as not to 74 | // surprise users of tfupdate. 75 | func safeParseConfig(src []byte, filename string, start hcl.Pos) (f *hclwrite.File, e error) { 76 | defer func() { 77 | if err := recover(); err != nil { 78 | log.Printf("[DEBUG] failed to parse input: %s\nstacktrace: %s", filename, string(debug.Stack())) 79 | // Set a return value from panic recover 80 | e = fmt.Errorf(`failed to parse input: %s 81 | panic: %s 82 | This may be caused by a bug in the hclwrite parser. 83 | As a workaround, you can ignore this file with --ignore-path option`, filename, err) 84 | } 85 | }() 86 | 87 | f, diags := hclwrite.ParseConfig(src, filename, start) 88 | 89 | if diags.HasErrors() { 90 | return nil, fmt.Errorf("failed to parse input: %s", diags) 91 | } 92 | 93 | return f, nil 94 | } 95 | -------------------------------------------------------------------------------- /tfupdate/update_test.go: -------------------------------------------------------------------------------- 1 | package tfupdate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/davecgh/go-spew/spew" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "github.com/minamijoyo/tfupdate/lock" 12 | "github.com/spf13/afero" 13 | ) 14 | 15 | func TestNewUpdater(t *testing.T) { 16 | cases := []struct { 17 | o Option 18 | want Updater 19 | ok bool 20 | }{ 21 | { 22 | o: Option{ 23 | updateType: "terraform", 24 | version: "0.12.7", 25 | }, 26 | want: &TerraformUpdater{ 27 | version: "0.12.7", 28 | }, 29 | ok: true, 30 | }, 31 | { 32 | o: Option{ 33 | updateType: "opentofu", 34 | version: "1.9.0", 35 | }, 36 | want: &OpenTofuUpdater{ 37 | version: "1.9.0", 38 | }, 39 | ok: true, 40 | }, 41 | { 42 | o: Option{ 43 | updateType: "provider", 44 | name: "aws", 45 | version: "2.23.0", 46 | }, 47 | want: &ProviderUpdater{ 48 | name: "aws", 49 | version: "2.23.0", 50 | }, 51 | ok: true, 52 | }, 53 | { 54 | o: Option{ 55 | updateType: "module", 56 | name: "terraform-aws-modules/vpc/aws", 57 | version: "2.14.0", 58 | }, 59 | want: &ModuleUpdater{ 60 | name: "terraform-aws-modules/vpc/aws", 61 | version: "2.14.0", 62 | }, 63 | ok: true, 64 | }, 65 | { 66 | o: Option{ 67 | updateType: "lock", 68 | platforms: []string{"darwin_arm64", "darwin_amd64", "linux_amd64"}, 69 | }, 70 | want: &LockUpdater{ 71 | platforms: []string{"darwin_arm64", "darwin_amd64", "linux_amd64"}, 72 | }, 73 | ok: true, 74 | }, 75 | { 76 | o: Option{ 77 | updateType: "hoge", 78 | version: "0.0.1", 79 | }, 80 | want: nil, 81 | ok: false, 82 | }, 83 | } 84 | 85 | for _, tc := range cases { 86 | got, err := NewUpdater(tc.o) 87 | if tc.ok && err != nil { 88 | t.Errorf("NewUpdater() with o = %#v returns unexpected err: %+v", tc.o, err) 89 | } 90 | 91 | if !tc.ok && err == nil { 92 | t.Errorf("NewUpdater() with o = %#v expects to return an error, but no error", tc.o) 93 | } 94 | 95 | opts := []cmp.Option{ 96 | cmp.AllowUnexported(TerraformUpdater{}), 97 | cmp.AllowUnexported(OpenTofuUpdater{}), 98 | cmp.AllowUnexported(ProviderUpdater{}), 99 | cmp.AllowUnexported(ModuleUpdater{}), 100 | cmp.AllowUnexported(LockUpdater{}), 101 | cmpopts.IgnoreInterfaces(struct{ lock.Index }{}), 102 | } 103 | if diff := cmp.Diff(got, tc.want, opts...); diff != "" { 104 | t.Errorf("got: %s, want = %s, diff = %s", spew.Sdump(got), spew.Sdump(tc.want), diff) 105 | } 106 | } 107 | } 108 | 109 | func TestUpdateHCL(t *testing.T) { 110 | cases := []struct { 111 | src string 112 | o Option 113 | want string 114 | isUpdated bool 115 | ok bool 116 | }{ 117 | { 118 | src: ` 119 | terraform { 120 | required_version = "0.12.4" 121 | } 122 | `, 123 | o: Option{ 124 | updateType: "terraform", 125 | version: "0.12.7", 126 | }, 127 | // Note the lack of space here. 128 | // the current implementation of (*hclwrite.Body).SetAttributeValue() 129 | // does not seem to preserve an original SpaceBefore value of attribute. 130 | // This is a bug of upstream. 131 | // We avoid this by formating the output of this function. 132 | want: ` 133 | terraform { 134 | required_version ="0.12.7" 135 | } 136 | `, 137 | isUpdated: true, 138 | ok: true, 139 | }, 140 | { 141 | src: ` 142 | terraform { 143 | required_version = "1.8.0" 144 | } 145 | `, 146 | o: Option{ 147 | updateType: "opentofu", 148 | version: "1.9.0", 149 | }, 150 | want: ` 151 | terraform { 152 | required_version ="1.9.0" 153 | } 154 | `, 155 | isUpdated: true, 156 | ok: true, 157 | }, 158 | { 159 | src: ` 160 | provider "aws" { 161 | version = "2.11.0" 162 | } 163 | `, 164 | o: Option{ 165 | updateType: "provider", 166 | name: "aws", 167 | version: "2.23.0", 168 | }, 169 | want: ` 170 | provider "aws" { 171 | version ="2.23.0" 172 | } 173 | `, 174 | isUpdated: true, 175 | ok: true, 176 | }, 177 | { 178 | src: ` 179 | provider "aws" { 180 | version = "2.11.0" 181 | } 182 | `, 183 | o: Option{ 184 | updateType: "provider", 185 | name: "hoge", 186 | version: "2.23.0", 187 | }, 188 | want: ` 189 | provider "aws" { 190 | version = "2.11.0" 191 | } 192 | `, 193 | isUpdated: false, 194 | ok: true, 195 | }, 196 | { 197 | src: ` 198 | provider "invalid" { 199 | `, 200 | o: Option{ 201 | updateType: "provider", 202 | name: "hoge", 203 | version: "2.23.0", 204 | }, 205 | want: "", 206 | isUpdated: false, 207 | ok: false, 208 | }, 209 | { 210 | // not panic even if a map index is a variable reference 211 | src: `resource "not_panic" "hoge" { 212 | b = a[var.env] 213 | } 214 | `, 215 | o: Option{ 216 | updateType: "provider", 217 | name: "hoge", 218 | version: "2.23.0", 219 | }, 220 | want: `resource "not_panic" "hoge" { 221 | b = a[var.env] 222 | } 223 | `, 224 | isUpdated: false, 225 | ok: true, 226 | }, 227 | } 228 | 229 | for _, tc := range cases { 230 | r := bytes.NewBufferString(tc.src) 231 | w := &bytes.Buffer{} 232 | 233 | fs := afero.NewMemMapFs() 234 | gc, err := NewGlobalContext(fs, tc.o) 235 | if err != nil { 236 | t.Fatalf("failed to new global context: %s", err) 237 | } 238 | 239 | mc, err := NewModuleContext(".", gc) 240 | if err != nil { 241 | t.Fatalf("failed to new module context: %s", err) 242 | } 243 | 244 | isUpdated, err := UpdateHCL(context.Background(), mc, r, w, "main.tf") 245 | if tc.ok && err != nil { 246 | t.Errorf("UpdateHCL() with src = %s, o = %#v returns unexpected err: %+v", tc.src, tc.o, err) 247 | } 248 | 249 | if !tc.ok && err == nil { 250 | t.Errorf("UpdateHCL() with src = %s, o = %#v expects to return an error, but no error", tc.src, tc.o) 251 | } 252 | 253 | if isUpdated != tc.isUpdated { 254 | t.Errorf("UpdateHCL() with src = %s, o = %#v expects to return isUpdated = %t, but want = %t", tc.src, tc.o, isUpdated, tc.isUpdated) 255 | } 256 | 257 | got := w.String() 258 | if got != tc.want { 259 | t.Errorf("UpdateHCL() with src = %s, o = %#v returns %s, but want = %s", tc.src, tc.o, got, tc.want) 260 | } 261 | } 262 | } 263 | --------------------------------------------------------------------------------