├── .changelog.yml ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── feature_request.md ├── dependabot.yml ├── labeler.yml ├── linters │ ├── .cspell.json │ ├── .golangci.yml │ ├── .markdown-lint.yml │ └── .yaml-lint.yml ├── release.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── goreport.yml │ ├── multi-labeler.yml │ ├── publish.yml │ ├── release.yml │ └── super-linter.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.howto.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── install.sh ├── lib ├── checksum.go ├── checksum_test.go ├── command.go ├── command_test.go ├── common.go ├── defaults.go ├── dir_perm.go ├── dir_perm_windows.go ├── download.go ├── download_test.go ├── files.go ├── files_test.go ├── install.go ├── install_test.go ├── list_versions.go ├── list_versions_test.go ├── lockfile.go ├── lockfile_test.go ├── logging.go ├── param_parsing │ ├── environment.go │ ├── environment_test.go │ ├── parameters.go │ ├── parameters_test.go │ ├── terraform_version.go │ ├── terraform_version_test.go │ ├── terragrunt.go │ ├── terragrunt_test.go │ ├── tfswitch.go │ ├── tfswitch_test.go │ ├── toml.go │ ├── toml_test.go │ ├── versiontf.go │ └── versiontf_test.go ├── product.go ├── product_test.go ├── recent.go ├── recent_test.go ├── semver.go ├── semver_test.go ├── symlink.go ├── symlink_test.go └── utils.go ├── main.go ├── test-data ├── checksum-check-file ├── integration-tests │ ├── test_terraform-version │ │ └── .terraform-version │ ├── test_terragrunt_hcl │ │ └── terragrunt.hcl │ ├── test_tfswitchrc │ │ └── .tfswitchrc │ ├── test_tfswitchtoml │ │ └── .tfswitch.toml │ └── test_versiontf │ │ └── version.tf ├── recent │ └── recent_as_json │ │ └── .terraform.versions │ │ └── RECENT ├── skip-integration-tests │ ├── test_no_file │ │ └── dummy_file │ ├── test_precedence │ │ └── .gitignore │ ├── test_terragrunt_error_hcl │ │ └── terragrunt.hcl │ ├── test_terragrunt_no_version │ │ └── terragrunt.hcl │ ├── test_tfswitchtoml_error │ │ └── .tfswitch.toml │ ├── test_tfswitchtoml_no_version │ │ └── .tfswitch.toml │ ├── test_versiontf_error │ │ └── version.tf │ ├── test_versiontf_no_version_constraint │ │ └── version.tf │ ├── test_versiontf_non_existent │ │ └── version.tf │ └── test_versiontf_non_matching_constraints │ │ └── version.tf ├── terraform_1.7.5_SHA256SUMS ├── test-data.zip └── test-data_windows.zip ├── tfswitch-completion.bash └── www ├── docs ├── CNAME ├── Continuous-Integration.md ├── How-to-Contribute.md ├── Installation.md ├── Troubleshoot.md ├── Upgrade-or-Uninstall.md ├── index.md ├── static │ ├── circleci_tfswitch.png │ ├── contribute │ │ ├── tfswitch-build.gif │ │ ├── tfswitch-git-clone.gif │ │ ├── tfswitch-go-get.gif │ │ ├── tfswitch-gopath.gif │ │ └── tfswitch-workspace.gif │ ├── favicon_tfswitch_16.png │ ├── favicon_tfswitch_48.png │ ├── jenkins_tfswitch.png │ ├── logo.png │ ├── tfswitch-v4.gif │ ├── tfswitch-v5.gif │ ├── tfswitch-v6.gif │ ├── tfswitch-v7.gif │ ├── tfswitch-v8.gif │ ├── tfswitch.gif │ └── versiontf.gif └── usage │ ├── ci-cd.md │ ├── commandline.md │ ├── config-files.md │ └── general.md └── mkdocs.yml /.changelog.yml: -------------------------------------------------------------------------------- 1 | --- 2 | file_name: CHANGELOG.md.new 3 | excluded_labels: 4 | - maintenance 5 | - dependencies 6 | sections: 7 | added: 8 | - feature 9 | - new feature 10 | changed: 11 | - backwards-incompatible 12 | - depricated 13 | fixed: 14 | - bug 15 | - bugfix 16 | - enhancement 17 | - fix 18 | - fixed 19 | skip_entries_without_label: false 20 | show_unreleased: true 21 | check_for_updates: true 22 | logger: console 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [install.sh] 2 | indent_style = space 3 | indent_size = 2 4 | switch_case_indent = true 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | * text eol=lf 3 | 4 | *.png binary 5 | *.jpg binary 6 | *.gif binary 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | 3 | # NOTICE: Order is important. The last matching pattern takes the most precedence. 4 | 5 | # The entries are placed in a historical order 6 | # 20240408: initial CODEOWNERS commit (added repo owner and all current collaborators) 7 | # 20250301: take code ownership for safekeeping after more than half a year of inactivity (@jukie @MatrixCrawler @crablab) 8 | * @warrensbox @yermulnik @MatthewJohn 9 | 10 | # The `CODEOWNERS` file is owned by repo owner 11 | /.github/CODEOWNERS @warrensbox 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: warrensbox 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | 21 | ### If you would like to contribute to the code, see step-by-step instructions here 22 | 23 | ## Required version 24 | 25 | ```sh 26 | go version 1.13 27 | ``` 28 | 29 | ### Step 1 - Create workspace 30 | 31 | _Skip this step if you already have a GitHub Go workspace_ 32 | Create a GitHub workspace. 33 | drawing 34 | 35 | ### Step 2 - Set GOPATH 36 | 37 | _Skip this step if you already have a GitHub Go workspace_ 38 | Export your GOPATH environment variable in your `go` directory. 39 | 40 | ```sh 41 | export GOPATH=`pwd` 42 | ``` 43 | 44 | drawing 45 | 46 | ### Step 3 - Clone repository 47 | 48 | Git clone this repository. 49 | 50 | ```sh 51 | git clone git@github.com:warrensbox/terraform-switcher.git 52 | ``` 53 | 54 | drawing 55 | 56 | ### Step 4 - Get dependencies 57 | 58 | Go get all the dependencies. 59 | 60 | ```sh 61 | go mod download 62 | ``` 63 | 64 | ```sh 65 | go get -v -t -d ./... 66 | ``` 67 | 68 | Test the code (optional). 69 | 70 | ```sh 71 | go vet -tests=false ./... 72 | ``` 73 | 74 | ```sh 75 | go test -v ./... 76 | ``` 77 | 78 | drawing 79 | 80 | ### Step 5 - Build executable 81 | 82 | Create a new branch. 83 | 84 | ```sh 85 | git checkout -b feature/put-your-branch-name-here 86 | ``` 87 | 88 | Refactor and add new features to the code. 89 | Go build the code. 90 | 91 | ```sh 92 | go build -o test-tfswitch 93 | ``` 94 | 95 | Test the code and create a new pull request! 96 | 97 | drawing 98 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | version: 2 6 | updates: 7 | - package-ecosystem: gomod 8 | directory: / 9 | schedule: 10 | interval: daily 11 | # Check for updates at 7am UTC. 12 | time: "07:00" 13 | commit-message: 14 | prefix: "go:" 15 | labels: 16 | - golang 17 | - dependencies 18 | - package-ecosystem: "github-actions" 19 | directory: / 20 | schedule: 21 | interval: daily 22 | time: "07:00" 23 | commit-message: 24 | prefix: "gh-actions:" 25 | labels: 26 | - gh-actions 27 | - dependencies 28 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/fuxingloh/multi-labeler 2 | # https://www.conventionalcommits.org/ 3 | # https://github.com/warrensbox/terraform-switcher/labels 4 | 5 | version: v1 6 | 7 | labels: 8 | - label: "chore" 9 | matcher: 10 | title: "^(c(hore|i)|style):" 11 | branch: "^(c(hore|i)|style)/" 12 | commits: "^(c(hore|i)|style):" 13 | 14 | - label: "dependencies" 15 | matcher: 16 | title: "^deps:" 17 | branch: "^deps/" 18 | commits: "^deps:" 19 | 20 | - label: "documentation" 21 | matcher: 22 | title: "^docs?:" 23 | branch: "^docs?/" 24 | commits: "^docs?:" 25 | 26 | - label: "enhancement" 27 | matcher: 28 | title: "^fix:" 29 | branch: "^fix/" 30 | commits: "^fix:" 31 | 32 | - label: "golang" 33 | matcher: 34 | title: "^go(lang)?:" 35 | branch: "^go(lang)?/" 36 | commits: "^go(lang)?:" 37 | 38 | - label: "new feature" 39 | matcher: 40 | title: "^feat(ure)?:" 41 | branch: "^feat(ure)?/" 42 | commits: "^feat(ure)?:" 43 | -------------------------------------------------------------------------------- /.github/linters/.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "ignorePaths": [], 5 | "words": [] 6 | } 7 | -------------------------------------------------------------------------------- /.github/linters/.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | -------------------------------------------------------------------------------- /.github/linters/.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ############### 3 | # Rules by id # 4 | ############### 5 | MD004: false # Unordered list style 6 | MD007: 7 | indent: 2 # Unordered list indentation 8 | MD013: false # Disable line length checks (to allow flexibility) 9 | MD026: 10 | punctuation: ".,;:!。,;:" # List of not allowed 11 | MD029: false # Ordered list item prefix 12 | MD033: false # Allow inline HTML 13 | MD036: false # Emphasis used instead of a heading 14 | 15 | ################# 16 | # Rules by tags # 17 | ################# 18 | blank_lines: false # Error on blank lines 19 | -------------------------------------------------------------------------------- /.github/linters/.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This config is used by `super-linter` 3 | # https://github.com/super-linter/super-linter/blob/main/README.md?plain=1#L409 4 | # https://github.com/super-linter/super-linter/blob/main/TEMPLATES/.yaml-lint.yml 5 | rules: 6 | braces: 7 | level: warning 8 | min-spaces-inside: 0 9 | max-spaces-inside: 0 10 | min-spaces-inside-empty: 1 11 | max-spaces-inside-empty: 5 12 | brackets: 13 | level: warning 14 | min-spaces-inside: 0 15 | max-spaces-inside: 0 16 | min-spaces-inside-empty: 1 17 | max-spaces-inside-empty: 5 18 | colons: 19 | level: warning 20 | max-spaces-before: 0 21 | max-spaces-after: 1 22 | commas: 23 | level: warning 24 | max-spaces-before: 0 25 | min-spaces-after: 1 26 | max-spaces-after: 1 27 | comments: disable 28 | comments-indentation: disable 29 | document-end: disable 30 | document-start: disable 31 | empty-lines: 32 | level: warning 33 | max: 2 34 | max-start: 0 35 | max-end: 0 36 | hyphens: 37 | level: warning 38 | max-spaces-after: 1 39 | indentation: 40 | level: warning 41 | spaces: consistent 42 | indent-sequences: true 43 | check-multi-line-strings: false 44 | key-duplicates: enable 45 | line-length: 46 | level: warning 47 | max: 185 48 | allow-non-breakable-words: true 49 | allow-non-breakable-inline-mappings: true 50 | new-line-at-end-of-file: enable 51 | new-lines: 52 | type: unix 53 | trailing-spaces: disable 54 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 2 | changelog: 3 | categories: 4 | - title: Added 5 | labels: 6 | - experimental 7 | - feature 8 | - new feature 9 | - title: Changed 10 | labels: 11 | - backwards-incompatible 12 | - depricated 13 | - enhancement 14 | - title: Fixed 15 | labels: 16 | - bug 17 | - bugfix 18 | - fix 19 | - fixed 20 | - title: Documentation 21 | labels: 22 | - docs 23 | - documentation 24 | - title: Security 25 | labels: 26 | - security 27 | - title: Dependencies 28 | labels: 29 | - dependencies 30 | - title: Other 31 | labels: 32 | - "*" 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | permissions: {} # yamllint disable-line rule:braces 3 | 4 | on: 5 | push: 6 | branches: 7 | - "*" # matches every branch that doesn't contain a '/' 8 | - "*/*" # matches every branch containing a single '/' 9 | - "**" # matches every branch 10 | - "!master" # excludes `master` branch 11 | 12 | env: 13 | CGO_ENABLED: 0 # Build statically linked binaries 14 | 15 | jobs: 16 | fmt_and_vet: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Install Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version-file: "go.mod" 26 | cache: false 27 | 28 | - name: Check vet 29 | run: | 30 | go vet ./... 31 | 32 | - name: Check fmt 33 | run: | 34 | go fmt ./... 35 | if [[ -z "$(git status --porcelain)" ]]; then 36 | echo "Formatting is consistent with 'go fmt'." 37 | else 38 | echo "Run 'go fmt ./...' to automatically apply standard Go style to all packages." 39 | git status --porcelain 40 | exit 1 41 | fi 42 | 43 | integration_tests_linux: 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest] 47 | runs-on: ${{ matrix.os }} 48 | steps: 49 | - name: Checkout repo 50 | uses: actions/checkout@v4 51 | 52 | - name: Install Go 53 | uses: actions/setup-go@v5 54 | with: 55 | go-version-file: "go.mod" 56 | cache: false 57 | 58 | - name: Build code 59 | run: go build -v ./... 60 | 61 | - name: Running unit tests 62 | run: | 63 | go test -v ./... 64 | 65 | - name: Running integration tests 66 | run: | 67 | set -e 68 | mkdir -p build 69 | go build -v -o build/tfswitch 70 | mkdir "$(pwd)/bin/" 71 | find ./test-data/integration-tests/* -type d -print0 | while read -r -d $'\0' TEST_PATH; do 72 | if test -f "${TEST_PATH}/.tfswitch.toml" 73 | then 74 | cp "${TEST_PATH}/.tfswitch.toml" ~/ 75 | else 76 | rm -f ~/.tfswitch.toml 77 | fi 78 | ./build/tfswitch -c "${TEST_PATH}" -b "$(pwd)/bin/terraform" || exit 1 79 | done 80 | 81 | integration_tests_windows: 82 | strategy: 83 | matrix: 84 | os: [windows-latest] 85 | runs-on: ${{ matrix.os }} 86 | steps: 87 | - name: Checkout repo 88 | uses: actions/checkout@v4 89 | 90 | - name: Install Go 91 | uses: actions/setup-go@v5 92 | with: 93 | go-version-file: "go.mod" 94 | cache: false 95 | 96 | - name: Running unit tests 97 | run: | 98 | go test -v ./... 99 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | permissions: {} # yamllint disable-line rule:braces 14 | 15 | on: 16 | push: 17 | branches: ["master"] 18 | pull_request: 19 | branches: ["master"] 20 | paths-ignore: 21 | - "**/.*" 22 | - "**/*.md" 23 | - "**/*_test.go" 24 | - "test-data/**/*" 25 | - "www/**/*" 26 | schedule: 27 | - cron: "16 12 * * 1" 28 | 29 | jobs: 30 | analyze: 31 | name: Analyze (${{ matrix.language }}) 32 | # Runner size impacts CodeQL analysis time. To learn more, please see: 33 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 34 | # - https://gh.io/supported-runners-and-hardware-resources 35 | # - https://gh.io/using-larger-runners 36 | # Consider using larger runners for possible analysis time improvements. 37 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 38 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 39 | permissions: 40 | # required for all workflows 41 | security-events: write 42 | 43 | # only required for workflows in private repositories 44 | actions: read 45 | contents: read 46 | 47 | strategy: 48 | fail-fast: false 49 | matrix: 50 | include: 51 | - language: go 52 | build-mode: manual 53 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 54 | # Use `c-cpp` to analyze code written in C, C++ or both 55 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 56 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 57 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 58 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 59 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 60 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 61 | steps: 62 | - name: Checkout repository 63 | uses: actions/checkout@v4 64 | 65 | - name: Set up Go 66 | uses: actions/setup-go@v5 67 | with: 68 | go-version-file: "go.mod" 69 | cache: false 70 | 71 | # Initializes the CodeQL tools for scanning. 72 | - name: Initialize CodeQL 73 | uses: github/codeql-action/init@v3 74 | with: 75 | languages: ${{ matrix.language }} 76 | build-mode: ${{ matrix.build-mode }} 77 | # If you wish to specify custom queries, you can do so here or in a config file. 78 | # By default, queries listed here will override any specified in a config file. 79 | # Prefix the list here with "+" to use these queries and those in the config file. 80 | 81 | # For more details on CodeQL's query packs, refer to: 82 | # https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 83 | # queries: security-extended,security-and-quality 84 | 85 | # If the analyze step fails for one of the languages you are analyzing with 86 | # "We were unable to automatically build your code", modify the matrix above 87 | # to set the build mode to "manual" for that language. Then modify this step 88 | # to build your code. 89 | # ℹ️ Command-line programs to run using the OS shell. 90 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 91 | - run: go build -v ./... 92 | env: 93 | CGO_ENABLED: 0 # Build statically linked binaries 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@v3 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | -------------------------------------------------------------------------------- /.github/workflows/goreport.yml: -------------------------------------------------------------------------------- 1 | name: Update goreport card 2 | permissions: {} # yamllint disable-line rule:braces 3 | 4 | on: 5 | push: 6 | branches: 7 | - "master" 8 | 9 | jobs: 10 | goreport: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Update goreport card 14 | run: curl -X POST -F "repo=github.com/$GITHUB_REPOSITORY" https://goreportcard.com/checks 15 | -------------------------------------------------------------------------------- /.github/workflows/multi-labeler.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/fuxingloh/multi-labeler 2 | # https://www.conventionalcommits.org/ 3 | # https://github.com/warrensbox/terraform-switcher/labels 4 | 5 | on: 6 | pull_request: 7 | types: [opened, edited, synchronize, ready_for_review] 8 | branches: [master, main] 9 | 10 | pull_request_target: # for OSS with public contributions (forked PR) 11 | types: [opened, edited, synchronize, ready_for_review] 12 | branches: [master, main] 13 | 14 | permissions: 15 | # Setting up permissions in the workflow to limit the scope of what it can do. Optional! 16 | contents: read # the config file 17 | issues: write # for labeling issues (on: issues) 18 | pull-requests: write # for labeling pull requests (on: pull_request_target or on: pull_request) 19 | statuses: write # to generate status 20 | checks: write # to generate status 21 | 22 | jobs: 23 | labeler: 24 | name: Multi Labeler 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: fuxingloh/multi-labeler@v4 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Update Go Module Index 2 | permissions: {} # yamllint disable-line rule:braces 3 | 4 | on: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | bump-index: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v4 15 | - name: Ping endpoint 16 | run: curl "https://proxy.golang.org/github.com/warrensbox/terraform-switcher/@v/$(git describe HEAD --tags --abbrev=0).info" 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release (Manual Step) 2 | permissions: {} # yamllint disable-line rule:braces 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | #checkov:skip=CKV_GHA_7: This is a manual step intended for user input 8 | name: 9 | description: "Enter - major, minor, patch" 10 | default: "patch" 11 | 12 | jobs: 13 | tfswitch-release: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | - name: Check if provided input is valid 20 | run: | 21 | echo "Semantic Version: ${{ github.event.inputs.name }}" 22 | VERSION=${{ github.event.inputs.name }} 23 | if [ "$VERSION" != "major" ] && [ "$VERSION" != "minor" ] && [ "$VERSION" != "patch" ]; then 24 | echo "Error: Provided input string must be 'major', 'minor', or 'patch'" 25 | exit 1 26 | fi 27 | 28 | # Checkout code from repo 29 | - name: Checkout repo 30 | uses: actions/checkout@v4 31 | with: 32 | ref: ${{ github.head_ref }} # required for better experience using pre-releases 33 | fetch-depth: "0" 34 | 35 | # Install Go 36 | - name: Install Go 37 | uses: actions/setup-go@v5 38 | with: 39 | go-version-file: "go.mod" 40 | cache: false 41 | 42 | # Double check Go version 43 | - name: Go version 44 | id: Version 45 | run: go version 46 | 47 | # Download Go dependencies 48 | - name: Go download 49 | run: go mod download 50 | 51 | # Test to see if tfswitch works with --help 52 | - name: Go build 53 | env: 54 | CGO_ENABLED: 0 # Build statically linked binaries 55 | run: mkdir -p build && go build -v -o build/tfswitch && build/tfswitch --help 56 | continue-on-error: false 57 | 58 | - name: Create dry tag 59 | uses: anothrNick/github-tag-action@1.73.0 60 | id: semver-tag-dry 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | WITH_V: true 64 | INITIAL_VERSION: 1.0.0 65 | RELEASE_BRANCHES: master 66 | DEFAULT_BUMP: ${{ github.event.inputs.name }} 67 | PRERELEASE: false 68 | DRY_RUN: true # Only get the tag - dry 69 | VERBOSE: false 70 | 71 | # Echo version 72 | - name: Echo version 73 | run: | 74 | echo ${{ steps.semver-tag-dry.outputs.tag }} 75 | 76 | # Push the changes to remote 77 | # - name: Push changes 78 | # uses: ad-m/github-push-action@master 79 | # with: 80 | # github_token: ${{ secrets.GITHUB_TOKEN }} 81 | # branch: release-${{ steps.semver-tag-dry.outputs.tag }} 82 | 83 | # - name: Create Pull Request 84 | # id: cpr 85 | # uses: peter-evans/create-pull-request@v6 86 | # with: 87 | # token: ${{ secrets.GITHUB_TOKEN }} 88 | # branch: release-${{ steps.semver-tag-dry.outputs.tag }} 89 | # title: Release ${{ steps.semver-tag-dry.outputs.tag }} 90 | # labels: automerge 91 | 92 | # - name: Check outputs 93 | # if: ${{ steps.cpr.outputs.pull-request-number }} 94 | # run: | 95 | # echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" 96 | # echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" 97 | 98 | # - name: Merging release PR 99 | # run: gh pr merge --merge --auto "${{ steps.cpr.outputs.pull-request-number }}" 100 | # env: 101 | # GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 102 | 103 | # Introduce new tag (for real) 104 | - name: Bump version and push tag 105 | uses: anothrNick/github-tag-action@1.73.0 106 | id: semver-tag 107 | env: 108 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 109 | WITH_V: true 110 | INITIAL_VERSION: 1.0.0 111 | RELEASE_BRANCHES: master 112 | DEFAULT_BUMP: ${{ github.event.inputs.name }} 113 | PRERELEASE: false 114 | DRY_RUN: false # Not dry 115 | VERBOSE: true 116 | 117 | # Run goreleaser to create new binaries 118 | - name: Run GoReleaser 119 | uses: goreleaser/goreleaser-action@v6 120 | with: 121 | version: latest 122 | args: release --clean 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | RELEASE_VERSION: ${{ steps.semver-tag.outputs.tag }} 126 | PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 127 | NO_CREATE_CHANGELOG: false 128 | 129 | # Install Python 130 | - name: Install Python 131 | uses: actions/setup-python@v5 132 | with: 133 | python-version: 3.x 134 | 135 | # Install Py dependencies 136 | - name: Install dependencies 137 | run: | 138 | python -m pip install --upgrade pip 139 | pip install mkdocs-material 140 | 141 | # Build WWW page 142 | - name: Build page 143 | run: cd www && mkdocs gh-deploy --force 144 | env: 145 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 146 | -------------------------------------------------------------------------------- /.github/workflows/super-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: super-linter 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: null 6 | pull_request: null 7 | 8 | permissions: {} # yamllint disable-line rule:braces 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: ${{ !contains(fromJSON('["refs/heads/main", "refs/heads/master"]'), github.ref) }} 13 | 14 | jobs: 15 | lint: 16 | name: Lint Code Base 17 | runs-on: ubuntu-latest 18 | 19 | permissions: 20 | contents: read 21 | packages: read 22 | # To report GitHub Actions status checks 23 | statuses: write 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | with: 29 | persist-credentials: false 30 | # super-linter needs the full git history to get the 31 | # list of files that changed across commits 32 | fetch-depth: 0 33 | 34 | - name: super-linter 35 | uses: super-linter/super-linter/slim@v7 36 | env: 37 | # To report GitHub Actions status checks 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | FILTER_REGEX_EXCLUDE: "^(/?|/github/workspace/)test-data/" 40 | VALIDATE_ALL_CODEBASE: false 41 | VALIDATE_JSCPD: false 42 | VALIDATE_GO: false 43 | BASH_EXEC_IGNORE_LIBRARIES: true 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | pkg 3 | src 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 18 | .glide/ 19 | 20 | main 21 | build 22 | dist 23 | 24 | .DS_Store 25 | 26 | docs/vendor/** 27 | 28 | docs/.bundle/** 29 | 30 | .sass-cache 31 | 32 | tfswitch* 33 | !tfswitch-completion.* 34 | 35 | build-script.sh 36 | 37 | .idea 38 | 39 | /vendor 40 | /www/site 41 | /go.bin 42 | 43 | # Temp file used to update actual CHANGELOG.md (see CHANGELOG.howto.md) 44 | /CHANGELOG.md.new 45 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # https://goreleaser.com/ 2 | version: 2 3 | 4 | env: 5 | - CGO_ENABLED=0 # Build statically linked binaries 6 | 7 | builds: 8 | - 9 | ldflags: 10 | - -s -w -X "main.version={{.Env.RELEASE_VERSION}}" 11 | main: main.go 12 | binary: tfswitch 13 | goos: 14 | - darwin 15 | - linux 16 | - windows 17 | goarch: 18 | - 386 19 | - amd64 20 | - arm 21 | - arm64 22 | goarm: 23 | - 6 24 | - 7 25 | ignore: 26 | - goos: windows 27 | goarch: arm64 28 | 29 | checksum: 30 | name_template: '{{ .ProjectName }}_{{.Env.RELEASE_VERSION}}_checksums.txt' 31 | 32 | archives: 33 | - id: archives 34 | name_template: >- 35 | {{- .ProjectName }}_ 36 | {{- .Env.RELEASE_VERSION }}_ 37 | {{- .Os }}_ 38 | {{- .Arch }} 39 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 40 | files: 41 | - CHANGELOG.md 42 | - LICENSE 43 | - README.md 44 | - tfswitch-completion.* 45 | format_overrides: 46 | - goos: windows 47 | formats: ['zip'] 48 | 49 | brews: 50 | - 51 | # Name of the recipe 52 | # Default to project name 53 | name: tfswitch 54 | 55 | # GitHub/GitLab repository to push the formula to 56 | # Gitea is not supported yet, but the support coming 57 | repository: 58 | owner: warrensbox 59 | name: homebrew-tap 60 | token: "{{ .Env.PERSONAL_ACCESS_TOKEN }}" 61 | 62 | # Reporitory to push the tap to. 63 | # github: 64 | # owner: warrensbox 65 | # name: homebrew-tap 66 | 67 | # Allows you to set a custom download strategy. 68 | # Default is empty. 69 | #download_strategy: GitHubPrivateRepositoryReleaseDownloadStrategy 70 | 71 | # Git author used to commit to the repository. 72 | # Defaults are shown. 73 | commit_author: 74 | name: Warren Veerasingam 75 | email: warren.veerasingam@gmail.com 76 | 77 | # Folder inside the repository to put the formula. 78 | # Default is the root folder. 79 | directory: Formula 80 | 81 | # Caveats for the user of your binary. 82 | # Default is empty. 83 | caveats: "Type 'tfswitch' on your command line and choose Terraform version that you want from the dropdown" 84 | 85 | # Your app's homepage. 86 | # Default is empty. 87 | homepage: "https://warrensbox.github.io/terraform-switcher" 88 | 89 | # Your app's description. 90 | # Default is empty. 91 | description: "The tfswitch command lets you switch between terraform versions." 92 | 93 | # Packages that conflict with your package. 94 | conflicts: 95 | - terraform 96 | 97 | # Setting this will prevent goreleaser to actually try to commit the updated 98 | # formula - instead, the formula file will be stored on the dist folder only, 99 | # leaving the responsibility of publishing it to the user. 100 | # Default is false. 101 | skip_upload: false 102 | 103 | # So you can `brew test` your formula. 104 | # Default is empty. 105 | test: | 106 | system "#{bin}/tfswitch --version" 107 | # Custom install script for brew. 108 | # Default is 'bin.install "program"'. 109 | install: | 110 | bin.install "tfswitch" 111 | bash_completion.install "tfswitch-completion.bash" => "tfswitch" 112 | 113 | changelog: 114 | # Set this to true if you don't want any changelog at all. 115 | # Templates: allowed 116 | disable: "{{ .Env.NO_CREATE_CHANGELOG }}" 117 | 118 | # Changelog generation implementation to use. 119 | # 120 | # Valid options are: 121 | # - `git`: uses `git log`; 122 | # - `github`: uses the compare GitHub API, appending the author login to the changelog. 123 | # - `gitlab`: uses the compare GitLab API, appending the author name and email to the changelog. 124 | # - `github-native`: uses the GitHub release notes generation API, disables the groups feature. 125 | # 126 | # Default: 'git' 127 | use: github 128 | 129 | # Format to use for commit formatting. 130 | # 131 | # Templates: allowed. 132 | # 133 | # Default: 134 | # if 'git': '{{ .SHA }} {{ .Message }}' 135 | # otherwise: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'. 136 | # 137 | # Extra template fields: 138 | # - `SHA`: the commit SHA1 139 | # - `Message`: the first line of the commit message, otherwise known as commit subject 140 | # - `AuthorName`: the author full name (considers mailmap if 'git') 141 | # - `AuthorEmail`: the author email (considers mailmap if 'git') 142 | # - `AuthorUsername`: github/gitlab/gitea username - not available if 'git' 143 | # 144 | # Usage with 'git': Since: v2.8. 145 | format: "{{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})" 146 | 147 | # Sorts the changelog by the commit's messages. 148 | # Could either be asc, desc or empty 149 | # Empty means 'no sorting', it'll use the output of `git log` as is. 150 | sort: 151 | 152 | # Max commit hash length to use in the changelog. 153 | # 154 | # 0: use whatever the changelog implementation gives you 155 | # -1: remove the commit hash from the changelog 156 | # any other number: max length. 157 | abbrev: -1 158 | 159 | # Group commits messages by given regex and title. 160 | # Order value defines the order of the groups. 161 | # Providing no regex means all commits will be grouped under the default group. 162 | # 163 | # Matches are performed against the first line of the commit message only, 164 | # prefixed with the commit SHA1, usually in the form of 165 | # `[:] `. 166 | # Groups are disabled when using github-native, as it already groups things by itself. 167 | # Regex use RE2 syntax as defined here: https://github.com/google/re2/wiki/Syntax. 168 | groups: 169 | - title: Features 170 | order: 0 171 | regexp: '^.*?feat(ure)?(\([[:word:]]+\))??!?:.+$' 172 | - title: "Bug fixes" 173 | order: 1 174 | regexp: '^.*?(bug(fix)?|fix)(\([[:word:]]+\))??!?:.+$' 175 | - title: Documentation 176 | order: 2 177 | regexp: "^.*?doc(s|umentation).*" 178 | - title: Go 179 | order: 3 180 | regexp: "^go: " 181 | - title: Others 182 | order: 999 183 | 184 | filters: 185 | # Commit messages matching the regexp listed here will be removed from 186 | # the changelog 187 | # 188 | # Matches are performed against the first line of the commit message only, 189 | # prefixed with the commit SHA1, usually in the form of 190 | # `[:] `. 191 | exclude: 192 | - "^.*?Merge pull request " 193 | - "^.*?test(ing)?" 194 | 195 | # Commit messages matching the regexp listed here will be the only ones 196 | # added to the changelog 197 | # 198 | # If include is not-empty, exclude will be ignored. 199 | # 200 | # Matches are performed against the first line of the commit message only, 201 | # prefixed with the commit SHA1, usually in the form of 202 | # `[:] `. 203 | # 204 | # Since: v1.19 205 | #include: 206 | # - "^feat:" 207 | -------------------------------------------------------------------------------- /CHANGELOG.howto.md: -------------------------------------------------------------------------------- 1 | # How to update CHANGELOG with info on latest release 2 | 3 | 1. [Install GH CLI](https://github.com/cli/cli?tab=readme-ov-file#installation). 4 | - [Configure it](https://cli.github.com/manual/#configuration) 5 | 1. [Install `gh-changelog`](https://github.com/chelnak/gh-changelog?tab=readme-ov-file#installation-and-usage) 6 | - Ensure the `.changelog.yml` file is in the root of the repository: 7 | ```yaml 8 | --- 9 | file_name: CHANGELOG.md.new 10 | excluded_labels: 11 | - maintenance 12 | - dependencies 13 | sections: 14 | added: 15 | - feature 16 | - new feature 17 | changed: 18 | - backwards-incompatible 19 | - depricated 20 | fixed: 21 | - bug 22 | - bugfix 23 | - enhancement 24 | - fix 25 | - fixed 26 | skip_entries_without_label: false 27 | show_unreleased: true 28 | check_for_updates: true 29 | logger: console 30 | ``` 31 | 1. Pull latest data from the `origin` 32 | 1. Create new branch and name it accordingly (e.g. `docs/Update_CHANGELOG_with_`). 33 | 1. Run `gh changelog new --from-version --next-version ` to generate CHANGELOG since the `` to ``. 34 | 1. Open `CHANGELOG.md.new`, re-arrange log entries to improve readability if applicable and copy everything under the `The format is based on […]` line (release version(s) with description of changes). 35 | 1. Open [`CHANGELOG.md`](CHANGELOG.md) and paste copied data right under `The format is based on […]` line (keep empty line between this line and pasted data). 36 | 1. Push your changes using conventional commit messages like ``docs: Update CHANGELOG with `` ``, create PR and have someone from [CODEOWNERS](.github/CODEOWNERS) review and approve it. 37 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [support@warrensbox.com](mailto:support@warrensbox.com). The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 warrensbox 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | EXE := tfswitch 2 | PKG := github.com/warrensbox/terraform-switcher 3 | BUILDPATH := build 4 | PATH := $(BUILDPATH):$(PATH) 5 | VER ?= $(shell git ls-remote --tags --sort=version:refname git@github.com:warrensbox/terraform-switcher.git | awk '{if ($$2 ~ "\\^\\{\\}$$") next; print vers[split($$2,vers,"\\/")]}' | tail -1) 6 | # Managing Go installations: Installing multiple Go versions 7 | # https://go.dev/doc/manage-install 8 | GOBINARY ?= $(shell (egrep -m1 '^go[[:space:]]+[[:digit:]]+\.' go.mod | tr -d '[:space:]' | xargs which) || echo go) 9 | GOOS ?= $(shell $(GOBINARY) env GOOS) 10 | GOARCH ?= $(shell $(GOBINARY) env GOARCH) 11 | 12 | $(EXE): version go.mod *.go lib/*.go 13 | mkdir -p "$(BUILDPATH)/" 14 | $(GOBINARY) build -v -ldflags "-X 'main.version=$(VER)'" -o "$(BUILDPATH)/$@" $(PKG) 15 | 16 | .PHONY: release 17 | release: $(EXE) darwin linux windows 18 | 19 | .PHONY: darwin linux windows 20 | darwin linux windows: version 21 | GOOS=$@ $(GOBINARY) build -ldflags "-X 'main.version=$(VER)'" -o "$(BUILDPATH)/$(EXE)-$(word 1, $(VER))-$@-$(GOARCH)" $(PKG) 22 | 23 | .PHONY: clean 24 | clean: 25 | rm -vrf "$(BUILDPATH)/" 26 | 27 | .PHONY: test 28 | test: vet $(EXE) 29 | $(GOBINARY) test -v ./... 30 | 31 | .PHONY: test-single-function 32 | test-single-function: vet 33 | @([ -z "$(TEST_FUNC_NAME)" ] && echo "TEST_FUNC_NAME is not set" && false) || true 34 | $(GOBINARY) test -v -run="$(TEST_FUNC_NAME)" ./... 35 | 36 | .PHONY: vet 37 | vet: version 38 | $(GOBINARY) vet ./... 39 | 40 | .PHONY: version 41 | version: 42 | @echo "Running $(GOBINARY) ($(shell $(GOBINARY) version))" 43 | 44 | .PHONY: install 45 | install: $(EXE) 46 | mkdir -p ~/bin 47 | mv "$(BUILDPATH)/$(EXE)" ~/bin/ 48 | 49 | .PHONY: docs 50 | docs: 51 | @#cd docs; bundle install --path vendor/bundler; bundle exec jekyll build -c _config.yml; cd .. 52 | cd www && mkdocs gh-deploy --force 53 | 54 | .PHONY: goreleaser-release-snapshot 55 | goreleaser-release-snapshot: 56 | RELEASE_VERSION=$(VER) goreleaser release --config ./.goreleaser.yml --snapshot --clean 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Build Status](https://github.com/warrensbox/terraform-switcher/actions/workflows/build.yml/badge.svg)](https://github.com/warrensbox/terraform-switcher/actions/workflows/build.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/warrensbox/terraform-switcher)](https://goreportcard.com/report/github.com/warrensbox/terraform-switcher) 5 | ![GitHub Release](https://img.shields.io/github/v/release/warrensbox/terraform-switcher) 6 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/warrensbox/terraform-switcher/total) 7 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/warrensbox/terraform-switcher) 8 | 9 | # Terraform Switcher 10 | 11 | drawing 12 | 13 | The `tfswitch` command-line tool lets you switch between different versions of [Terraform](https://www.terraform.io/). 14 | If you do not have a particular version of Terraform installed, `tfswitch` will download and verify the version you desire. 15 | The installation is minimal and easy. 16 | Once installed, simply select the version you require from the dropdown and start using Terraform. 17 | 18 | ## Documentation 19 | 20 | Click [here](https://tfswitch.warrensbox.com) for our extended documentation. 21 | 22 | ## NOTE 23 | 24 | Going forward we will change the version identifier of `tfswitch` to align with the common go package versioning. 25 | Please be advised to change any automated implementation you might have that is relying on the `tfswitch` version string. 26 | **Old version string:** `0.1.2412` 27 | **New version string:** `v1.0.0` Note the `v` that is preceding all version numbers. 28 | 29 | ## Installation 30 | 31 | `tfswitch` is available as a binary and on various package managers (eg. Homebrew). 32 | 33 | ## Windows 34 | 35 | Download and extract the Windows version of `tfswitch` that is compatible with your system. 36 | We are building binaries for 386, amd64, arm6 and arm7 CPU structure. 37 | See the [release page](https://github.com/warrensbox/terraform-switcher/releases/latest) for your download. 38 | 39 | ## Homebrew 40 | 41 | For macOS or various Linux distributions, Homebrew offers the simplest installation process. If you do not have Homebrew installed, click here. 42 | 43 | ```shell 44 | brew install warrensbox/tap/tfswitch 45 | ``` 46 | 47 | ## Linux 48 | 49 | Installation for Linux operating systems. 50 | 51 | ```sh 52 | curl -L https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh | bash 53 | ``` 54 | 55 | ## Arch User Repository (AUR) packages for Arch Linux 56 | 57 | ```sh 58 | # compiled from source 59 | yay tfswitch 60 | 61 | # precompiled 62 | yay tfswitch-bin 63 | ``` 64 | 65 | ## Install from source 66 | 67 | Alternatively, you can install the binary from the source here. 68 | 69 | See [our installation documentation](https://tfswitch.warrensbox.com/Installation) for more details. 70 | 71 | > [!IMPORTANT] 72 | > The version identifier of `tfswitch` has changed to align with the common `go` package versioning. 73 | > 74 | > Version numbers will now be prefixed with a `v` - eg. `v1.0.3`. 75 | > 76 | > Please change any automated implementations relying on the `tfswitch` version string. 77 | > 78 | > **Old version string:** `0.1.2412` 79 | > **New version string:** `v1.0.3` 80 | 81 | [Having trouble installing](https://tfswitch.warrensbox.com/Troubleshoot/) 82 | 83 | ## Quick Start 84 | 85 | ### Dropdown Menu 86 | 87 | Execute `tfswitch` and select the desired Terraform version via the dropdown menu. 88 | 89 | ### Version on command line 90 | 91 | Use `tfswitch 1.7.0` to install Terraform version 1.7.0. Replace the version number as required. 92 | 93 | More [usage guide here](https://tfswitch.warrensbox.com/usage/commandline/) 94 | 95 | ## How to contribute 96 | 97 | An open source project becomes meaningful when people collaborate to improve the code. 98 | Feel free to look at the code, critique and make suggestions. Let's make `tfswitch` better! 99 | 100 | See step-by-step instructions on how to contribute here: [Contribute](https://tfswitch.warrensbox.com/How-to-Contribute/) 101 | 102 | ## Additional Info 103 | 104 | See how to [_upgrade_ and _uninstall_](https://tfswitch.warrensbox.com/Upgrade-or-Uninstall/) or [_troubleshoot_](https://tfswitch.warrensbox.com/Troubleshoot/) 105 | 106 | ## Issues 107 | 108 | Please open _issues_ here: [New Issue](https://github.com/warrensbox/terraform-switcher/issues) 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/warrensbox/terraform-switcher 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/ProtonMail/gopenpgp/v3 v3.3.0 9 | github.com/gookit/color v1.5.4 10 | github.com/gookit/slog v0.5.8 11 | github.com/hashicorp/go-version v1.7.0 12 | github.com/hashicorp/hcl/v2 v2.23.0 13 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250401063509-d2d12f9a63bb 14 | github.com/manifoldco/promptui v0.9.0 15 | github.com/mitchellh/go-homedir v1.1.0 16 | github.com/pborman/getopt v1.1.0 17 | github.com/spf13/viper v1.20.1 18 | github.com/stretchr/testify v1.10.0 19 | golang.org/x/sys v0.33.0 20 | ) 21 | 22 | require ( 23 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 24 | github.com/agext/levenshtein v1.2.3 // indirect 25 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 26 | github.com/chzyer/readline v1.5.1 // indirect 27 | github.com/cloudflare/circl v1.6.1 // indirect 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 29 | github.com/fsnotify/fsnotify v1.9.0 // indirect 30 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 31 | github.com/google/go-cmp v0.7.0 // indirect 32 | github.com/gookit/goutil v0.6.18 // indirect 33 | github.com/gookit/gsr v0.1.1 // indirect 34 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 35 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 36 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 37 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 38 | github.com/rogpeppe/go-internal v1.12.0 // indirect 39 | github.com/sagikazarmark/locafero v0.9.0 // indirect 40 | github.com/sourcegraph/conc v0.3.0 // indirect 41 | github.com/spf13/afero v1.14.0 // indirect 42 | github.com/spf13/cast v1.8.0 // indirect 43 | github.com/spf13/pflag v1.0.6 // indirect 44 | github.com/subosito/gotenv v1.6.0 // indirect 45 | github.com/valyala/bytebufferpool v1.0.0 // indirect 46 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 47 | github.com/zclconf/go-cty v1.16.2 // indirect 48 | go.uber.org/multierr v1.11.0 // indirect 49 | golang.org/x/crypto v0.37.0 // indirect 50 | golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect 51 | golang.org/x/mod v0.24.0 // indirect 52 | golang.org/x/sync v0.13.0 // indirect 53 | golang.org/x/term v0.31.0 // indirect 54 | golang.org/x/text v0.24.0 // indirect 55 | golang.org/x/tools v0.32.0 // indirect 56 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 57 | gopkg.in/yaml.v3 v3.0.1 // indirect 58 | ) 59 | -------------------------------------------------------------------------------- /lib/checksum.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bufio" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/ProtonMail/gopenpgp/v3/crypto" 13 | ) 14 | 15 | // getChecksumFromFile Extract the checksum from the signature file 16 | func getChecksumFromHashFile(signatureFilePath string, terraformFileName string) (string, error) { 17 | readFile, err := os.Open(signatureFilePath) 18 | if err != nil { 19 | logger.Errorf("Could not open %q: %v", signatureFilePath, err) 20 | return "", err 21 | } 22 | defer readFile.Close() 23 | 24 | scanner := bufio.NewScanner(readFile) 25 | scanner.Split(bufio.ScanLines) 26 | for scanner.Scan() { 27 | split := strings.Split(scanner.Text(), " ") 28 | if len(split) == 2 && split[1] == terraformFileName { 29 | return split[0], nil 30 | } 31 | } 32 | return "", nil 33 | } 34 | 35 | // checkChecksumMatches This will calculate and compare the check sum of the downloaded zip file 36 | func checkChecksumMatches(hashFile string, targetFile *os.File) bool { 37 | logger.Debugf("Checksum comparison for %q", targetFile.Name()) 38 | var fileHandlersToClose []*os.File 39 | fileHandlersToClose = append(fileHandlersToClose, targetFile) 40 | defer closeFileHandlers(fileHandlersToClose) 41 | 42 | _, fileName := filepath.Split(targetFile.Name()) 43 | expectedChecksum, err := getChecksumFromHashFile(hashFile, fileName) 44 | if err != nil { 45 | logger.Errorf("Could not get checksum from file %q: %v", hashFile, err) 46 | return false 47 | } 48 | hash := sha256.New() 49 | if _, err := io.Copy(hash, targetFile); err != nil { 50 | logger.Errorf("Checksum calculation failed for %q: %v", fileName, err) 51 | return false 52 | } 53 | checksum := hex.EncodeToString(hash.Sum(nil)) 54 | if expectedChecksum != checksum { 55 | logger.Errorf("Checksum mismatch for %q. Expected: %q, calculated: %v", fileName, expectedChecksum, checksum) 56 | return false 57 | } 58 | return true 59 | } 60 | 61 | // checkSignatureOfChecksums This will verify the signature of the file containing the hash sums 62 | func checkSignatureOfChecksums(keyFile *os.File, hashFile *os.File, signatureFile *os.File) bool { 63 | var fileHandlersToClose []*os.File 64 | fileHandlersToClose = append(fileHandlersToClose, keyFile) 65 | fileHandlersToClose = append(fileHandlersToClose, hashFile) 66 | fileHandlersToClose = append(fileHandlersToClose, signatureFile) 67 | defer closeFileHandlers(fileHandlersToClose) 68 | 69 | logger.Infof("Verifying PGP signature of checksum file: %q", hashFile.Name()) 70 | 71 | keyFileContent, err := io.ReadAll(keyFile) 72 | if err != nil { 73 | logger.Errorf("Could not read PGP key file %q: %v", keyFile.Name(), err) 74 | return false 75 | } 76 | 77 | keyFromArmored, err := crypto.NewKeyFromArmored(string(keyFileContent)) 78 | if err != nil { 79 | logger.Errorf("Could not read PGP armored key: %v", err) 80 | return false 81 | } 82 | 83 | signingKey, err := crypto.PGP().Verify().VerificationKey(keyFromArmored).New() 84 | if err != nil { 85 | logger.Errorf("Could not read PGP signing key: %v", err) 86 | return false 87 | } 88 | 89 | hashFileContent, err := io.ReadAll(hashFile) 90 | if err != nil { 91 | logger.Errorf("Could not read hash file %q: %v", hashFile.Name(), err) 92 | return false 93 | } 94 | 95 | signatureContent, err := io.ReadAll(signatureFile) 96 | if err != nil { 97 | logger.Errorf("Could not read PGP signature file %q: %v", signatureFile.Name(), err) 98 | return false 99 | } 100 | 101 | verifyRes, err := signingKey.VerifyDetached(hashFileContent, signatureContent, crypto.Auto) 102 | if err != nil { 103 | logger.Errorf("Could not verify detached signature PGP message: %v", err) 104 | return false 105 | } 106 | 107 | if err := verifyRes.SignatureError(); err != nil { 108 | logger.Errorf("Could not verify PGP signature: %v", err) 109 | return false 110 | } 111 | 112 | logger.Info("Checksum file PGP signature verification successful") 113 | return true 114 | } 115 | -------------------------------------------------------------------------------- /lib/checksum_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func Test_getChecksumFromHashFile(t *testing.T) { 9 | expected := "3ff056b5e8259003f67fd0f0ed7229499cfb0b41f3ff55cc184088589994f7a5" 10 | got, err := getChecksumFromHashFile("../test-data/terraform_1.7.5_SHA256SUMS", "terraform_1.7.5_linux_amd64.zip") 11 | if err != nil { 12 | t.Errorf("getChecksumFromHashFile() error = %v", err) 13 | return 14 | } 15 | if got != expected { 16 | t.Errorf("getChecksumFromHashFile() got = %v, expected %v", got, expected) 17 | } 18 | } 19 | 20 | func Test_checkChecksumMatches(t *testing.T) { 21 | InitLogger("TRACE") 22 | targetFile, err := os.Open("../test-data/checksum-check-file") 23 | if err != nil { 24 | t.Errorf("[Error]: Could not open testfile for signature verification.") 25 | } 26 | 27 | if got := checkChecksumMatches("../test-data/terraform_1.7.5_SHA256SUMS", targetFile); got != true { 28 | t.Errorf("checkChecksumMatches() = %v, want %v", got, true) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/command.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | ) 9 | 10 | // string `windows` has 12 occurrences, make it a constant (goconst) 11 | const windows = "windows" 12 | 13 | // Command : type string 14 | type Command struct { 15 | name string 16 | } 17 | 18 | // NewCommand : get command 19 | func NewCommand(name string) *Command { 20 | return &Command{name: name} 21 | } 22 | 23 | // PathList : get bin path list 24 | func (cmd *Command) PathList() []string { 25 | path := os.Getenv("PATH") 26 | return strings.Split(path, string(os.PathListSeparator)) 27 | } 28 | 29 | func isDir(path string) bool { 30 | fileInfo, err := os.Stat(path) 31 | if err != nil || os.IsNotExist(err) { 32 | return false 33 | } 34 | return fileInfo.IsDir() 35 | } 36 | 37 | func isExecutable(path string) bool { 38 | if isDir(path) { 39 | return false 40 | } 41 | 42 | fileInfo, err := os.Stat(path) 43 | if err != nil || os.IsNotExist(err) { 44 | return false 45 | } 46 | 47 | if runtime.GOOS == windows { 48 | return true 49 | } 50 | 51 | if fileInfo.Mode()&0o111 != 0 { 52 | return true 53 | } 54 | 55 | return false 56 | } 57 | 58 | // Find : find all bin path 59 | func (cmd *Command) Find() func() string { 60 | pathChan := make(chan string) 61 | go func() { 62 | for _, p := range cmd.PathList() { 63 | if !isDir(p) { 64 | continue 65 | } 66 | fileList, err := os.ReadDir(p) 67 | if err != nil { 68 | continue 69 | } 70 | 71 | for _, f := range fileList { 72 | path := filepath.Join(p, f.Name()) 73 | if isExecutable(path) && f.Name() == cmd.name { 74 | pathChan <- path 75 | } 76 | } 77 | } 78 | pathChan <- "" 79 | }() 80 | 81 | return func() string { 82 | return <-pathChan 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/command_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/warrensbox/terraform-switcher/lib" 8 | ) 9 | 10 | // TestNewCommand : pass value and check if returned value is a pointer 11 | func TestNewCommand(t *testing.T) { 12 | testCmd := "terraform" 13 | cmd := lib.NewCommand(testCmd) 14 | 15 | if reflect.ValueOf(cmd).Kind() == reflect.Ptr { 16 | t.Logf("Value returned is a pointer %v [expected]", cmd) 17 | } else { 18 | t.Errorf("Value returned is not a pointer %v [expected", cmd) 19 | } 20 | } 21 | 22 | // TestPathList : check if bin path exist 23 | func TestPathList(t *testing.T) { 24 | testCmd := "" 25 | cmd := lib.NewCommand(testCmd) 26 | listBin := cmd.PathList() 27 | 28 | if listBin == nil { 29 | t.Error("No bin path found [unexpected]") 30 | } else { 31 | t.Logf("Found bin path [expected]") 32 | } 33 | } 34 | 35 | // TestFind : check common "cd" command exist 36 | // This is assuming that Windows and linux has the "cd" command 37 | func TestFind(t *testing.T) { 38 | testCmd := "cd" 39 | cmd := lib.NewCommand(testCmd) 40 | 41 | next := cmd.Find() 42 | for path := next(); len(path) > 0; path = next() { 43 | if path != "" { 44 | t.Logf("Found installation path: %v [expected]\n", path) 45 | } else { 46 | t.Errorf("Unable to find '%v' command in this operating system [unexpected]", testCmd) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/common.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func checkFileExist(file string) bool { 9 | _, err := os.Stat(file) 10 | return err == nil 11 | } 12 | 13 | func createFile(path string) { 14 | // detect if file exists 15 | _, err := os.Stat(path) 16 | 17 | // create file if not exists 18 | if os.IsNotExist(err) { 19 | file, err := os.Create(path) 20 | if err != nil { 21 | logger.Error(err) 22 | return 23 | } 24 | defer file.Close() 25 | } 26 | 27 | logger.Infof("==> done creating %q file", path) 28 | } 29 | 30 | func cleanUp(path string) { 31 | err := removeContents(path) 32 | if err != nil { 33 | logger.Error(err) 34 | } 35 | removeFiles(path) 36 | } 37 | 38 | func removeFiles(src string) { 39 | files, err := filepath.Glob(src) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | for _, f := range files { 45 | if err := os.Remove(f); err != nil { 46 | panic(err) 47 | } 48 | } 49 | } 50 | 51 | func removeContents(dir string) error { 52 | d, err := os.Open(dir) 53 | if err != nil { 54 | return err 55 | } 56 | defer d.Close() 57 | names, err := d.Readdirnames(-1) 58 | if err != nil { 59 | return err 60 | } 61 | for _, name := range names { 62 | err = os.RemoveAll(filepath.Join(dir, name)) 63 | if err != nil { 64 | return err 65 | } 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /lib/defaults.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | const ( 4 | DefaultMirror = "https://releases.hashicorp.com/terraform" 5 | DefaultLatest = "" 6 | InstallDir = ".terraform.versions" 7 | pubKeySuffix = ".asc" 8 | recentFile = "RECENT" 9 | tfDarwinArm64StartVersion = "1.0.2" 10 | DefaultProductId = "terraform" // nolint:revive // FIXME: var-naming: const DefaultProductId should be DefaultProductID (revive) 11 | ) 12 | -------------------------------------------------------------------------------- /lib/dir_perm.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package lib 5 | 6 | import "golang.org/x/sys/unix" 7 | 8 | // Check if user has permission to directory : 9 | // dir=path to file 10 | // return bool 11 | func CheckDirWritable(dir string) bool { 12 | return unix.Access(dir, unix.W_OK) == nil 13 | } 14 | -------------------------------------------------------------------------------- /lib/dir_perm_windows.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func CheckDirWritable(path string) bool { 8 | 9 | info, err := os.Stat(path) 10 | if err != nil { 11 | logger.Errorf("Path doesn't exist: %q", path) 12 | return false 13 | } 14 | 15 | err = nil 16 | if !info.IsDir() { 17 | logger.Errorf("Path isn't a directory: %q", path) 18 | return false 19 | } 20 | 21 | // Check if the user bit is enabled in file permission 22 | if info.Mode().Perm()&(1<<(uint(7))) == 0 { 23 | logger.Errorf("Path is not writable by the user: %q", path) 24 | return false 25 | } 26 | 27 | return true 28 | } 29 | -------------------------------------------------------------------------------- /lib/download.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | // DownloadFromURL : Downloads the terraform binary and its hash from the source url 14 | func DownloadFromURL(installLocation, mirrorURL, tfversion, versionPrefix, goos, goarch string) (string, error) { 15 | product := getLegacyProduct() 16 | return DownloadProductFromURL(product, installLocation, mirrorURL, tfversion, versionPrefix, goos, goarch) 17 | } 18 | 19 | func DownloadProductFromURL(product Product, installLocation, mirrorURL, tfversion, versionPrefix, goos, goarch string) (string, error) { 20 | var wg sync.WaitGroup 21 | defer wg.Done() 22 | // nolint:revive // FIXME: var-naming: var zipUrl should be zipURL (revive) 23 | zipUrl := mirrorURL + "/" + versionPrefix + tfversion + "_" + goos + "_" + goarch + ".zip" 24 | // nolint:revive // FIXME: var-naming: var hashUrl should be hashURL (revive) 25 | hashUrl := mirrorURL + "/" + versionPrefix + tfversion + "_SHA256SUMS" 26 | // nolint:revive // FIXME: var-naming: var hashSignatureUrl should be hashSignatureURL (revive) 27 | hashSignatureUrl := mirrorURL + "/" + versionPrefix + tfversion + "_SHA256SUMS." + product.GetShaSignatureSuffix() 28 | 29 | pubKeyFilename, err := downloadPublicKey(product, installLocation, &wg) 30 | if err != nil { 31 | logger.Error("Could not download public PGP key file") 32 | return "", err 33 | } 34 | 35 | logger.Infof("Downloading %q", zipUrl) 36 | zipFilePath, err := downloadFromURL(installLocation, zipUrl, &wg) 37 | if err != nil { 38 | logger.Error("Could not download zip file") 39 | return "", err 40 | } 41 | 42 | logger.Infof("Downloading %q", hashUrl) 43 | hashFilePath, err := downloadFromURL(installLocation, hashUrl, &wg) 44 | if err != nil { 45 | logger.Error("Could not download hash file") 46 | return "", err 47 | } 48 | 49 | logger.Infof("Downloading %q", hashSignatureUrl) 50 | hashSigFilePath, err := downloadFromURL(installLocation, hashSignatureUrl, &wg) 51 | if err != nil { 52 | logger.Error("Could not download hash signature file") 53 | return "", err 54 | } 55 | 56 | // // Wait for wait group, as the file downloads are required for the below functionality 57 | // wg.Wait() 58 | 59 | publicKeyFile, err := os.Open(pubKeyFilename) 60 | if err != nil { 61 | logger.Errorf("Could not open public key %q: %v", pubKeyFilename, err) 62 | return "", err 63 | } 64 | 65 | signatureFile, err := os.Open(hashSigFilePath) 66 | if err != nil { 67 | logger.Errorf("Could not open hash signature file %q: %v", hashSigFilePath, err) 68 | return "", err 69 | } 70 | 71 | targetFile, err := os.Open(zipFilePath) 72 | if err != nil { 73 | logger.Errorf("Could not open zip file %q: %v", zipFilePath, err) 74 | return "", err 75 | } 76 | 77 | hashFile, err := os.Open(hashFilePath) 78 | if err != nil { 79 | logger.Errorf("Could not open hash file %q: %v", hashFilePath, err) 80 | return "", err 81 | } 82 | 83 | var filesToCleanup []string 84 | filesToCleanup = append(filesToCleanup, hashFilePath) 85 | filesToCleanup = append(filesToCleanup, hashSigFilePath) 86 | defer cleanup(filesToCleanup, &wg) 87 | 88 | verified := checkSignatureOfChecksums(publicKeyFile, hashFile, signatureFile) 89 | if !verified { 90 | return "", errors.New("Signature of checksum file could not be verified") 91 | } 92 | match := checkChecksumMatches(hashFilePath, targetFile) 93 | if !match { 94 | return "", errors.New("Checksums did not match") 95 | } 96 | return zipFilePath, err 97 | } 98 | 99 | func downloadFromURL(installLocation string, url string, wg *sync.WaitGroup) (string, error) { 100 | wg.Add(1) 101 | tokens := strings.Split(url, "/") 102 | fileName := tokens[len(tokens)-1] 103 | logger.Infof("Downloading to %q", filepath.Join(installLocation, "/", fileName)) 104 | 105 | response, err := http.Get(url) // nolint:gosec // `url' is expected to be variable 106 | if err != nil { 107 | logger.Errorf("Error downloading %s: %v", url, err) 108 | return "", err 109 | } 110 | defer response.Body.Close() 111 | 112 | if response.StatusCode != 200 { 113 | // Sometimes hashicorp terraform file names are not consistent 114 | // For example 0.12.0-alpha4 naming convention in the release repo is not consistent 115 | return "", errors.New("Unable to download from " + url) 116 | } 117 | 118 | filePath := filepath.Join(installLocation, fileName) 119 | output, err := os.Create(filePath) 120 | if err != nil { 121 | logger.Errorf("Error creating %q: %v", filePath, err) 122 | return "", err 123 | } 124 | defer output.Close() 125 | 126 | n, err := io.Copy(output, response.Body) 127 | if err != nil { 128 | logger.Errorf("Error while downloading %s: %v", url, err) 129 | return "", err 130 | } 131 | 132 | logger.Info(n, "bytes downloaded") 133 | return filePath, nil 134 | } 135 | 136 | func downloadPublicKey(product Product, installLocation string, wg *sync.WaitGroup) (string, error) { 137 | pubKeyFilePath := filepath.Join(installLocation, "/", product.GetId()+"_"+product.GetPublicKeyId()+pubKeySuffix) 138 | logger.Debugf("Looking up public key file at %q", pubKeyFilePath) 139 | publicKeyFileExists := FileExistsAndIsNotDir(pubKeyFilePath) 140 | if !publicKeyFileExists { 141 | // Public key does not exist. Let's grab it from hashicorp 142 | pubKeyFile, errDl := downloadFromURL(installLocation, product.GetPublicKeyUrl(), wg) 143 | if errDl != nil { 144 | logger.Errorf("Error fetching public key file from %s", product.GetPublicKeyUrl()) 145 | return "", errDl 146 | } 147 | errRename := os.Rename(pubKeyFile, pubKeyFilePath) 148 | if errRename != nil { 149 | logger.Errorf("Error renaming public key file from %q to %q", pubKeyFile, pubKeyFilePath) 150 | return "", errRename 151 | } 152 | } 153 | return pubKeyFilePath, nil 154 | } 155 | 156 | func cleanup(paths []string, wg *sync.WaitGroup) { 157 | for _, path := range paths { 158 | wg.Add(1) 159 | logger.Infof("Deleting %q", path) 160 | err := os.Remove(path) 161 | if err != nil { 162 | logger.Error("Error deleting %q: %v", path, err) 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /lib/files.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "archive/zip" 5 | "bufio" 6 | "bytes" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/mitchellh/go-homedir" 15 | ) 16 | 17 | // RenameFile : rename file name 18 | func RenameFile(src string, dest string) { 19 | logger.Debugf("Renaming file %q to %q", src, dest) 20 | err := os.Rename(src, dest) 21 | if err != nil { 22 | logger.Fatal(err) 23 | } 24 | } 25 | 26 | // RemoveFiles : remove file 27 | func RemoveFiles(src string) { 28 | // Keep both identical functions for backward compatibility 29 | // FIXME: need to plan deprecation of either of them 30 | // 09-Mar-2025 31 | removeFiles(src) 32 | } 33 | 34 | // CheckFileExist : check if file exist in directory 35 | func CheckFileExist(file string) bool { 36 | _, err := os.Stat(file) 37 | return err == nil 38 | } 39 | 40 | // Unzip will decompress a zip archive, moving all files and folders 41 | // within the zip file (parameter 1) to an output directory (parameter 2). 42 | // fileToUnzip (parameter 3) specifies the file within the zipfile to be extracted. 43 | // This is optional and default to "terraform" 44 | func Unzip(src string, dest string, fileToUnzipSlice ...string) ([]string, error) { 45 | logger.Debugf("Unzipping file %q", src) 46 | 47 | // Handle old signature of method, where fileToUnzip did not exist 48 | legacyProduct := getLegacyProduct() 49 | fileToUnzip := legacyProduct.GetExecutableName() 50 | if len(fileToUnzipSlice) == 1 { 51 | fileToUnzip = fileToUnzipSlice[0] 52 | } else if len(fileToUnzipSlice) > 1 { 53 | logger.Fatal("Too many args passed to Unzip") 54 | } 55 | 56 | var filenames []string 57 | 58 | reader, err := zip.OpenReader(src) 59 | if err != nil { 60 | return filenames, err 61 | } 62 | defer reader.Close() 63 | destination, err := filepath.Abs(dest) 64 | if err != nil { 65 | logger.Fatalf("Could not open destination: %v", err) 66 | } 67 | var unzipWaitGroup sync.WaitGroup 68 | for _, f := range reader.File { 69 | // Only extract the main binary 70 | // from the archive, ignoring LICENSE and other files 71 | if f.Name != ConvertExecutableExt(fileToUnzip) { 72 | continue 73 | } 74 | 75 | unzipWaitGroup.Add(1) 76 | unzipErr := unzipFile(f, destination, &unzipWaitGroup) 77 | if unzipErr != nil { 78 | return nil, fmt.Errorf("Error unzipping: %v", unzipErr) 79 | } 80 | // nolint:gosec // The "G305: File traversal when extracting zip/tar archive" is handled by unzipFile() 81 | filenames = append(filenames, filepath.Join(destination, f.Name)) 82 | } 83 | logger.Debug("Waiting for deferred functions") 84 | unzipWaitGroup.Wait() 85 | 86 | if len(filenames) < 1 { 87 | logger.Fatalf("Could not find %s file in release archive to unzip", fileToUnzip) 88 | } else if len(filenames) > 1 { 89 | logger.Fatal("Extracted more files than expected in release archive") 90 | } 91 | 92 | return filenames, nil 93 | } 94 | 95 | // createDirIfNotExist : create directory if directory does not exist 96 | func createDirIfNotExist(dir string) { 97 | if _, err := os.Stat(dir); os.IsNotExist(err) { 98 | logger.Infof("Creating %q directory", dir) 99 | err = os.MkdirAll(dir, 0o755) 100 | if err != nil { 101 | logger.Panicf("Unable to create %q directory: %v", dir, err) 102 | } 103 | } 104 | } 105 | 106 | // WriteLines : writes into file 107 | // 108 | // Deprecated: This method has been deprecated and will be removed in v2.0.0 109 | func WriteLines(lines []string, path string) (err error) { 110 | var file *os.File 111 | 112 | if file, err = os.Create(path); err != nil { 113 | return err 114 | } 115 | defer file.Close() 116 | 117 | for _, item := range lines { 118 | _, err := file.WriteString(strings.TrimSpace(item) + "\n") 119 | if err != nil { 120 | logger.Error(err) 121 | break 122 | } 123 | } 124 | 125 | return nil 126 | } 127 | 128 | // ReadLines : Read a whole file into the memory and store it as array of lines 129 | // 130 | // Deprecated: This method has been deprecated and will be removed in v2.0.0 131 | func ReadLines(path string) (lines []string, err error) { 132 | var ( 133 | file *os.File 134 | part []byte 135 | prefix bool 136 | ) 137 | if file, err = os.Open(path); err != nil { 138 | return 139 | } 140 | defer file.Close() 141 | 142 | reader := bufio.NewReader(file) 143 | buffer := bytes.NewBuffer(make([]byte, 0)) 144 | for { 145 | if part, prefix, err = reader.ReadLine(); err != nil { 146 | break 147 | } 148 | buffer.Write(part) 149 | if !prefix { 150 | lines = append(lines, buffer.String()) 151 | buffer.Reset() 152 | } 153 | } 154 | if err == io.EOF { 155 | err = nil 156 | } 157 | return 158 | } 159 | 160 | // IsDirEmpty : check if directory is empty (TODO UNIT TEST) 161 | func IsDirEmpty(dir string) bool { 162 | exist := false 163 | 164 | f, err := os.Open(dir) 165 | if err != nil { 166 | logger.Fatal(err) 167 | } 168 | defer f.Close() 169 | 170 | _, err = f.Readdirnames(1) // Or f.Readdir(1) 171 | if err == io.EOF { 172 | exist = true 173 | } 174 | return exist // Either not empty or error, suits both cases 175 | } 176 | 177 | // CheckDirHasTGBin : // check binary exist (TODO UNIT TEST) 178 | func CheckDirHasTGBin(dir, prefix string) bool { 179 | exist := false 180 | 181 | files, err := os.ReadDir(dir) 182 | if err != nil { 183 | logger.Fatal(err) 184 | } 185 | for _, f := range files { 186 | if !f.IsDir() && strings.HasPrefix(f.Name(), prefix) { 187 | exist = true 188 | } 189 | } 190 | return exist 191 | } 192 | 193 | // CheckDirExist : check if directory exist 194 | // dir=path to file 195 | // return bool 196 | func CheckDirExist(dir string) bool { 197 | if _, err := os.Stat(dir); os.IsNotExist(err) { 198 | logger.Debugf("Directory %q doesn't exist", dir) 199 | return false 200 | } 201 | 202 | return true 203 | } 204 | 205 | // CheckIsDir: check if is directory 206 | // dir=path to file 207 | // return bool 208 | func CheckIsDir(dir string) bool { 209 | fi, err := os.Stat(dir) 210 | 211 | if err != nil { 212 | logger.Debugf("Error checking %q: %v", dir, err) 213 | return false 214 | } else if !fi.IsDir() { 215 | logger.Debugf("The %q is not a directory", dir) 216 | return false 217 | } 218 | 219 | return true 220 | } 221 | 222 | // Path : returns path of directory 223 | // value=path to file 224 | func Path(value string) string { 225 | return filepath.Dir(value) 226 | } 227 | 228 | // GetFileName : remove file ext. .tfswitch.config returns .tfswitch 229 | func GetFileName(configfile string) string { 230 | return strings.TrimSuffix(configfile, filepath.Ext(configfile)) 231 | } 232 | 233 | // GetCurrentDirectory : return the current directory 234 | func GetCurrentDirectory() string { 235 | dir, err := os.Getwd() // get current directory 236 | if err != nil { 237 | logger.Fatalf("Failed to get current directory: %v", err) 238 | } 239 | return dir 240 | } 241 | 242 | // GetHomeDirectory : return the user's home directory 243 | func GetHomeDirectory() string { 244 | homedir, err := homedir.Dir() 245 | if err != nil { 246 | logger.Fatalf("Failed to get user's home directory: %v", err) 247 | } 248 | return homedir 249 | } 250 | 251 | func unzipFile(f *zip.File, destination string, wg *sync.WaitGroup) error { 252 | defer wg.Done() 253 | // 1. Check if file paths are not vulnerable to Zip Slip 254 | filePath := filepath.Join(destination, f.Name) // nolint:gosec // The "G305: File traversal when extracting zip/tar archive" is handled below 255 | if !strings.HasPrefix(filePath, filepath.Clean(destination)+string(os.PathSeparator)) { 256 | return fmt.Errorf("Invalid file path: %q", filePath) 257 | } 258 | 259 | // 2. Create directory tree 260 | if f.FileInfo().IsDir() { 261 | logger.Debugf("Extracting directory %q", filePath) 262 | if err := os.MkdirAll(filePath, os.ModePerm); err != nil { 263 | return err 264 | } 265 | return nil 266 | } 267 | 268 | if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { 269 | return err 270 | } 271 | 272 | // 3. Create a destination file for unzipped content 273 | destinationFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 274 | defer func(destinationFile *os.File) { 275 | logger.Debugf("Closing destination file handler %q", destinationFile.Name()) 276 | _ = destinationFile.Close() 277 | }(destinationFile) 278 | if err != nil { 279 | return err 280 | } 281 | 282 | // 4. Unzip the content of a file and copy it to the destination file 283 | zippedFile, err := f.Open() 284 | defer func(zippedFile io.ReadCloser) { 285 | logger.Debugf("Closing zipped file handler %q", f.Name) 286 | _ = zippedFile.Close() 287 | }(zippedFile) 288 | if err != nil { 289 | return err 290 | } 291 | 292 | logger.Debugf("Extracting file %q to %q", f.Name, destinationFile.Name()) 293 | // Prevent the "G110: Potential DoS vulnerability via decompression bomb (gosec)" 294 | totalCopied := int64(0) 295 | maxSize := int64(1024 * 1024 * 1024) // 1 GB 296 | for { 297 | copied, err := io.CopyN(destinationFile, zippedFile, 1024*1024) 298 | totalCopied += copied 299 | if totalCopied%(10*1024*1024) == 0 { // Print stats every 10 MB 300 | logger.Debugf("Size copied so far: %3.d MB\r", totalCopied/1024/1024) 301 | } 302 | if err != nil { 303 | if err == io.EOF { 304 | logger.Debugf("Total size copied: %4.d MB\r", totalCopied/1024/1024) 305 | break 306 | } 307 | return err 308 | } 309 | if totalCopied > maxSize { 310 | return fmt.Errorf("file %q is too large (> %d MB)", f.Name, maxSize/1024/1024) 311 | } 312 | } 313 | return nil 314 | } 315 | -------------------------------------------------------------------------------- /lib/install_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "os/user" 5 | "testing" 6 | 7 | "github.com/mitchellh/go-homedir" 8 | ) 9 | 10 | func TestInstall(t *testing.T) { 11 | 12 | t.Run("User should exist", 13 | func(t *testing.T) { 14 | _, errCurr := user.Current() 15 | if errCurr != nil { 16 | t.Errorf("Unable to get user %v [unexpected]", errCurr) 17 | } 18 | 19 | _, errCurr = homedir.Dir() 20 | if errCurr != nil { 21 | t.Errorf("Unable to get user home directory: %v [unexpected]", errCurr) 22 | } 23 | }, 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /lib/list_versions.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "reflect" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | type tfVersionList struct { 13 | tflist []string 14 | } 15 | 16 | func getVersionsFromBody(body string, preRelease bool, tfVersionList *tfVersionList) { 17 | var semver string 18 | if preRelease { 19 | // Getting versions from body; should return match /X.X.X-@/ where X is a number,@ is a word character between a-z or A-Z 20 | semver = `\/?(\d+\.\d+\.\d+)(-[a-zA-z]+\d*)?/?"` 21 | } else if !preRelease { 22 | // Getting versions from body; should return match /X.X.X/ where X is a number 23 | // without the ending '"' pre-release folders would be tried and break. 24 | semver = `\/?(\d+\.\d+\.\d+)\/?"` 25 | } 26 | r, err := regexp.Compile(semver) 27 | if err != nil { 28 | logger.Fatalf("Error compiling %q regex: %v", semver, err) 29 | } 30 | 31 | matches := r.FindAllString(body, -1) 32 | if matches == nil { 33 | return 34 | } 35 | for _, match := range matches { 36 | trimstr := strings.Trim(match, "/\"") // remove '/' or '"' from /X.X.X/" or /X.X.X" 37 | tfVersionList.tflist = append(tfVersionList.tflist, trimstr) 38 | } 39 | } 40 | 41 | // getTFList : Get the list of available versions given the mirror URL 42 | func getTFList(mirrorURL string, preRelease bool) ([]string, error) { 43 | logger.Debug("Getting list of versions") 44 | result, err := getTFURLBody(mirrorURL) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | var tfVersionList tfVersionList 50 | getVersionsFromBody(result, preRelease, &tfVersionList) 51 | 52 | if len(tfVersionList.tflist) == 0 { 53 | logger.Errorf("Cannot get version list from mirror: %s", mirrorURL) 54 | } 55 | return tfVersionList.tflist, nil 56 | } 57 | 58 | // getTFLatest : Get the latest terraform version given the hashicorp url 59 | func getTFLatest(mirrorURL string) (string, error) { 60 | result, err := getTFURLBody(mirrorURL) 61 | if err != nil { 62 | return "", err 63 | } 64 | // Getting versions from body; should return match /X.X.X/ where X is a number 65 | semver := `\/?(\d+\.\d+\.\d+)\/?"` 66 | r, errSemVer := regexp.Compile(semver) 67 | if errSemVer != nil { 68 | return "", fmt.Errorf("Error compiling %q regex: %v", semver, errSemVer) 69 | } 70 | bodyLines := strings.Split(result, "\n") 71 | for i := range result { 72 | if r.MatchString(bodyLines[i]) { 73 | str := r.FindString(bodyLines[i]) 74 | trimstr := strings.Trim(str, "/\"") // remove '/' or '"' from /X.X.X/" or /X.X.X" 75 | return trimstr, nil 76 | } 77 | } 78 | return "", nil 79 | } 80 | 81 | // getTFLatestImplicit : Get the latest implicit terraform version given the hashicorp url 82 | func getTFLatestImplicit(mirrorURL string, preRelease bool, version string) (string, error) { 83 | if preRelease { 84 | // TODO: use getTFList() instead of getTFURLBody 85 | body, err := getTFURLBody(mirrorURL) 86 | if err != nil { 87 | return "", err 88 | } 89 | // Getting versions from body; should return match /X.X.X-@/ where X is a number,@ is a word character between a-z or A-Z 90 | semver := fmt.Sprintf(`\/?(%s{1}\.\d+\-[a-zA-z]+\d*)\/?"`, version) 91 | r, errReSemVer := regexp.Compile(semver) 92 | if errReSemVer != nil { 93 | return "", errReSemVer 94 | } 95 | versions := strings.Split(body, "\n") 96 | for i := range versions { 97 | if r.MatchString(versions[i]) { 98 | str := r.FindString(versions[i]) 99 | trimstr := strings.Trim(str, "/\"") // remove '/' or '"' from /X.X.X/" or /X.X.X" 100 | return trimstr, nil 101 | } 102 | } 103 | } else if !preRelease { 104 | listAll := false 105 | tflist, errTFList := getTFList(mirrorURL, listAll) // get list of versions 106 | if errTFList != nil { 107 | return "", fmt.Errorf("Error getting list of versions from %q: %v", mirrorURL, errTFList) 108 | } 109 | 110 | version = fmt.Sprintf("~> %v", version) 111 | semv, err := SemVerParser(&version, tflist) 112 | if err != nil { 113 | return "", err 114 | } 115 | return semv, nil 116 | } 117 | return "", nil 118 | } 119 | 120 | // getTFURLBody : Get list of terraform versions from hashicorp releases 121 | func getTFURLBody(mirrorURL string) (string, error) { 122 | hasSlash := strings.HasSuffix(mirrorURL, "/") 123 | if !hasSlash { 124 | // if it does not have slash - append slash 125 | mirrorURL = fmt.Sprintf("%s/", mirrorURL) 126 | } 127 | resp, errURL := http.Get(mirrorURL) // nolint:gosec // `mirrorURL' is expected to be variable 128 | if errURL != nil { 129 | logger.Fatalf("Error getting url: %v", errURL) 130 | } 131 | defer resp.Body.Close() 132 | 133 | if resp.StatusCode != 200 { 134 | logger.Fatalf("Error retrieving contents from url: %s", mirrorURL) 135 | } 136 | 137 | body, errBody := io.ReadAll(resp.Body) 138 | if errBody != nil { 139 | logger.Fatalf("Error reading body: %v", errBody) 140 | } 141 | 142 | bodyString := string(body) 143 | 144 | return bodyString, nil 145 | } 146 | 147 | // versionExist : check if requested version exist 148 | func versionExist(val interface{}, array interface{}) (exists bool) { 149 | exists = false 150 | switch reflect.TypeOf(array).Kind() { 151 | case reflect.Slice: 152 | s := reflect.ValueOf(array) 153 | 154 | for i := 0; i < s.Len(); i++ { 155 | if reflect.DeepEqual(val, s.Index(i).Interface()) { 156 | exists = true 157 | return exists 158 | } 159 | } 160 | default: 161 | panic("unhandled default case") 162 | } 163 | return exists 164 | } 165 | 166 | // removeDuplicateVersions : remove duplicate version 167 | func removeDuplicateVersions(elements []string) []string { 168 | // Use map to record duplicates as we find them. 169 | encountered := map[string]bool{} 170 | result := []string{} 171 | 172 | for _, val := range elements { 173 | versionOnly := strings.TrimSuffix(val, " *recent") 174 | if !encountered[versionOnly] { 175 | // Record this element as an encountered element. 176 | encountered[versionOnly] = true 177 | // Append to result slice. 178 | result = append(result, val) 179 | } 180 | } 181 | // Return the new slice. 182 | return result 183 | } 184 | 185 | // validVersionFormat : returns valid version format 186 | /* For example: 0.1.2 = valid 187 | // For example: 0.1.2-beta1 = valid 188 | // For example: 0.1.2-alpha = valid 189 | // For example: a.1.2 = invalid 190 | // For example: 0.1. 2 = invalid 191 | */ 192 | func validVersionFormat(version string) bool { 193 | // Getting versions from body; should return match /X.X.X-@/ where X is a number,@ is a word character between a-z or A-Z 194 | // Follow https://semver.org/spec/v1.0.0-beta.html 195 | // Check regular expression at https://rubular.com/r/ju3PxbaSBALpJB 196 | semverRegex := regexp.MustCompile(`^(\d+\.\d+\.\d+)(-[a-zA-z]+\d*)?$`) 197 | return semverRegex.MatchString(version) 198 | } 199 | 200 | // ValidMinorVersionFormat : returns valid MINOR version format 201 | /* For example: 0.1 = valid 202 | // For example: a.1.2 = invalid 203 | // For example: 0.1.2 = invalid 204 | */ 205 | func validMinorVersionFormat(version string) bool { 206 | // Getting versions from body; should return match /X.X./ where X is a number 207 | semverRegex := regexp.MustCompile(`^(\d+\.\d+)$`) 208 | 209 | return semverRegex.MatchString(version) 210 | } 211 | 212 | // ShowLatestVersion show install latest stable tf version 213 | func ShowLatestVersion(mirrorURL string) { 214 | tfversion, err := getTFLatest(mirrorURL) 215 | if err != nil { 216 | logger.Fatalf("Error getting latest version from %q: %v", mirrorURL, err) 217 | } 218 | 219 | fmt.Printf("%s\n", tfversion) 220 | } 221 | 222 | // ShowLatestImplicitVersion show latest - argument (version) must be provided 223 | func ShowLatestImplicitVersion(requestedVersion, mirrorURL string, preRelease bool) { 224 | if validMinorVersionFormat(requestedVersion) { 225 | tfversion, err := getTFLatestImplicit(mirrorURL, preRelease, requestedVersion) 226 | if err != nil { 227 | logger.Fatalf("Error getting latest implicit version %q from %q: %v", requestedVersion, mirrorURL, err) 228 | } 229 | 230 | if len(tfversion) > 0 { 231 | fmt.Printf("%s\n", tfversion) 232 | } else { 233 | logger.Fatalf("Requested version does not exist: %q.\n\tTry `tfswitch -l` to see all available versions", requestedVersion) 234 | } 235 | } else { 236 | PrintInvalidMinorTFVersion() 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /lib/lockfile.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | ) 8 | 9 | // Acquire exclusive lock 10 | func acquireLock(lockFile string, lockWaitMaxAttempts int, lockWaitInterval time.Duration) (*os.File, error) { 11 | logger.Debugf("Attempting to acquire lock %q", lockFile) 12 | 13 | for lockAttempt := 1; lockAttempt <= lockWaitMaxAttempts; lockAttempt++ { 14 | if file, err := os.OpenFile(lockFile, os.O_CREATE|os.O_EXCL, 0o644); err == nil { 15 | logger.Debugf("Acquired lock %q", lockFile) 16 | return file, nil 17 | } 18 | 19 | logger.Infof("Waiting for lock %q to be released (attempt %d out of %d)", lockFile, lockAttempt, lockWaitMaxAttempts) 20 | 21 | if lockFileInfo, err := os.Stat(lockFile); err == nil { 22 | logger.Debugf("Lock %q last modification time: %s", lockFile, lockFileInfo.ModTime()) 23 | } else { 24 | logger.Warnf("Unable to get lock %q last modification time: %v", lockFile, err) 25 | } 26 | 27 | if lockAttempt < lockWaitMaxAttempts { 28 | time.Sleep(lockWaitInterval) 29 | } 30 | } 31 | 32 | return nil, fmt.Errorf("Failed to acquire lock %q", lockFile) 33 | } 34 | 35 | // Release and remove lock 36 | func releaseLock(lockFile string, lockedFH *os.File) { 37 | logger.Debugf("Releasing lock %q", lockFile) 38 | 39 | if lockedFH == nil { 40 | logger.Warnf("Lock is `nil` on %q", lockFile) 41 | if CheckFileExist(lockFile) { 42 | logger.Warnf("Lock %q exists. This is NOT expected!", lockFile) 43 | } 44 | return 45 | } 46 | 47 | if err := lockedFH.Close(); err != nil { 48 | logger.Warnf("Failed to release lock %q: %v", lockFile, err) 49 | } else { 50 | logger.Debugf("Released lock %q", lockFile) 51 | } 52 | 53 | logger.Debugf("Removing lock %q", lockFile) 54 | 55 | if CheckFileExist(lockFile) { 56 | if err := os.Remove(lockFile); err != nil { 57 | logger.Warnf("Failed to remove lock %q: %v", lockFile, err) 58 | } else { 59 | logger.Debugf("Removed lock %q", lockFile) 60 | } 61 | } else { 62 | logger.Warnf("Lock %q doesn't exist", lockFile) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/lockfile_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // Test Locking 10 | func TestLocking(t *testing.T) { 11 | lockFile := ".tfswitch.lock" 12 | lockFilePath := filepath.Join(t.TempDir(), lockFile) 13 | 14 | t.Logf("Testing lock acquirement: %s", lockFilePath) 15 | 16 | // Acquire lock 17 | if lockedFile, err := acquireLock(lockFilePath, 1, 1*time.Second); err == nil { 18 | t.Logf("Lock acquired successfully: %s", lockFilePath) 19 | 20 | // Concurrent lock 21 | t.Logf("Testing concurrent lock acquirement: %s", lockFilePath) 22 | if _, err := acquireLock(lockFilePath, 1, 1*time.Second); err == nil { 23 | t.Errorf("Concurrent lock acquired successfully: %s. This is NOT expected!", lockFilePath) 24 | } else { 25 | t.Logf("Concurrent lock failed: %s. This is expected.", lockFilePath) 26 | } 27 | 28 | // Release lock 29 | releaseLock(lockFilePath, lockedFile) 30 | if CheckFileExist(lockFilePath) { 31 | t.Errorf("Lock %s still exists. This is NOT expected!", lockFilePath) 32 | } else { 33 | t.Logf("Lock released successfully: %s", lockFilePath) 34 | } 35 | } else { 36 | t.Errorf("Failed to acquire lock: %s", lockFilePath) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/logging.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gookit/color" 7 | "github.com/gookit/slog" 8 | "github.com/gookit/slog/handler" 9 | ) 10 | 11 | var ( 12 | loggingTemplateDebug = "{{datetime}} {{level}} [{{caller}}] {{message}} {{data}} {{extra}}\n" 13 | loggingTemplate = "{{datetime}} {{level}} {{message}} {{data}} {{extra}}\n" 14 | logger *slog.Logger 15 | ErrorLogging = slog.Levels{slog.PanicLevel, slog.FatalLevel, slog.ErrorLevel} 16 | NormalLogging = slog.Levels{slog.PanicLevel, slog.FatalLevel, slog.ErrorLevel, slog.WarnLevel, slog.InfoLevel} 17 | NoticeLogging = slog.Levels{slog.PanicLevel, slog.FatalLevel, slog.ErrorLevel, slog.WarnLevel, slog.InfoLevel, slog.NoticeLevel} 18 | DebugLogging = slog.Levels{slog.PanicLevel, slog.FatalLevel, slog.ErrorLevel, slog.WarnLevel, slog.InfoLevel, slog.NoticeLevel, slog.DebugLevel} 19 | TraceLogging = slog.Levels{slog.PanicLevel, slog.FatalLevel, slog.ErrorLevel, slog.WarnLevel, slog.InfoLevel, slog.NoticeLevel, slog.DebugLevel, slog.TraceLevel} 20 | ) 21 | 22 | func NewStderrConsoleWithLF(lf slog.LevelFormattable) *handler.ConsoleHandler { 23 | h := handler.NewIOWriterWithLF(os.Stderr, lf) 24 | 25 | // default use text formatter 26 | f := slog.NewTextFormatter() 27 | // default enable color on console 28 | f.WithEnableColor(color.SupportColor()) 29 | 30 | h.SetFormatter(f) 31 | return h 32 | } 33 | 34 | func NewStderrConsoleHandler(levels []slog.Level) *handler.ConsoleHandler { 35 | return NewStderrConsoleWithLF(slog.NewLvsFormatter(levels)) 36 | } 37 | 38 | func InitLogger(logLevel string) *slog.Logger { 39 | formatter := slog.NewTextFormatter() 40 | formatter.EnableColor = true 41 | formatter.ColorTheme = slog.ColorTheme 42 | formatter.TimeFormat = "15:04:05.000" 43 | 44 | var h *handler.ConsoleHandler 45 | switch logLevel { 46 | case "ERROR": 47 | h = NewStderrConsoleHandler(ErrorLogging) 48 | formatter.SetTemplate(loggingTemplateDebug) 49 | case "TRACE": 50 | h = NewStderrConsoleHandler(TraceLogging) 51 | formatter.SetTemplate(loggingTemplateDebug) 52 | case "DEBUG": 53 | h = NewStderrConsoleHandler(DebugLogging) 54 | formatter.SetTemplate(loggingTemplateDebug) 55 | case "NOTICE": 56 | h = NewStderrConsoleHandler(NoticeLogging) 57 | formatter.SetTemplate(loggingTemplate) 58 | default: 59 | h = NewStderrConsoleHandler(NormalLogging) 60 | formatter.SetTemplate(loggingTemplate) 61 | } 62 | 63 | h.SetFormatter(formatter) 64 | newLogger := slog.NewWithHandlers(h) 65 | newLogger.ExitFunc = os.Exit 66 | logger = newLogger 67 | return newLogger 68 | } 69 | -------------------------------------------------------------------------------- /lib/param_parsing/environment.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "reflect" 7 | ) 8 | 9 | func GetParamsFromEnvironment(params Params) Params { 10 | reflectedParams := reflect.ValueOf(¶ms) 11 | for _, envVar := range paramMappings { 12 | description := envVar.description 13 | env := envVar.env 14 | param := envVar.param 15 | toml := envVar.toml 16 | 17 | if len(env) == 0 { 18 | logger.Errorf("Internal error: environment variable name is empty for parameter %q mapping, skipping assignment", param) 19 | continue 20 | } 21 | if len(param) == 0 { 22 | logger.Errorf("Internal error: parameter name is empty for environment variable %q mapping, skipping assignment", env) 23 | continue 24 | } 25 | if len(description) == 0 { 26 | description = param 27 | } 28 | 29 | paramKey := reflect.Indirect(reflectedParams).FieldByName(param) 30 | if paramKey.Kind() != reflect.String { 31 | logger.Warnf("Parameter %q is not a string, skipping assignment from environment variable %q", param, env) 32 | continue 33 | } 34 | 35 | if envVarValue := os.Getenv(env); envVarValue != "" { 36 | logger.Debugf("%s (%q) from environment variable %q: %q", description, toml, env, envVarValue) 37 | if !paramKey.CanSet() { 38 | logger.Warnf("Parameter %q cannot be set, skipping assignment from environment variable %q", param, env) 39 | } 40 | paramKey.SetString(envVarValue) 41 | } 42 | } 43 | return params 44 | } 45 | -------------------------------------------------------------------------------- /lib/param_parsing/environment_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "testing" 7 | 8 | "github.com/warrensbox/terraform-switcher/lib" 9 | ) 10 | 11 | func TestGetParamsFromEnvironment_arch_from_env(t *testing.T) { 12 | logger = lib.InitLogger("DEBUG") 13 | var params Params 14 | expected := "amd64_from_env" 15 | _ = os.Setenv("TF_ARCH", expected) 16 | params = initParams(params) 17 | params = GetParamsFromEnvironment(params) 18 | _ = os.Unsetenv("TF_ARCH") 19 | if params.Arch != expected { 20 | t.Error("Determined arch is not matching. Got " + params.Arch + ", expected " + expected) 21 | } 22 | } 23 | 24 | func TestGetParamsFromEnvironment_version_from_env(t *testing.T) { 25 | logger = lib.InitLogger("DEBUG") 26 | var params Params 27 | expected := "1.0.0_from_env" 28 | _ = os.Setenv("TF_VERSION", expected) 29 | params = initParams(params) 30 | params = GetParamsFromEnvironment(params) 31 | _ = os.Unsetenv("TF_VERSION") 32 | if params.Version != expected { 33 | t.Error("Determined version is not matching. Got " + params.Version + ", expected " + expected) 34 | } 35 | } 36 | 37 | func TestGetParamsFromEnvironment_default_version_from_env(t *testing.T) { 38 | var params Params 39 | expected := "1.0.0_from_env" 40 | _ = os.Setenv("TF_DEFAULT_VERSION", expected) 41 | params = initParams(params) 42 | params = GetParamsFromEnvironment(params) 43 | _ = os.Unsetenv("TF_DEFAULT_VERSION") 44 | if params.DefaultVersion != expected { 45 | t.Error("Determined default version is not matching. Got " + params.DefaultVersion + ", expected " + expected) 46 | } 47 | } 48 | 49 | func TestGetParamsFromEnvironment_product_from_env(t *testing.T) { 50 | logger = lib.InitLogger("DEBUG") 51 | var params Params 52 | expected := "opentofu" 53 | _ = os.Setenv("TF_PRODUCT", expected) 54 | params = initParams(params) 55 | params = GetParamsFromEnvironment(params) 56 | _ = os.Unsetenv("TF_PRODUCT") 57 | if params.Product != expected { 58 | t.Error("Determined product is not matching. Got " + params.Product + ", expected " + expected) 59 | } 60 | } 61 | 62 | func TestGetParamsFromEnvironment_bin_from_env(t *testing.T) { 63 | logger = lib.InitLogger("DEBUG") 64 | var params Params 65 | expected := "custom_binary_path_from_env" 66 | _ = os.Setenv("TF_BINARY_PATH", expected) 67 | params = initParams(params) 68 | params = GetParamsFromEnvironment(params) 69 | _ = os.Unsetenv("TF_BINARY_PATH") 70 | if params.CustomBinaryPath != expected { 71 | t.Errorf("Determined custom binary path is not matching. Got %q, expected %q", params.CustomBinaryPath, expected) 72 | } 73 | } 74 | 75 | func TestGetParamsFromEnvironment_install_from_env(t *testing.T) { 76 | logger = lib.InitLogger("DEBUG") 77 | var params Params 78 | expected := "/custom_install_path_from_env" 79 | _ = os.Setenv("TF_INSTALL_PATH", expected) 80 | params = initParams(params) 81 | params = GetParamsFromEnvironment(params) 82 | _ = os.Unsetenv("TF_INSTALL_PATH") 83 | if params.InstallPath != expected { 84 | t.Errorf("Determined custom install path is not matching. Got %q, expected %q", params.InstallPath, expected) 85 | } 86 | } 87 | 88 | func TestGetParamsFromEnvironment_log_level_from_env(t *testing.T) { 89 | logger = lib.InitLogger("DEBUG") 90 | var params Params 91 | expected := "DEBUG" 92 | _ = os.Setenv("TF_LOG_LEVEL", expected) 93 | params = initParams(params) 94 | params = GetParamsFromEnvironment(params) 95 | _ = os.Unsetenv("TF_LOG_LEVEL") 96 | if params.LogLevel != expected { 97 | t.Errorf("Determined log level is not matching. Got %q, expected %q", params.LogLevel, expected) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/param_parsing/parameters.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "fmt" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/gookit/slog" 11 | "github.com/pborman/getopt" 12 | "github.com/warrensbox/terraform-switcher/lib" 13 | ) 14 | 15 | type Params struct { 16 | Arch string 17 | ChDirPath string 18 | CustomBinaryPath string 19 | DefaultVersion string 20 | DryRun bool 21 | HelpFlag bool 22 | InstallPath string 23 | LatestFlag bool 24 | LatestPre string 25 | LatestStable string 26 | ListAllFlag bool 27 | LogLevel string 28 | MirrorURL string 29 | ShowLatestFlag bool 30 | ShowLatestPre string 31 | ShowLatestStable string 32 | Product string 33 | ProductEntity lib.Product 34 | TomlDir string 35 | Version string 36 | VersionFlag bool 37 | } 38 | 39 | type paramMapping struct { 40 | description string 41 | env string 42 | param string 43 | toml string 44 | } 45 | 46 | // This is used to automatically instate Environment variables and TOML keys 47 | var paramMappings = []paramMapping{ 48 | {param: "Arch", env: "TF_ARCH", toml: "arch", description: "CPU architecture"}, 49 | {param: "CustomBinaryPath", env: "TF_BINARY_PATH", toml: "bin", description: "Custom binary path"}, 50 | {param: "DefaultVersion", env: "TF_DEFAULT_VERSION", toml: "default-version", description: "Default version"}, 51 | {param: "InstallPath", env: "TF_INSTALL_PATH", toml: "install", description: "Custom install path"}, 52 | {param: "LogLevel", env: "TF_LOG_LEVEL", toml: "log-level", description: "Log level"}, 53 | {param: "Product", env: "TF_PRODUCT", toml: "product", description: "Product"}, 54 | {param: "Version", env: "TF_VERSION", toml: "version", description: "Version"}, 55 | } 56 | 57 | var logger *slog.Logger 58 | 59 | func GetParameters() Params { 60 | var params Params 61 | params = initParams(params) 62 | params = populateParams(params) 63 | return params 64 | } 65 | 66 | //nolint:gocyclo 67 | func populateParams(params Params) Params { 68 | var productIds []string 69 | var defaultMirrors []string 70 | for _, product := range lib.GetAllProducts() { 71 | productIds = append(productIds, product.GetId()) 72 | defaultMirrors = append(defaultMirrors, fmt.Sprintf("%s: %s", product.GetName(), product.GetDefaultMirrorUrl())) 73 | } 74 | 75 | getopt.StringVarLong(¶ms.Arch, "arch", 'A', fmt.Sprintf("Override CPU architecture type for downloaded binary. Ex: `tfswitch --arch amd64` will attempt to download the amd64 version of the binary. Default: %s", runtime.GOARCH)) 76 | getopt.StringVarLong(¶ms.ChDirPath, "chdir", 'c', "Switch to a different working directory before executing the given command. Ex: tfswitch --chdir terraform_project will run tfswitch in the terraform_project directory") 77 | getopt.StringVarLong(¶ms.CustomBinaryPath, "bin", 'b', "Custom binary path. Ex: tfswitch -b "+lib.ConvertExecutableExt("/Users/username/bin/terraform")) 78 | getopt.StringVarLong(¶ms.DefaultVersion, "default", 'd', "Default to this version in case no other versions could be detected. Ex: tfswitch --default 1.2.4") 79 | getopt.BoolVarLong(¶ms.DryRun, "dry-run", 'r', "Only show what tfswitch would do. Don't download anything") 80 | getopt.BoolVarLong(¶ms.HelpFlag, "help", 'h', "Displays help message") 81 | getopt.StringVarLong(¶ms.InstallPath, "install", 'i', "Custom install path. Ex: tfswitch -i /Users/username. The binaries will be in the sub installDir directory e.g. /Users/username/"+lib.InstallDir) 82 | getopt.BoolVarLong(¶ms.LatestFlag, "latest", 'u', "Get latest stable version") 83 | getopt.StringVarLong(¶ms.LatestPre, "latest-pre", 'p', "Latest pre-release implicit version. Ex: tfswitch --latest-pre 0.13 downloads 0.13.0-rc1 (latest)") 84 | getopt.StringVarLong(¶ms.LatestStable, "latest-stable", 's', "Latest implicit version based on a constraint. Ex: tfswitch --latest-stable 0.13.0 downloads 0.13.7 and 0.13 downloads 0.15.5 (latest)") 85 | getopt.BoolVarLong(¶ms.ListAllFlag, "list-all", 'l', "List all versions of terraform - including beta and rc") 86 | getopt.StringVarLong(¶ms.LogLevel, "log-level", 'g', "Set loglevel for tfswitch. One of (ERROR, INFO, NOTICE, DEBUG, TRACE)") 87 | getopt.StringVarLong(¶ms.MirrorURL, "mirror", 'm', "install from a remote API other than the default. Default (based on product):\n"+strings.Join(defaultMirrors, "\n")) 88 | getopt.BoolVarLong(¶ms.ShowLatestFlag, "show-latest", 'U', "Show latest stable version") 89 | getopt.StringVarLong(¶ms.ShowLatestPre, "show-latest-pre", 'P', "Show latest pre-release implicit version. Ex: tfswitch --show-latest-pre 0.13 prints 0.13.0-rc1 (latest)") 90 | getopt.StringVarLong(¶ms.ShowLatestStable, "show-latest-stable", 'S', "Show latest implicit version. Ex: tfswitch --show-latest-stable 0.13 prints 0.13.7 (latest)") 91 | getopt.StringVarLong(¶ms.Product, "product", 't', fmt.Sprintf("Specifies which product to use. Ex: `tfswitch --product opentofu` will install OpenTofu. Options: (%s). Default: %s", strings.Join(productIds, ", "), lib.DefaultProductId)) 92 | getopt.BoolVarLong(¶ms.VersionFlag, "version", 'v', "Displays the version of tfswitch") 93 | 94 | // Parse the command line parameters to fetch stuff like chdir 95 | getopt.Parse() 96 | 97 | isShortRun := !params.VersionFlag && !params.HelpFlag 98 | 99 | if isShortRun { 100 | oldLogLevel := params.LogLevel 101 | logger = lib.InitLogger(params.LogLevel) 102 | 103 | var err error 104 | // Read configuration files 105 | // TOML from Homedir 106 | if tomlFileExists(params) { 107 | params, err = getParamsTOML(params) 108 | if err != nil { 109 | logger.Fatalf("Failed to obtain settings from TOML config in home directory: %v", err) 110 | } 111 | } 112 | 113 | // First pass to obtain environment variables to override product 114 | params = GetParamsFromEnvironment(params) 115 | 116 | // Set defaults based on product 117 | // This must be performed after TOML file, to obtain product. 118 | // But the mirror URL, if set to default product URL, 119 | // is used by some of the version getter methods, to 120 | // obtain list of versions. 121 | product := lib.GetProductById(params.Product) 122 | if product == nil { 123 | logger.Fatalf("Invalid \"product\" configuration value: %q", params.Product) 124 | } else { // Use else as there is a warning that params maybe nil, as it does not see Fatalf as a break condition 125 | if params.MirrorURL == "" { 126 | params.MirrorURL = product.GetDefaultMirrorUrl() 127 | logger.Debugf("Default mirror URL: %q", params.MirrorURL) 128 | } 129 | 130 | // Set default bin directory, if not configured 131 | if params.CustomBinaryPath == "" { 132 | if runtime.GOOS == "windows" { 133 | params.CustomBinaryPath = filepath.Join(lib.GetHomeDirectory(), "bin", lib.ConvertExecutableExt(product.GetExecutableName())) 134 | } else { 135 | params.CustomBinaryPath = filepath.Join("/usr/local/bin", product.GetExecutableName()) 136 | } 137 | } 138 | params.ProductEntity = product 139 | } 140 | 141 | if tfSwitchFileExists(params) { 142 | params, err = GetParamsFromTfSwitch(params) 143 | if err != nil { 144 | logger.Fatalf("Failed to obtain settings from \".tfswitch\" file: %v", err) 145 | } 146 | } 147 | 148 | if terraformVersionFileExists(params) { 149 | params, err = GetParamsFromTerraformVersion(params) 150 | if err != nil { 151 | logger.Fatalf("Failed to obtain settings from \".terraform-version\" file: %v", err) 152 | } 153 | } 154 | 155 | if isTerraformModule(params) { 156 | params, err = GetVersionFromVersionsTF(params) 157 | if err != nil { 158 | logger.Fatalf("Failed to obtain settings from Terraform module: %v", err) 159 | } 160 | } 161 | 162 | if terraGruntFileExists(params) { 163 | params, err = GetVersionFromTerragrunt(params) 164 | if err != nil { 165 | logger.Fatalf("Failed to obtain settings from Terragrunt configuration: %v", err) 166 | } 167 | } 168 | 169 | params = GetParamsFromEnvironment(params) 170 | 171 | // Logger config was changed by the config files. Reinitialise. 172 | if params.LogLevel != oldLogLevel { 173 | logger = lib.InitLogger(params.LogLevel) 174 | } 175 | } 176 | 177 | // Parse again to overwrite anything that might by defined on the cli AND in any config file (CLI always wins) 178 | getopt.Parse() 179 | args := getopt.Args() 180 | if len(args) == 1 { 181 | /* version provided on command line as arg */ 182 | params.Version = args[0] 183 | } 184 | 185 | if isShortRun { 186 | if params.DryRun { 187 | logger.Info("[DRY-RUN] No changes will be made") 188 | } else { 189 | logger.Debugf("Resolved dry-run: %t", params.DryRun) 190 | } 191 | 192 | logger.Debugf("Resolved CPU architecture: %q", params.Arch) 193 | if params.DefaultVersion != "" { 194 | logger.Debugf("Resolved fallback version: %q", params.DefaultVersion) 195 | } 196 | logger.Debugf("Resolved binary path: %q", params.CustomBinaryPath) 197 | logger.Debugf("Resolved install path: %q", filepath.Join(params.InstallPath, lib.InstallDir)) 198 | logger.Debugf("Resolved install version: %q", params.Version) 199 | logger.Debugf("Resolved log level: %q", params.LogLevel) 200 | logger.Debugf("Resolved mirror URL: %q", params.MirrorURL) 201 | logger.Debugf("Resolved product name: %q", params.Product) 202 | logger.Debugf("Resolved working directory: %q", params.ChDirPath) 203 | } 204 | 205 | return params 206 | } 207 | 208 | func initParams(params Params) Params { 209 | params.Arch = runtime.GOARCH 210 | params.ChDirPath = lib.GetCurrentDirectory() 211 | params.CustomBinaryPath = "" 212 | params.DefaultVersion = lib.DefaultLatest 213 | params.DryRun = false 214 | params.HelpFlag = false 215 | params.InstallPath = lib.GetHomeDirectory() 216 | params.LatestFlag = false 217 | params.LatestPre = lib.DefaultLatest 218 | params.LatestStable = lib.DefaultLatest 219 | params.ListAllFlag = false 220 | params.LogLevel = "INFO" 221 | params.MirrorURL = "" 222 | params.ShowLatestFlag = false 223 | params.ShowLatestPre = lib.DefaultLatest 224 | params.ShowLatestStable = lib.DefaultLatest 225 | params.TomlDir = lib.GetHomeDirectory() 226 | params.Version = lib.DefaultLatest 227 | params.Product = lib.DefaultProductId 228 | params.VersionFlag = false 229 | return params 230 | } 231 | -------------------------------------------------------------------------------- /lib/param_parsing/terraform_version.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/warrensbox/terraform-switcher/lib" 10 | ) 11 | 12 | const terraformVersionFileName = ".terraform-version" 13 | 14 | func GetParamsFromTerraformVersion(params Params) (Params, error) { 15 | filePath := filepath.Join(params.ChDirPath, terraformVersionFileName) 16 | if lib.CheckFileExist(filePath) { 17 | logger.Infof("Reading configuration from %q", filePath) 18 | content, err := os.ReadFile(filePath) 19 | if err != nil { 20 | logger.Errorf("Could not read file content at %q: %v", filePath, err) 21 | return params, err 22 | } 23 | params.Version = strings.TrimSpace(string(content)) 24 | logger.Debugf("Using version from %q: %q", filePath, params.Version) 25 | } 26 | return params, nil 27 | } 28 | 29 | func terraformVersionFileExists(params Params) bool { 30 | filePath := filepath.Join(params.ChDirPath, terraformVersionFileName) 31 | return lib.CheckFileExist(filePath) 32 | } 33 | -------------------------------------------------------------------------------- /lib/param_parsing/terraform_version_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "testing" 6 | ) 7 | 8 | func TestGetParamsFromTerraformVersion(t *testing.T) { 9 | var params Params 10 | params.ChDirPath = "../../test-data/integration-tests/test_terraform-version" 11 | params, err := GetParamsFromTerraformVersion(params) 12 | expected := "0.11.0" 13 | if err != nil { 14 | t.Fatalf("Got error '%s'", err) 15 | } 16 | if params.Version != expected { 17 | t.Errorf("Version from .terraform-version not read correctly. Got: %v, Expect: %v", params.Version, expected) 18 | } 19 | } 20 | 21 | func TestGetParamsFromTerraformVersion_no_version(t *testing.T) { 22 | var params Params 23 | params.ChDirPath = "../../test-data/skip-integration-tests/test_versiontf_no_version_constraint" 24 | params, err := GetParamsFromTerraformVersion(params) 25 | if err != nil { 26 | t.Fatalf("Got error '%s'", err) 27 | } 28 | if params.Version != "" { 29 | t.Errorf("Expected empty version string. Got: %v", params.Version) 30 | } 31 | } 32 | 33 | func TestGetParamsFromTerraformVersion_no_file(t *testing.T) { 34 | var params Params 35 | params.ChDirPath = "../../test-data/skip-integration-tests/test_no_file" 36 | params, err := GetParamsFromTerraformVersion(params) 37 | if err != nil { 38 | t.Fatalf("Got error '%s'", err) 39 | } 40 | if params.Version != "" { 41 | t.Errorf("Expected empty version string. Got: %v", params.Version) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/param_parsing/terragrunt.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/hashicorp/hcl/v2/gohcl" 9 | "github.com/hashicorp/hcl/v2/hclparse" 10 | "github.com/warrensbox/terraform-switcher/lib" 11 | ) 12 | 13 | const terraGruntFileName = "terragrunt.hcl" 14 | 15 | type terragruntVersionConstraints struct { 16 | TerraformVersionConstraint string `hcl:"terraform_version_constraint"` 17 | } 18 | 19 | func GetVersionFromTerragrunt(params Params) (Params, error) { 20 | filePath := filepath.Join(params.ChDirPath, terraGruntFileName) 21 | if lib.CheckFileExist(filePath) { 22 | logger.Infof("Reading configuration from %q", filePath) 23 | parser := hclparse.NewParser() 24 | hclFile, diagnostics := parser.ParseHCLFile(filePath) 25 | if diagnostics.HasErrors() { 26 | return params, fmt.Errorf("unable to parse HCL file %q", filePath) 27 | } 28 | var versionFromTerragrunt terragruntVersionConstraints 29 | diagnostics = gohcl.DecodeBody(hclFile.Body, nil, &versionFromTerragrunt) 30 | // do not fail on failure to decode the body, as it may f.e. miss a required block, 31 | // though we don't want to fail execution because of that 32 | if diagnostics.HasErrors() { 33 | logger.Errorf(diagnostics.Error()) 34 | } 35 | if versionFromTerragrunt.TerraformVersionConstraint == "" { 36 | logger.Infof("No terraform version constraint in %q", filePath) 37 | return params, nil 38 | } 39 | version, err := lib.GetSemver(versionFromTerragrunt.TerraformVersionConstraint, params.MirrorURL) 40 | if err != nil { 41 | return params, fmt.Errorf("no version found matching %q", versionFromTerragrunt.TerraformVersionConstraint) 42 | } 43 | params.Version = version 44 | logger.Debugf("Using version from %q: %q", filePath, params.Version) 45 | } 46 | return params, nil 47 | } 48 | 49 | func terraGruntFileExists(params Params) bool { 50 | filePath := filepath.Join(params.ChDirPath, terraGruntFileName) 51 | return lib.CheckFileExist(filePath) 52 | } 53 | -------------------------------------------------------------------------------- /lib/param_parsing/terragrunt_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/hashicorp/go-version" 8 | "github.com/warrensbox/terraform-switcher/lib" 9 | ) 10 | 11 | func TestGetVersionFromTerragrunt(t *testing.T) { 12 | var params Params 13 | logger = lib.InitLogger("DEBUG") 14 | params = initParams(params) 15 | params.ChDirPath = "../../test-data/integration-tests/test_terragrunt_hcl" 16 | params.MirrorURL = lib.GetProductById("terraform").GetDefaultMirrorUrl() 17 | params, err := GetVersionFromTerragrunt(params) 18 | if err != nil { 19 | t.Errorf("Unexpected error: %v", err) 20 | } 21 | v1, v1Err := version.NewVersion("0.13") 22 | if v1Err != nil { 23 | t.Errorf("Error parsing v1 version: %v", v1Err) 24 | } 25 | v2, v2Err := version.NewVersion("0.14") 26 | if v2Err != nil { 27 | t.Errorf("Error parsing v2 version: %v", v2Err) 28 | } 29 | actualVersion, actualVersionErr := version.NewVersion(params.Version) 30 | if actualVersionErr != nil { 31 | t.Errorf("Error parsing actualVersion version: %v", actualVersionErr) 32 | } 33 | if !actualVersion.GreaterThanOrEqual(v1) || !actualVersion.LessThan(v2) { 34 | t.Error("Determined version is not between 0.13 and 0.14") 35 | } 36 | } 37 | 38 | func TestGetVersionTerragrunt_with_no_terragrunt_file(t *testing.T) { 39 | var params Params 40 | logger = lib.InitLogger("DEBUG") 41 | params = initParams(params) 42 | params.ChDirPath = "../../test-data/skip-integration-tests/test_no_file" 43 | params, err := GetVersionFromTerragrunt(params) 44 | if err != nil { 45 | t.Errorf("Unexpected error: %v", err) 46 | } 47 | if params.Version != "" { 48 | t.Error("Version should be empty") 49 | } 50 | } 51 | 52 | func TestGetVersionTerragrunt_with_no_version(t *testing.T) { 53 | var params Params 54 | logger = lib.InitLogger("DEBUG") 55 | params = initParams(params) 56 | params.ChDirPath = "../../test-data/skip-integration-tests/test_terragrunt_no_version" 57 | params, err := GetVersionFromTerragrunt(params) 58 | if err != nil { 59 | t.Errorf("Unexpected error: %v", err) 60 | } 61 | if params.Version != "" { 62 | t.Error("Version should be empty") 63 | } 64 | } 65 | 66 | func TestGetVersionFromTerragrunt_erroneous_file(t *testing.T) { 67 | var params Params 68 | logger = lib.InitLogger("DEBUG") 69 | params = initParams(params) 70 | params.ChDirPath = "../../test-data/skip-integration-tests/test_terragrunt_error_hcl" 71 | params, err := GetVersionFromTerragrunt(params) 72 | if err != nil { 73 | t.Errorf("Unexpected error: %v", err) 74 | } 75 | expected := "" 76 | if params.Version != expected { 77 | t.Errorf("Expected version %q, got %q", expected, params.Version) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/param_parsing/tfswitch.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/warrensbox/terraform-switcher/lib" 10 | ) 11 | 12 | const tfSwitchFileName = ".tfswitchrc" 13 | 14 | func GetParamsFromTfSwitch(params Params) (Params, error) { 15 | filePath := filepath.Join(params.ChDirPath, tfSwitchFileName) 16 | if lib.CheckFileExist(filePath) { 17 | logger.Infof("Reading configuration from %q", filePath) 18 | content, err := os.ReadFile(filePath) 19 | if err != nil { 20 | logger.Errorf("Could not read file content from %q: %v", filePath, err) 21 | return params, err 22 | } 23 | params.Version = strings.TrimSpace(string(content)) 24 | logger.Debugf("Using version from %q: %q", filePath, params.Version) 25 | } 26 | return params, nil 27 | } 28 | 29 | func tfSwitchFileExists(params Params) bool { 30 | filePath := filepath.Join(params.ChDirPath, tfSwitchFileName) 31 | return lib.CheckFileExist(filePath) 32 | } 33 | -------------------------------------------------------------------------------- /lib/param_parsing/tfswitch_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "testing" 6 | ) 7 | 8 | func TestGetParamsFromTfSwitch(t *testing.T) { 9 | var params Params 10 | params.ChDirPath = "../../test-data/integration-tests/test_tfswitchrc" 11 | params, err := GetParamsFromTfSwitch(params) 12 | expected := "0.10.5" 13 | if err != nil { 14 | t.Errorf("Expected no error. Got: %v", err) 15 | } 16 | if params.Version != expected { 17 | t.Error("Version from tfswitchrc not read correctly. Actual: " + params.Version + ", Expected: " + expected) 18 | } 19 | } 20 | 21 | func TestGetParamsFromTfSwitch_no_file(t *testing.T) { 22 | var params Params 23 | params.ChDirPath = "../../test-data/skip-integration-tests/test_no_file" 24 | params, err := GetParamsFromTfSwitch(params) 25 | if err != nil { 26 | t.Errorf("Expected no error. Got: %v", err) 27 | } 28 | if params.Version != "" { 29 | t.Errorf("Expected empty version string. Got: %v", params.Version) 30 | } 31 | } 32 | 33 | func TestGetParamsFromTfSwitch_no_version(t *testing.T) { 34 | var params Params 35 | params.ChDirPath = "../../test-data/skip-integration-tests/test_no_version" 36 | params, err := GetParamsFromTfSwitch(params) 37 | if err != nil { 38 | t.Errorf("Expected no error. Got: %v", err) 39 | } 40 | if params.Version != "" { 41 | t.Errorf("Expected empty version string. Got: %v", params.Version) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/param_parsing/toml.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | 9 | "github.com/spf13/viper" 10 | "github.com/warrensbox/terraform-switcher/lib" 11 | ) 12 | 13 | const tfSwitchTOMLFileName = ".tfswitch.toml" 14 | 15 | // getParamsTOML parses everything in the toml file, return required version and bin path 16 | func getParamsTOML(params Params) (Params, error) { 17 | tomlPath := filepath.Join(params.TomlDir, tfSwitchTOMLFileName) 18 | if tomlFileExists(params) { 19 | logger.Infof("Reading configuration from %q", tomlPath) 20 | configfileName := lib.GetFileName(tfSwitchTOMLFileName) 21 | viperParser := viper.New() 22 | viperParser.SetConfigType("toml") 23 | viperParser.SetConfigName(configfileName) 24 | viperParser.AddConfigPath(params.TomlDir) 25 | 26 | errs := viperParser.ReadInConfig() // Find and read the config file 27 | if errs != nil { 28 | logger.Errorf("Could not to read %q: %v", tomlPath, errs) 29 | return params, errs 30 | } 31 | 32 | reflectedParams := reflect.ValueOf(¶ms) 33 | for _, configKey := range paramMappings { 34 | description := configKey.description 35 | param := configKey.param 36 | toml := configKey.toml 37 | 38 | if len(toml) == 0 { 39 | logger.Errorf("Internal error: TOML key name is empty for parameter %q mapping, skipping assignment", param) 40 | continue 41 | } 42 | if len(param) == 0 { 43 | logger.Errorf("Internal error: parameter name is empty for TOML key %q mapping, skipping assignment", toml) 44 | continue 45 | } 46 | if len(description) == 0 { 47 | description = param 48 | } 49 | 50 | paramKey := reflect.Indirect(reflectedParams).FieldByName(param) 51 | if paramKey.Kind() != reflect.String { 52 | logger.Warnf("Parameter %q is not a string, skipping assignment from TOML key %q", param, toml) 53 | continue 54 | } 55 | if viperParser.Get(toml) != nil { 56 | configKeyValue := viperParser.GetString(toml) 57 | 58 | switch toml { 59 | case "bin", "install": 60 | envExpandedConfigKeyValue := os.ExpandEnv(configKeyValue) 61 | logger.Debugf("Expanded environment variables in %q TOML key value (if any): %q -> %q", toml, configKeyValue, envExpandedConfigKeyValue) 62 | configKeyValue = envExpandedConfigKeyValue 63 | } 64 | 65 | logger.Debugf("%s (%q) from %q: %q", description, toml, tomlPath, configKeyValue) 66 | if !paramKey.CanSet() { 67 | logger.Warnf("Parameter %q cannot be set, skipping assignment from TOML key %q", param, toml) 68 | } 69 | paramKey.SetString(configKeyValue) 70 | } 71 | } 72 | } 73 | return params, nil 74 | } 75 | 76 | func tomlFileExists(params Params) bool { 77 | tomlPath := filepath.Join(params.TomlDir, tfSwitchTOMLFileName) 78 | return lib.CheckFileExist(tomlPath) 79 | } 80 | -------------------------------------------------------------------------------- /lib/param_parsing/toml_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "testing" 7 | 8 | "github.com/warrensbox/terraform-switcher/lib" 9 | ) 10 | 11 | func prepare() Params { 12 | var params Params 13 | params.TomlDir = "../../test-data/integration-tests/test_tfswitchtoml" 14 | logger = lib.InitLogger("DEBUG") 15 | return params 16 | } 17 | 18 | func TestGetParamsTOML_BinaryPath(t *testing.T) { 19 | expected := "/usr/local/bin/terraform_from_toml" 20 | os.Setenv("BIN_DIR_FROM_TOML", "/usr/local/bin") 21 | params := prepare() 22 | params, err := getParamsTOML(params) 23 | if err != nil { 24 | t.Fatalf("Got error '%s'", err) 25 | } 26 | if params.CustomBinaryPath != expected { 27 | t.Errorf("BinaryPath not matching. Got %v, expected %v", params.CustomBinaryPath, expected) 28 | } 29 | os.Unsetenv("BIN_DIR_FROM_TOML") 30 | } 31 | 32 | func TestGetParamsTOML_InstallPath(t *testing.T) { 33 | expected := "/tmp" 34 | os.Setenv("INSTALL_DIR_FROM_TOML", "/tmp") 35 | params := prepare() 36 | params, err := getParamsTOML(params) 37 | if err != nil { 38 | t.Fatalf("Got error %v", err) 39 | } 40 | if params.InstallPath != expected { 41 | t.Errorf("InstallPath not matching. Got %q, expected %q", params.InstallPath, expected) 42 | } 43 | os.Unsetenv("INSTALL_DIR_FROM_TOML") 44 | } 45 | 46 | func TestGetParamsTOML_Version(t *testing.T) { 47 | expected := "1.6.2" 48 | params := prepare() 49 | params, err := getParamsTOML(params) 50 | if err != nil { 51 | t.Fatalf("Got error '%s'", err) 52 | } 53 | if params.Version != expected { 54 | t.Errorf("Version not matching. Got %v, expected %v", params.Version, expected) 55 | } 56 | } 57 | 58 | func TestGetParamsTOML_Default_Version(t *testing.T) { 59 | expected := "1.5.4" 60 | params := prepare() 61 | params, err := getParamsTOML(params) 62 | if err != nil { 63 | t.Fatalf("Got error '%s'", err) 64 | } 65 | if params.DefaultVersion != expected { 66 | t.Errorf("Version not matching. Got %v, expected %v", params.DefaultVersion, expected) 67 | } 68 | } 69 | 70 | func TestGetParamsTOML_log_level(t *testing.T) { 71 | expected := "NOTICE" 72 | params := prepare() 73 | params, err := getParamsTOML(params) 74 | if err != nil { 75 | t.Fatalf("Got error '%s'", err) 76 | } 77 | if params.LogLevel != expected { 78 | t.Errorf("Version not matching. Got %v, expected %v", params.LogLevel, expected) 79 | } 80 | } 81 | 82 | func TestGetParamsTOML_no_file(t *testing.T) { 83 | var params Params 84 | params.TomlDir = "../../test-data/skip-integration-tests/test_no_file" 85 | params, err := getParamsTOML(params) 86 | if err != nil { 87 | t.Fatalf("Got error '%s'", err) 88 | } 89 | if params.Version != "" { 90 | t.Errorf("Expected empty version string. Got: %v", params.Version) 91 | } 92 | } 93 | 94 | func TestGetParamsTOML_error_in_file(t *testing.T) { 95 | logger = lib.InitLogger("DEBUG") 96 | var params Params 97 | params.TomlDir = "../../test-data/skip-integration-tests/test_tfswitchtoml_error" 98 | params, err := getParamsTOML(params) 99 | if err == nil { 100 | t.Errorf("Expected error for reading erroneous toml file. Got nil") 101 | } 102 | if params.Version != "" { 103 | t.Errorf("Version should be empty") 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lib/param_parsing/versiontf.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | semver "github.com/hashicorp/go-version" 10 | 11 | "github.com/hashicorp/terraform-config-inspect/tfconfig" 12 | "github.com/warrensbox/terraform-switcher/lib" 13 | ) 14 | 15 | func GetVersionFromVersionsTF(params Params) (Params, error) { 16 | var tfConstraints []string 17 | 18 | curDir, err := os.Getwd() 19 | if err != nil { 20 | logger.Fatalf("Could not get current working directory: %v", err) 21 | } 22 | 23 | absPath := params.ChDirPath 24 | if !filepath.IsAbs(params.ChDirPath) { 25 | absPath, err = filepath.Abs(params.ChDirPath) 26 | if err != nil { 27 | logger.Fatalf("Could not derive absolute path to %q: %v", params.ChDirPath, err) 28 | } 29 | } 30 | 31 | relPath, err := filepath.Rel(curDir, absPath) 32 | if err != nil { 33 | logger.Fatalf("Could not derive relative path to %q: %v", params.ChDirPath, err) 34 | } 35 | 36 | logger.Infof("Reading version from Terraform module at %q", relPath) 37 | module, _ := tfconfig.LoadModule(params.ChDirPath) // nolint:errcheck // covered by conditional below 38 | if module.Diagnostics.HasErrors() { 39 | logger.Fatalf("Could not load Terraform module at %q", params.ChDirPath) 40 | } 41 | 42 | requiredVersions := module.RequiredCore 43 | 44 | for key := range requiredVersions { 45 | // Check if the version contraint is valid 46 | constraint, constraintErr := semver.NewConstraint(requiredVersions[key]) 47 | if constraintErr != nil { 48 | logger.Errorf("Invalid version constraint found: %q", requiredVersions[key]) 49 | return params, constraintErr 50 | } 51 | // It's valid. Add to list 52 | tfConstraints = append(tfConstraints, constraint.String()) 53 | } 54 | 55 | tfConstraint := strings.Join(tfConstraints, ", ") 56 | 57 | version, err2 := lib.GetSemver(tfConstraint, params.MirrorURL) 58 | if err2 != nil { 59 | logger.Errorf("No version found matching %q", tfConstraint) 60 | return params, err2 61 | } 62 | params.Version = version 63 | logger.Debugf("Using version from Terraform module at %q: %q", relPath, params.Version) 64 | return params, nil 65 | } 66 | 67 | func isTerraformModule(params Params) bool { 68 | module, err := tfconfig.LoadModule(params.ChDirPath) 69 | if err != nil { 70 | logger.Warnf("Error parsing Terraform module: %v", err) 71 | return false 72 | } 73 | if len(module.RequiredCore) == 0 { 74 | logger.Debugf("No required version constraints defined by Terraform module at %q", params.ChDirPath) 75 | } 76 | return err == nil && len(module.RequiredCore) > 0 77 | } 78 | -------------------------------------------------------------------------------- /lib/param_parsing/versiontf_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive // FIXME: don't use an underscore in package name 2 | package param_parsing 3 | 4 | import ( 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/hashicorp/go-version" 9 | "github.com/warrensbox/terraform-switcher/lib" 10 | ) 11 | 12 | func TestGetVersionFromVersionsTF_matches_version(t *testing.T) { 13 | logger = lib.InitLogger("DEBUG") 14 | var params Params 15 | var getVerErr error 16 | params = initParams(params) 17 | params.ChDirPath = "../../test-data/integration-tests/test_versiontf" 18 | params.MirrorURL = lib.GetProductById("terraform").GetDefaultMirrorUrl() 19 | params, getVerErr = GetVersionFromVersionsTF(params) 20 | if getVerErr != nil { 21 | t.Errorf("Error getting version from Terraform module: %v", getVerErr) 22 | } 23 | v1, v1Err := version.NewVersion("1.0.5") 24 | if v1Err != nil { 25 | t.Errorf("Error parsing v1 version: %v", v1Err) 26 | } 27 | actualVersion, actualVersionErr := version.NewVersion(params.Version) 28 | if actualVersionErr != nil { 29 | t.Errorf("Error parsing actualVersion version: %v", actualVersionErr) 30 | } 31 | if !actualVersion.Equal(v1) { 32 | t.Errorf("Determined version is not 1.0.5, but %s", params.Version) 33 | } 34 | } 35 | 36 | func TestGetVersionFromVersionsTF_impossible_constraints(t *testing.T) { 37 | logger = lib.InitLogger("DEBUG") 38 | var params Params 39 | params = initParams(params) 40 | params.ChDirPath = "../../test-data/skip-integration-tests/test_versiontf_non_matching_constraints" 41 | params.MirrorURL = lib.GetProductById("terraform").GetDefaultMirrorUrl() 42 | params, err := GetVersionFromVersionsTF(params) 43 | expectedError := "Did not find version matching constraint: ~> 1.0.0, =1.0.5, <= 1.0.4" 44 | if err == nil { 45 | t.Errorf("Expected error '%s', got nil", expectedError) 46 | } else { 47 | if err.Error() == expectedError { 48 | t.Logf("Got expected error '%s'", err) 49 | } else { 50 | t.Errorf("Got unexpected error '%s'", err) 51 | } 52 | } 53 | } 54 | 55 | func TestGetVersionFromVersionsTF_erroneous_file(t *testing.T) { 56 | logger = lib.InitLogger("DEBUG") 57 | var params Params 58 | params = initParams(params) 59 | params.ChDirPath = "../../test-data/skip-integration-tests/test_versiontf_error" 60 | params.MirrorURL = lib.GetProductById("terraform").GetDefaultMirrorUrl() 61 | params, err := GetVersionFromVersionsTF(params) 62 | if err == nil { 63 | t.Error("Expected error got nil") 64 | } else { 65 | expected := "Malformed constraint: ~527> 1.0.0" 66 | if fmt.Sprint(err) != expected { 67 | t.Errorf("Expected error %q, got %q", expected, err) 68 | } 69 | } 70 | } 71 | 72 | func TestGetVersionFromVersionsTF_non_existent_constraint(t *testing.T) { 73 | logger = lib.InitLogger("DEBUG") 74 | var params Params 75 | params = initParams(params) 76 | params.ChDirPath = "../../test-data/skip-integration-tests/test_versiontf_non_existent" 77 | params.MirrorURL = lib.GetProductById("terraform").GetDefaultMirrorUrl() 78 | params, err := GetVersionFromVersionsTF(params) 79 | if err == nil { 80 | t.Error("Expected error got nil") 81 | } else { 82 | expected := "Did not find version matching constraint: > 99999.0.0" 83 | if fmt.Sprint(err) != expected { 84 | t.Errorf("Expected error %q, got %q", expected, err) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/product.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // nolint:revive // FIXME: var-naming: const legacyProductId should be legacyProductID (revive) 9 | const legacyProductId = "terraform" 10 | 11 | // nolint:revive // FIXME: var-naming: struct field PublicKeyId should be PublicKeyID (revive) 12 | // nolint:revive // FIXME: var-naming: struct field PublicKeyUrl should be PublicKeyURL (revive) 13 | type ProductDetails struct { 14 | ID string 15 | Name string 16 | DefaultMirror string 17 | VersionPrefix string 18 | DefaultDownloadMirror string 19 | ExecutableName string 20 | ArchivePrefix string 21 | PublicKeyId string 22 | PublicKeyUrl string 23 | } 24 | 25 | type TerraformProduct struct { 26 | ProductDetails 27 | } 28 | type OpenTofuProduct struct { 29 | ProductDetails 30 | } 31 | 32 | // nolint:revive // FIXME: var-naming: method GetId should be GetID (revive) 33 | // nolint:revive // FIXME: var-naming: method GetDefaultMirrorUrl should be GetDefaultMirrorURL (revive) 34 | // nolint:revive // FIXME: var-naming: method GetArtifactUrl should be GetArtifactURL (revive) 35 | // nolint:revive // FIXME: var-naming: method GetPublicKeyId should be GetPublicKeyID (revive) 36 | // nolint:revive // FIXME: var-naming: method GetPublicKeyUrl should be GetPublicKeyURL (revive) 37 | type Product interface { 38 | GetId() string 39 | GetName() string 40 | GetDefaultMirrorUrl() string 41 | GetVersionPrefix() string 42 | GetExecutableName() string 43 | GetArchivePrefix() string 44 | GetPublicKeyId() string 45 | GetPublicKeyUrl() string 46 | GetShaSignatureSuffix() string 47 | GetArtifactUrl(mirrorURL string, version string) string 48 | GetRecentVersionProduct(recentFile *RecentFile) []string 49 | SetRecentVersionProduct(recentFile *RecentFile, versions []string) 50 | } 51 | 52 | // Terraform Product 53 | // nolint:revive // FIXME: var-naming: method GetId should be GetID (revive) 54 | func (p TerraformProduct) GetId() string { 55 | return p.ID 56 | } 57 | 58 | func (p TerraformProduct) GetName() string { 59 | return p.Name 60 | } 61 | 62 | // nolint:revive // FIXME: var-naming: method GetDefaultMirrorUrl should be GetDefaultMirrorURL (revive) 63 | func (p TerraformProduct) GetDefaultMirrorUrl() string { 64 | return p.DefaultMirror 65 | } 66 | 67 | func (p TerraformProduct) GetVersionPrefix() string { 68 | return p.VersionPrefix 69 | } 70 | 71 | func (p TerraformProduct) GetExecutableName() string { 72 | return p.ExecutableName 73 | } 74 | 75 | func (p TerraformProduct) GetArchivePrefix() string { 76 | return p.ArchivePrefix 77 | } 78 | 79 | // nolint:revive // FIXME: var-naming: method GetArtifactUrl should be GetArtifactURL (revive) 80 | func (p TerraformProduct) GetArtifactUrl(mirrorURL string, version string) string { 81 | mirrorURL = strings.TrimRight(mirrorURL, "/") 82 | return fmt.Sprintf("%s/%s", mirrorURL, version) 83 | } 84 | 85 | // nolint:revive // FIXME: var-naming: method GetPublicKeyId should be GetPublicKeyID (revive) 86 | func (p TerraformProduct) GetPublicKeyId() string { 87 | return p.PublicKeyId 88 | } 89 | 90 | // nolint:revive // FIXME: var-naming: method GetPublicKeyUrl should be GetPublicKeyURL (revive) 91 | func (p TerraformProduct) GetPublicKeyUrl() string { 92 | return p.PublicKeyUrl 93 | } 94 | 95 | func (p TerraformProduct) GetShaSignatureSuffix() string { 96 | return p.GetPublicKeyId() + ".sig" 97 | } 98 | 99 | func (p TerraformProduct) GetRecentVersionProduct(recentFile *RecentFile) []string { 100 | return recentFile.Terraform 101 | } 102 | 103 | func (p TerraformProduct) SetRecentVersionProduct(recentFile *RecentFile, versions []string) { 104 | recentFile.Terraform = versions 105 | } 106 | 107 | // OpenTofu methods 108 | // nolint:revive // FIXME: var-naming: method GetId should be GetID (revive) 109 | func (p OpenTofuProduct) GetId() string { 110 | return p.ID 111 | } 112 | 113 | func (p OpenTofuProduct) GetName() string { 114 | return p.Name 115 | } 116 | 117 | // nolint:revive // FIXME: var-naming: method GetDefaultMirrorUrl should be GetDefaultMirrorURL (revive) 118 | func (p OpenTofuProduct) GetDefaultMirrorUrl() string { 119 | return p.DefaultMirror 120 | } 121 | 122 | func (p OpenTofuProduct) GetVersionPrefix() string { 123 | return p.VersionPrefix 124 | } 125 | 126 | func (p OpenTofuProduct) GetExecutableName() string { 127 | return p.ExecutableName 128 | } 129 | 130 | func (p OpenTofuProduct) GetArchivePrefix() string { 131 | return p.ArchivePrefix 132 | } 133 | 134 | // nolint:revive // FIXME: parameter 'mirrorURL' is not used (custom Mirror URL is not implemented for OpenTofu? 10-Mar-2025) 135 | // nolint:revive // FIXME: var-naming: method GetArtifactUrl should be GetArtifactURL (revive) 136 | func (p OpenTofuProduct) GetArtifactUrl(mirrorURL string, version string) string { 137 | return fmt.Sprintf("%s/v%s", p.DefaultDownloadMirror, version) 138 | } 139 | 140 | // nolint:revive // FIXME: var-naming: method GetPublicKeyId should be GetPublicKeyID (revive) 141 | func (p OpenTofuProduct) GetPublicKeyId() string { 142 | return p.PublicKeyId 143 | } 144 | 145 | // nolint:revive // FIXME: var-naming: method GetPublicKeyUrl should be GetPublicKeyURL (revive) 146 | func (p OpenTofuProduct) GetPublicKeyUrl() string { 147 | return p.PublicKeyUrl 148 | } 149 | 150 | func (p OpenTofuProduct) GetShaSignatureSuffix() string { 151 | return "gpgsig" 152 | } 153 | 154 | func (p OpenTofuProduct) GetRecentVersionProduct(recentFile *RecentFile) []string { 155 | return recentFile.OpenTofu 156 | } 157 | 158 | func (p OpenTofuProduct) SetRecentVersionProduct(recentFile *RecentFile, versions []string) { 159 | recentFile.OpenTofu = versions 160 | } 161 | 162 | // Factory methods 163 | var products = []Product{ 164 | TerraformProduct{ 165 | ProductDetails{ 166 | ID: "terraform", 167 | Name: "Terraform", 168 | DefaultMirror: "https://releases.hashicorp.com/terraform", 169 | VersionPrefix: "terraform_", 170 | ExecutableName: "terraform", 171 | ArchivePrefix: "terraform_", 172 | PublicKeyId: "72D7468F", 173 | PublicKeyUrl: "https://www.hashicorp.com/.well-known/pgp-key.txt", 174 | }, 175 | }, 176 | OpenTofuProduct{ 177 | ProductDetails{ 178 | ID: "opentofu", 179 | Name: "OpenTofu", 180 | DefaultMirror: "https://get.opentofu.org/tofu", 181 | DefaultDownloadMirror: "https://github.com/opentofu/opentofu/releases/download", 182 | VersionPrefix: "opentofu_", 183 | ExecutableName: "tofu", 184 | ArchivePrefix: "tofu_", 185 | PublicKeyId: "0C0AF313E5FD9F80", 186 | PublicKeyUrl: "https://get.opentofu.org/opentofu.asc", 187 | }, 188 | }, 189 | } 190 | 191 | // nolint:revive // FIXME: var-naming: func GetProductById should be GetProductByID (revive) 192 | func GetProductById(id string) Product { 193 | for _, product := range products { 194 | if strings.EqualFold(product.GetId(), id) { 195 | return product 196 | } 197 | } 198 | return nil 199 | } 200 | 201 | func GetAllProducts() []Product { 202 | return products 203 | } 204 | 205 | // Obtain produced used by deprecated public methods that 206 | // now expect a product to be called. 207 | // Once these public methods are removed, this function can be removed 208 | func getLegacyProduct() Product { 209 | product := GetProductById(legacyProductId) 210 | if product == nil { 211 | logger.Fatal("Default product could not be found") 212 | } 213 | return product 214 | } 215 | -------------------------------------------------------------------------------- /lib/product_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // Factory method tests 8 | func Test_GetProductById(t *testing.T) { 9 | product := GetProductById("terraform") 10 | if product == nil { 11 | t.Errorf("Terraform product returned nil") 12 | } else { 13 | if expected := "terraform"; product.GetId() != expected { 14 | t.Errorf("Product ID does not match expected Id. Expected: %q, actual: %q", expected, product.GetId()) 15 | } 16 | } 17 | 18 | product = GetProductById("opentofu") 19 | if product == nil { 20 | t.Errorf("Terraform product returned nil") 21 | } else { 22 | if expected := "opentofu"; product.GetId() != expected { 23 | t.Errorf("Product ID does not match expected Id. Expected: %q, actual: %q", expected, product.GetId()) 24 | } 25 | } 26 | 27 | // Test case-insensitve match 28 | product = GetProductById("oPeNtOfU") 29 | if product == nil { 30 | t.Errorf("Terraform product returned nil") 31 | } else { 32 | if expected := "opentofu"; product.GetId() != expected { 33 | t.Errorf("Product ID does not match expected Id. Expected: %q, actual: %q", expected, product.GetId()) 34 | } 35 | } 36 | 37 | product = GetProductById("doesnotexist") 38 | if product != nil { 39 | t.Errorf("Unknown product returned non-nil response") 40 | } 41 | } 42 | 43 | // Terraform Tests 44 | func Test_GetId_Terraform(t *testing.T) { 45 | product := GetProductById("terraform") 46 | actual := product.GetId() 47 | if expected := "terraform"; actual != expected { 48 | t.Errorf("Product GetId does not match expected ID. Expected: %q, actual: %q", expected, actual) 49 | } 50 | } 51 | 52 | func Test_GetName_Terraform(t *testing.T) { 53 | product := GetProductById("terraform") 54 | actual := product.GetName() 55 | if expected := "Terraform"; actual != expected { 56 | t.Errorf("Product GetProductById does not match expected ID. Expected: %q, actual: %q", expected, actual) 57 | } 58 | } 59 | 60 | func Test_GetDefaultMirrorUrl_Terraform(t *testing.T) { 61 | product := GetProductById("terraform") 62 | actual := product.GetDefaultMirrorUrl() 63 | if expected := "https://releases.hashicorp.com/terraform"; actual != expected { 64 | t.Errorf("Product GetDefaultMirrorUrl does not match expected ID. Expected: %q, actual: %q", expected, actual) 65 | } 66 | } 67 | 68 | func Test_GetVersionPrefix_Terraform(t *testing.T) { 69 | product := GetProductById("terraform") 70 | actual := product.GetVersionPrefix() 71 | if expected := "terraform_"; actual != expected { 72 | t.Errorf("Product GetVersionPrefix does not match expected ID. Expected: %q, actual: %q", expected, actual) 73 | } 74 | } 75 | 76 | func Test_GetExecutableName_Terraform(t *testing.T) { 77 | product := GetProductById("terraform") 78 | actual := product.GetExecutableName() 79 | if expected := "terraform"; actual != expected { 80 | t.Errorf("Product GetExecutableName does not match expected ID. Expected: %q, actual: %q", expected, actual) 81 | } 82 | } 83 | 84 | func Test_GetArchivePrefix_Terraform(t *testing.T) { 85 | product := GetProductById("terraform") 86 | actual := product.GetArchivePrefix() 87 | if expected := "terraform_"; actual != expected { 88 | t.Errorf("Product GetArchivePrefix does not match expected ID. Expected: %q, actual: %q", expected, actual) 89 | } 90 | } 91 | 92 | func Test_GetArtifactUrl_Terraform(t *testing.T) { 93 | product := GetProductById("terraform") 94 | actual := product.GetArtifactUrl("https://example.com/terraform", "5.3.2") 95 | if expected := "https://example.com/terraform/5.3.2"; actual != expected { 96 | t.Errorf("Product GetArchivePrefix does not match expected ID. Expected: %q, actual: %q", expected, actual) 97 | } 98 | } 99 | 100 | func Test_GetPublicKeyId_Terraform(t *testing.T) { 101 | product := GetProductById("terraform") 102 | actual := product.GetPublicKeyId() 103 | if expected := "72D7468F"; actual != expected { 104 | t.Errorf("Product GetPublicKeyId does not match expected ID. Expected: %q, actual: %q", expected, actual) 105 | } 106 | } 107 | 108 | func Test_GetPublicKeyUrl_Terraform(t *testing.T) { 109 | product := GetProductById("terraform") 110 | actual := product.GetPublicKeyUrl() 111 | if expected := "https://www.hashicorp.com/.well-known/pgp-key.txt"; actual != expected { 112 | t.Errorf("Product GetPublicKeyUrl does not match expected ID. Expected: %q, actual: %q", expected, actual) 113 | } 114 | } 115 | 116 | func Test_GetShaSignatureSuffix_Terraform(t *testing.T) { 117 | product := GetProductById("terraform") 118 | actual := product.GetShaSignatureSuffix() 119 | if expected := "72D7468F.sig"; actual != expected { 120 | t.Errorf("Product GetShaSignatureSuffix does not match expected ID. Expected: %q, actual: %q", expected, actual) 121 | } 122 | } 123 | 124 | func Test_GetRecentVersionProduct_Terraform(t *testing.T) { 125 | recentFile := RecentFile{ 126 | OpenTofu: []string{"1.2.3", "3.2.1"}, 127 | Terraform: []string{"5.4.3", "3.4.5"}, 128 | } 129 | expected := []string{"5.4.3", "3.4.5"} 130 | 131 | product := GetProductById("terraform") 132 | actual := product.GetRecentVersionProduct(&recentFile) 133 | err := compareLists(actual, expected) 134 | if err != nil { 135 | t.Error(err) 136 | } 137 | } 138 | 139 | func Test_SetRecentVersionProduct_Terraform(t *testing.T) { 140 | recentFile := RecentFile{ 141 | OpenTofu: []string{"1.2.3", "3.2.1"}, 142 | Terraform: []string{"5.4.3", "3.4.5"}, 143 | } 144 | expected := []string{"1.0.0", "1.0.1"} 145 | 146 | product := GetProductById("terraform") 147 | product.SetRecentVersionProduct(&recentFile, expected) 148 | err := compareLists(recentFile.Terraform, expected) 149 | if err != nil { 150 | t.Error(err) 151 | } 152 | 153 | err = compareLists(recentFile.OpenTofu, expected) 154 | if err == nil { 155 | t.Error("OpenTofu version list should not match version set for Terraform") 156 | } 157 | } 158 | 159 | // OpenTofu Tests 160 | func Test_GetId_OpenTofu(t *testing.T) { 161 | product := GetProductById("opentofu") 162 | actual := product.GetId() 163 | if expected := "opentofu"; actual != expected { 164 | t.Errorf("Product GetId does not match expected ID. Expected: %q, actual: %q", expected, actual) 165 | } 166 | } 167 | 168 | func Test_GetName_OpenTofu(t *testing.T) { 169 | product := GetProductById("opentofu") 170 | actual := product.GetName() 171 | if expected := "OpenTofu"; actual != expected { 172 | t.Errorf("Product GetProductById does not match expected ID. Expected: %q, actual: %q", expected, actual) 173 | } 174 | } 175 | 176 | func Test_GetDefaultMirrorUrl_OpenTofu(t *testing.T) { 177 | product := GetProductById("opentofu") 178 | actual := product.GetDefaultMirrorUrl() 179 | if expected := "https://get.opentofu.org/tofu"; actual != expected { 180 | t.Errorf("Product GetDefaultMirrorUrl does not match expected ID. Expected: %q, actual: %q", expected, actual) 181 | } 182 | } 183 | 184 | func Test_GetVersionPrefix_OpenTofu(t *testing.T) { 185 | product := GetProductById("opentofu") 186 | actual := product.GetVersionPrefix() 187 | if expected := "opentofu_"; actual != expected { 188 | t.Errorf("Product GetVersionPrefix does not match expected ID. Expected: %q, actual: %q", expected, actual) 189 | } 190 | } 191 | 192 | func Test_GetExecutableName_OpenTofu(t *testing.T) { 193 | product := GetProductById("opentofu") 194 | actual := product.GetExecutableName() 195 | if expected := "tofu"; actual != expected { 196 | t.Errorf("Product GetExecutableName does not match expected ID. Expected: %q, actual: %q", expected, actual) 197 | } 198 | } 199 | 200 | func Test_GetArchivePrefix_OpenTofu(t *testing.T) { 201 | product := GetProductById("opentofu") 202 | actual := product.GetArchivePrefix() 203 | if expected := "tofu_"; actual != expected { 204 | t.Errorf("Product GetArchivePrefix does not match expected ID. Expected: %q, actual: %q", expected, actual) 205 | } 206 | } 207 | 208 | func Test_GetArtifactUrl_OpenTofu(t *testing.T) { 209 | product := GetProductById("opentofu") 210 | actual := product.GetArtifactUrl("https://example.com/opentofu", "5.3.2") 211 | if expected := "https://github.com/opentofu/opentofu/releases/download/v5.3.2"; actual != expected { 212 | t.Errorf("Product GetArchivePrefix does not match expected ID. Expected: %q, actual: %q", expected, actual) 213 | } 214 | } 215 | 216 | func Test_GetPublicKeyId_OpenTofu(t *testing.T) { 217 | product := GetProductById("opentofu") 218 | actual := product.GetPublicKeyId() 219 | if expected := "0C0AF313E5FD9F80"; actual != expected { 220 | t.Errorf("Product GetPublicKeyId does not match expected ID. Expected: %q, actual: %q", expected, actual) 221 | } 222 | } 223 | 224 | func Test_GetPublicKeyUrl_OpenTofu(t *testing.T) { 225 | product := GetProductById("opentofu") 226 | actual := product.GetPublicKeyUrl() 227 | if expected := "https://get.opentofu.org/opentofu.asc"; actual != expected { 228 | t.Errorf("Product GetPublicKeyUrl does not match expected ID. Expected: %q, actual: %q", expected, actual) 229 | } 230 | } 231 | 232 | func Test_GetShaSignatureSuffix_OpenTofu(t *testing.T) { 233 | product := GetProductById("opentofu") 234 | actual := product.GetShaSignatureSuffix() 235 | if expected := "gpgsig"; actual != expected { 236 | t.Errorf("Product GetShaSignatureSuffix does not match expected ID. Expected: %q, actual: %q", expected, actual) 237 | } 238 | } 239 | 240 | func Test_GetRecentVersionProduct_OpenTofu(t *testing.T) { 241 | recentFile := RecentFile{ 242 | Terraform: []string{"1.2.3", "3.2.1"}, 243 | OpenTofu: []string{"5.4.3", "3.4.5"}, 244 | } 245 | expected := []string{"5.4.3", "3.4.5"} 246 | 247 | product := GetProductById("opentofu") 248 | actual := product.GetRecentVersionProduct(&recentFile) 249 | err := compareLists(actual, expected) 250 | if err != nil { 251 | t.Error(err) 252 | } 253 | } 254 | 255 | func Test_SetRecentVersionProduct_OpenTofu(t *testing.T) { 256 | recentFile := RecentFile{ 257 | Terraform: []string{"1.2.3", "3.2.1"}, 258 | OpenTofu: []string{"5.4.3", "3.4.5"}, 259 | } 260 | expected := []string{"1.0.0", "1.0.1"} 261 | 262 | product := GetProductById("opentofu") 263 | product.SetRecentVersionProduct(&recentFile, expected) 264 | err := compareLists(recentFile.OpenTofu, expected) 265 | if err != nil { 266 | t.Error(err) 267 | } 268 | 269 | err = compareLists(recentFile.Terraform, expected) 270 | if err == nil { 271 | t.Error("OpenTofu version list should not match version set for Terraform") 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /lib/recent.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | type RecentFile struct { 11 | Terraform []string `json:"terraform"` 12 | OpenTofu []string `json:"opentofu"` 13 | } 14 | 15 | func addRecent(requestedVersion string, installPath string, product Product) { 16 | if !validVersionFormat(requestedVersion) { 17 | logger.Errorf("The version %q is not a valid version string and won't be stored", requestedVersion) 18 | return 19 | } 20 | installLocation := GetInstallLocation(installPath) 21 | recentFilePath := filepath.Join(installLocation, recentFile) 22 | var recentFileData RecentFile 23 | unmarshalRecentFileData(recentFilePath, &recentFileData) 24 | prependRecentVersionToList(requestedVersion, product, &recentFileData) 25 | saveRecentFile(recentFileData, recentFilePath) 26 | } 27 | 28 | func prependRecentVersionToList(version string, product Product, r *RecentFile) { 29 | sliceToCheck := product.GetRecentVersionProduct(r) 30 | for versionIndex, versionValue := range sliceToCheck { 31 | if versionValue == version { 32 | sliceToCheck = append(sliceToCheck[:versionIndex], sliceToCheck[versionIndex+1:]...) 33 | } 34 | } 35 | sliceToCheck = append([]string{version}, sliceToCheck...) 36 | 37 | product.SetRecentVersionProduct(r, sliceToCheck) 38 | } 39 | 40 | func getRecentVersions(installPath string, product Product) ([]string, error) { 41 | installLocation := GetInstallLocation(installPath) 42 | recentFilePath := filepath.Join(installLocation, recentFile) 43 | var recentFileData RecentFile 44 | unmarshalRecentFileData(recentFilePath, &recentFileData) 45 | listOfRecentVersions := product.GetRecentVersionProduct(&recentFileData) 46 | var maxCount int 47 | if len(listOfRecentVersions) >= 5 { 48 | maxCount = 5 49 | } else { 50 | maxCount = len(listOfRecentVersions) 51 | } 52 | var returnedRecentVersions []string 53 | for i := 0; i < maxCount; i++ { 54 | returnedRecentVersions = append(returnedRecentVersions, listOfRecentVersions[i]) 55 | } 56 | return returnedRecentVersions, nil 57 | } 58 | 59 | func unmarshalRecentFileData(recentFilePath string, recentFileData *RecentFile) { 60 | if !CheckFileExist(recentFilePath) { 61 | return 62 | } 63 | 64 | recentFileContent, err := os.ReadFile(recentFilePath) 65 | if err != nil { 66 | logger.Errorf("Could not open recent versions file %q", recentFilePath) 67 | } 68 | if len(string(recentFileContent)) >= 1 && string(recentFileContent[0:1]) != "{" { 69 | convertOldRecentFile(recentFileContent, recentFileData) 70 | } else { 71 | err = json.Unmarshal(recentFileContent, &recentFileData) 72 | if err != nil { 73 | logger.Errorf("Could not unmarshal recent versions content from %q file", recentFilePath) 74 | } 75 | } 76 | } 77 | 78 | func convertOldRecentFile(content []byte, recentFileData *RecentFile) { 79 | lines := strings.Split(string(content), "\n") 80 | for _, s := range lines { 81 | if s != "" { 82 | recentFileData.Terraform = append(recentFileData.Terraform, s) 83 | } 84 | } 85 | } 86 | 87 | func saveRecentFile(data RecentFile, path string) { 88 | bytes, err := json.Marshal(data) 89 | if err != nil { 90 | logger.Errorf("Could not marshal data to JSON: %v", err) 91 | } 92 | err = os.WriteFile(path, bytes, 0o600) 93 | if err != nil { 94 | logger.Errorf("Could not save file %q: %v", path, err) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/recent_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_convertData(t *testing.T) { 12 | recentFileContent := []byte("1.5.6\n0.13.0-rc1\n1.0.11\n") 13 | 14 | var recentFileData RecentFile 15 | convertOldRecentFile(recentFileContent, &recentFileData) 16 | assert.Equal(t, 3, len(recentFileData.Terraform)) 17 | assert.Equal(t, 0, len(recentFileData.OpenTofu)) 18 | assert.Equal(t, "1.5.6", recentFileData.Terraform[0]) 19 | assert.Equal(t, "0.13.0-rc1", recentFileData.Terraform[1]) 20 | assert.Equal(t, "1.0.11", recentFileData.Terraform[2]) 21 | 22 | // Test with empty data 23 | recentFileContent = []byte("") 24 | recentFileData = RecentFile{} 25 | convertOldRecentFile(recentFileContent, &recentFileData) 26 | assert.Equal(t, 0, len(recentFileData.Terraform)) 27 | assert.Equal(t, 0, len(recentFileData.OpenTofu)) 28 | } 29 | 30 | // Test_unmarshalRecentFileData_conversion : Test unmarshalRecentFileData with old version format 31 | func Test_unmarshalRecentFileData_conversion(t *testing.T) { 32 | var expectedRecentFileData RecentFile 33 | 34 | t.Log("Test empty file") 35 | expectedRecentFileData = RecentFile{} 36 | performUnmarshalRecentFileDataTest(t, "", &expectedRecentFileData) 37 | 38 | t.Log("Test one version") 39 | expectedRecentFileData = RecentFile{ 40 | Terraform: []string{"1.3.2"}, 41 | } 42 | performUnmarshalRecentFileDataTest(t, "1.3.2", &expectedRecentFileData) 43 | 44 | t.Log("Test trailing new line") 45 | expectedRecentFileData = RecentFile{ 46 | Terraform: []string{"1.3.2"}, 47 | } 48 | performUnmarshalRecentFileDataTest(t, "1.3.2\n", &expectedRecentFileData) 49 | 50 | t.Log("Test multiple versions") 51 | expectedRecentFileData = RecentFile{ 52 | Terraform: []string{"1.3.2", "1.2.3"}, 53 | } 54 | performUnmarshalRecentFileDataTest(t, "1.3.2\n1.2.3\n", &expectedRecentFileData) 55 | } 56 | 57 | // Test_unmarshalRecentFileData_invalid_data : Test unmarshalRecentFileData with invalid data in recentfile 58 | func Test_unmarshalRecentFileData_invalid_data(t *testing.T) { 59 | expectedRecentFileData := RecentFile{} 60 | performUnmarshalRecentFileDataTest(t, "{This Is not valid JSON}", &expectedRecentFileData) 61 | 62 | performUnmarshalRecentFileDataTest(t, `{"valid": ["json", "but", "will"], "not": {"unmarshall": ["to", "RecentFile"]}}`, &expectedRecentFileData) 63 | } 64 | 65 | // Test_unmarshalRecentFileData : Test unmarshalRecentFileData 66 | func Test_unmarshalRecentFileData(t *testing.T) { 67 | var expectedRecentFileData RecentFile 68 | 69 | t.Log("Test only Terraform") 70 | expectedRecentFileData = RecentFile{ 71 | Terraform: []string{"1.5.0", "1.6.0"}, 72 | } 73 | performUnmarshalRecentFileDataTest(t, `{"terraform": ["1.5.0", "1.6.0"]}`, &expectedRecentFileData) 74 | 75 | t.Log("Test only OpenTofu") 76 | expectedRecentFileData = RecentFile{ 77 | OpenTofu: []string{"1.5.0", "1.6.0"}, 78 | } 79 | performUnmarshalRecentFileDataTest(t, `{"opentofu": ["1.5.0", "1.6.0"]}`, &expectedRecentFileData) 80 | 81 | t.Log("Test both") 82 | expectedRecentFileData = RecentFile{ 83 | Terraform: []string{"1.2.3", "1.3.2"}, 84 | OpenTofu: []string{"1.5.0", "1.6.0"}, 85 | } 86 | performUnmarshalRecentFileDataTest(t, `{"terraform": ["1.2.3", "1.3.2"], "opentofu": ["1.5.0", "1.6.0"]}`, &expectedRecentFileData) 87 | } 88 | 89 | func performUnmarshalRecentFileDataTest(t *testing.T, recentFileContent string, expectedRecentFileData *RecentFile) { 90 | temp, err := os.MkdirTemp("", "recent-test") 91 | if err != nil { 92 | t.Errorf("Could not create temporary directory") 93 | } 94 | defer os.RemoveAll(temp) 95 | pathToTempFile := filepath.Join(temp, "recent.json") 96 | 97 | if err := os.WriteFile(pathToTempFile, []byte(recentFileContent), 0o600); err != nil { 98 | t.Errorf("Could not write to temporary file %q: %v", pathToTempFile, err) 99 | } 100 | 101 | recentFileData := RecentFile{} 102 | unmarshalRecentFileData(pathToTempFile, &recentFileData) 103 | t.Log("Comparing Terraform versions") 104 | assert.Equal(t, expectedRecentFileData.Terraform, recentFileData.Terraform) 105 | t.Log("Comparing OpenTofu versions") 106 | assert.Equal(t, expectedRecentFileData.OpenTofu, recentFileData.OpenTofu) 107 | } 108 | 109 | func Test_saveFile(t *testing.T) { 110 | recentFileData := RecentFile{ 111 | Terraform: []string{"1.2.3", "4.5.6"}, 112 | OpenTofu: []string{"6.6.6"}, 113 | } 114 | temp, err := os.MkdirTemp("", "recent-test") 115 | if err != nil { 116 | t.Errorf("Could not create temporary directory") 117 | } 118 | defer func(path string) { 119 | _ = os.RemoveAll(path) 120 | }(temp) 121 | pathToTempFile := filepath.Join(temp, "recent.json") 122 | saveRecentFile(recentFileData, pathToTempFile) 123 | 124 | content, err := os.ReadFile(pathToTempFile) 125 | if err != nil { 126 | t.Errorf("Could not read converted file %v", pathToTempFile) 127 | } 128 | assert.Equal(t, "{\"terraform\":[\"1.2.3\",\"4.5.6\"],\"opentofu\":[\"6.6.6\"]}", string(content)) 129 | } 130 | 131 | func Test_getRecentVersionsForTerraform(t *testing.T) { 132 | logger = InitLogger("DEBUG") 133 | product := GetProductById("terraform") 134 | strings, err := getRecentVersions("../test-data/recent/recent_as_json/", product) 135 | if err != nil { 136 | t.Error("Unable to get versions from recent file") 137 | } 138 | assert.Equal(t, 5, len(strings)) 139 | assert.Equal(t, []string{"1.2.3", "4.5.6", "4.5.7", "4.5.8", "4.5.9"}, strings) 140 | } 141 | 142 | func Test_getRecentVersionsForOpenTofu(t *testing.T) { 143 | logger = InitLogger("DEBUG") 144 | product := GetProductById("opentofu") 145 | strings, err := getRecentVersions("../test-data/recent/recent_as_json", product) 146 | if err != nil { 147 | t.Error("Unable to get versions from recent file") 148 | } 149 | assert.Equal(t, []string{"6.6.6"}, strings) 150 | } 151 | 152 | func Test_addRecent(t *testing.T) { 153 | logger = InitLogger("DEBUG") 154 | terraform := GetProductById("terraform") 155 | opentofu := GetProductById("opentofu") 156 | temp, err := os.MkdirTemp("", "recent-test") 157 | defer func(path string) { 158 | _ = os.RemoveAll(path) 159 | }(temp) 160 | if err != nil { 161 | t.Errorf("Could not create temporary directory") 162 | } 163 | addRecent("3.7.0", temp, terraform) 164 | addRecent("3.7.1", temp, terraform) 165 | addRecent("3.7.2", temp, terraform) 166 | filePath := filepath.Join(temp, ".terraform.versions", "RECENT") 167 | bytes, err := os.ReadFile(filePath) 168 | if err != nil { 169 | t.Errorf("Could not open file %v", filePath) 170 | t.Error(err) 171 | } 172 | assert.Equal(t, "{\"terraform\":[\"3.7.2\",\"3.7.1\",\"3.7.0\"],\"opentofu\":null}", string(bytes)) 173 | addRecent("3.7.0", temp, terraform) 174 | bytes, err = os.ReadFile(filePath) 175 | if err != nil { 176 | t.Errorf("Could not open file %v", filePath) 177 | t.Error(err) 178 | } 179 | assert.Equal(t, "{\"terraform\":[\"3.7.0\",\"3.7.2\",\"3.7.1\"],\"opentofu\":null}", string(bytes)) 180 | 181 | addRecent("1.1.1", temp, opentofu) 182 | bytes, err = os.ReadFile(filePath) 183 | if err != nil { 184 | t.Error("Could not open file") 185 | t.Error(err) 186 | } 187 | assert.Equal(t, "{\"terraform\":[\"3.7.0\",\"3.7.2\",\"3.7.1\"],\"opentofu\":[\"1.1.1\"]}", string(bytes)) 188 | } 189 | 190 | func Test_prependExistingVersionIsMovingToTop(t *testing.T) { 191 | product := GetProductById("terraform") 192 | recentFileData := RecentFile{ 193 | Terraform: []string{"1.2.3", "4.5.6", "7.7.7"}, 194 | OpenTofu: []string{"6.6.6"}, 195 | } 196 | prependRecentVersionToList("7.7.7", product, &recentFileData) 197 | assert.Equal(t, 3, len(recentFileData.Terraform)) 198 | assert.Equal(t, "7.7.7", recentFileData.Terraform[0]) 199 | assert.Equal(t, "1.2.3", recentFileData.Terraform[1]) 200 | assert.Equal(t, "4.5.6", recentFileData.Terraform[2]) 201 | 202 | prependRecentVersionToList("1.2.3", product, &recentFileData) 203 | assert.Equal(t, 3, len(recentFileData.Terraform)) 204 | assert.Equal(t, "1.2.3", recentFileData.Terraform[0]) 205 | assert.Equal(t, "7.7.7", recentFileData.Terraform[1]) 206 | assert.Equal(t, "4.5.6", recentFileData.Terraform[2]) 207 | } 208 | 209 | func Test_prependNewVersion(t *testing.T) { 210 | product := GetProductById("terraform") 211 | recentFileData := RecentFile{ 212 | Terraform: []string{"1.2.3", "4.5.6", "4.5.7", "4.5.8", "4.5.9"}, 213 | OpenTofu: []string{"6.6.6"}, 214 | } 215 | prependRecentVersionToList("7.7.7", product, &recentFileData) 216 | assert.Equal(t, 6, len(recentFileData.Terraform)) 217 | assert.Equal(t, "7.7.7", recentFileData.Terraform[0]) 218 | assert.Equal(t, "1.2.3", recentFileData.Terraform[1]) 219 | assert.Equal(t, "4.5.6", recentFileData.Terraform[2]) 220 | } 221 | -------------------------------------------------------------------------------- /lib/semver.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | semver "github.com/hashicorp/go-version" 8 | ) 9 | 10 | // GetSemver : returns version that will be installed based on server constraint provided 11 | func GetSemver(tfconstraint string, mirrorURL string) (string, error) { 12 | listAll := true 13 | tflist, errTFList := getTFList(mirrorURL, listAll) // get list of versions 14 | if errTFList != nil { 15 | return "", fmt.Errorf("Error getting list of versions from %q: %v", mirrorURL, errTFList) 16 | } 17 | logger.Infof("Reading required version from constraint: %q", tfconstraint) 18 | tfversion, err := SemVerParser(&tfconstraint, tflist) 19 | return tfversion, err 20 | } 21 | 22 | // SemVerParser : Goes through the list of versions, returns a valid version for contraint provided 23 | func SemVerParser(tfconstraint *string, tflist []string) (string, error) { 24 | tfversion := "" 25 | constraints, err := semver.NewConstraint(*tfconstraint) // NewConstraint returns a Constraints instance that a Version instance can be checked against 26 | if err != nil { 27 | return "", fmt.Errorf("Error parsing constraint: %s", err) 28 | } 29 | versions := make([]*semver.Version, len(tflist)) 30 | // put tfversion into semver object 31 | for i, tfvals := range tflist { 32 | version, err := semver.NewVersion(tfvals) // NewVersion parses a given version and returns an instance of Version or an error if unable to parse the version. 33 | if err != nil { 34 | return "", fmt.Errorf("Error parsing constraint: %s", err) 35 | } 36 | versions[i] = version 37 | } 38 | 39 | sort.Sort(sort.Reverse(semver.Collection(versions))) 40 | 41 | for _, element := range versions { 42 | if constraints.Check(element) { // Validate a version against a constraint 43 | tfversion = element.String() 44 | if validVersionFormat(tfversion) { // check if version format is correct 45 | logger.Infof("Matched version: %q", tfversion) 46 | return tfversion, nil 47 | } 48 | PrintInvalidTFVersion() 49 | } 50 | } 51 | 52 | return "", fmt.Errorf("Did not find version matching constraint: %s", *tfconstraint) 53 | } 54 | 55 | // PrintInvalidTFVersion Print invalid TF version 56 | func PrintInvalidTFVersion() { 57 | logger.Error("Version does not exist or invalid terraform version format.\n\tFormat should be #.#.# or #.#.#-@# where # are numbers and @ are word characters.\n\tFor example, 1.11.7 and 0.11.9-beta1 are valid versions") 58 | } 59 | 60 | // PrintInvalidMinorTFVersion Print invalid minor TF version 61 | func PrintInvalidMinorTFVersion() { 62 | logger.Error("Invalid minor terraform version format.\n\tFormat should be #.# where # are numbers. For example, 1.11 is valid version") 63 | } 64 | -------------------------------------------------------------------------------- /lib/semver_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/warrensbox/terraform-switcher/lib" 7 | ) 8 | 9 | var versionsRaw = []string{ 10 | "1.1", 11 | "1.2.1", 12 | "1.2.2", 13 | "1.2.3", 14 | "1.3", 15 | "1.1.4", 16 | "0.7.1", 17 | "1.4-beta", 18 | "1.4", 19 | "2", 20 | } 21 | 22 | // TestSemverParser1 : Test to see if SemVerParser parses valid version 23 | // Test version 1.1 24 | func TestSemverParserCase1(t *testing.T) { 25 | tfconstraint := "1.1" 26 | tfversion, semVerErr := lib.SemVerParser(&tfconstraint, versionsRaw) 27 | if semVerErr != nil { 28 | t.Errorf("Error parsing version %q: %v", tfconstraint, semVerErr) 29 | } 30 | expected := "1.1.0" 31 | if tfversion == expected { 32 | t.Logf("Version %q exists in list [expected]", expected) 33 | } else { 34 | t.Logf("Version %q does not exist in list [unexpected]", tfconstraint) 35 | t.Errorf("This is unexpected. Parsing failed. Expected: %q", expected) 36 | } 37 | } 38 | 39 | // TestSemverParserCase2 : Test to see if SemVerParser parses valid version 40 | // Test version ~> 1.1 should return 1.1.4 41 | func TestSemverParserCase2(t *testing.T) { 42 | tfconstraint := "~> 1.1.0" 43 | tfversion, semVerErr := lib.SemVerParser(&tfconstraint, versionsRaw) 44 | if semVerErr != nil { 45 | t.Errorf("Error parsing version %q: %v", tfconstraint, semVerErr) 46 | } 47 | expected := "1.1.4" 48 | if tfversion == expected { 49 | t.Logf("Version %q exist in list [expected]", expected) 50 | } else { 51 | t.Logf("Version %q does not exist in list [unexpected]", tfconstraint) 52 | t.Errorf("This is unexpected. Parsing failed. Expected: %q", expected) 53 | } 54 | } 55 | 56 | // TestSemverParserCase3 : Test to see if SemVerParser parses valid version 57 | // Test version ~> 1.1 should return 1.1.4 58 | func TestSemverParserCase3(t *testing.T) { 59 | tfconstraint := "~> 1.A.0" 60 | _, err := lib.SemVerParser(&tfconstraint, versionsRaw) 61 | if err != nil { 62 | t.Logf("This test is supposed to error on %q [expected]", tfconstraint) 63 | } else { 64 | t.Errorf("This test is supposed to error on %q but passed [unexpected]", tfconstraint) 65 | } 66 | } 67 | 68 | // TestSemverParserCase4 : Test to see if SemVerParser parses valid version 69 | // Test version ~> >= 1.0, < 1.4 should return 1.3.0 70 | func TestSemverParserCase4(t *testing.T) { 71 | tfconstraint := ">= 1.0, < 1.4" 72 | tfversion, semVerErr := lib.SemVerParser(&tfconstraint, versionsRaw) 73 | if semVerErr != nil { 74 | t.Errorf("Error parsing version %q: %v", tfconstraint, semVerErr) 75 | } 76 | expected := "1.3.0" 77 | if tfversion == expected { 78 | t.Logf("Version %q exist in list [expected]", expected) 79 | } else { 80 | t.Logf("Version %q does not exist in list [unexpected]", tfconstraint) 81 | t.Errorf("This is unexpected. Parsing failed. Expected: %q", expected) 82 | } 83 | } 84 | 85 | // TestSemverParserCase5 : Test to see if SemVerParser parses valid version 86 | // Test version ~> >= 1.0 should return 2.0.0 87 | func TestSemverParserCase5(t *testing.T) { 88 | tfconstraint := ">= 1.0" 89 | tfversion, semVerErr := lib.SemVerParser(&tfconstraint, versionsRaw) 90 | if semVerErr != nil { 91 | t.Errorf("Error parsing version %q: %v", tfconstraint, semVerErr) 92 | } 93 | expected := "2.0.0" 94 | if tfversion == expected { 95 | t.Logf("Version %q exist in list [expected]", expected) 96 | } else { 97 | t.Logf("Version %q does not exist in list [unexpected]", tfconstraint) 98 | t.Errorf("This is unexpected. Parsing failed. Expected: %q", expected) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/symlink.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | // CreateSymlink : create symlink or copy file to bin directory if windows 13 | func CreateSymlink(cwd string, dir string) error { 14 | // If we are on windows the symlink is not working correctly. 15 | // Copy the desired terraform binary to the path environment. 16 | if runtime.GOOS == windows { 17 | r, err := os.Open(cwd) 18 | if err != nil { 19 | return fmt.Errorf("Unable to open source binary: %q", cwd) 20 | } 21 | defer r.Close() 22 | 23 | w, err := os.Create(dir) 24 | if err != nil { 25 | return fmt.Errorf("Could not create target binary: %q", dir) 26 | } 27 | defer func() { 28 | if c := w.Close(); err == nil { 29 | err = c 30 | } 31 | }() 32 | _, err = io.Copy(w, r) 33 | } else { 34 | err := os.Symlink(cwd, dir) 35 | if err != nil { 36 | return fmt.Errorf(` 37 | Unable to create new symlink. 38 | Maybe symlink already exist. Try removing existing symlink manually. 39 | Try running "unlink %q" to remove existing symlink. 40 | If error persist, you may not have the permission to create a symlink at %q. 41 | Error: %v 42 | `, dir, dir, err) 43 | } 44 | } 45 | return nil 46 | } 47 | 48 | // RemoveSymlink : remove symlink 49 | func RemoveSymlink(symlinkPath string) error { 50 | _, err := os.Lstat(symlinkPath) 51 | if err != nil { 52 | return fmt.Errorf(` 53 | Unable to stat symlink. 54 | Maybe symlink already exist. Try removing existing symlink manually. 55 | Try running "unlink %q" to remove existing symlink. 56 | If error persist, you may not have the permission to create a symlink at %q. 57 | Error: %v 58 | `, symlinkPath, symlinkPath, err) 59 | } 60 | 61 | if errRemove := os.Remove(symlinkPath); errRemove != nil { 62 | return fmt.Errorf(` 63 | Unable to remove symlink. 64 | Maybe symlink already exist. Try removing existing symlink manually. 65 | Try running "unlink %q" to remove existing symlink. 66 | If error persist, you may not have the permission to create a symlink at %q. 67 | Error: %v 68 | `, symlinkPath, symlinkPath, errRemove) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // CheckSymlink : check file is symlink 75 | func CheckSymlink(symlinkPath string) bool { 76 | fi, err := os.Lstat(symlinkPath) 77 | if err != nil { 78 | return false 79 | } 80 | 81 | if fi.Mode()&os.ModeSymlink != 0 { 82 | return true 83 | } 84 | 85 | return false 86 | } 87 | 88 | // ChangeSymlink : move symlink to existing binary for Terraform 89 | // 90 | // Deprecated: This function has been deprecated in favor of ChangeProductSymlink and will be removed in v2.0.0 91 | func ChangeSymlink(binVersionPath string, binPath string) { 92 | product := getLegacyProduct() 93 | err := ChangeProductSymlink(product, binVersionPath, binPath) 94 | if err != nil { 95 | logger.Fatal(err) 96 | } 97 | } 98 | 99 | // ChangeProductSymlink : move symlink for product to existing binary 100 | // 101 | //nolint:gocyclo 102 | func ChangeProductSymlink(product Product, binVersionPath string, userBinPath string) error { 103 | homedir := GetHomeDirectory() // get user's home directory 104 | homeBinPath := filepath.Join(homedir, "bin", product.GetExecutableName()) 105 | 106 | var err error 107 | var locationsFmt string 108 | 109 | // Possible install locations with boolean property as to whether to attempt to create 110 | type installLocations struct { 111 | path string 112 | create bool 113 | } 114 | possibleInstallLocations := []installLocations{ 115 | {path: userBinPath, create: false}, 116 | {path: homeBinPath, create: true}, 117 | } 118 | 119 | for idx, location := range possibleInstallLocations { 120 | isFallback := false 121 | if idx > 0 { 122 | isFallback = true 123 | } 124 | convertedPath := ConvertExecutableExt(location.path) 125 | possibleInstallLocations[idx].path = convertedPath 126 | locationsFmt += fmt.Sprintf("\n\t• №%d: %q (create: %-5t, isFallack: %t)", idx+1, convertedPath, location.create, isFallback) 127 | } 128 | logger.Noticef("Possible install locations:%s", locationsFmt) 129 | 130 | for idx, location := range possibleInstallLocations { 131 | dirPath := Path(location.path) 132 | attempt := idx + 1 133 | 134 | if attempt > 1 { 135 | logger.Warnf("Falling back to install to %q directory", dirPath) 136 | } 137 | 138 | logger.Noticef("Attempting to install to %q directory (possible install location №%d)", dirPath, attempt) 139 | 140 | // If directory does not exist, check if we should create it, otherwise skip 141 | if !CheckDirExist(dirPath) { 142 | logger.Warnf("Installation directory %q doesn't exist!", dirPath) 143 | if location.create { 144 | logger.Infof("Creating %q directory", dirPath) 145 | err = os.MkdirAll(dirPath, 0o755) 146 | if err != nil { 147 | logger.Errorf("Unable to create %q directory: %v", dirPath, err) 148 | continue 149 | } 150 | } else { 151 | continue 152 | } 153 | } else if !CheckIsDir(dirPath) { 154 | logger.Warnf("The %q is not a directory!", dirPath) 155 | continue 156 | } 157 | logger.Noticef("Installation location: %q", location.path) 158 | 159 | /* remove current symlink if exist */ 160 | if CheckSymlink(location.path) { 161 | logger.Debugf("Clearing away symlink before re-creating it: %q", location.path) 162 | if err := RemoveSymlink(location.path); err != nil { 163 | return fmt.Errorf("Error removing symlink %q: %v", location.path, err) 164 | } 165 | } 166 | 167 | /* set symlink to desired version */ 168 | err = CreateSymlink(binVersionPath, location.path) 169 | if err == nil { 170 | logger.Noticef("Symlink created at %q", location.path) 171 | 172 | // Print helper message to export PATH if the directory is not in PATH only for non-Windows systems, 173 | // as it's all complicated on Windows. See https://github.com/warrensbox/terraform-switcher/issues/558 174 | if runtime.GOOS != windows { 175 | isDirInPath := false 176 | 177 | for _, envPathElement := range strings.Split(os.Getenv("PATH"), ":") { 178 | expandedEnvPathElement := strings.TrimRight(strings.Replace(envPathElement, "~", homedir, 1), "/") 179 | 180 | if expandedEnvPathElement == strings.TrimRight(dirPath, "/") { 181 | isDirInPath = true 182 | break 183 | } 184 | } 185 | 186 | if !isDirInPath { 187 | logger.Warnf("Run `export PATH=\"$PATH:%s\"` to append %q to $PATH", dirPath, location.path) 188 | } 189 | } 190 | 191 | return nil 192 | } 193 | } 194 | 195 | if err == nil { 196 | return fmt.Errorf("None of the installation directories exist:%s\n\t%s", locationsFmt, 197 | "Manually create one of them and try again") 198 | } 199 | 200 | return err 201 | } 202 | -------------------------------------------------------------------------------- /lib/symlink_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/mitchellh/go-homedir" 10 | ) 11 | 12 | // TestCreateSymlink : check if symlink exist-remove if exist, 13 | // create symlink, check if symlink exist, remove symlink 14 | func TestCreateSymlink(t *testing.T) { 15 | testSymlinkDest := "/test-tfswitcher-dest" 16 | testSymlinkSrc := "/test-tfswitcher-src" 17 | if runtime.GOOS == windows { 18 | testSymlinkSrc += ".exe" 19 | } 20 | 21 | home, err := homedir.Dir() 22 | if err != nil { 23 | t.Errorf("Could not detect home directory.") 24 | } 25 | symlinkPathSrc := filepath.Join(home, testSymlinkSrc) 26 | symlinkPathDest := filepath.Join(home, testSymlinkDest) 27 | 28 | // Create file for test as windows does not like no source 29 | create, err := os.Create(symlinkPathDest) 30 | if err != nil { 31 | t.Errorf("Could not create test dest file for symlink at %v", symlinkPathDest) 32 | } 33 | defer create.Close() 34 | 35 | if runtime.GOOS != windows { 36 | ln, _ := os.Readlink(symlinkPathSrc) // nolint:errcheck // covered by conditional below 37 | 38 | if ln != symlinkPathDest { 39 | t.Logf("Symlink does not exist %v [expected]", ln) 40 | } else { 41 | t.Logf("Symlink exist %v [expected]", ln) 42 | _ = os.Remove(symlinkPathSrc) 43 | t.Logf("Removed existing symlink for testing purposes") 44 | } 45 | } 46 | 47 | lnCreateErr := CreateSymlink(symlinkPathDest, symlinkPathSrc) 48 | if lnCreateErr != nil { 49 | t.Errorf("Could not create symlink at %q to %q: %v", symlinkPathSrc, symlinkPathDest, lnCreateErr) 50 | } 51 | 52 | if runtime.GOOS == windows { 53 | _, err := os.Stat(symlinkPathSrc) 54 | if err != nil { 55 | t.Logf("Could not stat file copy at %v. [unexpected]", symlinkPathSrc) 56 | t.Error("File copy was not created.") 57 | } else { 58 | t.Logf("File copy exists at %v [expected]", symlinkPathSrc) 59 | } 60 | } else { 61 | lnCheck, _ := os.Readlink(symlinkPathSrc) // nolint:errcheck // covered by conditional below 62 | if lnCheck == symlinkPathDest { 63 | t.Logf("Symlink exist %v [expected]", lnCheck) 64 | } else { 65 | t.Logf("Symlink does not exist %v [unexpected]", lnCheck) 66 | t.Error("Symlink was not created") 67 | } 68 | } 69 | 70 | symlinkPathSrcErr := os.Remove(symlinkPathSrc) 71 | if symlinkPathSrcErr != nil { 72 | t.Logf("Could not remove %q: %v [internal failure]", symlinkPathSrc, symlinkPathSrcErr) 73 | } 74 | symlinkPathDestErr := os.Remove(symlinkPathDest) 75 | if symlinkPathDestErr != nil { 76 | t.Logf("Could not remove %q: %v [internal failure]", symlinkPathDest, symlinkPathDestErr) 77 | } 78 | } 79 | 80 | // TestRemoveSymlink : check if symlink exist-create if does not exist, 81 | // remove symlink, check if symlink exist 82 | func TestRemoveSymlink(t *testing.T) { 83 | testSymlinkSrc := "/test-tfswitcher-src" 84 | testSymlinkDest := "/test-tfswitcher-dest" 85 | 86 | homedir, errCurr := homedir.Dir() 87 | if errCurr != nil { 88 | t.Error(errCurr) 89 | } 90 | symlinkPathSrc := filepath.Join(homedir, testSymlinkSrc) 91 | symlinkPathDest := filepath.Join(homedir, testSymlinkDest) 92 | 93 | ln, _ := os.Readlink(symlinkPathSrc) // nolint:errcheck // covered by conditional below 94 | 95 | if ln != symlinkPathDest { 96 | t.Logf("Symlink does exist %v [expected]", ln) 97 | t.Log("Creating symlink") 98 | if err := os.Symlink(symlinkPathDest, symlinkPathSrc); err != nil { 99 | t.Error(err) 100 | } 101 | } 102 | 103 | RemoveSymlink(symlinkPathSrc) // nolint:errcheck // covered by conditional below 104 | lnCheck, _ := os.Readlink(symlinkPathSrc) // nolint:errcheck // covered by conditional below 105 | if lnCheck == symlinkPathDest { 106 | t.Logf("Symlink should not exist %v [unexpected]", lnCheck) 107 | t.Error("Symlink was not removed") 108 | } else { 109 | t.Logf("Symlink was removed %v [expected]", lnCheck) 110 | } 111 | } 112 | 113 | // TestCheckSymlink : Create symlink, test if file is symlink 114 | func TestCheckSymlink(t *testing.T) { 115 | testSymlinkSrc := "/test-tfswitcher-src" 116 | testSymlinkDest := "/test-tfswitcher-dest" 117 | 118 | homedir, errCurr := homedir.Dir() 119 | if errCurr != nil { 120 | t.Error(errCurr) 121 | } 122 | symlinkPathSrc := filepath.Join(homedir, testSymlinkSrc) 123 | symlinkPathDest := filepath.Join(homedir, testSymlinkDest) 124 | 125 | ln, _ := os.Readlink(symlinkPathSrc) // nolint:errcheck // it is okay to ignore error here 126 | 127 | if ln != symlinkPathDest { 128 | t.Log("Creating symlink") 129 | if err := os.Symlink(symlinkPathDest, symlinkPathSrc); err != nil { 130 | t.Error(err) 131 | } 132 | } 133 | 134 | symlinkExist := CheckSymlink(symlinkPathSrc) 135 | 136 | if symlinkExist { 137 | t.Logf("Symlink does exist %v [expected]", ln) 138 | } else { 139 | t.Logf("Symlink does not exist %v [unexpected]", ln) 140 | } 141 | 142 | symlinkPathSrcErr := os.Remove(symlinkPathSrc) 143 | if symlinkPathSrcErr != nil { 144 | t.Logf("Could not remove %q: %v [internal failure]", symlinkPathSrc, symlinkPathSrcErr) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /lib/utils.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pborman/getopt" 7 | ) 8 | 9 | // FileExistsAndIsNotDir checks if a file exists and is not a directory before we try using it to prevent further errors 10 | func FileExistsAndIsNotDir(filename string) bool { 11 | info, err := os.Stat(filename) 12 | if os.IsNotExist(err) { 13 | return false 14 | } 15 | return !info.IsDir() 16 | } 17 | 18 | func closeFileHandlers(handlers []*os.File) { 19 | for _, handler := range handlers { 20 | logger.Debugf("Closing file handler %q", handler.Name()) 21 | _ = handler.Close() 22 | } 23 | } 24 | 25 | func UsageMessage() { 26 | getopt.PrintUsage(os.Stderr) 27 | } 28 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | * https://tfswitch.warrensbox.com/ 5 | * A command line tool to switch between different versions of terraform 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | 12 | lib "github.com/warrensbox/terraform-switcher/lib" 13 | "github.com/warrensbox/terraform-switcher/lib/param_parsing" 14 | ) 15 | 16 | var ( 17 | parameters = param_parsing.GetParameters() 18 | logger = lib.InitLogger(parameters.LogLevel) 19 | version string 20 | ) 21 | 22 | func main() { 23 | var err error 24 | switch { 25 | case parameters.VersionFlag: 26 | fmt.Printf("Version: ") 27 | if version != "" { 28 | fmt.Println(version) 29 | } else { 30 | fmt.Println("not defined during build") 31 | } 32 | os.Exit(0) 33 | case parameters.HelpFlag: 34 | lib.UsageMessage() 35 | os.Exit(0) 36 | case parameters.ListAllFlag: 37 | /* show all terraform version including betas and RCs*/ 38 | err = lib.InstallProductOption(parameters.ProductEntity, true, parameters.DryRun, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch) 39 | case parameters.LatestPre != "": 40 | /* latest pre-release implicit version. Ex: tfswitch --latest-pre 0.13 downloads 0.13.0-rc1 (latest) */ 41 | err = lib.InstallLatestProductImplicitVersion(parameters.ProductEntity, parameters.DryRun, parameters.LatestPre, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch, true) 42 | case parameters.ShowLatestPre != "": 43 | /* show latest pre-release implicit version. Ex: tfswitch --latest-pre 0.13 downloads 0.13.0-rc1 (latest) */ 44 | lib.ShowLatestImplicitVersion(parameters.ShowLatestPre, parameters.MirrorURL, true) 45 | case parameters.LatestStable != "": 46 | /* latest implicit version. Ex: tfswitch --latest-stable 0.13 downloads 0.13.5 (latest) */ 47 | err = lib.InstallLatestProductImplicitVersion(parameters.ProductEntity, parameters.DryRun, parameters.LatestStable, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch, false) 48 | case parameters.ShowLatestStable != "": 49 | /* show latest implicit stable version. Ex: tfswitch --show-latest-stable 0.13 downloads 0.13.5 (latest) */ 50 | lib.ShowLatestImplicitVersion(parameters.ShowLatestStable, parameters.MirrorURL, false) 51 | case parameters.LatestFlag: 52 | /* latest stable version */ 53 | err = lib.InstallLatestProductVersion(parameters.ProductEntity, parameters.DryRun, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch) 54 | case parameters.ShowLatestFlag: 55 | /* show latest stable version */ 56 | lib.ShowLatestVersion(parameters.MirrorURL) 57 | case parameters.Version != "": 58 | err = lib.InstallProductVersion(parameters.ProductEntity, parameters.DryRun, parameters.Version, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch) 59 | case parameters.DefaultVersion != "": 60 | /* if default version is provided - Pick this instead of going for prompt */ 61 | err = lib.InstallProductVersion(parameters.ProductEntity, parameters.DryRun, parameters.DefaultVersion, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch) 62 | default: 63 | // Set list all false - only official release will be displayed 64 | err = lib.InstallProductOption(parameters.ProductEntity, false, parameters.DryRun, parameters.CustomBinaryPath, parameters.InstallPath, parameters.MirrorURL, parameters.Arch) 65 | } 66 | if err != nil { 67 | logger.Fatal(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test-data/checksum-check-file: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. 2 | 3 | Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. 4 | 5 | Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. 6 | 7 | Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. 8 | -------------------------------------------------------------------------------- /test-data/integration-tests/test_terraform-version/.terraform-version: -------------------------------------------------------------------------------- 1 | 0.11.0 2 | -------------------------------------------------------------------------------- /test-data/integration-tests/test_terragrunt_hcl/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | terragrunt_version_constraint = "= 0.36.2" 2 | terraform_version_constraint = ">= 0.13, < 0.14" 3 | -------------------------------------------------------------------------------- /test-data/integration-tests/test_tfswitchrc/.tfswitchrc: -------------------------------------------------------------------------------- 1 | 0.10.5 2 | -------------------------------------------------------------------------------- /test-data/integration-tests/test_tfswitchtoml/.tfswitch.toml: -------------------------------------------------------------------------------- 1 | arch = "amd64" 2 | bin = "$BIN_DIR_FROM_TOML/terraform_from_toml" 3 | install = "$INSTALL_DIR_FROM_TOML" 4 | default-version = "1.5.4" 5 | log-level = "NOTICE" 6 | product = "opentofu" 7 | version = "1.6.2" 8 | -------------------------------------------------------------------------------- /test-data/integration-tests/test_versiontf/version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.0.0" 3 | } 4 | 5 | terraform { 6 | required_version = "<=1.0.5" 7 | } 8 | -------------------------------------------------------------------------------- /test-data/recent/recent_as_json/.terraform.versions/RECENT: -------------------------------------------------------------------------------- 1 | {"terraform":["1.2.3","4.5.6","4.5.7","4.5.8","4.5.9","4.5.10"],"openTofu":["6.6.6"]} 2 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_no_file/dummy_file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/test-data/skip-integration-tests/test_no_file/dummy_file -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_precedence/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform-version 2 | .tfswitchrc 3 | main.tf 4 | .tfswitch.toml 5 | terragrunt.hcl 6 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_terragrunt_error_hcl/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | terraform_vers_agrtz_ion_constraint = ">= 0.13, < 0.14" 2 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_terragrunt_no_version/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | terragrunt_version_constraint = "= 0.36.2" 2 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_tfswitchtoml_error/.tfswitch.toml: -------------------------------------------------------------------------------- 1 | bin sdf= "/usr/local/bin/terraform_from_toml" 2 | version =sdfh "0.11.4" 3 | log-level =w35 "NOTICE" 4 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_tfswitchtoml_no_version/.tfswitch.toml: -------------------------------------------------------------------------------- 1 | bin = "/usr/local/bin/terraform_from_toml" 2 | log-level = "NOTICE" 3 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_versiontf_error/version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~527> 1.0.0" 3 | 4 | required_providers { 5 | aws = ">325= 2.52.0" 6 | kubernetes = ">32= 1.11.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_versiontf_no_version_constraint/version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = ">= 2.52.0" 4 | kubernetes = ">= 1.11.1" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_versiontf_non_existent/version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "> 99999.0.0" 3 | 4 | required_providers { 5 | aws = ">= 2.52.0" 6 | kubernetes = ">= 1.11.1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test-data/skip-integration-tests/test_versiontf_non_matching_constraints/version.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.0.0" 3 | } 4 | 5 | terraform { 6 | required_version = "=1.0.5" 7 | } 8 | 9 | terraform { 10 | required_version = "<= 1.0.4" 11 | } 12 | -------------------------------------------------------------------------------- /test-data/terraform_1.7.5_SHA256SUMS: -------------------------------------------------------------------------------- 1 | 0eaf64e28f82e2defd06f7a6f3187d8cea03d5d9fcd2af54f549a6c32d6833f7 terraform_1.7.5_darwin_amd64.zip 2 | 99c4d4feafb0183af2f7fbe07beeea6f83e5f5a29ae29fee3168b6810e37ff98 terraform_1.7.5_darwin_arm64.zip 3 | 3885fd4ce29788e040cfb350db88c6c1f3e34491c3f89f7a36ff8476f6c03959 terraform_1.7.5_freebsd_386.zip 4 | 3846d408255336460f40d7f5eeb7e987936f0359c9f45d1eb659a09f3d8893ab terraform_1.7.5_freebsd_amd64.zip 5 | b02aa14ecb4052482bf4e64fca7de7a743fce691511f96b4cd58610350886c9e terraform_1.7.5_freebsd_arm.zip 6 | e188ce5b45c9d10fa9a5a118438add2eab056d16587f774ecab93e3ca39e1bca terraform_1.7.5_linux_386.zip 7 | 3ff056b5e8259003f67fd0f0ed7229499cfb0b41f3ff55cc184088589994f7a5 terraform_1.7.5_linux_amd64.zip 8 | 4e74db9394d5cdf0f91cf8fecd290216edf6cf06273eb8f55e35f26eac4a936a terraform_1.7.5_linux_arm.zip 9 | 08631c385667dd28f03b3a3f77cb980393af4a2fcfc2236c148a678ad9150c8c terraform_1.7.5_linux_arm64.zip 10 | c0416b6b9fe0155bb3377e39a3f584b9f7b9a11a1236b9ea8cf7c074a804a513 terraform_1.7.5_openbsd_386.zip 11 | 3bd02023764365b7ae7ee3e597a6258e896049e3fd115896bc01113384864cba terraform_1.7.5_openbsd_amd64.zip 12 | 385af229bd76a058c221b9c0be56f02a7d0fa2535620040c9c895df00e0f09ee terraform_1.7.5_solaris_amd64.zip 13 | 2639c9444c6091fd5ad76f112040d592e99931489582ada4d485c12a64a79052 terraform_1.7.5_windows_386.zip 14 | 9b7be6ae159191ec1f4b5b9d27529ae5243e41020fb545c0041235bec8d92269 terraform_1.7.5_windows_amd64.zip 15 | 4083a1996695af4c8d1ac1079c47746cbf4bb58011faa644311c98a55b1de630 checksum-check-file 16 | -------------------------------------------------------------------------------- /test-data/test-data.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/test-data/test-data.zip -------------------------------------------------------------------------------- /test-data/test-data_windows.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/test-data/test-data_windows.zip -------------------------------------------------------------------------------- /tfswitch-completion.bash: -------------------------------------------------------------------------------- 1 | # Remove once https://github.com/warrensbox/terraform-switcher/issues/537 is implemented 2 | # - SC2015 (info): Note that A && B || C is not if-then-else. C may run when A is true. 3 | # - SC2207 W: Prefer mapfile or read -a to split command output (or quote to avoid splitting). 4 | # shellcheck disable=SC2015,SC2207 5 | _tfswitch() { 6 | local cur prev 7 | cur=${COMP_WORDS[COMP_CWORD]} 8 | prev=${COMP_WORDS[COMP_CWORD - 1]} 9 | 10 | if [[ ${cur} == -* ]]; then 11 | COMPREPLY=($(compgen -W "$(tfswitch --help 2>&1 | grep -Eo '[[:space:]]+(-{1,2}[a-zA-Z0-9-]+)')" -- "$cur")) 12 | return 0 13 | fi 14 | 15 | case "${prev}" in 16 | -A | --arch) 17 | COMPREPLY=($(compgen -W "386 amd64 arm arm64" -- "$cur")) 18 | return 0 19 | ;; 20 | -b | --bin) 21 | [[ $(type -t _comp_compgen) == "function" ]] && _comp_compgen -a filedir || _filedir 22 | return 0 23 | ;; 24 | -c | --chdir | -i | --install) 25 | [[ $(type -t _comp_compgen) == "function" ]] && _comp_compgen -a filedir -d || _filedir -d 26 | return 0 27 | ;; 28 | -g | --log-level) 29 | COMPREPLY=($(compgen -W "DEBUG ERROR INFO NOTICE TRACE" -- "$cur")) 30 | return 0 31 | ;; 32 | -t | --product) 33 | COMPREPLY=($(compgen -W "opentofu terraform" -- "$cur")) 34 | return 0 35 | ;; 36 | esac 37 | 38 | } 39 | complete -F _tfswitch tfswitch 40 | 41 | # vim: set filetype=bash shiftwidth=4 tabstop=4 noexpandtab autoindent: 42 | -------------------------------------------------------------------------------- /www/docs/CNAME: -------------------------------------------------------------------------------- 1 | tfswitch.warrensbox.com -------------------------------------------------------------------------------- /www/docs/Continuous-Integration.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Jenkins setup 4 | 5 | ![jenkins_tfswitch](static/jenkins_tfswitch.png) 6 | 7 | ```sh 8 | #!/bin/bash 9 | 10 | echo "Installing tfswitch locally" 11 | wget https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh #Get the installer on to your machine 12 | 13 | chmod 755 install.sh #Make installer executable 14 | 15 | ./install.sh -b `pwd`/.bin #Install tfswitch in a location you have permission 16 | 17 | CUSTOMBIN=`pwd`/.bin #set custom bin path 18 | 19 | export PATH=$PATH:$CUSTOMBIN #Add custom bin path to PATH environment 20 | 21 | $CUSTOMBIN/tfswitch -b $CUSTOMBIN/terraform 0.11.7 #or simply tfswitch -b $CUSTOMBIN/terraform 0.11.7 22 | 23 | #OR 24 | $CUSTOMBIN/tfswitch -d 0.11.7 -b $CUSTOMBIN/terraform #or simply tfswitch -d 0.11.7 -b $CUSTOMBIN/terraform 25 | 26 | terraform -v #testing version 27 | ``` 28 | 29 | ## Circle CI setup 30 | 31 | ![cirecleci_tfswitch](static/circleci_tfswitch.png) 32 | 33 | Example config YAML 34 | 35 | ```yaml 36 | version: 2 37 | jobs: 38 | build: 39 | docker: 40 | - image: ubuntu 41 | 42 | working_directory: /go/src/github.com/warrensbox/terraform-switcher 43 | 44 | steps: 45 | - checkout 46 | - run: 47 | command: | 48 | set +e 49 | apt-get update 50 | apt-get install -y wget 51 | rm -rf /var/lib/apt/lists/* 52 | 53 | echo "Installing tfswitch locally" 54 | 55 | wget https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh #Get the installer on to your machine 56 | 57 | chmod 755 install.sh #Make installer executable 58 | 59 | ./install.sh -b `pwd`/.bin #Install tfswitch in a location you have permission 60 | 61 | CUSTOMBIN=`pwd`/.bin #set custom bin path 62 | 63 | export PATH=$PATH:$CUSTOMBIN #Add custom bin path to PATH environment 64 | 65 | $CUSTOMBIN/tfswitch -b $CUSTOMBIN/terraform 0.11.7 #or simply tfswitch -b $CUSTOMBIN/terraform 0.11.7 66 | 67 | #OR 68 | $CUSTOMBIN/tfswitch -d 0.11.7 -b $CUSTOMBIN/terraform #or simply tfswitch -d 0.11.7 -b $CUSTOMBIN/terraform 69 | 70 | terraform -v #testing version 71 | ``` 72 | -------------------------------------------------------------------------------- /www/docs/How-to-Contribute.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Step-by-step instructions 4 | 5 | An open source project becomes meaningful when people collaborate to improve the code. 6 | 7 | Feel free to look at the code, critique and make suggestions. Let's make `tfswitch` better! 8 | 9 | ## Required version 10 | 11 | ```sh 12 | go version 1.23 13 | ``` 14 | 15 | ### Step 1 - Create workspace 16 | 17 | _Skip this step if you already have a GitHub go workspace_ 18 | Create a GitHub workspace. 19 | 20 | ![GitHub Workspace](static/contribute/tfswitch-workspace.gif "Create GitHub Workspace") 21 | 22 | ### Step 2 - Set GOPATH 23 | 24 | _Skip this step if you already have a GitHub go workspace_ 25 | Export your GOPATH environment variable in your `go` directory. 26 | 27 | ```sh 28 | export GOPATH=`pwd` 29 | ``` 30 | 31 | ![gopath](static/contribute/tfswitch-gopath.gif "gopath") 32 | 33 | ### Step 3 - Clone repository 34 | 35 | Git clone this repository. 36 | 37 | ```sh 38 | git clone git@github.com:warrensbox/terraform-switcher.git 39 | ``` 40 | 41 | ![gitclone](static/contribute/tfswitch-git-clone.gif "Git Clone") 42 | 43 | ### Step 4 - Get dependencies 44 | 45 | Go get all the dependencies. 46 | 47 | ```sh 48 | go mod download 49 | ``` 50 | 51 | ```sh 52 | go get -v -t -d ./... 53 | ``` 54 | 55 | Test the code (optional). 56 | 57 | ```sh 58 | go vet -tests=false ./... 59 | ``` 60 | 61 | ```sh 62 | go test -v ./... 63 | ``` 64 | 65 | ![go get](static/contribute/tfswitch-go-get.gif) 66 | 67 | ### Step 5 - Build executable 68 | 69 | Create a new branch. 70 | 71 | ```sh 72 | git checkout -b feature/put-your-branch-name-here 73 | ``` 74 | 75 | Refactor and add new features to the code. 76 | Go build the code. 77 | 78 | ```sh 79 | go build -o test-tfswitch 80 | ``` 81 | 82 | Test the code and create a new pull request! 83 | 84 | ![go build](static/contribute/tfswitch-build.gif) 85 | 86 | ### Contributors 87 | 88 | Click here to see all contributors. 89 | -------------------------------------------------------------------------------- /www/docs/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | `tfswitch` is available for Windows, macOS and Linux based operating systems. 4 | 5 | ## Windows 6 | 7 | Download and extract the Windows version of `tfswitch` that is compatible with your system. 8 | We are building binaries for 386, amd64, arm6 and arm7 CPU structure. 9 | See the [release page](https://github.com/warrensbox/terraform-switcher/releases/latest) for your download. 10 | 11 | ## Homebrew 12 | 13 | Installation for macOS is the easiest with Homebrew. If you do not have Homebrew installed, click here. 14 | 15 | ```shell 16 | brew install warrensbox/tap/tfswitch 17 | ``` 18 | 19 | ## Linux 20 | 21 | Installation for Linux operating systems. 22 | 23 | ```sh 24 | curl -L https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh | bash 25 | ``` 26 | 27 | By default installer script will try to download `tfswitch` binary into `/usr/local/bin` 28 | To install at custom path use below: 29 | 30 | ```sh 31 | curl -L https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh | bash -s -- -b $HOME/.local/bin 32 | ``` 33 | 34 | By default installer script will try to download latest version of `tfswitch` binary 35 | To install custom (not latest) version use: 36 | 37 | ```sh 38 | curl -L https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh | bash -s -- 1.1.1 39 | ``` 40 | 41 | Both options can be combined though: 42 | 43 | ```sh 44 | curl -L https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh | bash -s -- -b $HOME/.local/bin 1.1.1 45 | ``` 46 | 47 | ## Arch User Repository (AUR) packages for Arch Linux 48 | 49 | ```sh 50 | # compiled from source 51 | yay tfswitch 52 | 53 | # precompiled 54 | yay tfswitch-bin 55 | ``` 56 | 57 | ## Install from source 58 | 59 | Alternatively, you can install the binary from the source here. 60 | 61 | [Having trouble installing](https://tfswitch.warrensbox.com/Troubleshoot/). 62 | -------------------------------------------------------------------------------- /www/docs/Troubleshoot.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Problem: 4 | 5 | ```sh 6 | install: can't change permissions of /usr/local/bin: Operation not permitted 7 | ``` 8 | 9 | ```sh 10 | "Unable to remove symlink. You must have SUDO privileges" 11 | ``` 12 | 13 | ```sh 14 | "Unable to create symlink. You must have SUDO privileges" 15 | ``` 16 | 17 | ```sh 18 | install: cannot create regular file '/usr/local/bin/tfswitch': Permission denied 19 | ``` 20 | 21 | Solution: You probably need to have privileges to install _tfswitch_ at /usr/local/bin. 22 | 23 | Try the following: 24 | 25 | ```sh 26 | wget https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh #Get the installer on to your machine: 27 | 28 | chmod 755 install.sh #Make installer executable 29 | 30 | ./install.sh -b $HOME/.bin #Install tfswitch in a location you have permission: 31 | 32 | $HOME/.bin/tfswitch #test 33 | 34 | export PATH=$PATH:$HOME/.bin #Export your .bin into your path 35 | 36 | #You should probably add step 4 in your `.bash_profile` in your $HOME directory. 37 | 38 | #Next, try: 39 | `tfswitch -b $HOME/.bin/terraform 0.11.7` 40 | 41 | #or simply 42 | 43 | `tfswitch -b $HOME/.bin/terraform` 44 | 45 | 46 | ``` 47 | 48 | See the custom directory option `-b`: 49 | ![custom directory](static/tfswitch-v7.gif "Custom binary path") 50 | -------------------------------------------------------------------------------- /www/docs/Upgrade-or-Uninstall.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Upgrade 4 | 5 | ### Homebrew 6 | 7 | ```shell 8 | brew upgrade warrensbox/tap/tfswitch 9 | ``` 10 | 11 | ### Linux 12 | 13 | Rerun: 14 | 15 | ```sh 16 | curl -L https://raw.githubusercontent.com/warrensbox/terraform-switcher/master/install.sh | bash 17 | ``` 18 | 19 | ## Uninstall 20 | 21 | ### Homebrew 22 | 23 | ```shell 24 | brew uninstall warrensbox/tap/tfswitch 25 | ``` 26 | 27 | ### Linux 28 | 29 | Run (replace `/usr/local/bin` if you installed `tfswitch` to a custom location): 30 | 31 | ```sh 32 | rm /usr/local/bin/tfswitch 33 | ``` 34 | -------------------------------------------------------------------------------- /www/docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction to tfswitch 2 | 3 | ![Logo](static/logo.png) 4 | 5 | The `tfswitch` command-line tool lets you switch between different versions of terraform. 6 | If you do not have a particular version of terraform installed, `tfswitch` lets you download the version you desire. 7 | The installation is minimal and easy. 8 | Once installed, simply select the version you require from the dropdown and start using terraform. 9 | -------------------------------------------------------------------------------- /www/docs/static/circleci_tfswitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/circleci_tfswitch.png -------------------------------------------------------------------------------- /www/docs/static/contribute/tfswitch-build.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/contribute/tfswitch-build.gif -------------------------------------------------------------------------------- /www/docs/static/contribute/tfswitch-git-clone.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/contribute/tfswitch-git-clone.gif -------------------------------------------------------------------------------- /www/docs/static/contribute/tfswitch-go-get.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/contribute/tfswitch-go-get.gif -------------------------------------------------------------------------------- /www/docs/static/contribute/tfswitch-gopath.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/contribute/tfswitch-gopath.gif -------------------------------------------------------------------------------- /www/docs/static/contribute/tfswitch-workspace.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/contribute/tfswitch-workspace.gif -------------------------------------------------------------------------------- /www/docs/static/favicon_tfswitch_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/favicon_tfswitch_16.png -------------------------------------------------------------------------------- /www/docs/static/favicon_tfswitch_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/favicon_tfswitch_48.png -------------------------------------------------------------------------------- /www/docs/static/jenkins_tfswitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/jenkins_tfswitch.png -------------------------------------------------------------------------------- /www/docs/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/logo.png -------------------------------------------------------------------------------- /www/docs/static/tfswitch-v4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/tfswitch-v4.gif -------------------------------------------------------------------------------- /www/docs/static/tfswitch-v5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/tfswitch-v5.gif -------------------------------------------------------------------------------- /www/docs/static/tfswitch-v6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/tfswitch-v6.gif -------------------------------------------------------------------------------- /www/docs/static/tfswitch-v7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/tfswitch-v7.gif -------------------------------------------------------------------------------- /www/docs/static/tfswitch-v8.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/tfswitch-v8.gif -------------------------------------------------------------------------------- /www/docs/static/tfswitch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/tfswitch.gif -------------------------------------------------------------------------------- /www/docs/static/versiontf.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrensbox/terraform-switcher/9b0cbc64348cf8ad4ae418dce33f29f7257d5b8a/www/docs/static/versiontf.gif -------------------------------------------------------------------------------- /www/docs/usage/ci-cd.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Set a default TF version for CI/CD pipeline 4 | 5 | 1. When using a CI/CD pipeline, you may want a default or fallback version to avoid the pipeline from hanging. 6 | 2. Ex: `tfswitch -d 1.2.3` or `tfswitch --default 1.2.3` installs version `1.2.3` when no other versions could be detected. 7 | [Also, see CICD example](../Continuous-Integration.md) 8 | 9 | ## Automatically switch with bash 10 | 11 | Add the following to the end of your `~/.bashrc` file: 12 | (Use either `.tfswitchrc` or `.terraform-version`) 13 | 14 | ```sh 15 | cdtfswitch(){ 16 | builtin cd "$@"; 17 | cdir=$PWD; 18 | if [ -e "$cdir/.tfswitchrc" ]; then 19 | tfswitch 20 | fi 21 | } 22 | alias cd='cdtfswitch' 23 | ``` 24 | 25 | ## Automatically switch with Zsh 26 | 27 | Add the following to the end of your `~/.zshrc` file: 28 | 29 | ```sh 30 | load-tfswitch() { 31 | local tfswitchrc_path=".tfswitchrc" 32 | 33 | if [ -f "$tfswitchrc_path" ]; then 34 | tfswitch 35 | fi 36 | } 37 | add-zsh-hook chpwd load-tfswitch 38 | load-tfswitch 39 | ``` 40 | 41 | > NOTE: if you see an error like this: `command not found: add-zsh-hook`, then you might be on an older version of zsh (see below), or you simply need to load `add-zsh-hook` by adding this to your `.zshrc`: 42 | > 43 | > ```sh 44 | > autoload -U add-zsh-hook 45 | > ``` 46 | 47 | ### Older version of Zsh 48 | 49 | ```sh 50 | cd(){ 51 | builtin cd "$@"; 52 | cdir=$PWD; 53 | if [ -e "$cdir/.tfswitchrc" ]; then 54 | tfswitch 55 | fi 56 | } 57 | ``` 58 | 59 | ## Automatically switch with fish shell 60 | 61 | Add the following to the end of your `~/.config/fish/config.fish` file: 62 | 63 | ```sh 64 | function switch_terraform --on-event fish_postexec 65 | string match --regex '^cd\s' "$argv" > /dev/null 66 | set --local is_command_cd $status 67 | 68 | if test $is_command_cd -eq 0 69 | if count *.tf > /dev/null 70 | 71 | grep -c "required_version" *.tf > /dev/null 72 | set --local tf_contains_version $status 73 | 74 | if test $tf_contains_version -eq 0 75 | command tfswitch 76 | end 77 | end 78 | end 79 | end 80 | ``` 81 | -------------------------------------------------------------------------------- /www/docs/usage/commandline.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Use dropdown menu to select version 4 | 5 | ![tfswitch](../static/tfswitch.gif "tfswitch") 6 | 7 | 1. You can switch between different versions of terraform by typing the command `tfswitch` on your terminal. 8 | 2. Select the version of terraform you require by using the up and down arrow. 9 | 3. Hit **Enter** to select the desired version. 10 | 11 | The most recently selected versions are presented at the top of the dropdown. 12 | 13 | ## Supply version on command line 14 | 15 | drawing 16 | 17 | 1. You can also supply the desired version as an argument on the command line. 18 | 2. For example, `tfswitch 0.10.5` for version 0.10.5 of terraform. 19 | 3. Hit **Enter** to switch. 20 | 21 | ## See all versions including beta, alpha and release candidates(rc) 22 | 23 | drawing 24 | 25 | 1. Display all versions including beta, alpha and release candidates(rc). 26 | 2. For example, `tfswitch -l` or `tfswitch --list-all` to see all versions. 27 | 3. Hit **Enter** to select the desired version. 28 | 29 | ## Use environment variables 30 | 31 | You can also set environment variables for tfswitch to override some configurations: 32 | 33 | ### `TF_VERSION` 34 | 35 | `TF_VERSION` environment variable can be set to your desired terraform version. 36 | 37 | For example: 38 | 39 | ```bash 40 | export TF_VERSION="0.14.4" 41 | tfswitch # Will automatically switch to terraform version 0.14.4 42 | ``` 43 | 44 | ### `TF_DEFAULT_VERSION` 45 | 46 | `TF_DEFAULT_VERSION` environment variable can be set to your desired terraform version that will be used as a fallback version, if not other sources are found. 47 | 48 | For example: 49 | 50 | ```bash 51 | export TF_DEFAULT_VERSION="0.14.4" 52 | tfswitch # Will automatically switch to terraform version 0.14.4 53 | ``` 54 | 55 | ### `TF_PRODUCT` 56 | 57 | `TF_PRODUCT` environment variable can be set to your desired product/tool. 58 | 59 | This can either be set to: 60 | 61 | - `terraform` 62 | - `opentofu` 63 | 64 | For example: 65 | 66 | ```bash 67 | export TF_PRODUCT="opentofu" 68 | tfswitch # Will install opentofu instead of terraform 69 | ``` 70 | 71 | ### `TF_LOG_LEVEL` 72 | 73 | `TF_LOG_LEVEL` environment variable can be set to override default log level. 74 | 75 | - Supported log levels: 76 | - `ERROR`: includes `PANIC`, `FATAL`, `ERROR` 77 | - `INFO`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `INFO` (default) 78 | - `NOTICE`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `NOTICE`, `INFO` 79 | - `DEBUG`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `NOTICE`, `INFO`, `DEBUG` 80 | - `TRACE`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `NOTICE`, `INFO`, `DEBUG`, `TRACE` 81 | - Any other log level value falls under default logging level 82 | 83 | For example: 84 | 85 | ```bash 86 | export TF_LOG_LEVEL="DEBUG" 87 | tfswitch # Will output debug logs 88 | ``` 89 | 90 | ### `TF_ARCH` 91 | 92 | `TF_ARCH` environment variable can be set to override default CPU architecture of downloaded binaries. 93 | 94 | - This can be set to any string, though incorrect values will result in download failure. 95 | - Suggested values: `amd64`, `arm64`, `386`. 96 | - Check available Arch types at: 97 | - [Terraform Downloads](https://releases.hashicorp.com/terraform/) 98 | - [OpenTofu Downloads](https://get.opentofu.org/tofu/) 99 | 100 | For example: 101 | 102 | ```bash 103 | export TF_ARCH="amd64" 104 | tfswitch # Will install binary for amd64 architecture 105 | ``` 106 | 107 | ### `TF_BINARY_PATH` 108 | 109 | `tfswitch` defaults to install to the `/usr/local/bin/` directory (and falls back to `$HOME/bin/` otherwise). The target filename is resolved automatically based on the `product` parameter. 110 | `TF_BINARY_PATH` environment variable can be set to specify a **full installation path** (directory + filename). If target directory does not exist, `tfswitch` falls back to `$HOME/bin/` directory. 111 | 112 | For example: 113 | 114 | ```bash 115 | export TF_BINARY_PATH="$HOME/bin/terraform" # Path to the file 116 | tfswitch # Will install binary as $HOME/bin/terraform 117 | ``` 118 | 119 | ### `TF_INSTALL_PATH` 120 | 121 | `tfswitch` defaults to download binaries to the `$HOME/.terraform.versions/` directory. 122 | `TF_INSTALL_PATH` environment variable can be set to specify the parent directory for `.terraform.versions` directory. Current user must have write permissions to the target directory. If the target directory does not exist, `tfswitch` will create it. 123 | 124 | For example: 125 | 126 | ```bash 127 | export TF_INSTALL_PATH="/var/cache" # Path to the directory where `.terraform.versions` directory resides 128 | tfswitch # Will download actual binary to /var/cache/.terraform.versions/ 129 | ``` 130 | 131 | ## Install latest version only 132 | 133 | 1. Install the latest stable version only. 134 | 2. Run `tfswitch -u` or `tfswitch --latest`. 135 | 3. Hit **Enter** to install. 136 | 137 | ## Install latest implicit version for stable releases 138 | 139 | 1. Install the latest implicit stable version. 140 | 2. Ex: `tfswitch -s 0.13` or `tfswitch --latest-stable 0.13` downloads 0.13.6 (latest) version. 141 | 3. Hit **Enter** to install. 142 | 143 | ## Install latest implicit version for beta, alpha and release candidates(rc) 144 | 145 | 1. Install the latest implicit prerelease version. 146 | 2. Ex: `tfswitch -p 0.13` or `tfswitch --latest-pre 0.13` downloads 0.13.0-rc1 (latest) version. 147 | 3. Hit **Enter** to install. 148 | 149 | ## Show latest version only 150 | 151 | 1. Just show what the latest version is. 152 | 2. Run `tfswitch -U` or `tfswitch --show-latest` 153 | 3. Hit **Enter** to show. 154 | 155 | ## Show latest implicit version for stable releases 156 | 157 | 1. Show the latest implicit stable version. 158 | 2. Ex: `tfswitch -S 0.13` or `tfswitch --show-latest-stable 0.13` shows 0.13.6 (latest) version. 159 | 3. Hit **Enter** to show. 160 | 161 | ## Show latest implicit version for beta, alpha and release candidates(rc) 162 | 163 | 1. Show the latest implicit prerelease version. 164 | 2. Ex: `tfswitch -P 0.13` or `tfswitch --show-latest-pre 0.13` shows 0.13.0-rc1 (latest) version. 165 | 3. Hit **Enter** to show. 166 | 167 | ## Use custom mirror 168 | 169 | To install from a remote mirror other than the default (). Use the `-m` or `--mirror` parameter. 170 | 171 | ```bash 172 | tfswitch --mirror https://example.jfrog.io/artifactory/hashicorp` 173 | ``` 174 | 175 | ## Install to non-default location 176 | 177 | By default `tfswitch` will download the Terraform binary to the user home directory under this path: `$HOME/.terraform.versions` 178 | 179 | If you want to install the binaries outside of the home directory then you can provide the `-i` or `--install` to install Terraform binaries to a non-standard path. Useful if you want to install versions of Terraform that can be shared with multiple users. 180 | 181 | The Terraform binaries will then be placed in the directory `.terraform.versions` under the custom install path e.g. `/opt/terraform/.terraform.versions` 182 | 183 | ```bash 184 | tfswitch -i /opt/terraform 185 | ``` 186 | 187 | **NOTE**: The directory passed in `-i`/`--install` must be created before running `tfswitch` 188 | 189 | ## Install binary for non-default architecture 190 | 191 | By default `tfswitch` will download the binary for the architecture of the host machine. 192 | 193 | If you want to download the binary for non-default CPU architecture then you can provide the `-A` or `--arch` command line argument to download binaries for custom CPU architecture. Useful if you need to override binary architecture for whatever reason. 194 | 195 | ```bash 196 | tfswitch --arch amd64 197 | ``` 198 | 199 | **NOTE**: If the target file already exists in the download directory (See [Install to non-default location](#install-to-non-default-location) section above), it will be not downloaded. Downloaded files are stored without the architecture in the filename. Format of the filenames in download directory: `_`. E.g. `terraform_1.10.4`. 200 | -------------------------------------------------------------------------------- /www/docs/usage/config-files.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Get the version from a subdirectory 4 | 5 | While using the file configuration it might be necessary to change the working directory. You can do that with the `--chdir` or `-c` parameter. 6 | 7 | ```bash 8 | tfswitch --chdir terraform_dir 9 | tfswitch -c terraform_dir 10 | ``` 11 | 12 | ## Use `version.tf` file 13 | 14 | If a `.tf` file with the version constraints is included in the current directory, `tfswitch` should automatically download or switch to that terraform version. 15 | Also please refer to [Order of Terraform version definition precedence](general.md) page for more information on how `tfswitch` determines the version to use. 16 | For example, the following should automatically switch to the latest available version newer than `0.12.8`: 17 | 18 | ```hcl 19 | terraform { 20 | required_version = ">= 0.12.9" 21 | 22 | required_providers { 23 | aws = ">= 2.52.0" 24 | kubernetes = ">= 1.11.1" 25 | } 26 | } 27 | ``` 28 | 29 | ![versiontf](../static/versiontf.gif "Use version.tf") 30 | 31 | ## Use `.tfswitchrc` file 32 | 33 | ![tfswitchrc](../static/tfswitch-v6.gif) 34 | 35 | 1. Create a `.tfswitchrc` file containing the desired version 36 | 2. For example, `echo "0.10.5" >> .tfswitchrc` for version `0.10.5` 37 | 3. Run the command `tfswitch` in the same directory as this `.tfswitchrc` file 38 | 39 | _Instead of a `.tfswitchrc` file, a `.terraform-version` file may be used for compatibility with [`tfenv`](https://github.com/tfutils/tfenv#terraform-version-file) and other tools which use it_ 40 | 41 | ## Use `.tfswitch.toml` file (For non-admin users with limited privilege on their computers) 42 | 43 | `tfswitch` defaults to install to the `/usr/local/bin/` directory (and falls back to `$HOME/bin/` otherwise). The target filename is resolved automatically based on the `product` attribute ([see below](#setting-product-using-tfswitchtoml-file)). If you do not have write access to `/usr/local/bin/` directory, you can use the `.tfswitch.toml` file to specify a **full installation path** (directory + filename). 44 | This is similar to using a `.tfswitchrc` file, but you specify a custom binary path for the installation: 45 | 46 | ![toml1](../static/tfswitch-v7.gif) 47 | ![toml2](../static/tfswitch-v8.gif) 48 | 49 | 1. Create a directory for the custom binary path. Ex: `mkdir -p "$HOME/bin/"` 50 | 2. Add the path to the directory to your `PATH` environment variable. Ex: `export PATH="$PATH:$HOME/bin"` (add this to your Bash profile or Zsh profile) 51 | 3. Pass `-b` or `--bin` parameter with the custom binary path as value (this must be a first level pointer inside the directory from above). Ex: `tfswitch -b "$HOME/bin/terraform" 0.10.8` 52 | - If target directory for custom binary path does not exist, `tfswitch` falls back to `$HOME/bin/` directory 53 | 4. Optionally, you can create a `.tfswitch.toml` file in your home directory (`~/.tfswitch.toml`) 54 | 5. Your `~/.tfswitch.toml` file should look like this: 55 | 56 | ```toml 57 | bin = "$HOME/bin/terraform" 58 | version = "0.11.3" 59 | ``` 60 | 61 | 6. Run `tfswitch` and it should automatically install the required version in the specified binary path 62 | 63 | Below is an example for `$HOME/.tfswitch.toml` on Windows: 64 | 65 | ```toml 66 | bin = "C:\\Users\\<%USRNAME%>\\bin\\terraform.exe" 67 | ``` 68 | 69 | ## Setting the default (fallback) version using `.tfswitch.toml` file 70 | 71 | By default, if `tfswsitch` is unable to determine the version to use, it errors out. 72 | The `.tfswitch.toml` file can be configured with a `default-version` attribute for `tfswitch` to use a particular version, if no other sources of versions are found 73 | 74 | ```toml 75 | default-version = "1.5.4" 76 | ``` 77 | 78 | ## Setting product using `.tfswitch.toml` file 79 | 80 | `tfswitch` defaults to install Terraform binaries. 81 | The `.tfswitch.toml` file can be configured with a `product` attribute for `tfswitch` to use either Terraform or OpenTofu by default: 82 | 83 | ```toml 84 | product = "opentofu" 85 | ``` 86 | 87 | or 88 | 89 | ```toml 90 | product = "terraform" 91 | ``` 92 | 93 | ## Setting log level using `.tfswitch.toml` file 94 | 95 | `tfswitch` defaults to `INFO` log level. 96 | The `.tfswitch.toml` file can be configured with a `log-level` attribute for `tfswitch` to use non-default logging verbosity: 97 | 98 | ```toml 99 | log-level = "INFO" 100 | ``` 101 | 102 | - Supported log levels: 103 | - `ERROR`: includes `PANIC`, `FATAL`, `ERROR` 104 | - `INFO`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `INFO` (default) 105 | - `NOTICE`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `NOTICE`, `INFO` 106 | - `DEBUG`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `NOTICE`, `INFO`, `DEBUG` 107 | - `TRACE`: includes `PANIC`, `FATAL`, `ERROR`, `WARN`, `NOTICE`, `INFO`, `DEBUG`, `TRACE` 108 | - Any other log level value falls under default logging level 109 | 110 | ## Overriding CPU architecture type for the downloaded binary using `.tfswitch.toml` file 111 | 112 | CPU architecture of the downloaded binaries defaults to `tfswitch`'s host architecture. 113 | The `.tfswitch.toml` file can be configured with a `arch` attribute for `tfswitch` to download binary of non-default architecture type: 114 | 115 | ```toml 116 | arch = "arm64" 117 | ``` 118 | 119 | - This can be set to any string, though incorrect values will result in download failure. 120 | - Suggested values: `amd64`, `arm64`, `386`. 121 | - Check available Arch types at: 122 | - [Terraform Downloads](https://releases.hashicorp.com/terraform/) 123 | - [OpenTofu Downloads](https://get.opentofu.org/tofu/) 124 | 125 | ## Overriding installation directory, where actual binaries are stored, using `.tfswitch.toml` file 126 | 127 | `tfswitch` defaults to download binaries to the `$HOME/.terraform.versions/` directory. 128 | The `.tfswitch.toml` file can be configured with a `install` attribute to specify the parent directory for `.terraform.versions` directory. 129 | 130 | ```toml 131 | install = "/var/cache" 132 | ``` 133 | 134 | **NOTE**: 135 | 136 | - Current user must have write permissions to the target directory 137 | - If the target directory does not exist, `tfswitch` will create it 138 | 139 | ## Use `terragrunt.hcl` file 140 | 141 | If a terragrunt.hcl file with the terraform constraint is included in the current directory, it should automatically download or switch to that terraform version. 142 | For example, the following should automatically switch Terraform to the latest version 0.13: 143 | 144 | ```hcl 145 | terragrunt_version_constraint = ">= 0.26, < 0.27" 146 | terraform_version_constraint = ">= 0.13, < 0.14" 147 | ... 148 | ``` 149 | -------------------------------------------------------------------------------- /www/docs/usage/general.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Order of Terraform version definition precedence 4 | 5 | | Order | Method | 6 | | ----- | ----------------------------------------------------------- | 7 | | 1 | `$HOME/.tfswitch.toml` (`version` parameter) | 8 | | 2 | `.tfswitchrc` (version as a string) | 9 | | 3 | `.terraform-version` (version as a string) | 10 | | 4 | Terraform root module (`required_version` constraint) | 11 | | 5 | `terragrunt.hcl` (`terraform_version_constraint` parameter) | 12 | | 6 | Environment variable (`TF_VERSION`) | 13 | 14 | With 1 being the **lowest** precedence and 7 — the **highest** 15 | _(If you disagree with this order of precedence, please open an issue)_ 16 | -------------------------------------------------------------------------------- /www/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: TFSwitch 2 | site_description: A command line tool to switch between different versions of Terraform (install with Homebrew and more) 3 | copyright: This project is maintained by warrensbox 4 | repo_name: warrensbox/terraform-switcher 5 | repo_url: https://github.com/warrensbox/terraform-switcher 6 | site_url: https://tfswitch.warrensbox.com 7 | 8 | theme: 9 | name: material 10 | palette: 11 | primary: indigo 12 | favicon: static/favicon_tfswitch_16.png 13 | logo: static/logo.png 14 | 15 | nav: 16 | - Home: index.md 17 | - Installation: Installation.md 18 | - Usage: 19 | - General: usage/general.md 20 | - Command line: usage/commandline.md 21 | - Config files: usage/config-files.md 22 | - CI/CD: usage/ci-cd.md 23 | - CI/CD Examples: Continuous-Integration.md 24 | - Upgrade-or-Uninstall.md 25 | - How-to-Contribute.md 26 | - Troubleshoot.md 27 | # WARNING - Config value 'google_analytics': The configuration option 28 | # google_analytics has been deprecated and will be removed in a future release 29 | # of MkDocs. See the options available on your theme for an alternative. 30 | # See https://squidfunk.github.io/mkdocs-material/setup/setting-up-site-analytics/ for new format 31 | #google_analytics: 32 | # - UA-120055973-1 33 | # - auto 34 | --------------------------------------------------------------------------------