├── .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 |
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 |
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 |
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 |
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 |
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 | [](https://github.com/warrensbox/terraform-switcher/actions/workflows/build.yml)
4 | [](https://goreportcard.com/report/github.com/warrensbox/terraform-switcher)
5 | 
6 | 
7 | 
8 |
9 | # Terraform Switcher
10 |
11 |
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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 |
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 | 
30 |
31 | ## Use `.tfswitchrc` file
32 |
33 | 
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 | 
47 | 
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 |
--------------------------------------------------------------------------------