diff --git a/README.md b/README.md index 20ba2c2..ca303b8 100644 --- a/README.md +++ b/README.md @@ -136,19 +136,29 @@ steps: ## Inputs -The following inputs are supported. +The action supports the following inputs: -- `cli_config_credentials_hostname` - (optional) The hostname of a Terraform Cloud/Enterprise instance to place within the credentials block of the Terraform CLI configuration file. Defaults to `app.terraform.io`. +- `cli_config_credentials_hostname` - (optional) The hostname of a Terraform Cloud/Enterprise instance to + place within the credentials block of the Terraform CLI configuration file. Defaults to `app.terraform.io`. -- `cli_config_credentials_token` - (optional) The API token for a Terraform Cloud/Enterprise instance to place within the credentials block of the Terraform CLI configuration file. +- `cli_config_credentials_token` - (optional) The API token for a Terraform Cloud/Enterprise instance to + place within the credentials block of the Terraform CLI configuration file. -- `terraform_version` - (optional) The version of Terraform CLI to install. A value of `latest` will install the latest version of Terraform CLI. Defaults to `latest`. +- `terraform_version` - (optional) The version of Terraform CLI to install. Instead of a full version string, + you can also specify a constraint string (see [Semver Ranges](https://www.npmjs.com/package/semver#ranges) + for available range specifications). Examples are: `<1.13.0`, `~1.12`, `1.12.x` (all three installing + the latest available 1.12 version). The special value of `latest` installs the latest version of + Terraform CLI. Defaults to `latest`. + +- `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_wrapper` - (optional) 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`. ## Outputs -This action does not configure any outputs directly. However, when the `terraform_wrapper` input is set to `true`, the following outputs will be available for subsequent steps that call the `terraform` binary. +This action does not configure any outputs directly. However, when you set the `terraform_wrapper` input +to `true`, the following outputs is available for subsequent steps that call the `terraform` binary. - `stdout` - The STDOUT stream of the call to the `terraform` binary. diff --git a/action.yml b/action.yml index 21366dc..7f0c91b 100644 --- a/action.yml +++ b/action.yml @@ -10,7 +10,7 @@ inputs: description: 'The API token for a Terraform Cloud/Enterprise instance to place within the credentials block of the Terraform CLI configuration file.' required: false terraform_version: - description: 'The version of Terraform CLI to install. A value of `latest` will install the latest version of Terraform CLI. Defaults to `latest`.' + 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_wrapper: diff --git a/dist/index.js b/dist/index.js index f4d7dd5..9eded5f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2068,14 +2068,23 @@ function findLatest (allVersions) { // Find specific version given list of all available function findSpecific (allVersions, version) { core.debug(`Parsing version list for version ${version}`); + return allVersions.versions[version]; +} - const versionObj = allVersions.versions[version]; - - if (!versionObj) { - throw new Error(`Could not find Terraform version ${version} in version list`); +// Find specific version given list of all available +function findLatestMatchingSpecification (allVersions, version) { + core.debug(`Parsing version list for latest matching specification ${version}`); + const versionList = []; + for (const _version in allVersions.versions) { + versionList.push(_version); } + const bestMatchVersion = semver.maxSatisfying(versionList, version); + if (!bestMatchVersion) { + throw new Error(`Could not find Terraform version matching ${version} in version list`); + } + core.info(`Latest version satisfying ${version} is ${bestMatchVersion}`); - return versionObj; + return allVersions.versions[bestMatchVersion]; } async function downloadMetadata () { @@ -2215,30 +2224,37 @@ async function run () { // Download metadata about all versions of Terraform CLI const versionMetadata = await downloadMetadata(); + const specificMatch = findSpecific(versionMetadata, version); // Find latest or a specific version like 0.1.0 - const versionObj = version.toLowerCase() === 'latest' ? findLatest(versionMetadata) : findSpecific(versionMetadata, version); + const versionObj = version.toLowerCase() === 'latest' + ? findLatest(versionMetadata) : specificMatch || findLatestMatchingSpecification(versionMetadata, version); - // Get the build available for this runner's OS and a 64 bit architecture - const buildObj = getBuild(versionObj, osPlat, osArch); + if (versionObj) { + // Get the build available for this runner's OS and a 64 bit architecture + const buildObj = getBuild(versionObj, osPlat, osArch); - // Download requested version - const pathToCLI = await downloadCLI(buildObj.url); + // Download requested version + const pathToCLI = await downloadCLI(buildObj.url); - // Install our wrapper - if (wrapper) { - await installWrapper(pathToCLI); - } + // Install our wrapper + if (wrapper) { + await installWrapper(pathToCLI); + } - // Add to path - core.addPath(pathToCLI); + // Add to path + core.addPath(pathToCLI); - // Add credentials to file if they are provided - if (credentialsHostname && credentialsToken) { - await addCredentials(credentialsHostname, credentialsToken, osPlat); + // Add credentials to file if they are provided + if (credentialsHostname && credentialsToken) { + await addCredentials(credentialsHostname, credentialsToken, osPlat); + } + return versionObj; + } else { + core.setFailed(`Could not find Terraform version ${version} in version list`); } } catch (error) { core.error(error); - throw new Error(error); + throw error; } } diff --git a/lib/setup-terraform.js b/lib/setup-terraform.js index b531916..1dbe9b3 100644 --- a/lib/setup-terraform.js +++ b/lib/setup-terraform.js @@ -33,14 +33,23 @@ function findLatest (allVersions) { // Find specific version given list of all available function findSpecific (allVersions, version) { core.debug(`Parsing version list for version ${version}`); + return allVersions.versions[version]; +} - const versionObj = allVersions.versions[version]; - - if (!versionObj) { - throw new Error(`Could not find Terraform version ${version} in version list`); +// Find specific version given list of all available +function findLatestMatchingSpecification (allVersions, version) { + core.debug(`Parsing version list for latest matching specification ${version}`); + const versionList = []; + for (const _version in allVersions.versions) { + versionList.push(_version); } + const bestMatchVersion = semver.maxSatisfying(versionList, version); + if (!bestMatchVersion) { + throw new Error(`Could not find Terraform version matching ${version} in version list`); + } + core.info(`Latest version satisfying ${version} is ${bestMatchVersion}`); - return versionObj; + return allVersions.versions[bestMatchVersion]; } async function downloadMetadata () { @@ -180,30 +189,37 @@ async function run () { // Download metadata about all versions of Terraform CLI const versionMetadata = await downloadMetadata(); + const specificMatch = findSpecific(versionMetadata, version); // Find latest or a specific version like 0.1.0 - const versionObj = version.toLowerCase() === 'latest' ? findLatest(versionMetadata) : findSpecific(versionMetadata, version); + const versionObj = version.toLowerCase() === 'latest' + ? findLatest(versionMetadata) : specificMatch || findLatestMatchingSpecification(versionMetadata, version); - // Get the build available for this runner's OS and a 64 bit architecture - const buildObj = getBuild(versionObj, osPlat, osArch); + if (versionObj) { + // Get the build available for this runner's OS and a 64 bit architecture + const buildObj = getBuild(versionObj, osPlat, osArch); - // Download requested version - const pathToCLI = await downloadCLI(buildObj.url); + // Download requested version + const pathToCLI = await downloadCLI(buildObj.url); - // Install our wrapper - if (wrapper) { - await installWrapper(pathToCLI); - } + // Install our wrapper + if (wrapper) { + await installWrapper(pathToCLI); + } - // Add to path - core.addPath(pathToCLI); + // Add to path + core.addPath(pathToCLI); - // Add credentials to file if they are provided - if (credentialsHostname && credentialsToken) { - await addCredentials(credentialsHostname, credentialsToken, osPlat); + // Add credentials to file if they are provided + if (credentialsHostname && credentialsToken) { + await addCredentials(credentialsHostname, credentialsToken, osPlat); + } + return versionObj; + } else { + core.setFailed(`Could not find Terraform version ${version} in version list`); } } catch (error) { core.error(error); - throw new Error(error); + throw error; } } diff --git a/test/setup-terraform.test.js b/test/setup-terraform.test.js index beb0386..fa2fd33 100644 --- a/test/setup-terraform.test.js +++ b/test/setup-terraform.test.js @@ -68,11 +68,11 @@ describe('Setup Terraform', () => { .get('/terraform/index.json') .reply(200, json); - await setup(); + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.1.1'); // downloaded CLI has been added to path expect(core.addPath).toHaveBeenCalled(); - // expect credentials are in ${HOME}.terraformrc const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); @@ -110,7 +110,8 @@ describe('Setup Terraform', () => { .get('/terraform/index.json') .reply(200, json); - await setup(); + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.1.1'); // downloaded CLI has been added to path expect(core.addPath).toHaveBeenCalled(); @@ -152,7 +153,8 @@ describe('Setup Terraform', () => { .get('/terraform/index.json') .reply(200, json); - await setup(); + const versionObj = await setup(); + expect(versionObj.version).toEqual('0.10.0'); // downloaded CLI has been added to path expect(core.addPath).toHaveBeenCalled(); @@ -163,6 +165,259 @@ describe('Setup Terraform', () => { expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); }); + test('gets latest version matching specification adds token and hostname on linux, amd64', async () => { + const version = '<0.10.0'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .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.1.1'); + + // downloaded CLI has been added to path + expect(core.addPath).toHaveBeenCalled(); + + // expect credentials are in ${HOME}.terraformrc + const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); + expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); + expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); + }); + + test('gets latest version matching tilde range patch', async () => { + const version = '~0.1.0'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .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.1.1'); + + // downloaded CLI has been added to path + expect(core.addPath).toHaveBeenCalled(); + // expect credentials are in ${HOME}.terraformrc + const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); + expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); + expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); + }); + + test('gets latest version matching tilde range minor', async () => { + const version = '~0.1'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .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.1.1'); + + // downloaded CLI has been added to path + expect(core.addPath).toHaveBeenCalled(); + // expect credentials are in ${HOME}.terraformrc + const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); + expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); + expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); + }); + + test('gets latest version matching tilde range minor', async () => { + const version = '~0'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .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'); + + // downloaded CLI has been added to path + expect(core.addPath).toHaveBeenCalled(); + // expect credentials are in ${HOME}.terraformrc + const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); + expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); + expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); + }); + + test('gets latest version matching .X range ', async () => { + const version = '0.1.x'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .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.1.1'); + + // downloaded CLI has been added to path + expect(core.addPath).toHaveBeenCalled(); + // expect credentials are in ${HOME}.terraformrc + const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); + expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); + expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); + }); + + test('gets latest version matching - range ', async () => { + const version = '0.1.0 - 0.1.1'; + const credentialsHostname = 'app.terraform.io'; + const credentialsToken = 'asdfjkl'; + + core.getInput = jest + .fn() + .mockReturnValueOnce(version) + .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.1.1'); + + // downloaded CLI has been added to path + expect(core.addPath).toHaveBeenCalled(); + // expect credentials are in ${HOME}.terraformrc + const creds = await fs.readFile(`${process.env.HOME}/.terraformrc`, { encoding: 'utf8' }); + expect(creds.indexOf(credentialsHostname)).toBeGreaterThan(-1); + expect(creds.indexOf(credentialsToken)).toBeGreaterThan(-1); + }); + test('fails when metadata cannot be downloaded', async () => { const version = 'latest'; const credentialsHostname = 'app.terraform.io';