diff --git a/README.md b/README.md index 38faa059..c97ce673 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,15 @@ steps: terraform_version: "1.1.7" ``` +A specific version of Terraform CLI can be installed using a version file: + +```yaml +steps: +- uses: hashicorp/setup-terraform@v3 + with: + terraform_version_file: ".tool-versions" +``` + Credentials for HCP Terraform ([app.terraform.io](https://app.terraform.io/)) can be configured: ```yaml @@ -251,10 +260,24 @@ The action supports the following inputs: for available range specifications). Examples are: `"<1.2.0"`, `"~1.1.0"`, `"1.1.7"` (all three installing the latest available `1.1` version). Prerelease versions can be specified and a range will stay within the given tag such as `beta` or `rc`. If no version is given, it will default to `latest`. +- `terraform_version_file` - (optional) The path to a file containing terraform version. Supported file types are `.tool-versions` or `.terraform-version`. See more details in [about version-file](#Terraform-version-file). - `terraform_wrapper` - (optional) Whether to install a wrapper to wrap subsequent calls of the `terraform` binary and expose its STDOUT, STDERR, and exit code as outputs named `stdout`, `stderr`, and `exitcode` respectively. Defaults to `true`. +### Terraform version file + +If the `terraform_version_file` input is specified, the action will extract the version from the file and install it. + +Supported files names are `.tool-versions` or `.terraform-version`. +In `.tool-versions` file, terraform version should be preceded by the terraform keyword (e.g., `terraform 1.13.0`). +The `.tool-versions` file supports version specifications in accordance with Semantic Versioning ([semver](https://semver.org/)) and [Semver Ranges](https://www.npmjs.com/package/semver#ranges). +The `.terraform-version` file supports version specifications as explained in the `terraform_version` input. + +If both `terraform_version` and `terraform_version_file` inputs are provided, the `terraform_version` input will be used. + +If the file contains multiple versions, only the first one will be recognized. + ## Outputs This action does not configure any outputs directly. However, when you set the `terraform_wrapper` input diff --git a/action.yml b/action.yml index ea170be1..7ddd20e5 100644 --- a/action.yml +++ b/action.yml @@ -11,7 +11,9 @@ inputs: required: false terraform_version: description: 'The version of Terraform CLI to install. Instead of full version string you can also specify constraint string starting with "<" (for example `<1.13.0`) to install the latest version satisfying the constraint. A value of `latest` will install the latest version of Terraform CLI. Defaults to `latest`.' - default: 'latest' + required: false + terraform_version_file: + description: 'The path to the `.terraform-version` file. See examples of supported syntax in README file' required: false terraform_wrapper: description: 'Whether or not to install a wrapper to wrap subsequent calls of the `terraform` binary and expose its STDOUT, STDERR, and exit code as outputs named `stdout`, `stderr`, and `exitcode` respectively. Defaults to `true`.' diff --git a/lib/setup-terraform.js b/lib/setup-terraform.js index 87e7575c..5155931d 100644 --- a/lib/setup-terraform.js +++ b/lib/setup-terraform.js @@ -121,14 +121,70 @@ credentials "${credentialsHostname}" { await fs.writeFile(credsFile, creds); } +async function getVersionFromFileContent (versionFile) { + if (!versionFile) { + return; + } + + let versionRegExp; + const versionFileName = path.basename(versionFile); + if (versionFileName === '.tool-versions') { + versionRegExp = /^(terraform\s+)(?[^\s]+)$/m; + } else if (versionFileName === '.terraform-version') { + versionRegExp = /(?[^\s]+)/; + } else { + return; + } + + try { + const content = fs.readFileSync(versionFile).toString().trim(); + let fileContent = ''; + if (content.match(versionRegExp)?.groups?.version) { + fileContent = content.match(versionRegExp)?.groups?.version; + } + if (!fileContent) { + return; + } + core.debug(`Version from file '${fileContent}'`); + return fileContent; + } catch (error) { + if (error.code === 'ENOENT') { + return; + } + throw error; + } +} + +// get the Terraform version from the action inputs +async function getTerraformVersion (versionInput, versionFile) { + const DEFAULT_VERSION = 'latest'; + + let version = versionInput; + if (!versionInput && !versionFile) { + core.info(`Set default value for version to ${DEFAULT_VERSION}`); + version = DEFAULT_VERSION; + } + + if (!version && versionFile) { + version = await getVersionFromFileContent(versionFile); + if (!version) { + throw new Error(`No supported version was found in file ${versionFile}`); + } + } + return version; +} + async function run () { try { // Gather GitHub Actions inputs - const version = core.getInput('terraform_version'); + const versionInput = core.getInput('terraform_version', { required: false }); + const versionFile = core.getInput('terraform_version_file', { required: false }); const credentialsHostname = core.getInput('cli_config_credentials_hostname'); const credentialsToken = core.getInput('cli_config_credentials_token'); const wrapper = core.getInput('terraform_wrapper') === 'true'; + const version = await getTerraformVersion(versionInput, versionFile); + // Gather OS details const osPlatform = os.platform(); const osArch = os.arch(); diff --git a/test/setup-terraform.test.js b/test/setup-terraform.test.js index 57cda0f6..86708083 100644 --- a/test/setup-terraform.test.js +++ b/test/setup-terraform.test.js @@ -44,12 +44,14 @@ describe('Setup Terraform', () => { test('gets specific version and adds token and hostname on linux, amd64', async () => { const version = '0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -86,12 +88,14 @@ describe('Setup Terraform', () => { test('gets specific version and adds token and hostname on windows, 386', async () => { const version = '0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -131,12 +135,14 @@ describe('Setup Terraform', () => { test('gets latest version and adds token and hostname on linux, amd64', async () => { const version = 'latest'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -174,12 +180,14 @@ describe('Setup Terraform', () => { test('gets latest version matching specification adds token and hostname on linux, amd64', async () => { const version = '<0.10.0'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -217,12 +225,14 @@ describe('Setup Terraform', () => { test('gets latest version matching tilde range patch', async () => { const version = '~0.1.0'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -259,12 +269,14 @@ describe('Setup Terraform', () => { test('gets latest version matching tilde range minor', async () => { const version = '~0.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -301,12 +313,14 @@ describe('Setup Terraform', () => { test('gets latest version matching tilde range minor', async () => { const version = '~0'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -343,12 +357,14 @@ describe('Setup Terraform', () => { test('gets latest version matching .X range ', async () => { const version = '0.1.x'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -385,12 +401,14 @@ describe('Setup Terraform', () => { test('gets latest version matching - range ', async () => { const version = '0.1.0 - 0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -427,12 +445,14 @@ describe('Setup Terraform', () => { test('fails when metadata cannot be downloaded', async () => { const version = 'latest'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -449,12 +469,14 @@ describe('Setup Terraform', () => { test('fails when specific version cannot be found', async () => { const version = '0.9.9'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -471,12 +493,14 @@ describe('Setup Terraform', () => { test('fails when CLI for os and architecture cannot be found', async () => { const version = '0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -509,12 +533,14 @@ describe('Setup Terraform', () => { test('fails when CLI cannot be downloaded', async () => { const version = '0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken); @@ -547,6 +573,7 @@ describe('Setup Terraform', () => { test('installs wrapper on linux', async () => { const version = '0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; const wrapperPath = path.resolve([__dirname, '..', 'wrapper', 'dist', 'index.js'].join(path.sep)); @@ -559,6 +586,7 @@ describe('Setup Terraform', () => { core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken) .mockReturnValueOnce('true'); @@ -591,6 +619,7 @@ describe('Setup Terraform', () => { test('installs wrapper on windows', async () => { const version = '0.1.1'; + const versionFile = ''; const credentialsHostname = 'app.terraform.io'; const credentialsToken = 'asdfjkl'; const wrapperPath = path.resolve([__dirname, '..', 'wrapper', 'dist', 'index.js'].join(path.sep)); @@ -603,6 +632,7 @@ describe('Setup Terraform', () => { core.getInput = jest .fn() .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) .mockReturnValueOnce(credentialsHostname) .mockReturnValueOnce(credentialsToken) .mockReturnValueOnce('true'); @@ -632,4 +662,205 @@ describe('Setup Terraform', () => { expect(ioMv).toHaveBeenCalledWith(`file${path.sep}terraform.exe`, `file${path.sep}terraform-bin.exe`); expect(ioCp).toHaveBeenCalledWith(wrapperPath, `file${path.sep}terraform`); }); + + test('gets version from .tool-versions file', async () => { + const version = ''; + const versionFile = '.tool-versions'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) + .mockReturnValueOnce(credentialsHostname) + .mockReturnValueOnce(credentialsToken); + + tc.downloadTool = jest + .fn() + .mockReturnValueOnce('file.zip'); + tc.extractZip = jest + .fn() + .mockReturnValueOnce('file'); + + os.platform = jest + .fn() + .mockReturnValue('linux'); + + os.arch = jest + .fn() + .mockReturnValue('amd64'); + + nock('https://releases.hashicorp.com') + .get('/terraform/index.json') + .reply(200, json); + + fs.readFileSync = jest + .fn() + .mockReturnValueOnce('terraform 0.1.1'); + + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.1.1'); + }); + + test('gets version from version if both (version and .tool-versions file) are set', async () => { + const version = '0.1.0'; + const versionFile = '.tool-versions'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) + .mockReturnValueOnce(credentialsHostname) + .mockReturnValueOnce(credentialsToken); + + tc.downloadTool = jest + .fn() + .mockReturnValueOnce('file.zip'); + + tc.extractZip = jest + .fn() + .mockReturnValueOnce('file'); + + os.platform = jest + .fn() + .mockReturnValue('linux'); + + os.arch = jest + .fn() + .mockReturnValue('amd64'); + + nock('https://releases.hashicorp.com') + .get('/terraform/index.json') + .reply(200, json); + + fs.readFileSync = jest + .fn() + .mockReturnValueOnce('terraform 0.10.0'); + + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.1.0'); + }); + + test('gets latest version using empty inputs for versions', async () => { + const version = ''; + const versionFile = ''; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) + .mockReturnValueOnce(credentialsHostname) + .mockReturnValueOnce(credentialsToken); + + tc.downloadTool = jest + .fn() + .mockReturnValueOnce('file.zip'); + + tc.extractZip = jest + .fn() + .mockReturnValueOnce('file'); + + os.platform = jest + .fn() + .mockReturnValue('linux'); + + os.arch = jest + .fn() + .mockReturnValue('amd64'); + + nock('https://releases.hashicorp.com') + .get('/terraform/index.json') + .reply(200, json); + + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.10.0'); + }); + + test('gets version from .terraform-version file', async () => { + const version = ''; + const versionFile = '.terraform-version'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) + .mockReturnValueOnce(credentialsHostname) + .mockReturnValueOnce(credentialsToken); + + tc.downloadTool = jest + .fn() + .mockReturnValueOnce('file.zip'); + tc.extractZip = jest + .fn() + .mockReturnValueOnce('file'); + + os.platform = jest + .fn() + .mockReturnValue('linux'); + + os.arch = jest + .fn() + .mockReturnValue('amd64'); + + nock('https://releases.hashicorp.com') + .get('/terraform/index.json') + .reply(200, json); + + fs.readFileSync = jest + .fn() + .mockReturnValueOnce('0.1.1'); + + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.1.1'); + }); + + test('fails when unsupported terraform version file', async () => { + const version = ''; + const versionFile = 'unsupported-file'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .mockReturnValueOnce(versionFile) + .mockReturnValueOnce(credentialsHostname) + .mockReturnValueOnce(credentialsToken); + + tc.downloadTool = jest + .fn() + .mockReturnValueOnce('file.zip'); + tc.extractZip = jest + .fn() + .mockReturnValueOnce('file'); + + os.platform = jest + .fn() + .mockReturnValue('linux'); + + os.arch = jest + .fn() + .mockReturnValue('amd64'); + + nock('https://releases.hashicorp.com') + .get('/terraform/index.json') + .reply(200, json); + + fs.readFileSync = jest + .fn() + .mockReturnValueOnce('0.10.0'); + + try { + await setup(); + } catch (e) { + expect(core.error).toHaveBeenCalled(); + } + }); });