diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index adbfaa6..25da795 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,5 @@ FROM mcr.microsoft.com/devcontainers/python:1-3.12 -COPY ./requirements.txt / COPY ./.devcontainer/entrypoint.sh /entrypoint.sh diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index 4eaffa2..9a8b82e 100755 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -1,8 +1,8 @@ #!/bin/sh +pip install -e '.[dev]' pip install -e . -pip3 install -r requirements-dev.txt exec $@ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..68332de --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ + + +version: 2 +updates: + - package-ecosystem: "docker" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + + # - package-ecosystem: "github-actions" # See documentation for possible values + # directory: "/" # Location of package manifests + # schedule: + # interval: "weekly" diff --git a/.github/release.bash b/.github/release.bash index 9d3307c..013ad09 100755 --- a/.github/release.bash +++ b/.github/release.bash @@ -7,7 +7,11 @@ echo "Replace Version inside tfutility" echo "__version__= \"$2\"" > src/tfutility/__init__.py echo "Replace Docker image Reference inside README.md" -sed -i "s/tfutility:$1/tfutility:$1/" README.md +sed -i "s/tfutility:$1/tfutility:$2/" README.md + + +echo "Replace pre-commit Reference inside README.md" +sed -i "s/ref: $1/ref: $2/" README.md echo "Replace Docker image Reference inside pre-commit-hook" sed -i "s/tfutility:$1/tfutility:$1/" .pre-commit-hooks.yaml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a366d7..178d81f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: run: mv .releaserc.prerelease.yaml .releaserc.yaml - name: Semantic Release - uses: cycjimmy/semantic-release-action@v2 + uses: cycjimmy/semantic-release-action@v4 id: semantic # Need an `id` for output variables with: semantic_version: 17 diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 0252a90..410cac0 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -25,24 +25,23 @@ - id: check-forcedremotesource-docker name: forcedremotesource description: Check if all modules hase remote sources - args: ["importdate"] + args: ["forcedremotesource"] language: docker_image - entry: ghcr.io/eieste/tfutility:1.0.9 forcedremotesource + entry: ghcr.io/eieste/tfutility:1.1.1 files: (\.tf)$ - - id: check-importdate-docker name: importdate description: Check if import block has decorated dates args: ["importdate"] language: docker_image - entry: ghcr.io/eieste/tfutility:1.0.9 importdate + entry: ghcr.io/eieste/tfutility:1.1.1 files: (\.tf)$ - id: check-movedate-docker name: movedate description: Check if moved block has decorated dates - args: ["importdate"] + args: ["movedate"] language: docker_image - entry: ghcr.io/eieste/tfutility:1.0.9 movedate + entry: ghcr.io/eieste/tfutility:1.1.1 files: (\.tf)$ diff --git a/.releaserc.prerelease.yaml b/.releaserc.prerelease.yaml index 9fc229f..987349c 100644 --- a/.releaserc.prerelease.yaml +++ b/.releaserc.prerelease.yaml @@ -18,7 +18,7 @@ prepare: assets: - src/tfutility/__init__.py - README.md - - CHANELOG.md + - CHANGELOG.md publish: - path: "@semantic-release/github" diff --git a/README.md b/README.md index 8edaa3b..49e7840 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,35 @@ for detailed Examples visit the [Documentation](https://eieste.github.io/tfutili ### Use with Docker ``` -docker run -it --rm -v $(pwd):/workspace ghcr.io/eieste/tfutility:1.0.9 forcedremotesource /workspace +docker run -it --rm -v $(pwd):/workspace ghcr.io/eieste/tfutility:1.1.1 forcedremotesource /workspace ``` ### Use with pre-commit +Create a .pre-commit-config.yaml with the following content. +```yaml + +repos: + - repo: https://github.com/eieste/tfutility/ + rev: 1.1.1 + hooks: + - id: check-forcedremotesource + - id: check-importdate + - id: check-moveddate + +``` + +Its possible to attach the suffix `-docker` to each hook to use precommit docker hooks + + +### Autocompletion + +Run the following command to setup a bash autocompletion for this command + +```bash +register-python-argcomplete tfutilty > /etc/bash_completion.d/tfutilty +``` ## Quick-Reference diff --git a/docs/source/conf.py b/docs/source/conf.py index ffba85d..fbb9053 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -24,7 +24,6 @@ exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/docs/source/index.rst b/docs/source/index.rst index 5710fce..d21ae7f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,15 +1,8 @@ -.. TF-Utils documentation master file, created by - sphinx-quickstart on Sun Nov 3 09:06:25 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. -TF-Utils documentation -====================== - -Add your content using ``reStructuredText`` syntax. See the -`reStructuredText `_ -documentation for details. +tfutility documentation +======================= + tfutility allows performing commands on terraform/tofu files. This commands can be different things. Currently, there are possibilities to check if a module block has remote sources. Or import or moved blocks have creation dates on them. .. toctree:: :maxdepth: 2 diff --git a/docs/source/users/forcedremotesource.rst b/docs/source/users/forcedremotesource.rst index 5a7142e..b4cb290 100644 --- a/docs/source/users/forcedremotesource.rst +++ b/docs/source/users/forcedremotesource.rst @@ -7,7 +7,7 @@ It is usefull in pre-commit hooks and development scenatios. -*Szenario:* +**Szenario:** You are developing a large terraform/tofu project with different modules thre are pushed to an terraform module registry. But for development purposes you linked the module localy together. @@ -24,7 +24,7 @@ Write the following Decorator above modules to enforce this module must have a l This decorator works only above `module` blocks. It has no additional parameters Example usage -`# @forcedremotesource` +``# @forcedremotesource`` The following Code tells tfutility this module should have an remote source. @@ -39,6 +39,17 @@ But as you can see the module has an local path source = "../local/path" } +shell: + +.. code-block:: bash + :linenos: + + tfutility forcedremotesource test.tf + ERROR: Module Block had no Version Defined in main.tf:4 + ERROR: Module Block has no Remote Source in main.tf:4 + + + so the following command loggs error and exit the whole application with an exit code 1 .. code-block:: hcl @@ -52,11 +63,14 @@ so the following command loggs error and exit the whole application with an exit This raises an version missing error - .. code-block:: sh :linenos: $ tfutility forcedremotesource test.tf + 2024-11-18 23:05:25 ERROR: Module Block has no Remote Source in test.tf:4 + + +Its possible to prevent error messages or exit 1 status codes with the --silent and --allow-failure arguments Command Line Arguments diff --git a/docs/source/users/importdate.rst b/docs/source/users/importdate.rst index 2280ee6..fd7ef70 100644 --- a/docs/source/users/importdate.rst +++ b/docs/source/users/importdate.rst @@ -7,20 +7,28 @@ This decorator must contain an date when the block was created. It allows the detection of old import blocks which can be remoted at a certain point in time +**Szenario** + +If you want to import already existing cloud resources into your terraform you can use import blocks in your code. +If this blocks are used it is a good idea to remove them after a specific time or after it could be ensured that these blocks are taken by terraform. +By using the first option ( after a specific time ) this Command can help you. + +By executing ``tfutility importdate .`` it can be ensured every import block has an decorator which contains a create date. +If these create date are expired ( defined by an additional expire date or an duration command line argument ) these checks will be fail until this import block is removed + + Terraform ========= - .. code-block:: hcl :linenos: + #$ cat test.tf import { - to = "" - id = "" + to = "resource.aws_s3_bucket.example" + id = "arandoms3bucket" } - - .. code-block:: hcl :linenos: @@ -30,8 +38,6 @@ Terraform id = "" } - - .. code-block:: hcl :linenos: @@ -41,8 +47,6 @@ Terraform id = "" } - - you can also overwrite the expire date with your own duration like: ```bash tfutility moveddate --expire-after 60 /workspace diff --git a/docs/source/users/swapsource.rst b/docs/source/users/swapsource.rst index 9a02e63..3762a4a 100644 --- a/docs/source/users/swapsource.rst +++ b/docs/source/users/swapsource.rst @@ -4,6 +4,7 @@ Swap Source + Terraform ========= @@ -26,10 +27,6 @@ Terraform version = "" } - - - - .. argparse:: :module: tfutility.main :func: _get_parser_only diff --git a/pyproject.toml b/pyproject.toml index 1e5c420..280d8e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ readme = { file = 'README.md', content-type = 'text/markdown' } license = { file = 'LICENSE' } authors = [{ name = "Stefan Eiermann", email = "stefan.eiermann@eneka.de" }] dependencies = [ - "python-hcl2==4.3.5" + "python-hcl2==4.3.5", + "argcomplete==3.5.1", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/src/tfutility/controllers/blockdate.py b/src/tfutility/controllers/blockdate.py index 463a926..304015e 100644 --- a/src/tfutility/controllers/blockdate.py +++ b/src/tfutility/controllers/blockdate.py @@ -44,8 +44,8 @@ def new_block(self, options, block): self._error = True block_name = self.get_block_name() self.get_logger().error( - "Missing moveddate Decorator at block '{}' in file {} Line {}".format( - block_name, file_path, block.start + "Missing {} Decorator above block '{}' in file {}:{}".format( + self.get_command_name(), block_name, file_path, block.start ) ) else: @@ -54,22 +54,26 @@ def new_block(self, options, block): if not options.expire_after: if dec.parameter("expire"): + print("HIII") + dec_date_expire = datetime.strptime( dec.parameter("expire"), "%d-%m-%Y" ) if now > dec_date_expire: + print("FOOOHIII") + self._error = True self.get_logger().error( - "Moved Block expired in file: {} Line {}".format( - file_path, block.start + "{} Block expired in file: {}:{}".format( + self.get_command_name(), file_path, block.start ) ) else: if now > dec_date_create + timedelta(days=options.expire_after): self._error = True self.get_logger().error( - "Moved Block expired in file: {} Line {}".format( - file_path, block.start + "{} Block expired in file: {}:{}".format( + self.get_command_name(), file_path, block.start ) ) @@ -77,9 +81,6 @@ def handle(self, options): self._error = False results = super(BlockDateHandler, self).handle(options) - if not options.allow_failure and self._error: - sys.exit(1) - tf_files = self.get_file_list(options.paths) for file in tf_files: @@ -89,6 +90,9 @@ def handle(self, options): if block.id.startswith(self.get_block_name()): self.new_block(options, block) + if not options.allow_failure and self._error: + sys.exit(1) + return results diff --git a/src/tfutility/controllers/forcedremotesource.py b/src/tfutility/controllers/forcedremotesource.py index 11b7a27..e3f2beb 100644 --- a/src/tfutility/controllers/forcedremotesource.py +++ b/src/tfutility/controllers/forcedremotesource.py @@ -50,6 +50,7 @@ def new_decorator(self, options, block): def handle(self, options): self._error = False + if options.silent: self.get_logger().setLevel(1000) diff --git a/src/tfutility/controllers/sourceswap.py b/src/tfutility/controllers/sourceswap.py index 51a7dd6..3efdfe5 100644 --- a/src/tfutility/controllers/sourceswap.py +++ b/src/tfutility/controllers/sourceswap.py @@ -29,15 +29,6 @@ def add_arguments(self, parser): return parser def block_switch_to(self, options, block, dec, switch_to): - file_path = block.tffile.path - if not block.id.startswith("module"): - self.get_logger().error( - "The decorator @sourceswap applied to wrong blocktype in {}:{}".format( - file_path, block.start - ) - ) - sys.exit(1) - lines = block.tffile.lines source_line = -1 version_line = -1 @@ -88,20 +79,11 @@ def block_switch_to(self, options, block, dec, switch_to): del block.tffile.lines[version_line] def get_decorator(self, block): - file_path = block.tffile.path dec = block.get_decorator(self.get_command_name()) - general_error = False for param_key in ["remote_source", "remote_version", "local_source"]: if not dec.parameter(param_key): - self.get_logger().error( - "Decorator {} {}:{} requires the parameters remote_source, remote_version, local_source".format( - self.get_command_name(), file_path, block.start - ) - ) - general_error = True + return False - if general_error: - sys.exit(1) return dec def handle(self, options): @@ -116,10 +98,31 @@ def handle(self, options): switch_to = SWITCH_DIRECTION.TO_LOCAL tf_files = self.get_file_list(options.paths) - + precheck_error = False for file in tf_files: file = TfFile(file) + for block in file.get_blocks_with_decorator(self.get_command_name()): + dec = self.get_decorator(block) + if dec is False: + precheck_error = True + self.get_logger().error( + "Decorator {} {}:{} requires the parameters remote_source, remote_version, local_source".format( + self.get_command_name(), file.path, block.start + ) + ) + + if not block.id.startswith("module."): + precheck_error = True + self.get_logger().error( + "Decorator {} {}:{} is used on an invalid block. It is only allowd to use sourceswap on module blocks".format( + self.get_command_name(), file.path, block.start + ) + ) + + if precheck_error: + sys.exit(1) + for block in file.get_blocks_with_decorator(self.get_command_name()): dec = self.get_decorator(block) self.block_switch_to(options, block, dec, switch_to) diff --git a/src/tfutility/core/tffile.py b/src/tfutility/core/tffile.py index 735789a..7290c26 100644 --- a/src/tfutility/core/tffile.py +++ b/src/tfutility/core/tffile.py @@ -51,6 +51,9 @@ def get_name(self): @property def name(self): + """ + Name of the Decorator ( string after @ sign without arguments from braces text ) + """ return self._name def _parse(self, data: str): @@ -112,27 +115,39 @@ def end(self): return self._end_line def __str__(self): - return self._id + return self.id def __repr__(self): - return f"" + return f"" - def __eq__(self, value: object) -> bool: - self._start_line == value._start_line + def __eq__(self, other: "TfBlock") -> bool: + if not isinstance(other, self.__class__): + raise ValueError("Comparison only between TfBlock objects are allowed") + return self._start_line == other._start_line - def __lt__(self, other): + def __lt__(self, other: "TfBlock") -> bool: + if not isinstance(other, self.__class__): + raise ValueError("Comparison only between TfBlock objects are allowed") return self.start < other.start - def __le__(self, other): + def __le__(self, other: "TfBlock") -> bool: + if not isinstance(other, self.__class__): + raise ValueError("Comparison only between TfBlock objects are allowed") return self.start >= other.start - def __ne__(self, other): + def __ne__(self, other: "TfBlock") -> bool: + if not isinstance(other, self.__class__): + raise ValueError("Comparison only between TfBlock objects are allowed") return self.start != other.start - def __gt__(self, other): + def __gt__(self, other: "TfBlock") -> bool: + if not isinstance(other, self.__class__): + raise ValueError("Comparison only between TfBlock objects are allowed") return self.start > other.start - def __ge__(self, other): + def __ge__(self, other: "TfBlock") -> bool: + if not isinstance(other, self.__class__): + raise ValueError("Comparison only between TfBlock objects are allowed") return self.start >= other.start @property @@ -155,11 +170,11 @@ def tffile(self): @property def content(self): + """Returns a dict of the parsed block""" return self._content def get_decorator(self, name: str) -> TfUtilityDecorator | None: - """ - find a decorator with the given name above the current block + """find a decorator with the given name above the current block :param name: The name of the decorator to find :type name: str @@ -185,6 +200,7 @@ def has_decorator(self, name: str) -> bool: :return: True if the block has a decorator with the given name, False otherwise :rtype: bool """ + print("blubl" * 10) if self._decorators is None: self._decorators = self._find_decorators() for dec in self._decorators: @@ -199,9 +215,11 @@ def _find_decorators(self): :return: A list of decorators found above this block :rtype: list[TfUtilDecorator] """ + print("hiii" * 100) line_nr = self._start_line - 2 decorator_list = [] while self.tffile.lines[line_nr].strip().startswith("# @"): + print(self.tffile.lines[line_nr]) found_decorator = self.tffile.lines[line_nr].strip() result = TfBlock.DECORATOR_REGEX.fullmatch(found_decorator) decorator_list.append( @@ -227,6 +245,14 @@ def __repr__(self): def tffile(self): return self._tf_file + @property + def lines(self): + return self._tf_file.lines + + @property + def hcl(self): + return self._tf_file.parsed + @deprecated def get_tffile(self): return self._tf_file @@ -277,6 +303,7 @@ def _extract_blocks(self, blockdata: dict, name: str): def get_blocks_with_decorator(self, name: str): result = [] + for block in self.blocks: if block.has_decorator(name): result.append(block) diff --git a/src/tfutility/core/tfpaths.py b/src/tfutility/core/tfpaths.py index c6d5621..2900e49 100644 --- a/src/tfutility/core/tfpaths.py +++ b/src/tfutility/core/tfpaths.py @@ -43,8 +43,9 @@ def _get_files_from_path(self, path: pathlib.Path): return list(path.glob(self.__class__.SEARCH_GLOB)) return [path] - def get_file_list(self, paths: pathlib.Path) -> list[pathlib.Path]: - """Resolve all given cli nargs. If a given path has no file matching it logs a warning + def get_file_list(self, paths: list[pathlib.Path]) -> list[pathlib.Path]: + """Resolve all given cli nargs of Paths (to folders and files) to a simple list of file paths. + If a given path has no file matching it logs a warning :param paths: list[pathlib.Path] :type paths: pathlib.Path diff --git a/src/tfutility/main.py b/src/tfutility/main.py index b133f46..12eaf8d 100644 --- a/src/tfutility/main.py +++ b/src/tfutility/main.py @@ -7,6 +7,8 @@ import sys from collections import namedtuple +import argcomplete + import tfutility from tfutility.controllers.blockdate import ImportDateHandler, MovedDateHandler from tfutility.controllers.forcedremotesource import ForcedRemoteSourceHandler @@ -18,9 +20,7 @@ class TfUtility: - """ - TfUtility Initial class. This class initializes all CLI arguments - """ + """TfUtility Initial class. This class initializes all CLI arguments""" # List of all Available CLI Handlers handlers = [ @@ -34,8 +34,7 @@ def __init__(self): self.commands = {} def _init_handlers(self, parser: argparse.ArgumentParser): - """ - initialize all cli argparse commands + """Initialize all cli argparse commands :type parser: argparse.ArgumentParser :param parser: the main argparser @@ -46,11 +45,12 @@ def _init_handlers(self, parser: argparse.ArgumentParser): handler = handler_cls(self, parser) cmd_parser = handler._init(parser, subparser) handler.add_arguments(cmd_parser) - self.commands[handler.name] = HandlerInfo(handler_cls, handler, cmd_parser) + self.commands[handler.command_name] = HandlerInfo( + handler_cls, handler, cmd_parser + ) def get_logger(self) -> logging.Logger: - """ - get logger instance + """Get logger instance :return: the logger instance :rtype: logging.Logger @@ -58,8 +58,7 @@ def get_logger(self) -> logging.Logger: return log def add_arguments(self, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: - """ - Add global arguments to the parser + """Add global arguments to the parser :param parser: the main argparser :type parser: argparse.ArgumentParser @@ -70,8 +69,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> argparse.ArgumentPar return parser def _handle(self, options: argparse.Namespace): - """ - handle the cli arguments + """Handle the cli arguments :param options: the parsed cli arguments :type options: argparse.Namespace @@ -84,30 +82,31 @@ def _handle(self, options: argparse.Namespace): format="%(asctime)s %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) - if options.version: print(tfutility.__version__) sys.exit(0) + flag_command_found = False # find command which is used by user for name, handler in self.commands.items(): if handler.handler_obj.itsme(options): handler.handler_obj.handle(options) + flag_command_found = True + + if not flag_command_found: + log.error("tfutility: missing command") def _init_parser(self) -> argparse.ArgumentParser: - """ - Create the main Parser with all subparsers arguments - """ + """Create the main Parser with all subparsers arguments""" parser = argparse.ArgumentParser() parser = self.add_arguments(parser) self._init_handlers(parser) return parser def do(self): - """ - Execute command execution - """ + """Execute command execution""" parser = self._init_parser() + argcomplete.autocomplete(parser) options = parser.parse_args() self._handle(options) diff --git a/tests/test_cli.py b/tests/test_cli.py index b321399..6c18104 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- +import logging import re +import tempfile + +import pytest from tfutility.main import main @@ -22,3 +26,173 @@ def test_version(capsys, mocker): captured = capsys.readouterr() assert VESION_REGEX.match(captured.out) is not None assert sysexit.call_count == 1 + + +def test_forcedremotesource_local_source(mocker, caplog): + INVALID_TF = """ + # @forcedremotesource + module "test" { + source = "../../foo" + } + """ + + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(INVALID_TF.encode("utf-8")) + tmpfile.close() + + mocker.patch("sys.argv", ["tfutility", "forcedremotesource", tmpfile.name]) + sysexit = mocker.patch("sys.exit") + main() + + assert "Module Block has no" in caplog.text + assert sysexit.call_args.args == (1,) + assert sysexit.call_count == 1 + + +def test_forcedremotesource_remotesource(mocker, caplog): + VALID_TF = """ + # @forcedremotesource + module "test" { + source = "foo.com/test" + version = "0.0.1" + } + """ + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(VALID_TF.encode("utf-8")) + tmpfile.close() + + sysexit = mocker.patch("sys.exit") + + mocker.patch("sys.argv", ["tfutility", "forcedremotesource", tmpfile.name]) + sysexit = mocker.patch("sys.exit") + main() + + assert "" in caplog.text + assert sysexit.call_count == 0 + + +def test_importdate_missing_decorator(mocker, caplog): + MISSING_TF = """ + import { + id = "" + from = "" + } + """ + + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(MISSING_TF.encode("utf-8")) + tmpfile.close() + + mocker.patch("sys.argv", ["tfutility", "importdate", tmpfile.name]) + sysexit = mocker.patch("sys.exit") + main() + + assert "Missing importdate Decorator above block" in caplog.text + assert sysexit.call_args.args == (1,) + assert sysexit.call_count == 1 + + +def test_importdate_invaliddate(mocker, caplog): + INVALID_DATE = """ + # @importdate(create="04-10-2019") + import { + id = "" + from = "" + } + """ + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(INVALID_DATE.encode("utf-8")) + tmpfile.close() + + mocker.patch( + "sys.argv", ["tfutility", "importdate", "--expire-after", "10", tmpfile.name] + ) + sysexit = mocker.patch("sys.exit") + main() + + assert sysexit.call_args.args == (1,) + assert "importdate Block expired " in caplog.text + + +def test_importdate_expirede(mocker, caplog): + INVALID_DATE = """ + # @importdate(create="04-10-2019", expire="20-12-2019") + import { + id = "" + from = "" + } + """ + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(INVALID_DATE.encode("utf-8")) + tmpfile.close() + + mocker.patch("sys.argv", ["tfutility", "importdate", tmpfile.name]) + sysexit = mocker.patch("sys.exit") + main() + + assert sysexit.call_args.args == (1,) + assert "importdate Block expired " in caplog.text + + +def test_sourceswap_missing_parameters(mocker, caplog): + INVALID_DATE = """ + # @sourceswap() + module "hi" { + source = "" + version = "" + } + """ + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(INVALID_DATE.encode("utf-8")) + tmpfile.close() + + mocker.patch( + "sys.argv", ["tfutility", "sourceswap", tmpfile.name, "--switch-to", "local"] + ) + + with pytest.raises(SystemExit) as pytest_wrapped_e: + main() + + assert "requires the parameters" in caplog.text + + assert pytest_wrapped_e.type is SystemExit + assert pytest_wrapped_e.value.code == 1 + + +def test_sourceswap_wrong_block(mocker, caplog): + INVALID_DATE = """ + # @sourceswap(remotesource="..", remoteversion="0.0.1", localsource="..") + resource "hi" "foo" { + source = ".." + version = "0.0.1" + } + """ + caplog.set_level(logging.INFO) + + tmpfile = tempfile.NamedTemporaryFile(suffix=".tf", delete_on_close=False) + tmpfile.write(INVALID_DATE.encode("utf-8")) + tmpfile.close() + + mocker.patch( + "sys.argv", ["tfutility", "sourceswap", tmpfile.name, "--switch-to", "local"] + ) + + with pytest.raises(SystemExit) as pytest_wrapped_e: + main() + + assert "requires the parameters" in caplog.text + + assert pytest_wrapped_e.type is SystemExit + assert pytest_wrapped_e.value.code == 1 diff --git a/tests/test_core_base.py b/tests/test_core_base.py new file mode 100644 index 0000000..f49d1f6 --- /dev/null +++ b/tests/test_core_base.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +import argparse +import pathlib +import sys +import tempfile + +import pytest + +from tfutility.core.base import Command +from tfutility.core.tffile import TfFile +from tfutility.core.tfpaths import TfPaths +from tfutility.main import main + + +class FooBar(TfPaths, Command): + command_name = "foobardecorator" + help = "Check if a RemoteSource was set" + + def new_decorator(self, options, block): + file_path = block.tffile.path + if not block.id.startswith("foobarblock"): + self.get_logger().error( + "The decorator @foobarblock can only applied to modules" + ) + sys.exit(1) + + block_content = block.content + + if not block_content.get("mustexist"): + self._error = True + self.get_logger().error( + "foobar Block had no mustexist Defined in {}:{}".format( + file_path, block.start + ) + ) + + def handle(self, options): + self._error = False + + tf_files = self.get_file_list(options.paths) + + for file in tf_files: + file = TfFile(file) + for block in file.get_blocks_with_decorator(self.get_command_name()): + self.new_decorator(self, block) + + +def test_base_tfpaths_required(mocker): + mocker.patch("sys.argv", ["tfutility", "foobardecorator"]) + mocker.patch("tfutility.main.TfUtility.handlers", [FooBar]) + + def error(self, message): + raise ValueError(message) + + mocker.patch("argparse.ArgumentParser.error", error) + + with pytest.raises(ValueError, match=r".*required: paths"): + main() + + +def test_tfpath_parsing(mocker): + mocker.patch("tfutility.main.TfUtility.handlers", [FooBar]) + VALID_TF = """ + # @foobardecorator + foobarblock "test" { + source = "foo.com/test" + version = "0.0.1" + } + """ + valid_temp = tempfile.NamedTemporaryFile(suffix=".tf") + valid_temp.write(VALID_TF.encode("utf-8")) + valid_temp.seek(0) + mocker.patch("sys.argv", ["tfutility", "foobardecorator", valid_temp.name]) + + foobar_obj = FooBar(None, None) + + assert foobar_obj.get_command_name() == foobar_obj.command_name + assert foobar_obj.command_name == "foobardecorator" + assert foobar_obj.itsme(argparse.Namespace(command="hallo")) is False + assert foobar_obj.itsme(argparse.Namespace(command="foobardecorator")) + + tmpath = pathlib.Path(valid_temp.name) + assert all( + [p.as_posix() == valid_temp.name for p in foobar_obj.get_file_list([tmpath])] + ) + + new_dec = mocker.patch.object(foobar_obj, "new_decorator") + + main() + + foobar_obj.handle( + argparse.Namespace( + command="foobardecorator", paths=[pathlib.Path(valid_temp.name)] + ) + ) + assert new_dec.call_count == 1 + + +def test_cmd_handle(mocker): + VALID_TF = """ + # @foobardecorator + foobarblock "test" { + source = "foo.com/test" + version = "0.0.1" + } + """ + + valid_temp = tempfile.NamedTemporaryFile(suffix=".tf") + valid_temp.write(VALID_TF.encode("utf-8")) + testoptions = argparse.Namespace( + command="foobardecorator", paths=[pathlib.Path(valid_temp.name)] + ) + + foobar_obj = FooBar(None, None) + + tmpath = pathlib.Path(valid_temp.name) + + filelist = mocker.patch.object(foobar_obj, "get_file_list", return_value=[tmpath]) + getblock = mocker.patch("tfutility.core.tffile.TfFile.get_blocks_with_decorator") + new_dec = mocker.patch.object(foobar_obj, "new_decorator") + + foobar_obj.handle(testoptions) + + assert getblock.call_count == 1 + assert filelist.call_count == 1 + assert new_dec.call_count == 0 diff --git a/tests/test_core_tffile_tfblock.py b/tests/test_core_tffile_tfblock.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core_tfparse.py b/tests/test_core_tfparse.py index 3bb7805..c3c544b 100644 --- a/tests/test_core_tfparse.py +++ b/tests/test_core_tfparse.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +import pathlib +import tempfile + import hcl2 from tfutility.core.tffile import OpendTfFile, TfBlock, TfFile @@ -43,7 +46,6 @@ def test_filemetadata_extract_blocks_more(mocker): "module.testmodule_remotesource.source", "moved", ] - print(fm._blocks) assert all([block.id in targets for block in fm._blocks]) @@ -73,3 +75,39 @@ def test_find_decorators_found(mocker): assert len(x) == 1 assert x[0].parameter("bar") == "test" assert x[0].parameter("party") == "hard" + + +def test_tffile(mocker): + VALID_TF = """ + # @foobardecorator + foobarblock "test" { + source = "foo.com/test" + version = "0.0.1" + } + """ + valid_temp = tempfile.NamedTemporaryFile(suffix=".tf", delete=False) + valid_temp.write(VALID_TF.encode("utf-8")) + valid_temp.close() + + tmpfile = pathlib.Path(valid_temp.name) + f = TfFile(tmpfile) + + assert f.tffile.path == tmpfile + assert len(f.tffile.lines) == 7 + + assert f.tffile.parsed == { + "foobarblock": [ + { + "test": { + "source": "foo.com/test", + "version": "0.0.1", + "__start_line__": 3, + "__end_line__": 6, + } + } + ] + } + + assert len(f.blocks) == 1 + assert f.blocks[0].id == "foobarblock.test" + assert len(f.blocks[0].decorators) == 1