Skip to content

Commit

Permalink
rebuild terraform_deployment to target terraform_module as a root module
Browse files Browse the repository at this point in the history
  • Loading branch information
lilatomic committed Jun 14, 2023
1 parent c5f73c5 commit 005ecd8
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 37 deletions.
44 changes: 37 additions & 7 deletions src/python/pants/backend/terraform/dependencies.py
Expand Up @@ -3,21 +3,34 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Iterable, Tuple
from typing import Tuple

from pants.backend.terraform.partition import partition_files_by_directory
from pants.backend.terraform.target_types import (
TerraformBackendConfigField,
TerraformDependenciesField,
TerraformRootModuleField,
)
from pants.backend.terraform.tool import TerraformProcess
from pants.backend.terraform.utils import terraform_arg, terraform_relpath
from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest
from pants.engine.internals.native_engine import EMPTY_DIGEST, Digest, MergeDigests
from pants.engine.internals.native_engine import (
EMPTY_DIGEST,
Address,
AddressInput,
Digest,
MergeDigests,
)
from pants.engine.internals.selectors import Get, MultiGet
from pants.engine.process import FallibleProcessResult
from pants.engine.rules import collect_rules, rule
from pants.engine.target import DependenciesRequest, SourcesField, Targets
from pants.engine.target import (
DependenciesRequest,
SourcesField,
Targets,
WrappedTarget,
WrappedTargetRequest,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -88,7 +101,7 @@ async def get_terraform_providers(

@dataclass(frozen=True)
class TerraformInitRequest:
sources: Iterable[SourcesField]
root_module: TerraformRootModuleField
backend_config: TerraformBackendConfigField
dependencies: TerraformDependenciesField

Expand All @@ -105,12 +118,29 @@ class TerraformInitResponse:

@rule
async def init_terraform(request: TerraformInitRequest) -> TerraformInitResponse:
root_dependencies = await Get(Targets, DependenciesRequest(request.dependencies))
address_input = request.root_module.to_address_input()
module_address = await Get(Address, AddressInput, address_input)
module = await Get(
WrappedTarget,
WrappedTargetRequest(
module_address, description_of_origin=address_input.description_of_origin
),
)

root_dependencies, module_dependencies = await MultiGet(
Get(Targets, DependenciesRequest(request.dependencies)),
Get(Targets, DependenciesRequest(module.target.get(TerraformDependenciesField))),
)

source_files, backend_config, dependencies_files = await MultiGet(
Get(SourceFiles, SourceFilesRequest(request.sources)),
Get(SourceFiles, SourceFilesRequest([module.target.get(SourcesField)])),
Get(SourceFiles, SourceFilesRequest([request.backend_config])),
Get(SourceFiles, SourceFilesRequest([tgt.get(SourcesField) for tgt in root_dependencies])),
Get(
SourceFiles,
SourceFilesRequest(
[tgt.get(SourcesField) for tgt in (*root_dependencies, *module_dependencies)]
),
),
)
files_by_directory = partition_files_by_directory(source_files.files)

Expand Down
12 changes: 9 additions & 3 deletions src/python/pants/backend/terraform/dependencies_test.py
Expand Up @@ -30,7 +30,7 @@ def _do_init_terraform(
TerraformInitResponse,
[
TerraformInitRequest(
(field_set.sources,),
field_set.root_module,
field_set.backend_config,
field_set.dependencies,
initialise_backend=initialise_backend,
Expand Down Expand Up @@ -89,12 +89,18 @@ def test_init_terraform_without_backends(

def test_init_terraform_with_in_repo_module(rule_runner: RuleRunner, tmpdir) -> None:
deployment_files = {
"src/tf/deployment/BUILD": 'terraform_deployment(name="root")',
"src/tf/deployment/BUILD": textwrap.dedent(
"""\
terraform_deployment(name="root", root_module=":mod")
terraform_module(name="mod")
"""
),
"src/tf/deployment/main.tf": textwrap.dedent(
"""\
module "mod0" {
source = "../module/"
}"""
}
"""
),
}
module_files = {
Expand Down
6 changes: 3 additions & 3 deletions src/python/pants/backend/terraform/goals/check.py
Expand Up @@ -37,7 +37,7 @@ async def terraform_check(
Get(
TerraformInitResponse,
TerraformInitRequest(
(deployment.sources,), deployment.backend_config, deployment.dependencies
deployment.root_module, deployment.backend_config, deployment.dependencies
),
)
for deployment in request.field_sets
Expand All @@ -58,10 +58,10 @@ async def terraform_check(
)

check_results = []
for deployment, result in zip(initialised_terraforms, results):
for deployment, result, field_set in zip(initialised_terraforms, results, request.field_sets):
check_results.append(
CheckResult.from_fallible_process_result(
result, partition_description=f"`terraform validate` on `{deployment.chdir}`"
result, partition_description=f"`terraform validate` on `{field_set.address}`"
)
)

Expand Down
43 changes: 33 additions & 10 deletions src/python/pants/backend/terraform/goals/check_test.py
Expand Up @@ -81,7 +81,7 @@ def make_target(
rule_runner: RuleRunner, source_files: list[FileContent], *, target_name="target"
) -> Target:
files = {
"BUILD": f"terraform_deployment(name='{target_name}')\n",
"BUILD": f"terraform_module(name='{target_name}mod')\nterraform_deployment(name='{target_name}', root_module=':{target_name}mod')\n",
}
files.update({source_file.path: source_file.content.decode() for source_file in source_files})
rule_runner.write_files(files)
Expand Down Expand Up @@ -137,9 +137,14 @@ def test_multiple_targets(rule_runner: RuleRunner) -> None:
source_files = [
FileContent(
"BUILD",
'terraform_deployment(name="tgt_good")\nterraform_deployment(name="tgt_bad")'.encode(
"utf-8"
),
textwrap.dedent(
"""\
terraform_module(name="good", sources=["good.tf"])
terraform_deployment(name="tgt_good", root_module=":good")
terraform_module(name="bad", sources=["bad.tf"])
terraform_deployment(name="tgt_bad", root_module=":bad")
"""
).encode("utf-8"),
),
BAD_SOURCE,
GOOD_SOURCE,
Expand All @@ -157,9 +162,13 @@ def test_multiple_targets(rule_runner: RuleRunner) -> None:
check_results = run_terraform_validate(rule_runner, targets)
assert len(check_results) == 2
for check_result in check_results:
assert check_result.exit_code == 1
assert "bad.tf" in check_result.stderr
assert "good.tf" not in check_result.stderr
assert check_result.partition_description
if "bad" in check_result.partition_description:
assert check_result.exit_code == 1
elif "good" in check_result.partition_description:
assert check_result.exit_code == 0
else:
raise AssertionError(f"Did not find expected target in check result {check_result}")


def test_skip(rule_runner: RuleRunner) -> None:
Expand All @@ -183,7 +192,12 @@ def test_in_folder(rule_runner: RuleRunner) -> None:
"""Test that we can `check` terraform files not in the root folder."""
target_name = "in_folder"
files = {
"folder/BUILD": f"terraform_deployment(name='{target_name}')\n",
"folder/BUILD": textwrap.dedent(
f"""\
terraform_deployment(name='{target_name}', root_module=':mod0')
terraform_module(name='mod0')
"""
),
"folder/provided.tf": textwrap.dedent(
"""
resource "null_resource" "dep" {}
Expand All @@ -199,13 +213,22 @@ def test_in_folder(rule_runner: RuleRunner) -> None:


def test_conflicting_provider_versions(rule_runner: RuleRunner) -> None:
"""Test that 2 separate terraform_modules can request conflicting providers."""
"""Test that 2 separate terraform_modules can request conflicting providers.
I think this test is really only necessary because we don't really separate the files we pass.
If a large target glob is used (`::`), we get all the sources
"""
target_name = "in_folder"
versions = ["3.2.1", "3.0.0"]

def make_terraform_module(version: str) -> Dict[str, str]:
return {
f"folder{version}/BUILD": f"terraform_deployment(name='{target_name}')\n",
f"folder{version}/BUILD": textwrap.dedent(
f"""\
terraform_deployment(name='{target_name}', root_module=':mod')
terraform_module(name='mod')
"""
),
f"folder{version}/provided.tf": textwrap.dedent(
"""
terraform {
Expand Down
2 changes: 1 addition & 1 deletion src/python/pants/backend/terraform/goals/deploy.py
Expand Up @@ -39,7 +39,7 @@ async def prepare_terraform_deployment(
initialised_terraform = await Get(
TerraformInitResponse,
TerraformInitRequest(
(request.field_set.sources,),
request.field_set.root_module,
request.field_set.backend_config,
request.field_set.dependencies,
initialise_backend=True,
Expand Down
52 changes: 43 additions & 9 deletions src/python/pants/backend/terraform/goals/deploy_test.py
Expand Up @@ -59,17 +59,27 @@ def standard_deployment(tmpdir) -> StandardDeployment:
state_file = Path(str(tmpdir.mkdir(".terraform").join("state.json")))
return StandardDeployment(
{
"src/tf/BUILD": """terraform_deployment(name="stg", var_files=["stg.tfvars"],backend_config="stg.tfbackend")""",
"src/tf/BUILD": textwrap.dedent(
"""\
terraform_deployment(
name="stg",
var_files=["stg.tfvars"],
backend_config="stg.tfbackend",
root_module=":mod",
)
terraform_module(name="mod")
"""
),
"src/tf/main.tf": textwrap.dedent(
"""\
terraform {
backend "local" {
path = "/tmp/will/not/exist"
}
}
variable "var0" {}
resource "null_resource" "dep" {}
"""
terraform {
backend "local" {
path = "/tmp/will/not/exist"
}
}
variable "var0" {}
resource "null_resource" "dep" {}
"""
),
"src/tf/stg.tfvars": "var0=0",
"src/tf/stg.tfbackend": f'path="{state_file}"',
Expand Down Expand Up @@ -112,3 +122,27 @@ def test_deploy_terraform_forwards_args(rule_runner: RuleRunner, standard_deploy
assert "-var-file=stg.tfvars" in argv, "Did not find expected -var-file"
assert "-auto-approve" in argv, "Did not find expected passthrough args"
# assert standard_deployment.state_file.check()


def test_deploy_terraform_with_module(rule_runner: RuleRunner) -> None:
"""Test that we can deploy a root module with a nearby shared module."""
files = {
"src/tf/root/BUILD": """terraform_deployment(root_module=":mod")\nterraform_module(name="mod")""",
"src/tf/root/main.tf": """module "mod0" { source = "../mod0" }""",
"src/tf/mod0/BUILD": """terraform_module()""",
"src/tf/mod0/main.tf": """resource "null_resource" "dep" {}""",
}
rule_runner.write_files(files)

with mock_console(rule_runner.options_bootstrapper, stdin_content="yes") as (_, m):
result = rule_runner.run_goal_rule(
Deploy, args=["src/tf::", *rule_runner.options_bootstrapper.args]
)

# assert Pants thinks we succeeded
assert result.stdout.splitlines() == []

# assert deployment succeeded
assert "✓ src/tf/root:root deployed" in result.stderr.splitlines()
# assert module was not deployed
assert not any("src/tf/mod0" in line for line in result.stderr.splitlines())
40 changes: 36 additions & 4 deletions src/python/pants/backend/terraform/target_types.py
Expand Up @@ -5,20 +5,23 @@

from dataclasses import dataclass

from pants.engine.internals.native_engine import AddressInput
from pants.engine.rules import collect_rules, rule
from pants.engine.target import (
COMMON_TARGET_FIELDS,
AllTargets,
AsyncFieldMixin,
Dependencies,
DescriptionField,
FieldSet,
MultipleSourcesField,
OptionalSingleSourceField,
StringField,
Target,
Targets,
generate_multiple_sources_field_help_message,
)
from pants.util.strutil import help_text
from pants.util.strutil import help_text, softwrap


class TerraformDependenciesField(Dependencies):
Expand Down Expand Up @@ -56,6 +59,35 @@ class TerraformModuleTarget(Target):
)


class TerraformRootModuleField(StringField, AsyncFieldMixin):
"""The module to use as the root module for a Terraform deployment."""

required = True
alias = "root_module"
help = help_text(
"""
The Terraform module to use as the root module.
Example: `root_module=":my_module"`
"""
)

def to_address_input(self) -> AddressInput:
if not self.value:
raise ValueError(
softwrap(
f"""
A Terraform deployment must have a nonempty {self.alias} field,
but {self.address} was empty"""
)
)
return AddressInput.parse(
self.value,
relative_to=self.address.spec_path,
description_of_origin=f"the `{self.alias} field in the `{TerraformDeploymentTarget.alias}` target {self.address}",
)


class TerraformBackendConfigField(OptionalSingleSourceField):
alias = "backend_config"
help = "Configuration to be merged with what is in the configuration file's 'backend' block"
Expand All @@ -74,7 +106,7 @@ class TerraformDeploymentTarget(Target):
core_fields = (
*COMMON_TARGET_FIELDS,
TerraformDependenciesField,
TerraformModuleSourcesField,
TerraformRootModuleField,
TerraformBackendConfigField,
TerraformVarFileSourcesField,
)
Expand All @@ -85,10 +117,10 @@ class TerraformDeploymentTarget(Target):
class TerraformDeploymentFieldSet(FieldSet):
required_fields = (
TerraformDependenciesField,
TerraformModuleSourcesField,
TerraformRootModuleField,
)
description: DescriptionField
sources: TerraformModuleSourcesField
root_module: TerraformRootModuleField
dependencies: TerraformDependenciesField

backend_config: TerraformBackendConfigField
Expand Down

0 comments on commit 005ecd8

Please sign in to comment.