From 03f6c3a599a6a7ebeea558fe18d2fde247bbfc97 Mon Sep 17 00:00:00 2001 From: Matthew Sanabria <24284972+sudomateo@users.noreply.github.com> Date: Thu, 18 Jun 2020 20:06:47 -0400 Subject: [PATCH] Allow to specify range specification instead of fixed version That allows to install for example the latest bug-fix version of terraform 1.12.* even if 1.13 is already installed. --- README.md | 21 ++- action.yml | 6 +- dist/index.js | 56 +++++--- lib/setup-terraform.js | 66 +++++---- test/setup-terraform.test.js | 263 ++++++++++++++++++++++++++++++++++- 5 files changed, 354 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 20ba2c29..91cfec97 100644 --- a/README.md +++ b/README.md @@ -136,19 +136,28 @@ 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 full version string you + can also specify 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 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`. +- `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`. ## 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 21366dcd..456126f0 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ -name: 'HashiCorp - Setup Terraform' +name: 'HashiCorp - Setup Terraform With Constraints' description: 'Sets up Terraform CLI in your GitHub Actions workflow.' -author: 'HashiCorp, Inc.' +author: 'HashiCorp, Inc. (original) with jarek@potiuk.com modifications' inputs: cli_config_credentials_hostname: description: '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`.' @@ -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 f4d7dd52..9eded5f8 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 b531916b..1dbe9b35 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); - - // 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); - - // Install our wrapper - if (wrapper) { - await installWrapper(pathToCLI); - } - - // Add to path - core.addPath(pathToCLI); - - // Add credentials to file if they are provided - if (credentialsHostname && credentialsToken) { - await addCredentials(credentialsHostname, credentialsToken, osPlat); + const versionObj = version.toLowerCase() === 'latest' + ? findLatest(versionMetadata) : specificMatch || findLatestMatchingSpecification(versionMetadata, version); + + 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); + + // Install our wrapper + if (wrapper) { + await installWrapper(pathToCLI); + } + + // Add to path + core.addPath(pathToCLI); + + // 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 beb03864..fa2fd337 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';