Skip to content

Commit ef112a2

Browse files
fix(composer): add SHA256 checksum verification and pre-installed binary support for TerraformApplyOperator
1 parent a87ae0f commit ef112a2

3 files changed

Lines changed: 123 additions & 13 deletions

File tree

composer/workflows/terraform_apply_operator.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
# [START composer_terraform_apply_operator]
1818

19+
import hashlib
1920
import logging
2021
import os
2122
import platform
@@ -41,10 +42,14 @@ class TerraformApplyOperator(BaseOperator):
4142
"""Airflow Operator to execute `terraform apply` within Google Cloud Composer workers.
4243
4344
Key Features:
44-
- Dynamic download and bootstrapping of `terraform` binary into `/tmp/`.
45+
- Supports pre-installed Terraform binaries or dynamic download with cryptographic SHA-256 verification.
4546
- Staging `.tf` files from GCSFuse mount paths to local pod `/tmp/` disk storage to avoid GCSFuse file-locking errors.
4647
- Streaming real-time `terraform init` and `terraform apply` logs to Airflow task logs.
4748
- Automatic cleanup of temporary workspace directories upon task completion.
49+
50+
Security & Reliability Considerations:
51+
- Pre-installing Terraform or providing `binary_path` is recommended for Private IP Composer environments.
52+
- If dynamically downloading from HashiCorp releases, official SHA-256 checksum verification is enforced.
4853
"""
4954

5055
template_fields: Sequence[str] = ("terraform_dir", "variables", "terraform_version")
@@ -55,25 +60,71 @@ def __init__(
5560
terraform_dir: str,
5661
variables: Optional[Dict[str, Any]] = None,
5762
terraform_version: str = "1.5.7",
63+
binary_path: Optional[str] = None,
5864
auto_approve: bool = True,
5965
**kwargs,
6066
):
6167
super().__init__(**kwargs)
6268
self.terraform_dir = terraform_dir
6369
self.variables = variables or {}
6470
self.terraform_version = terraform_version
71+
self.binary_path = binary_path
6572
self.auto_approve = auto_approve
6673

