Skip to content

Commit

Permalink
feat: opentofu support (#99)
Browse files Browse the repository at this point in the history
Add support for OpenTofu with the useOpenTofu flag in the tfvm config.
This is because OIT will be adopting OpenTofu over Terraform
byu-oit/hw-fargate-api#1247 and our internal
tool seems to be better than alternatives, in my opinion.

---------

Co-authored-by: tab518 <[email protected]>
  • Loading branch information
chlohilt and tylerablackham authored Aug 19, 2024
1 parent 02974aa commit dcfc32e
Show file tree
Hide file tree
Showing 25 changed files with 478 additions and 72 deletions.
23 changes: 19 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Run `tfvm` in any command line, followed by one of these commands:
- `tfvm config disableErrors=true` - disables configuration warnings.
- `tfvm config disableAWSWarnings=true` - disables AWS warnings that appear when using older terraform versions.
- `tfvm config disableSettingPrompts=true` - disables prompts that show how to hide some error messages.
- `tfvm config useOpenTofu=true` - uses the open source version of Terraform, OpenTofu (experimental flag). This flag will also delete your terraform executable so you can only perfom tofu actions. When you switch back to `useOpenTofu=false`, the tofu executable will be deleted. This is so you don't perform any accidental commands in the wrong type of IAC.
- `tfvm config disableTofuWarnings=true` - disables warnings related to using Tofu (deleting executables, using Tofu instead of Terraform, etc.)
- `help`: prints usage information. Run `tfvm help <command>` to see information about the other tfvm commands.

## FAQ
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/lib/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import getSettings, { defaultSettings } from '../util/getSettings.js'
import getErrorMessage from '../util/errorChecker.js'
import { logger } from '../util/logger.js'
import { getOS } from '../util/tfvmOS.js'
import deleteExecutable from '../util/deleteExecutable.js'

const os = getOS()

Expand All @@ -20,10 +21,15 @@ async function config (setting) {
// we need to store logical true or false, not the string 'true' or 'false'. This converts to a boolean:
settingsObj[settingKey] = value === 'true'
await fs.writeFile(os.getSettingsDir(), JSON.stringify(settingsObj), 'utf8')

if (settingKey === 'useOpenTofu') {
await deleteExecutable(settingsObj[settingKey])
}
} else {
console.log(chalk.red.bold(`Invalid input for ${settingKey} setting. ` +
`Use either 'tfvm config ${settingKey}=true' or 'tfvm config ${settingKey}=false'`))
}

console.log(chalk.cyan.bold(`Successfully set ${setting}`))
} else {
logger.warn(`Invalid setting change attempt with setting=${setting}`)
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/lib/commands/current.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import chalk from 'chalk'
import getTerraformVersion from '../util/tfVersion.js'
import getErrorMessage from '../util/errorChecker.js'
import { logger } from '../util/logger.js'
import getSettings from '../util/getSettings.js'
import { getOS } from '../util/tfvmOS.js'

const os = getOS()
async function current () {
try {
const settings = await getSettings()
const currentTFVersion = await getTerraformVersion()
if (currentTFVersion !== null) {
console.log(chalk.white.bold('Current Terraform version:\n' +
console.log(chalk.white.bold(`Current ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} version:\n` +
currentTFVersion + ` (Currently using ${os.getBitWidth()}-bit executable)`))
} else {
console.log(chalk.cyan.bold('It appears there is no terraform version running on your computer, or ' +
console.log(chalk.cyan.bold(`It appears there is no ${settings.useOpenTofu ? 'opentofu' : 'terraform'} version running on your computer, or ` +
'there was an error extracting the version.\n'))
console.log(chalk.green.bold('Run tfvm use <version> to set your terraform version, ' +
'or `terraform -v` to manually check the current version.'))
console.log(chalk.green.bold(`Run tfvm use <version> to set your ${settings.useOpenTofu ? 'opentofu' : 'terraform'} version, ` +
`or ${settings.useOpenTofu ? 'tofu' : 'terraform'} -v to manually check the current version.`))
}
} catch (error) {
logger.fatal(error, 'Fatal error when running "current" command: ')
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/lib/commands/detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import getTerraformVersion from '../util/tfVersion.js'
import { installNewVersion, switchVersionTo, useVersion } from './use.js'
import { logger } from '../util/logger.js'
import { TfvmFS } from '../util/TfvmFS.js'
import getSettings from '../util/getSettings.js'

async function detect () {
const settings = await getSettings()
// set of objects that contain the constraints and the file name
const tfVersionConstraintSet = new Set()
try {
Expand All @@ -26,10 +28,10 @@ async function detect () {
await satisfyConstraints(tfVersionConstraintSet)
} else {
// todo let the user select from list of frequently used versions instead of this disappointing message
console.log(chalk.white.bold('No terraform files containing any version constraints are found in this directory.'))
console.log(chalk.white.bold(`No ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} files containing any version constraints are found in this directory.`))
}
} catch (error) {
logger.fatal(error, 'Fatal error when running "detect" command with these local terraform ' +
logger.fatal(error, `Fatal error when running "detect" command with these local ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} ` +
`constraints: ${Array.from(tfVersionConstraintSet).map(c => JSON.stringify(c)).join('; ')}: `)
getErrorMessage(error)
}
Expand All @@ -48,9 +50,10 @@ async function satisfyConstraints (tfVersionConstraintSet) {
? tfVersionConstraints // if the current version is null, then all the constraints are unmet
: getUnmetConstraints(tfVersionConstraints, currentTfVersion)
if (unmetVersionConstraints.length === 0) {
const settings = await getSettings()
// exit quickly if the current tf version satisfies all required constraints
console.log(chalk.cyan.bold(`Your current terraform version (${currentTfVersion}) already ` +
'satisfies the requirements of your local terraform files.'))
console.log(chalk.cyan.bold(`Your current ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} version (${currentTfVersion}) already ` +
`satisfies the requirements of your local ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} files.`))
} else if (unmetVersionConstraints.length === 1 && tfVersionConstraints.length === 1) {
await satisfySingleConstraint(unmetVersionConstraints[0])
} else if (unmetVersionConstraints.length >= 1) {
Expand All @@ -68,7 +71,8 @@ async function satisfyMultipleConstraints (tfVersionConstraints) {
// if all the constraints are single versions, give them a dropdown list to select from
await chooseAndUseVersionFrom(tfVersionConstraints)
} else {
console.log(chalk.white.bold('There are multiple terraform version constraints in this directory:'))
const settings = await getSettings()
console.log(chalk.white.bold(`There are multiple ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} version constraints in this directory:`))
tfVersionConstraints.forEach(c =>
console.log(chalk.white.bold(` - ${c.displayVersion} (${c.fileName})`)))

Expand Down Expand Up @@ -186,13 +190,14 @@ async function findLocalVersionConstraints (constraintSet) {
const fileName = TfvmFS.getFileNameFromPath(filePath)
const fileHclAsJson = parser.parse(content)
if (fileHclAsJson.required_core) {
const settings = await getSettings()
fileHclAsJson.required_core.forEach(version => {
// terraform supports '!=' and `~>' in semver but the 'compare-versions' package does not
for (const badOperator of ['!=']) {
if (version.includes(badOperator)) {
logger.error(`Failed to parse version ${version} because of '${badOperator}'.`)
console.log(chalk.red.bold(`Ignoring constraint from ${fileName} ` +
`because tfvm doesn't support parsing versions with '${badOperator}' in the terraform required_version.`))
`because tfvm doesn't support parsing versions with '${badOperator}' in the ${settings.useOpenTofu ? 'OpenTofu' : 'Terraform'} required_version.`))
return // functional equivalent of 'continue' in forEach
}
}
Expand Down
45 changes: 38 additions & 7 deletions packages/cli/lib/commands/install.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import chalk from 'chalk'
import deleteExecutable from '../util/deleteExecutable.js'
import fs from 'node:fs/promises'
import { versionRegEx } from '../util/constants.js'
import getInstalledVersions from '../util/getInstalledVersions.js'
Expand All @@ -8,32 +9,43 @@ import getErrorMessage from '../util/errorChecker.js'
import getTerraformVersion from '../util/tfVersion.js'
import getLatest from '../util/getLatest.js'
import { logger } from '../util/logger.js'
import getSettings from '../util/getSettings.js'
import { getOS, Mac } from '../util/tfvmOS.js'
import { compare } from 'compare-versions'
import * as semver from 'semver'
const os = getOS()

const LAST_TF_VERSION_WITHOUT_ARM = '1.0.1'
const LOWEST_OTF_VERSION = '1.6.0'

async function install (versionNum) {
try {
const settings = await getSettings()
const installVersion = 'v' + versionNum
const openTofuCheck = settings.useOpenTofu && semver.gte(versionNum, LOWEST_OTF_VERSION)

if (!versionRegEx.test(installVersion) && versionNum !== 'latest') {
logger.warn(`invalid version attempted to install with version ${installVersion}`)
console.log(chalk.red.bold('Invalid version syntax.'))
console.log(chalk.white.bold('Version should be formatted as \'vX.X.X\'\nGet a list of all current ' +
if (settings.useOpenTofu) {
console.log(chalk.white.bold('Version should be formatted as \'vX.X.X\'\nGet a list of all current ' +
'opentofu versions here: https://github.com/opentofu/opentofu/releases'))
} else {
console.log(chalk.white.bold('Version should be formatted as \'vX.X.X\'\nGet a list of all current ' +
'terraform versions here: https://releases.hashicorp.com/terraform/'))
}
} else if (versionNum === 'latest') {
const installedVersions = await getInstalledVersions()
const latest = await getLatest()
const currentVersion = await getTerraformVersion()
if (latest) {
const versionLatest = 'v' + latest
if (installedVersions.includes(versionLatest) && currentVersion !== versionLatest) {
console.log(chalk.bold.cyan(`The latest terraform version is ${latest} and is ` +
console.log(chalk.bold.cyan(`The latest ${openTofuCheck ? 'opentofu' : 'terraform'} version is ${latest} and is ` +
`already installed on your computer. Run 'tfvm use ${latest}' to use.`))
} else if (installedVersions.includes(versionLatest) && currentVersion === versionLatest) {
const currentVersion = await getTerraformVersion()
console.log(chalk.bold.cyan(`The latest terraform version is ${currentVersion} and ` +
console.log(chalk.bold.cyan(`The latest ${openTofuCheck ? 'opentofu' : 'terraform'} version is ${currentVersion} and ` +
'is already installed and in use on your computer.'))
} else {
await installFromWeb(latest)
Expand All @@ -42,7 +54,7 @@ async function install (versionNum) {
} else {
const installedVersions = await getInstalledVersions()
if (installedVersions.includes(installVersion)) {
console.log(chalk.white.bold(`Terraform version ${installVersion} is already installed.`))
console.log(chalk.white.bold(`${openTofuCheck ? 'OpenTofu' : 'Terraform'} version ${installVersion} is already installed.`))
} else {
await installFromWeb(versionNum)
}
Expand All @@ -56,8 +68,17 @@ async function install (versionNum) {
export default install

export async function installFromWeb (versionNum, printMessage = true) {
const zipPath = os.getPath(os.getTfVersionsDir(), `v${versionNum}.zip`)
const newVersionDir = os.getPath(os.getTfVersionsDir(), 'v' + versionNum)
const settings = await getSettings()
const openTofuCheck = settings.useOpenTofu && semver.gte(versionNum, LOWEST_OTF_VERSION)
const openTofuCheckLessThan = settings.useOpenTofu && semver.lt(versionNum, LOWEST_OTF_VERSION)
if (openTofuCheckLessThan) {
await deleteExecutable(false)
} else if (openTofuCheck) {
await deleteExecutable(true)
}
let url
let zipPath
let newVersionDir
let arch = os.getArchitecture()

// Only newer terraform versions include a release for ARM (Apple Silicon) hardware, but their chips *can*
Expand All @@ -68,7 +89,17 @@ export async function installFromWeb (versionNum, printMessage = true) {
console.log(chalk.bold.yellow(`Warning: There is no available ARM release of Terraform for version ${versionNum}.
Installing the amd64 version instead (should run without issue via Rosetta)...`))
}
const url = `https://releases.hashicorp.com/terraform/${versionNum}/terraform_${versionNum}_${os.getOSName()}_${arch}.zip`

if (openTofuCheck) {
zipPath = os.getPath(os.getOtfVersionsDir(), `v${versionNum}.zip`)
newVersionDir = os.getPath(os.getOtfVersionsDir(), 'v' + versionNum)
url = `https://github.com/opentofu/opentofu/releases/download/v${versionNum}/tofu_${versionNum}_${os.getOSName()}_${arch}.zip`
} else {
zipPath = os.getPath(os.getTfVersionsDir(), `v${versionNum}.zip`)
newVersionDir = os.getPath(os.getTfVersionsDir(), 'v' + versionNum)
url = `https://releases.hashicorp.com/terraform/${versionNum}/terraform_${versionNum}_${os.getOSName()}_${arch}.zip`
}

await download(url, zipPath, versionNum)
await fs.mkdir(newVersionDir)
await unzipFile(zipPath, newVersionDir)
Expand Down
30 changes: 27 additions & 3 deletions packages/cli/lib/commands/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,50 @@ import getInstalledVersions from '../util/getInstalledVersions.js'
import getErrorMessage from '../util/errorChecker.js'
import { logger } from '../util/logger.js'
import { getOS } from '../util/tfvmOS.js'
import * as semver from 'semver'
import getSettings from '../util/getSettings.js'
const os = getOS()

const LOWEST_OTF_VERSION = '1.6.0'
async function list () {
try {
const printList = []
const tfList = await getInstalledVersions()

if (tfList.length > 0) {
const currentTFVersion = await getTerraformVersion()
console.log('\n')
tfList.sort(compareVersions).reverse()
for (const versionDir of tfList) {
const settings = await getSettings()
const version = versionDir.substring(1, versionDir.length)

let type = ''
if (settings.useOpenTofu) {
// logic to get the correct spacing
const parsed = semver.parse(version)
type += (parsed.minor.toString().length === 1 && parsed.patch.toString().length === 1 ? ' ' : ' ')
if (semver.gte(version, LOWEST_OTF_VERSION)) {
type += '[OpenTofu]'
} else if (semver.lt(version, LOWEST_OTF_VERSION)) {
type += '[Terraform]'
}
}

if (versionDir === currentTFVersion) {
let printVersion = ' * '
printVersion = printVersion + versionDir.substring(1, versionDir.length)
printVersion += version
if (settings.useOpenTofu) {
printVersion += type
}
printVersion = printVersion + ` (Currently using ${os.getBitWidth()}-bit executable)`
printList.push(printVersion)
} else {
let printVersion = ' '
printVersion = printVersion + versionDir.substring(1, versionDir.length)
printVersion += version

if (settings.useOpenTofu) {
printVersion += type
}
printList.push(printVersion)
}
}
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/lib/commands/uninstall.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import chalk from 'chalk'
import { versionRegEx } from '../util/constants.js'
import getInstalledVersions from '../util/getInstalledVersions.js'
import { TfvmFS } from '../util/TfvmFS.js'
import getErrorMessage from '../util/errorChecker.js'
import { logger } from '../util/logger.js'
import getSettings from '../util/getSettings.js'
import { getOS } from '../util/tfvmOS.js'
import { TfvmFS } from '../util/TfvmFS.js'
import * as semver from 'semver'
const os = getOS()

const LOWEST_OTF_VERSION = '1.6.0'
async function uninstall (uninstallVersion) {
try {
uninstallVersion = 'v' + uninstallVersion
if (!versionRegEx.test(uninstallVersion)) {
console.log(chalk.red.bold('Invalid version syntax'))
} else {
const settings = await getSettings()
const installedVersions = await getInstalledVersions()
const semverCheck = semver.gte(uninstallVersion, LOWEST_OTF_VERSION)
const openTofuCheck = settings.useOpenTofu && semverCheck

if (!installedVersions.includes(uninstallVersion)) {
console.log(chalk.white.bold(`terraform ${uninstallVersion} is not installed. Type "tfvm list" to see what is installed.`))
console.log(chalk.white.bold(`${openTofuCheck ? 'opentofu' : 'terraform'} ${uninstallVersion} is not installed. Type "tfvm list" to see what is installed.`))
} else {
console.log(chalk.white.bold(`Uninstalling terraform ${uninstallVersion}...`))
await TfvmFS.deleteDirectory(os.getTfVersionsDir(), uninstallVersion)
console.log(chalk.cyan.bold(`Successfully uninstalled terraform ${uninstallVersion}`))
console.log(chalk.white.bold(`Uninstalling ${openTofuCheck ? 'opentofu' : 'terraform'} ${uninstallVersion}...`))
if (openTofuCheck) {
await TfvmFS.deleteDirectory(os.getOtfVersionsDir(), uninstallVersion)
} else {
await TfvmFS.deleteDirectory(os.getTfVersionsDir(), uninstallVersion)
}
console.log(chalk.cyan.bold(`Successfully uninstalled ${openTofuCheck ? 'opentofu' : 'terraform'} ${uninstallVersion}`))
}
}
} catch (error) {
Expand Down
Loading

0 comments on commit dcfc32e

Please sign in to comment.