Allow per-module terraform fixtures (#1914)

* Allow terraform fixtures for examples

* Allow defining multiple fixtures, and named fixtures under tests/fixtures/

* Enable e2e for wiktorn

* Fix prepare_files call for e2e

* Move fixture to separate file, fix test

* Revert shallow-copying symlinks, performane penalty - 20%

* Update tfdoc.py to list used fixtures

---------

Co-authored-by: Wiktor Niesiobędzki <wiktorn@google.com>
This commit is contained in:
Julio Castillo
2023-12-29 10:43:44 +01:00
committed by GitHub
parent 2ad109ae23
commit fde7b76036
7 changed files with 145 additions and 29 deletions

View File

@@ -31,6 +31,7 @@ Due to the complexity of the underlying resources, changes to the configuration
- [Files](#files)
- [Variables](#variables)
- [Outputs](#outputs)
- [Fixtures](#fixtures)
<!-- END TOC -->
### Minimal HTTP Example
@@ -40,18 +41,18 @@ An HTTP load balancer with a backend service pointing to a GCE instance group:
```hcl
module "glb-0" {
source = "./fabric/modules/net-lb-app-ext"
project_id = "myprj"
project_id = var.project_id
name = "glb-test-0"
backend_service_configs = {
default = {
backends = [
{ backend = "projects/myprj/zones/europe-west8-b/instanceGroups/myig-b" },
{ backend = "projects/myprj/zones/europe-west8-c/instanceGroups/myig-c" },
{ backend = module.compute-mig-a.group.id },
{ backend = module.compute-mig-b.group.id },
]
}
}
}
# tftest modules=1 resources=5
# tftest modules=3 resources=9 fixtures=fixtures/compute-mig-ab.tf e2e
```
### Minimal HTTPS examples
@@ -895,4 +896,8 @@ module "glb-0" {
| [neg_ids](outputs.tf#L67) | Autogenerated network endpoint group ids. | |
| [psc_neg_ids](outputs.tf#L74) | Autogenerated PSC network endpoint group ids. | |
| [serverless_neg_ids](outputs.tf#L81) | Autogenerated serverless network endpoint group ids. | |
## Fixtures
- [compute-mig-ab.tf](../../tests/fixtures/compute-mig-ab.tf)
<!-- END TFDOC -->

View File

@@ -23,8 +23,9 @@ import pytest
FABRIC_ROOT = Path(__file__).parents[2]
FILE_TEST_RE = re.compile(r'# tftest-file +id=([\w_.-]+) +path=([\S]+)')
FIXTURE_TEST_RE = re.compile(r'# tftest-fixture +id=([\w_.-]+)')
Example = collections.namedtuple('Example', 'name code module files')
Example = collections.namedtuple('Example', 'name code module files fixtures')
File = collections.namedtuple('File', 'path content')
@@ -49,16 +50,19 @@ def pytest_generate_tests(metafunc, test_group='example',
doc = marko.parse(readme.read_text())
index = 0
files = collections.defaultdict(dict)
fixtures = {}
# first pass: collect all examples tagged with tftest-file
last_header = None
for child in doc.children:
if isinstance(child, marko.block.FencedCode):
code = child.children[0].children
match = FILE_TEST_RE.search(code)
if match:
if match := FILE_TEST_RE.search(code):
name, path = match.groups()
files[last_header][name] = File(path, code)
if match := FIXTURE_TEST_RE.search(code):
name = match.groups()[0]
fixtures[name] = code
elif isinstance(child, marko.block.Heading):
last_header = child.children[0].children
@@ -82,8 +86,10 @@ def pytest_generate_tests(metafunc, test_group='example',
# this, together with `--dist loadgroup` will ensure that those tests will be run one after another
# even if multiple workers are used
# see: https://pytest-xdist.readthedocs.io/en/latest/distribution.html
marks = [pytest.mark.xdist_group("serial")] if 'serial' in tftest_tag else []
examples.append(pytest.param(Example(name, code, path, files[last_header]), marks=marks))
marks = [pytest.mark.xdist_group("serial")
] if 'serial' in tftest_tag else []
example = Example(name, code, path, files[last_header], fixtures)
examples.append(pytest.param(example, marks=marks))
elif isinstance(child, marko.block.Heading):
last_header = child.children[0].children
index = 0

View File

@@ -18,19 +18,34 @@ import yaml
from pathlib import Path
BASE_PATH = Path(__file__).parent
COUNT_TEST_RE = re.compile(r'# tftest +modules=(?P<modules>\d+) +resources=(?P<resources>\d+)' +
r'(?: +files=(?P<files>[\w@,_-]+))?' +
r'(?: +inventory=(?P<inventory>[\w\-.]+))?')
COUNT_TEST_RE = re.compile(
r'# tftest +modules=(?P<modules>\d+) +resources=(?P<resources>\d+)' +
r'(?: +files=(?P<files>[\w@,_-]+))?' +
r'(?: +fixtures=(?P<fixtures>[\w@,_/.-]+))?' +
r'(?: +inventory=(?P<inventory>[\w\-.]+))?')
def prepare_files(example, test_path, line):
if line is not None:
requested_files = line.split(',')
def prepare_files(example, test_path, files, fixtures):
if files is not None:
requested_files = files.split(',')
for f in requested_files:
destination = test_path / example.files[f].path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(example.files[f].content)
if fixtures is not None:
requested_fixtures = fixtures.split(',')
for f in requested_fixtures:
if f.startswith('fixtures/'):
# fixture is specified referencing a global fixture in (tests/fixtures/)
source = BASE_PATH.parent / f
destination = test_path / source.name
destination.symlink_to(source)
else:
# fixture is specified using an inline tftest-fixture tag
destination = test_path / f'{f}.tf'
destination.write_text(example.fixtures[f])
def test_example(plan_validator, tmp_path, example):
if match := COUNT_TEST_RE.search(example.code):
@@ -42,26 +57,27 @@ def test_example(plan_validator, tmp_path, example):
if assets_path.exists():
(tmp_path / 'assets').symlink_to(assets_path)
expected_modules = int(match.group("modules"))
expected_resources = int(match.group("resources"))
expected_modules = int(match.group('modules'))
expected_resources = int(match.group('resources'))
prepare_files(example, tmp_path, match.group("files"))
prepare_files(example, tmp_path, match.group('files'),
match.group('fixtures'))
inventory = []
if match.group("inventory") is not None:
if match.group('inventory') is not None:
python_test_path = str(example.module).replace('-', '_')
inventory = BASE_PATH.parent / python_test_path / 'examples'
inventory = inventory / match.group("inventory")
inventory = inventory / match.group('inventory')
# TODO: force plan_validator to never copy files (we're already
# running from a temp dir)
summary = plan_validator(module_path=tmp_path, inventory_paths=inventory,
tf_var_files=[])
print("\n")
print(yaml.dump({"values": summary.values}))
print(yaml.dump({"counts": summary.counts}))
print(yaml.dump({"outputs": summary.outputs}))
print('\n')
print(yaml.dump({'values': summary.values}))
print(yaml.dump({'counts': summary.counts}))
print(yaml.dump({'outputs': summary.outputs}))
counts = summary.counts
num_modules, num_resources = counts['modules'], counts['resources']

View File

@@ -21,7 +21,8 @@ BASE_PATH = Path(__file__).parent
def test_example(e2e_validator, tmp_path, examples_e2e, e2e_tfvars_path):
(tmp_path / 'fabric').symlink_to(BASE_PATH.parents[1])
(tmp_path / 'variables.tf').symlink_to(BASE_PATH.parent / 'examples' / 'variables.tf')
(tmp_path / 'variables.tf').symlink_to(BASE_PATH.parent / 'examples' /
'variables.tf')
(tmp_path / 'main.tf').write_text(examples_e2e.code)
assets_path = BASE_PATH.parent / str(examples_e2e.module).replace(
'-', '_') / 'assets'
@@ -31,7 +32,8 @@ def test_example(e2e_validator, tmp_path, examples_e2e, e2e_tfvars_path):
# add files the same way as it is done for examples
if match := COUNT_TEST_RE.search(examples_e2e.code):
prepare_files(examples_e2e, tmp_path, match.group("files"))
prepare_files(examples_e2e, tmp_path, match.group("files"),
match.group('fixtures'))
e2e_validator(module_path=tmp_path, extra_files=[],
tf_var_files=[(tmp_path / 'terraform.tfvars')])

View File

@@ -56,7 +56,9 @@ def _prepare_root_module(path):
with tempfile.TemporaryDirectory(dir=path.parent) as tmp_path:
tmp_path = Path(tmp_path)
shutil.copytree(path, tmp_path, dirs_exist_ok=True,
# Running tests in a copy made with symlinks=True makes them run
# ~20% slower than when run in a copy made with symlinks=False.
shutil.copytree(path, tmp_path, dirs_exist_ok=True, symlinks=False,
ignore=ignore_patterns)
lockfile = _REPO_ROOT / 'tools' / 'lockfile' / '.terraform.lock.hcl'
if lockfile.exists():

48
tests/fixtures/compute-mig-ab.tf vendored Normal file
View File

@@ -0,0 +1,48 @@
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
module "compute-mig-a" {
source = "./fabric/modules/compute-vm"
project_id = var.project_id
zone = "${var.region}-a"
name = "my-ig-a"
network_interfaces = [{
network = var.vpc.self_link
subnetwork = var.subnet.self_link
}]
boot_disk = {
initialize_params = {
image = "cos-cloud/cos-stable"
}
}
group = { named_ports = {} }
}
module "compute-mig-b" {
source = "./fabric/modules/compute-vm"
project_id = var.project_id
zone = "${var.region}-b"
name = "my-ig-b"
network_interfaces = [{
network = var.vpc.self_link
subnetwork = var.subnet.self_link
}]
boot_disk = {
initialize_params = {
image = "cos-cloud/cos-stable"
}
}
group = { named_ports = {} }
}

View File

@@ -43,11 +43,18 @@ import glob
import os
import re
import string
import sys
import urllib.parse
import click
import marko
# manipulate path to import COUNT_TEST_RE from tests/examples/test_plan.py
REPO_ROOT = os.path.dirname(os.path.dirname(__file__))
sys.path.append(os.path.join(REPO_ROOT, 'tests'))
from examples.test_plan import COUNT_TEST_RE
__version__ = '2.1.0'
# TODO(ludomagno): decide if we want to support variables*.tf and outputs*.tf
@@ -227,6 +234,25 @@ def parse_variables(basepath, exclude_files=None):
file=shortname, line=item['line'], nullable=nullable)
def parse_fixtures(basepath, readme):
'Return a list of file paths of all the unique fixtures used in the module.'
doc = marko.parse(readme)
used_fixtures = set()
for child in doc.children:
if isinstance(child, marko.block.FencedCode):
if child.lang == 'hcl':
code = child.children[0].children
if match := COUNT_TEST_RE.search(code):
if fixtures := match.group('fixtures'):
for fixture in fixtures.split(','):
fixture_full = os.path.join(REPO_ROOT, 'tests', fixture)
if not os.path.exists(fixture_full):
raise SystemExit(f'Unknown fixture: {fixture}')
fixture_relative = os.path.relpath(fixture_full, basepath)
used_fixtures.add(fixture_relative)
yield from sorted(used_fixtures)
# formatting functions
@@ -235,7 +261,7 @@ def _escape(s):
return ''.join(c if c in UNESCAPED else ('&#%s;' % ord(c)) for c in s)
def format_tfref(outputs, variables, files, show_extra=False):
def format_tfref(outputs, variables, files, fixtures, show_extra=False):
'Return formatted document.'
buffer = []
if files:
@@ -247,6 +273,9 @@ def format_tfref(outputs, variables, files, show_extra=False):
if outputs:
buffer += ['', '## Outputs', '']
buffer += list(format_tfref_outputs(outputs, show_extra))
if fixtures:
buffer += ['', '## Fixtures', '']
buffer += list(format_tfref_fixtures(fixtures))
return '\n'.join(buffer).strip()
@@ -323,6 +352,12 @@ def format_tfref_variables(items, show_extra=True):
yield format
def format_tfref_fixtures(items):
'Format fixtures table.'
for x in items:
yield f"- [{os.path.basename(x)}]({x})"
def create_toc(readme):
'Create a Markdown table of contents a for README.'
doc = marko.parse(readme)
@@ -384,9 +419,11 @@ def create_tfref(module_path, files=False, show_extra=False, exclude_files=None,
mod_files = list(parse_files(module_path, exclude_files)) if files else []
mod_variables = list(parse_variables(module_path, exclude_files))
mod_outputs = list(parse_outputs(module_path, exclude_files))
mod_fixtures = list(parse_fixtures(module_path, readme))
except (IOError, OSError) as e:
raise SystemExit(e)
doc = format_tfref(mod_outputs, mod_variables, mod_files, show_extra)
doc = format_tfref(mod_outputs, mod_variables, mod_files, mod_fixtures,
show_extra)
return Document(doc, mod_files, mod_variables, mod_outputs)