Skip to content

Commit 57513b5

Browse files
authored
Finer grained cache hash input (#60)
* add checksumdir to requirements * Update README.md * add tests * add new hash params * fix pep8 indenting
1 parent 88bfc6a commit 57513b5

4 files changed

Lines changed: 130 additions & 8 deletions

File tree

DEV-REQUIREMENTS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
autopep8
22
pytest>=5.2.2
3+
checksumdir

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_run_all_apply(run_all_apply_out):
8282

8383
The `TerraformTest` `setup`, `init`, `plan`, `apply`, `output` and `destroy` methods have the ability to cache it's associate output to a local `.tftest-cache` directory. For subsequent calls of the method, the cached value can be returned instead of calling the actual underlying `terraform` command. Using the cache value can be significantly faster than running the Terraform command again especially if the command is time-intensive.
8484

85-
To determine if the cache should be used, first a hash value is generated using the current `TerraformTest` instance `__init__` and calling method arguments. The hash value is compared to the hash value of the cached instance's associated arguments. If the hash is the same then the cache is used, otherwise the method is executed.
85+
To determine if the cache should be used, first a hash value is generated using the current `TerraformTest` instance `__init__` and calling method arguments, file contents of the `tfdir` and file contents of any `tf_var_file` or `extra_files` method argument. The hash value is compared to the hash value of the cached instance's associated arguments. If the hash is the same then the cache is used, otherwise the method is executed.
8686

8787
The benefits of the caching feature include:
8888
- Faster setup time for testing terraform modules that don't change between testing sessions
@@ -124,4 +124,4 @@ Tests use the `pytest` framework and have no other dependency except on the Terr
124124

125125
## Disclaimer
126126

127-
This is not an officially supported Google product.
127+
This is not an officially supported Google product.

test/test_cache.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import logging
1616
import os
1717
import shutil
18+
import uuid
19+
import json
1820
import pytest
1921
import tftest
2022
from unittest.mock import patch, DEFAULT, Mock
@@ -78,3 +80,99 @@ def test_no_use_cache(tf):
7880
for _ in range(expected_call_count):
7981
getattr(tf, method)(use_cache=False)
8082
assert mock_execute_command.call_count == expected_call_count
83+
84+
85+
@pytest.mark.parametrize("tf", [True], indirect=True)
86+
def test_use_cache_with_same_tf_var_file(tf, tmp_path):
87+
tf_var_file_methods = ["plan", "apply", "destroy"]
88+
89+
tf_vars_file = tmp_path / (str(uuid.uuid4()) + '.json')
90+
tf_vars_file.write_text(json.dumps({"foo": "old"}))
91+
92+
for method in tf_var_file_methods:
93+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
94+
for _ in range(2):
95+
getattr(tf, method)(use_cache=True, tf_var_file=tf_vars_file)
96+
97+
assert mock_execute_command.call_count == 1
98+
99+
100+
@pytest.mark.parametrize("tf", [True], indirect=True)
101+
def test_use_cache_with_new_tf_var_file(tf, tmp_path):
102+
tf_var_file_methods = ["plan", "apply", "destroy"]
103+
expected_call_count = 2
104+
105+
tf_vars_file = tmp_path / (str(uuid.uuid4()) + '.json')
106+
107+
for method in tf_var_file_methods:
108+
tf_vars_file.write_text(json.dumps({"foo": "old"}))
109+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
110+
for _ in range(expected_call_count):
111+
getattr(tf, method)(use_cache=True, tf_var_file=tf_vars_file)
112+
tf_vars_file.write_text(json.dumps({"foo": "new"}))
113+
114+
assert mock_execute_command.call_count == expected_call_count
115+
116+
117+
@pytest.mark.parametrize("tf", [True], indirect=True)
118+
def test_use_cache_with_new_extra_files(tf, tmp_path):
119+
expected_call_count = 2
120+
tf_vars_file = tmp_path / (str(uuid.uuid4()) + '.json')
121+
tf_vars_file.write_text(json.dumps({"foo": "old"}))
122+
123+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
124+
for _ in range(expected_call_count):
125+
tf.setup(use_cache=True, extra_files=[tf_vars_file])
126+
tf_vars_file.write_text(json.dumps({"foo": "new"}))
127+
128+
assert mock_execute_command.call_count == expected_call_count
129+
130+
131+
@pytest.mark.parametrize("tf", [True], indirect=True)
132+
def test_use_cache_with_same_extra_files(tf, tmp_path):
133+
tf_vars_file = tmp_path / (str(uuid.uuid4()) + '.json')
134+
tf_vars_file.write_text(json.dumps({"foo": "old"}))
135+
136+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
137+
for _ in range(2):
138+
tf.setup(use_cache=True, extra_files=[tf_vars_file])
139+
140+
assert mock_execute_command.call_count == 1
141+
142+
143+
@pytest.mark.parametrize("tf", [True], indirect=True)
144+
def test_use_cache_with_new_env(tf):
145+
expected_call_count = 2
146+
for method in cache_methods:
147+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
148+
for _ in range(expected_call_count):
149+
getattr(tf, method)(use_cache=True)
150+
tf._env["foo"] = "bar"
151+
152+
assert mock_execute_command.call_count == expected_call_count
153+
154+
del tf._env["foo"]
155+
156+
157+
@pytest.fixture
158+
def dummy_tf_filepath(tf):
159+
filepath = os.path.join(tf.tfdir, "bar.txt")
160+
with open(filepath, "w") as f:
161+
f.write("old")
162+
163+
yield filepath
164+
165+
os.remove(filepath)
166+
167+
168+
@pytest.mark.parametrize("tf", [True], indirect=True)
169+
def test_use_cache_with_new_tf_content(tf, dummy_tf_filepath):
170+
expected_call_count = 2
171+
for method in cache_methods:
172+
with patch.object(tf, 'execute_command', wraps=tf.execute_command) as mock_execute_command:
173+
for _ in range(expected_call_count):
174+
getattr(tf, method)(use_cache=True)
175+
with open(dummy_tf_filepath, "w") as f:
176+
f.write(str(uuid.uuid4()))
177+
178+
assert mock_execute_command.call_count == expected_call_count

tftest.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from hashlib import sha1
4343
from pathlib import Path
4444
from typing import List
45+
from checksumdir import dirhash
4546

4647
__version__ = '1.7.4'
4748

@@ -107,7 +108,8 @@ def parse_args(init_vars=None, tf_vars=None, targets=None, **kw):
107108
]
108109
for arg in _TG_KV_ARGS:
109110
if kw.get(f"tg_{arg}"):
110-
cmd_args += [f'--terragrunt-{arg.replace("_", "-")}', kw[f"tg_{arg}"]]
111+
cmd_args += [f'--terragrunt-{arg.replace("_", "-")}',
112+
kw[f"tg_{arg}"]]
111113
if kw.get('tg_parallelism'):
112114
cmd_args.append(f'--terragrunt-parallelism {kw["tg_parallelism"]}')
113115
if isinstance(kw.get('tg_override_attr'), dict):
@@ -321,10 +323,12 @@ def __init__(self, tfdir, basedir=None, binary='terraform', env=None,
321323
self._basedir = basedir or os.getcwd()
322324
self.binary = binary
323325
self.tfdir = self._abspath(tfdir)
326+
self._env = env or {}
324327
self.env = os.environ.copy()
325328
self.tg_run_all = False
326329
self._plan_formatter = lambda out: TerraformPlanOutput(json.loads(out))
327-
self._output_formatter = lambda out: TerraformValueDict(json.loads(out))
330+
self._output_formatter = lambda out: TerraformValueDict(
331+
json.loads(out))
328332
self.enable_cache = enable_cache
329333
if not cache_dir:
330334
self.cache_dir = Path(os.path.dirname(
@@ -359,11 +363,13 @@ def remove_readonly(func, path, excinfo):
359363
for tg_dir in glob.glob(path, recursive=True):
360364
if os.path.isdir(tg_dir):
361365
shutil.rmtree(tg_dir, onerror=remove_readonly)
362-
_LOGGER.debug('Restoring original TF files after prevent destroy changes')
366+
_LOGGER.debug(
367+
'Restoring original TF files after prevent destroy changes')
363368
if restore_files:
364369
for bkp_file in Path(tfdir).rglob('*.bkp'):
365370
try:
366-
shutil.copy(str(bkp_file), f'{str(bkp_file).strip(".bkp")}')
371+
shutil.copy(str(bkp_file),
372+
f'{str(bkp_file).strip(".bkp")}')
367373
except (IOError, OSError):
368374
_LOGGER.exception(
369375
f'Unable to restore terraform file {bkp_file.resolve()}')
@@ -404,11 +410,27 @@ def cache(self, **kwargs):
404410
k: v for k, v in self.__dict__.items()
405411
# only uses instance attributes that are involved in the results of
406412
# the decorated method
407-
if k in ["binary", "_basedir", "tfdir", "env"]
413+
if k in ["binary", "_basedir", "tfdir", "_env"]
408414
},
409415
**kwargs,
410416
}
411417

418+
# creates hash of file contents
419+
for path_param in ["extra_files", "tf_var_file"]:
420+
if path_param in kwargs:
421+
if isinstance(kwargs[path_param], list):
422+
params[path_param] = [
423+
sha1(open(fp, 'rb').read()).hexdigest() for fp in kwargs[path_param]]
424+
else:
425+
params[path_param] = sha1(
426+
open(kwargs[path_param], 'rb').read()).hexdigest()
427+
428+
# creates hash of all file content within tfdir
429+
# excludes hidden files from being used within hash (ignores .terraform/ or .terragrunt-cache/)
430+
# and excludes any local tfstate files
431+
params["tfdir"] = dirhash(
432+
self.tfdir, 'sha1', ignore_hidden=True, excluded_extensions=['backup', 'tfstate'])
433+
412434
hash_filename = sha1(
413435
json.dumps(params, sort_keys=True,
414436
default=str).encode("cp037")).hexdigest() + ".pickle"
@@ -575,7 +597,8 @@ def plan(self, input=False, color=False, refresh=True, tf_vars=None,
575597
try:
576598
return self._plan_formatter(result.out)
577599
except json.JSONDecodeError as e:
578-
raise TerraformTestError('Error decoding plan output: {}'.format(e))
600+
raise TerraformTestError(
601+
'Error decoding plan output: {}'.format(e))
579602

580603
@_cache
581604
def apply(self, input=False, color=False, auto_approve=True, tf_vars=None,

0 commit comments

Comments
 (0)