74+
def _fetch_expected_checksum(self, version: str, filename: str) -> Optional[str]:
75+
"""Downloads the official HashiCorp SHA256SUMS file and extracts the expected hash for filename."""
76+
sums_url = f"https://releases.hashicorp.com/terraform/{version}/terraform_{version}_SHA256SUMS"
77+
self.log.info("Fetching SHA256 checksums from %s", sums_url)
78+
with urllib.request.urlopen(sums_url) as response:
79+
content = response.read().decode("utf-8")
80+
81+
for line in content.splitlines():
82+
parts = line.strip().split()
83+
if len(parts) >= 2 and parts[1].endswith(filename):
84+
return parts[0]
85+
return None
86+
87+
def _verify_sha256(self, file_path: str, expected_checksum: str) -> None:
88+
"""Verifies that the SHA-256 digest of file_path matches expected_checksum."""
89+
sha256_hash = hashlib.sha256()
90+
with open(file_path, "rb") as f:
91+
for byte_block in iter(lambda: f.read(65536), b""):
92+
sha256_hash.update(byte_block)
93+
calculated_checksum = sha256_hash.hexdigest()
94+
95+
if calculated_checksum.lower() != expected_checksum.lower():
96+
raise ValueError(
97+
f"SHA256 checksum verification failed for {file_path}! "
98+
f"Expected: {expected_checksum}, Got: {calculated_checksum}"
99+
)
100+
self.log.info("SHA256 checksum verified successfully (%s)", calculated_checksum)
101+
67102
def _ensure_terraform_binary(self) -> str:
68-
"""Checks if the required terraform binary is available in `/tmp/`.
103+
"""Finds or bootstraps the terraform executable.
69104
70-
If not, downloads and extracts the specified version from HashiCorp releases.
105+
1. Uses `self.binary_path` if explicitly specified.
106+
2. Checks system PATH for pre-installed `terraform`.
107+
3. If unavailable, downloads and extracts the verified binary into `/tmp/`.
71108
"""
109+
# 1. Check custom binary path
110+
if self.binary_path:
111+
if os.path.exists(self.binary_path) and os.access(self.binary_path, os.X_OK):
112+
self.log.info("Using specified Terraform binary at %s", self.binary_path)
113+
return self.binary_path
114+
raise FileNotFoundError(f"Specified binary_path not found or executable: {self.binary_path}")
115+
116+
# 2. Check system PATH (pre-installed in custom worker images)
117+
path_binary = shutil.which("terraform")
118+
if path_binary:
119+
self.log.info("Using system Terraform binary found in PATH at %s", path_binary)
120+
return path_binary
121+
122+
# 3. Dynamic download with SHA-256 verification
72123
bin_dir = f"/tmp/terraform_bin_{self.terraform_version}"
73124
binary_path = os.path.join(bin_dir, "terraform")
74125

75126
if os.path.exists(binary_path) and os.access(binary_path, os.X_OK):
76-
self.log.info("Found existing Terraform binary at %s", binary_path)
127+
self.log.info("Found cached Terraform binary at %s", binary_path)
77128
return binary_path
78129

79130
os.makedirs(bin_dir, exist_ok=True)
@@ -86,15 +137,19 @@ def _ensure_terraform_binary(self) -> str:
86137
else:
87138
platform_arch = "linux_amd64"
88139

89-
url = (
90-
f"https://releases.hashicorp.com/terraform/{self.terraform_version}/"
91-
f"terraform_{self.terraform_version}_{platform_arch}.zip"
92-
)
93-
zip_path = os.path.join(bin_dir, "terraform.zip")
140+
zip_filename = f"terraform_{self.terraform_version}_{platform_arch}.zip"
141+
url = f"https://releases.hashicorp.com/terraform/{self.terraform_version}/{zip_filename}"
142+
zip_path = os.path.join(bin_dir, zip_filename)
94143

95144
self.log.info("Downloading Terraform v%s from %s", self.terraform_version, url)
96145
urllib.request.urlretrieve(url, zip_path)
97146

147+
expected_checksum = self._fetch_expected_checksum(self.terraform_version, zip_filename)
148+
if expected_checksum:
149+
self._verify_sha256(zip_path, expected_checksum)
150+
else:
151+
self.log.warning("Could not find official checksum for %s in SHA256SUMS file", zip_filename)
152+
98153
self.log.info("Extracting Terraform binary to %s", bin_dir)
99154
with zipfile.ZipFile(zip_path, "r") as zip_ref:
100155
zip_ref.extractall(bin_dir)

composer/workflows/terraform_apply_operator_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,52 @@ def test_stage_workspace_success(self):
6666
finally:
6767
shutil.rmtree(work_dir, ignore_errors=True)
6868

69+
def test_custom_binary_path(self):
70+
with tempfile.NamedTemporaryFile(delete=False) as f:
71+
f.write(b"mock binary")
72+
binary_file = f.name
73+
74+
try:
75+
os.chmod(binary_file, 0o755)
76+
operator = TerraformApplyOperator(
77+
task_id="test_binary",
78+
terraform_dir="/tmp/test",
79+
binary_path=binary_file,
80+
)
81+
self.assertEqual(operator._ensure_terraform_binary(), binary_file)
82+
finally:
83+
if os.path.exists(binary_file):
84+
os.remove(binary_file)
85+
86+
@mock.patch("shutil.which", return_value="/usr/local/bin/terraform")
87+
def test_path_binary_detection(self, mock_which):
88+
operator = TerraformApplyOperator(
89+
task_id="test_path",
90+
terraform_dir="/tmp/test",
91+
)
92+
self.assertEqual(operator._ensure_terraform_binary(), "/usr/local/bin/terraform")
93+
94+
def test_sha256_verification_success_and_failure(self):
95+
with tempfile.NamedTemporaryFile(delete=False) as f:
96+
f.write(b"sample data content")
97+
sample_file = f.name
98+
99+
operator = TerraformApplyOperator(
100+
task_id="test_hash",
101+
terraform_dir="/tmp/test",
102+
)
103+
try:
104+
# Correct SHA-256 for "sample data content"
105+
correct_hash = "4a922a0548a2e7d67bbff25c9bc4ea16b08eab6f314d8df525e2f6cef1334166"
106+
operator._verify_sha256(sample_file, correct_hash)
107+
108+
# Incorrect SHA-256 should raise ValueError
109+
with self.assertRaises(ValueError):
110+
operator._verify_sha256(sample_file, "deadbeef123456")
111+
finally:
112+
if os.path.exists(sample_file):
113+
os.remove(sample_file)
114+
69115
@mock.patch.object(TerraformApplyOperator, "_ensure_terraform_binary", return_value="/tmp/mock_terraform")
70116
@mock.patch.object(TerraformApplyOperator, "_stage_workspace", return_value="/tmp/mock_workdir")
71117
@mock.patch.object(TerraformApplyOperator, "_run_command")
@@ -97,3 +143,4 @@ def test_operator_execute_flow(self, mock_exists, mock_rmtree, mock_run_command,
97143
if __name__ == "__main__":
98144
unittest.main()
99145

146+

composer/workflows/terraform_sample/README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@ This sample demonstrates how to run Terraform configurations directly within an
1010
- `../terraform_apply_operator_test.py`: Unit tests for the operator.
1111
- `main.tf`: Example Terraform configuration that provisions a Google Cloud Storage bucket with labels.
1212

13-
## Prerequisites
13+
## Execution Approaches & Security
1414

15-
1. A Google Cloud project with the Cloud Composer API enabled.
16-
2. A Cloud Composer 2 / 3 environment.
17-
3. IAM permissions: Ensure the Cloud Composer environment service account has appropriate IAM roles (e.g. `roles/storage.admin`) to provision the desired resources.
15+
### 1. Pre-installed Binary (Recommended for Private IP Environments)
16+
In enterprise Cloud Composer environments with Private IP (no direct internet egress) or custom worker images, you can provide a pre-installed `terraform` binary:
17+
- Place `terraform` in the system `PATH` (e.g. `/usr/local/bin/terraform`).
18+
- Or pass `binary_path="/opt/bin/terraform"` to `TerraformApplyOperator`.
19+
20+
### 2. Verified Dynamic Download
21+
If no pre-installed binary is detected, `TerraformApplyOperator` downloads the official HashiCorp release binary and **cryptographically verifies its SHA-256 checksum** against HashiCorp's signed `SHA256SUMS` manifest before extraction and execution.
22+
23+
### 3. Containerized Alternative
24+
For workloads requiring dedicated execution environments with complex provider dependencies, consider executing Terraform in an isolated container using `GKEStartPodOperator` or `KubernetesPodOperator`.
1825

1926
## Deploying to Cloud Composer
2027

28+
2129
1. Copy `terraform_apply_operator.py`, `terraform_dag.py`, and the `terraform_sample/` directory into your Cloud Composer environment's `dags/` folder (or sync via Cloud Storage `gs://<your-composer-bucket>/dags/`).
2230
2. Update the `PROJECT_ID` variable in `terraform_dag.py` with your GCP project ID.
2331
3. Trigger the `composer_terraform_apply_dag` from the Airflow web UI.

0 commit comments

Comments
 (0)