Skip to content

Commit be1f665

Browse files
authored
Add dry_run and fix s3 backend merging behaviour (#50)
1 parent 5e38349 commit be1f665

File tree

5 files changed

+227
-26
lines changed

5 files changed

+227
-26
lines changed

.github/workflows/build.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ jobs:
3838
uses: actions/checkout@v3
3939
- name: Pull LocalStack Docker image
4040
run: docker pull localstack/localstack &
41-
- name: Set up Python 3.11
41+
- name: Set up Python 3.12
4242
uses: actions/setup-python@v2
4343
with:
44-
python-version: '3.11'
44+
python-version: '3.12'
4545
- name: Install dependencies
4646
run: make install
4747
- name: Run code linter

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pip install terraform-local
2424
## Configurations
2525

2626
The following environment variables can be configured:
27+
* `DRY_RUN`: Generate the override file without invoking Terraform
2728
* `TF_CMD`: Terraform command to call (default: `terraform`)
2829
* `AWS_ENDPOINT_URL`: hostname and port of the target LocalStack instance
2930
* `LOCALSTACK_HOSTNAME`: __(Deprecated)__ host name of the target LocalStack instance
@@ -48,6 +49,7 @@ please refer to the man pages of `terraform --help`.
4849

4950
## Change Log
5051

52+
* v0.18.0: Add `DRY_RUN` and patch S3 backend entrypoints
5153
* v0.17.1: Add `packaging` module to install requirements
5254
* v0.17.0: Add option to use new endpoints S3 backend options
5355
* v0.16.1: Update Setuptools to exclude tests during packaging

bin/tflocal

+76-19
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ if os.path.isdir(os.path.join(PARENT_FOLDER, ".venv")):
2727
from localstack_client import config # noqa: E402
2828
import hcl2 # noqa: E402
2929

30+
DRY_RUN = str(os.environ.get("DRY_RUN")).strip().lower() in ["1", "true"]
3031
DEFAULT_REGION = "us-east-1"
3132
DEFAULT_ACCESS_KEY = "test"
3233
AWS_ENDPOINT_URL = os.environ.get("AWS_ENDPOINT_URL")
@@ -35,6 +36,7 @@ LOCALHOST_HOSTNAME = "localhost.localstack.cloud"
3536
S3_HOSTNAME = os.environ.get("S3_HOSTNAME") or f"s3.{LOCALHOST_HOSTNAME}"
3637
USE_EXEC = str(os.environ.get("USE_EXEC")).strip().lower() in ["1", "true"]
3738
TF_CMD = os.environ.get("TF_CMD") or "terraform"
39+
TF_PROXIED_CMDS = ("init", "plan", "apply", "destroy")
3840
LS_PROVIDERS_FILE = os.environ.get("LS_PROVIDERS_FILE") or "localstack_providers_override.tf"
3941
LOCALSTACK_HOSTNAME = urlparse(AWS_ENDPOINT_URL).hostname or os.environ.get("LOCALSTACK_HOSTNAME") or "localhost"
4042
EDGE_PORT = int(urlparse(AWS_ENDPOINT_URL).port or os.environ.get("EDGE_PORT") or 4566)
@@ -153,12 +155,15 @@ def create_provider_config_file(provider_aliases=None):
153155

154156
# write temporary config file
155157
providers_file = get_providers_file_path()
156-
if os.path.exists(providers_file):
157-
msg = f"Providers override file {providers_file} already exists - please delete it first"
158-
raise Exception(msg)
158+
write_provider_config_file(providers_file, tf_config)
159+
160+
return providers_file
161+
162+
163+
def write_provider_config_file(providers_file, tf_config):
164+
"""Write provider config into file"""
159165
with open(providers_file, mode="w") as fp:
160166
fp.write(tf_config)
161-
return providers_file
162167

163168

164169
def get_providers_file_path() -> str:
@@ -186,9 +191,12 @@ def determine_provider_aliases() -> list:
186191

187192
def generate_s3_backend_config() -> str:
188193
"""Generate an S3 `backend {..}` block with local endpoints, if configured"""
194+
is_tf_legacy = TF_VERSION < version.Version("1.6")
189195
backend_config = None
190196
tf_files = parse_tf_files()
191-
for obj in tf_files.values():
197+
for filename, obj in tf_files.items():
198+
if LS_PROVIDERS_FILE == filename:
199+
continue
192200
tf_configs = ensure_list(obj.get("terraform", []))
193201
for tf_config in tf_configs:
194202
backend_config = ensure_list(tf_config.get("backend"))
@@ -199,6 +207,13 @@ def generate_s3_backend_config() -> str:
199207
if not backend_config:
200208
return ""
201209

210+
legacy_endpoint_mappings = {
211+
"endpoint": "s3",
212+
"iam_endpoint": "iam",
213+
"sts_endpoint": "sts",
214+
"dynamodb_endpoint": "dynamodb",
215+
}
216+
202217
configs = {
203218
# note: default values, updated by `backend_config` further below...
204219
"bucket": "tf-test-state",
@@ -213,15 +228,29 @@ def generate_s3_backend_config() -> str:
213228
"dynamodb": get_service_endpoint("dynamodb"),
214229
},
215230
}
231+
# Merge in legacy endpoint configs if not existing already
232+
if is_tf_legacy and backend_config.get("endpoints"):
233+
print("Warning: Unsupported backend option(s) detected (`endpoints`). Please make sure you always use the corresponding options to your Terraform version.")
234+
exit(1)
235+
for legacy_endpoint, endpoint in legacy_endpoint_mappings.items():
236+
if legacy_endpoint in backend_config and (not backend_config.get("endpoints") or endpoint not in backend_config["endpoints"]):
237+
if not backend_config.get("endpoints"):
238+
backend_config["endpoints"] = {}
239+
backend_config["endpoints"].update({endpoint: backend_config[legacy_endpoint]})
240+
# Add any missing default endpoints
241+
if backend_config.get("endpoints"):
242+
backend_config["endpoints"] = {
243+
k: backend_config["endpoints"].get(k) or v
244+
for k, v in configs["endpoints"].items()}
216245
configs.update(backend_config)
217-
get_or_create_bucket(configs["bucket"])
218-
get_or_create_ddb_table(configs["dynamodb_table"], region=configs["region"])
246+
if not DRY_RUN:
247+
get_or_create_bucket(configs["bucket"])
248+
get_or_create_ddb_table(configs["dynamodb_table"], region=configs["region"])
219249
result = TF_S3_BACKEND_CONFIG
220250
for key, value in configs.items():
221251
if isinstance(value, bool):
222252
value = str(value).lower()
223253
elif isinstance(value, dict):
224-
is_tf_legacy = not (TF_VERSION.major > 1 or (TF_VERSION.major == 1 and TF_VERSION.minor > 5))
225254
if key == "endpoints" and is_tf_legacy:
226255
value = textwrap.indent(
227256
text=textwrap.dedent(f"""\
@@ -241,6 +270,21 @@ def generate_s3_backend_config() -> str:
241270
return result
242271

243272

273+
def check_override_file(providers_file: str) -> None:
274+
"""Checks override file existance"""
275+
if os.path.exists(providers_file):
276+
msg = f"Providers override file {providers_file} already exists"
277+
err_msg = msg + " - please delete it first, exiting..."
278+
if DRY_RUN:
279+
msg += ". File will be overwritten."
280+
print(msg)
281+
print("\tOnly 'yes' will be accepted to approve.")
282+
if input("\tEnter a value: ") == "yes":
283+
return
284+
print(err_msg)
285+
exit(1)
286+
287+
244288
# ---
245289
# AWS CLIENT UTILS
246290
# ---
@@ -357,6 +401,11 @@ def get_or_create_ddb_table(table_name: str, region: str = None):
357401
# ---
358402
# TF UTILS
359403
# ---
404+
def is_override_needed(args) -> bool:
405+
if any(map(lambda x: x in args, TF_PROXIED_CMDS)):
406+
return True
407+
return False
408+
360409

361410
def parse_tf_files() -> dict:
362411
"""Parse the local *.tf files and return a dict of <filename> -> <resource_dict>"""
@@ -432,18 +481,26 @@ def main():
432481
print(f"Unable to determine version. See error message for details: {e}")
433482
exit(1)
434483

435-
# create TF provider config file
436-
providers = determine_provider_aliases()
437-
config_file = create_provider_config_file(providers)
484+
if is_override_needed(sys.argv[1:]):
485+
check_override_file(get_providers_file_path())
438486

439-
# call terraform command
440-
try:
441-
if USE_EXEC:
442-
run_tf_exec(cmd, env)
443-
else:
444-
run_tf_subprocess(cmd, env)
445-
finally:
446-
os.remove(config_file)
487+
# create TF provider config file
488+
providers = determine_provider_aliases()
489+
config_file = create_provider_config_file(providers)
490+
else:
491+
config_file = None
492+
493+
# call terraform command if not dry-run or any of the commands
494+
if not DRY_RUN or not is_override_needed(sys.argv[1:]):
495+
try:
496+
if USE_EXEC:
497+
run_tf_exec(cmd, env)
498+
else:
499+
run_tf_subprocess(cmd, env)
500+
finally:
501+
# fall through if haven't set during dry-run
502+
if config_file:
503+
os.remove(config_file)
447504

448505

449506
if __name__ == "__main__":

setup.cfg

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = terraform-local
3-
version = 0.17.1
3+
version = 0.18.0
44
url = https://github.com/localstack/terraform-local
55
author = LocalStack Team
66
author_email = [email protected]
@@ -15,6 +15,7 @@ classifiers =
1515
Programming Language :: Python :: 3.9
1616
Programming Language :: Python :: 3.10
1717
Programming Language :: Python :: 3.11
18+
Programming Language :: Python :: 3.12
1819
License :: OSI Approved :: Apache Software License
1920
Topic :: Software Development :: Testing
2021

tests/test_apply.py

+145-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
import subprocess
44
import tempfile
55
import uuid
6+
import json
67
from typing import Dict, Generator
8+
from shutil import rmtree
9+
from packaging import version
10+
711

812
import boto3
913
import pytest
14+
import hcl2
15+
1016

1117
THIS_PATH = os.path.abspath(os.path.dirname(__file__))
1218
ROOT_PATH = os.path.join(THIS_PATH, "..")
@@ -193,19 +199,154 @@ def test_s3_backend():
193199
assert result["ResponseMetadata"]["HTTPStatusCode"] == 200
194200

195201

202+
def test_dry_run(monkeypatch):
203+
monkeypatch.setenv("DRY_RUN", "1")
204+
state_bucket = "tf-state-dry-run"
205+
state_table = "tf-state-dry-run"
206+
bucket_name = "bucket.dry-run"
207+
config = """
208+
terraform {
209+
backend "s3" {
210+
bucket = "%s"
211+
key = "terraform.tfstate"
212+
dynamodb_table = "%s"
213+
region = "us-east-2"
214+
skip_credentials_validation = true
215+
}
216+
}
217+
resource "aws_s3_bucket" "test-bucket" {
218+
bucket = "%s"
219+
}
220+
""" % (state_bucket, state_table, bucket_name)
221+
is_legacy_tf = is_legacy_tf_version(get_version())
222+
223+
temp_dir = deploy_tf_script(config, cleanup=False, user_input="yes")
224+
override_file = os.path.join(temp_dir, "localstack_providers_override.tf")
225+
assert check_override_file_exists(override_file)
226+
227+
assert check_override_file_content(override_file, is_legacy=is_legacy_tf)
228+
229+
# assert that bucket with state file exists
230+
s3 = client("s3", region_name="us-east-2")
231+
232+
with pytest.raises(s3.exceptions.NoSuchBucket):
233+
s3.list_objects(Bucket=state_bucket)
234+
235+
# assert that DynamoDB table with state file locks exists
236+
dynamodb = client("dynamodb", region_name="us-east-2")
237+
with pytest.raises(dynamodb.exceptions.ResourceNotFoundException):
238+
dynamodb.describe_table(TableName=state_table)
239+
240+
# assert that S3 resource has been created
241+
s3 = client("s3")
242+
with pytest.raises(s3.exceptions.ClientError):
243+
s3.head_bucket(Bucket=bucket_name)
244+
245+
246+
@pytest.mark.parametrize("endpoints", [
247+
'',
248+
'endpoint = "http://s3-localhost.localstack.cloud:4566"',
249+
'endpoints = { "s3": "http://s3-localhost.localstack.cloud:4566" }',
250+
'''
251+
endpoint = "http://localhost-s3.localstack.cloud:4566"
252+
endpoints = { "s3": "http://s3-localhost.localstack.cloud:4566" }
253+
'''])
254+
def test_s3_backend_endpoints_merge(monkeypatch, endpoints: str):
255+
monkeypatch.setenv("DRY_RUN", "1")
256+
state_bucket = "tf-state-merge"
257+
state_table = "tf-state-merge"
258+
bucket_name = "bucket.merge"
259+
config = """
260+
terraform {
261+
backend "s3" {
262+
bucket = "%s"
263+
key = "terraform.tfstate"
264+
dynamodb_table = "%s"
265+
region = "us-east-2"
266+
skip_credentials_validation = true
267+
%s
268+
}
269+
}
270+
resource "aws_s3_bucket" "test-bucket" {
271+
bucket = "%s"
272+
}
273+
""" % (state_bucket, state_table, endpoints, bucket_name)
274+
is_legacy_tf = is_legacy_tf_version(get_version())
275+
if is_legacy_tf and endpoints not in ("", 'endpoint = "http://s3-localhost.localstack.cloud:4566"'):
276+
with pytest.raises(subprocess.CalledProcessError):
277+
deploy_tf_script(config, user_input="yes")
278+
else:
279+
temp_dir = deploy_tf_script(config, cleanup=False, user_input="yes")
280+
override_file = os.path.join(temp_dir, "localstack_providers_override.tf")
281+
assert check_override_file_exists(override_file)
282+
assert check_override_file_content(override_file, is_legacy=is_legacy_tf)
283+
rmtree(temp_dir)
284+
285+
286+
def check_override_file_exists(override_file):
287+
return os.path.isfile(override_file)
288+
289+
290+
def check_override_file_content(override_file, is_legacy: bool = False):
291+
legacy_options = (
292+
"endpoint",
293+
"iam_endpoint",
294+
"dynamodb_endpoint",
295+
"sts_endpoint",
296+
)
297+
new_options = (
298+
"iam",
299+
"dynamodb",
300+
"s3",
301+
"sso",
302+
"sts",
303+
)
304+
try:
305+
with open(override_file, "r") as fp:
306+
result = hcl2.load(fp)
307+
result = result["terraform"][0]["backend"][0]["s3"]
308+
except Exception as e:
309+
print(f'Unable to parse "{override_file}" as HCL file: {e}')
310+
311+
new_options_check = "endpoints" in result and all(map(lambda x: x in result.get("endpoints"), new_options))
312+
313+
if is_legacy:
314+
legacy_options_check = all(map(lambda x: x in result, legacy_options))
315+
return not new_options_check and legacy_options_check
316+
317+
legacy_options_check = any(map(lambda x: x in result, legacy_options))
318+
return new_options_check and not legacy_options_check
319+
320+
196321
###
197322
# UTIL FUNCTIONS
198323
###
199324

200-
def deploy_tf_script(script: str, env_vars: Dict[str, str] = None):
201-
with tempfile.TemporaryDirectory() as temp_dir:
325+
326+
def is_legacy_tf_version(tf_version, legacy_version: str = "1.6") -> bool:
327+
"""Check if Terraform version is legacy"""
328+
if tf_version < version.Version(legacy_version):
329+
return True
330+
return False
331+
332+
333+
def get_version():
334+
"""Get Terraform version"""
335+
output = run([TFLOCAL_BIN, "version", "-json"]).decode("utf-8")
336+
return version.parse(json.loads(output)["terraform_version"])
337+
338+
339+
def deploy_tf_script(script: str, cleanup: bool = True, env_vars: Dict[str, str] = None, user_input: str = None):
340+
with tempfile.TemporaryDirectory(delete=cleanup) as temp_dir:
202341
with open(os.path.join(temp_dir, "test.tf"), "w") as f:
203342
f.write(script)
204343
kwargs = {"cwd": temp_dir}
344+
if user_input:
345+
kwargs.update({"input": bytes(user_input, "utf-8")})
205346
kwargs["env"] = {**os.environ, **(env_vars or {})}
206347
run([TFLOCAL_BIN, "init"], **kwargs)
207-
out = run([TFLOCAL_BIN, "apply", "-auto-approve"], **kwargs)
208-
return out
348+
run([TFLOCAL_BIN, "apply", "-auto-approve"], **kwargs)
349+
return temp_dir
209350

210351

211352
def get_bucket_names(**kwargs: dict) -> list:

0 commit comments

Comments
 (0)