Skip to content

Commit

Permalink
Use tofu binary instead of terraform one (nebari-dev#2773)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelovilla authored Nov 20, 2024
2 parents b442200 + bbff007 commit d272176
Show file tree
Hide file tree
Showing 8 changed files with 88 additions and 114 deletions.
5 changes: 1 addition & 4 deletions src/_nebari/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

HELM_VERSION = "v3.15.3"
KUSTOMIZE_VERSION = "5.4.3"
# NOTE: Terraform cannot be upgraded further due to Hashicorp licensing changes
# implemented in August 2023.
# https://www.hashicorp.com/license-faq
TERRAFORM_VERSION = "1.5.7"
OPENTOFU_VERSION = "1.8.3"

KUBERHEALTHY_HELM_VERSION = "100"

Expand Down
119 changes: 59 additions & 60 deletions src/_nebari/provider/terraform.py → src/_nebari/provider/opentofu.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,65 +18,65 @@
logger = logging.getLogger(__name__)


class TerraformException(Exception):
class OpenTofuException(Exception):
pass


def deploy(
directory,
terraform_init: bool = True,
terraform_import: bool = False,
terraform_apply: bool = True,
terraform_destroy: bool = False,
tofu_init: bool = True,
tofu_import: bool = False,
tofu_apply: bool = True,
tofu_destroy: bool = False,
input_vars: Dict[str, Any] = {},
state_imports: List[Any] = [],
):
"""Execute a given terraform directory.
"""Execute a given directory with OpenTofu infrastructure configuration.
Parameters:
directory: directory in which to run terraform operations on
directory: directory in which to run tofu operations on
terraform_init: whether to run `terraform init` default True
tofu_init: whether to run `tofu init` default True
terraform_import: whether to run `terraform import` default
tofu_import: whether to run `tofu import` default
False for each `state_imports` supplied to function
terraform_apply: whether to run `terraform apply` default True
tofu_apply: whether to run `tofu apply` default True
terraform_destroy: whether to run `terraform destroy` default
tofu_destroy: whether to run `tofu destroy` default
False
input_vars: supply values for "variable" resources within
terraform module
state_imports: (addr, id) pairs for iterate through and attempt
to terraform import
to tofu import
"""
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", suffix=".tfvars.json"
) as f:
json.dump(input_vars, f.file)
f.file.flush()

if terraform_init:
if tofu_init:
init(directory)

if terraform_import:
if tofu_import:
for addr, id in state_imports:
tfimport(
addr, id, directory=directory, var_files=[f.name], exist_ok=True
)

if terraform_apply:
if tofu_apply:
apply(directory, var_files=[f.name])

if terraform_destroy:
if tofu_destroy:
destroy(directory, var_files=[f.name])

return output(directory)


def download_terraform_binary(version=constants.TERRAFORM_VERSION):
def download_opentofu_binary(version=constants.OPENTOFU_VERSION):
os_mapping = {
"linux": "linux",
"win32": "windows",
Expand All @@ -94,135 +94,134 @@ def download_terraform_binary(version=constants.TERRAFORM_VERSION):
"arm64": "arm64",
}

download_url = f"https://releases.hashicorp.com/terraform/{version}/terraform_{version}_{os_mapping[sys.platform]}_{architecture_mapping[platform.machine()]}.zip"
filename_directory = Path(tempfile.gettempdir()) / "terraform" / version
filename_path = filename_directory / "terraform"
download_url = f"https://github.com/opentofu/opentofu/releases/download/v{version}/tofu_{version}_{os_mapping[sys.platform]}_{architecture_mapping[platform.machine()]}.zip"

filename_directory = Path(tempfile.gettempdir()) / "opentofu" / version
filename_path = filename_directory / "tofu"

if not filename_path.is_file():
logger.info(
f"downloading and extracting terraform binary from url={download_url} to path={filename_path}"
f"downloading and extracting opentofu binary from url={download_url} to path={filename_path}"
)
with urllib.request.urlopen(download_url) as f:
bytes_io = io.BytesIO(f.read())
download_file = zipfile.ZipFile(bytes_io)
download_file.extract("terraform", filename_directory)
download_file.extract("tofu", filename_directory)

filename_path.chmod(0o555)
return filename_path


def run_terraform_subprocess(processargs, **kwargs):
terraform_path = download_terraform_binary()
logger.info(f" terraform at {terraform_path}")
exit_code, output = run_subprocess_cmd([terraform_path] + processargs, **kwargs)
def run_tofu_subprocess(processargs, **kwargs):
tofu_path = download_opentofu_binary()
logger.info(f" tofu at {tofu_path}")
exit_code, output = run_subprocess_cmd([tofu_path] + processargs, **kwargs)
if exit_code != 0:
raise TerraformException("Terraform returned an error")
raise OpenTofuException("OpenTofu returned an error")
return output


def version():
terraform_path = download_terraform_binary()
logger.info(f"checking terraform={terraform_path} version")
tofu_path = download_opentofu_binary()
logger.info(f"checking opentofu={tofu_path} version")

version_output = subprocess.check_output([terraform_path, "--version"]).decode(
"utf-8"
)
version_output = subprocess.check_output([tofu_path, "--version"]).decode("utf-8")
return re.search(r"(\d+)\.(\d+).(\d+)", version_output).group(0)


def init(directory=None, upgrade=True):
logger.info(f"terraform init directory={directory}")
with timer(logger, "terraform init"):
logger.info(f"tofu init directory={directory}")
with timer(logger, "tofu init"):
command = ["init"]
if upgrade:
command.append("-upgrade")
run_terraform_subprocess(command, cwd=directory, prefix="terraform")
run_tofu_subprocess(command, cwd=directory, prefix="tofu")


def apply(directory=None, targets=None, var_files=None):
targets = targets or []
var_files = var_files or []

logger.info(f"terraform apply directory={directory} targets={targets}")
logger.info(f"tofu apply directory={directory} targets={targets}")
command = (
["apply", "-auto-approve"]
+ ["-target=" + _ for _ in targets]
+ ["-var-file=" + _ for _ in var_files]
)
with timer(logger, "terraform apply"):
run_terraform_subprocess(command, cwd=directory, prefix="terraform")
with timer(logger, "tofu apply"):
run_tofu_subprocess(command, cwd=directory, prefix="tofu")


def output(directory=None):
terraform_path = download_terraform_binary()
tofu_path = download_opentofu_binary()

logger.info(f"terraform={terraform_path} output directory={directory}")
with timer(logger, "terraform output"):
logger.info(f"tofu={tofu_path} output directory={directory}")
with timer(logger, "tofu output"):
return json.loads(
subprocess.check_output(
[terraform_path, "output", "-json"], cwd=directory
[tofu_path, "output", "-json"], cwd=directory
).decode("utf8")[:-1]
)


def tfimport(addr, id, directory=None, var_files=None, exist_ok=False):
var_files = var_files or []

logger.info(f"terraform import directory={directory} addr={addr} id={id}")
logger.info(f"tofu import directory={directory} addr={addr} id={id}")
command = ["import"] + ["-var-file=" + _ for _ in var_files] + [addr, id]
logger.error(str(command))
with timer(logger, "terraform import"):
with timer(logger, "tofu import"):
try:
run_terraform_subprocess(
run_tofu_subprocess(
command,
cwd=directory,
prefix="terraform",
prefix="tofu",
strip_errors=True,
timeout=30,
)
except TerraformException as e:
except OpenTofuException as e:
if not exist_ok:
raise e


def show(directory=None, terraform_init: bool = True) -> dict:
def show(directory=None, tofu_init: bool = True) -> dict:

if terraform_init:
if tofu_init:
init(directory)

logger.info(f"terraform show directory={directory}")
logger.info(f"tofu show directory={directory}")
command = ["show", "-json"]
with timer(logger, "terraform show"):
with timer(logger, "tofu show"):
try:
output = json.loads(
run_terraform_subprocess(
run_tofu_subprocess(
command,
cwd=directory,
prefix="terraform",
prefix="tofu",
strip_errors=True,
capture_output=True,
)
)
return output
except TerraformException as e:
except OpenTofuException as e:
raise e


def refresh(directory=None, var_files=None):
var_files = var_files or []

logger.info(f"terraform refresh directory={directory}")
logger.info(f"tofu refresh directory={directory}")
command = ["refresh"] + ["-var-file=" + _ for _ in var_files]

with timer(logger, "terraform refresh"):
run_terraform_subprocess(command, cwd=directory, prefix="terraform")
with timer(logger, "tofu refresh"):
run_tofu_subprocess(command, cwd=directory, prefix="tofu")


def destroy(directory=None, targets=None, var_files=None):
targets = targets or []
var_files = var_files or []

logger.info(f"terraform destroy directory={directory} targets={targets}")
logger.info(f"tofu destroy directory={directory} targets={targets}")
command = (
[
"destroy",
Expand All @@ -232,8 +231,8 @@ def destroy(directory=None, targets=None, var_files=None):
+ ["-var-file=" + _ for _ in var_files]
)

with timer(logger, "terraform destroy"):
run_terraform_subprocess(command, cwd=directory, prefix="terraform")
with timer(logger, "tofu destroy"):
run_tofu_subprocess(command, cwd=directory, prefix="tofu")


def rm_local_state(directory=None):
Expand Down
34 changes: 17 additions & 17 deletions src/_nebari/stages/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from kubernetes import client, config
from kubernetes.client.rest import ApiException

from _nebari.provider import helm, kubernetes, kustomize, terraform
from _nebari.provider import helm, kubernetes, kustomize, opentofu
from _nebari.stages.tf_objects import NebariTerraformState
from nebari.hookspecs import NebariStage

Expand Down Expand Up @@ -248,7 +248,7 @@ def tf_objects(self) -> List[Dict]:

def render(self) -> Dict[pathlib.Path, str]:
contents = {
(self.stage_prefix / "_nebari.tf.json"): terraform.tf_render_objects(
(self.stage_prefix / "_nebari.tf.json"): opentofu.tf_render_objects(
self.tf_objects()
)
}
Expand Down Expand Up @@ -283,19 +283,19 @@ def deploy(
self,
stage_outputs: Dict[str, Dict[str, Any]],
disable_prompt: bool = False,
terraform_init: bool = True,
tofu_init: bool = True,
):
deploy_config = dict(
directory=str(self.output_directory / self.stage_prefix),
input_vars=self.input_vars(stage_outputs),
terraform_init=terraform_init,
tofu_init=tofu_init,
)
state_imports = self.state_imports()
if state_imports:
deploy_config["terraform_import"] = True
deploy_config["tofu_import"] = True
deploy_config["state_imports"] = state_imports

self.set_outputs(stage_outputs, terraform.deploy(**deploy_config))
self.set_outputs(stage_outputs, opentofu.deploy(**deploy_config))
self.post_deploy(stage_outputs, disable_prompt)
yield

Expand All @@ -318,27 +318,27 @@ def destroy(
):
self.set_outputs(
stage_outputs,
terraform.deploy(
opentofu.deploy(
directory=str(self.output_directory / self.stage_prefix),
input_vars=self.input_vars(stage_outputs),
terraform_init=True,
terraform_import=True,
terraform_apply=False,
terraform_destroy=False,
tofu_init=True,
tofu_import=True,
tofu_apply=False,
tofu_destroy=False,
),
)
yield
try:
terraform.deploy(
opentofu.deploy(
directory=str(self.output_directory / self.stage_prefix),
input_vars=self.input_vars(stage_outputs),
terraform_init=True,
terraform_import=True,
terraform_apply=False,
terraform_destroy=True,
tofu_init=True,
tofu_import=True,
tofu_apply=False,
tofu_destroy=True,
)
status["stages/" + self.name] = True
except terraform.TerraformException as e:
except opentofu.OpenTofuException as e:
if not ignore_errors:
raise e
status["stages/" + self.name] = False
8 changes: 3 additions & 5 deletions src/_nebari/stages/infrastructure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from pydantic import ConfigDict, Field, field_validator, model_validator

from _nebari import constants
from _nebari.provider import terraform
from _nebari.provider import opentofu
from _nebari.provider.cloud import amazon_web_services, azure_cloud, google_cloud
from _nebari.stages.base import NebariTerraformStage
from _nebari.stages.kubernetes_services import SharedFsEnum
Expand Down Expand Up @@ -701,7 +701,7 @@ def state_imports(self) -> List[Tuple[str, str]]:
def tf_objects(self) -> List[Dict]:
if self.config.provider == schema.ProviderEnum.gcp:
return [
terraform.Provider(
opentofu.Provider(
"google",
project=self.config.google_cloud_platform.project,
region=self.config.google_cloud_platform.region,
Expand All @@ -714,9 +714,7 @@ def tf_objects(self) -> List[Dict]:
]
elif self.config.provider == schema.ProviderEnum.aws:
return [
terraform.Provider(
"aws", region=self.config.amazon_web_services.region
),
opentofu.Provider("aws", region=self.config.amazon_web_services.region),
NebariTerraformState(self.name, self.config),
]
else:
Expand Down
Loading

0 comments on commit d272176

Please sign in to comment.