Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use tofu binary instead of terraform one #2773

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
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 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,
Expand Down Expand Up @@ -759,7 +759,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 @@ -776,9 +776,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
Loading