From fa1e83764af22fcb22f5a6b20092c44b7a2c0cd1 Mon Sep 17 00:00:00 2001 From: Leon Wright Date: Sun, 10 Mar 2024 14:09:13 +0800 Subject: [PATCH] feat: Initial Terraform Action + Cli This adds a Terraform Action processor, along with an initial cli printing the generated Stack output. --- cally/cdktf/__init__.py | 107 ++++++++++++++++++++++++++ cally/cdktf/resources/__init__.py | 0 cally/cdktf/stacks/__init__.py | 18 +++++ cally/commands/tf.py | 19 +++++ cally/tools/terraform.py | 55 +++++++++++++ pyproject.toml | 1 + tests/__init__.py | 22 ++++++ tests/cdktf/__init__.py | 11 +++ tests/cli/test_tf.py | 20 +++++ tests/testdata/cdktf/empty_synth.json | 17 ++++ tests/testdata/cli/empty_print.json | 18 +++++ 11 files changed, 288 insertions(+) create mode 100644 cally/cdktf/__init__.py create mode 100644 cally/cdktf/resources/__init__.py create mode 100644 cally/cdktf/stacks/__init__.py create mode 100644 cally/commands/tf.py create mode 100644 cally/tools/terraform.py create mode 100644 tests/cdktf/__init__.py create mode 100644 tests/cli/test_tf.py create mode 100644 tests/testdata/cdktf/empty_synth.json create mode 100644 tests/testdata/cli/empty_print.json diff --git a/cally/cdktf/__init__.py b/cally/cdktf/__init__.py new file mode 100644 index 0000000..858146a --- /dev/null +++ b/cally/cdktf/__init__.py @@ -0,0 +1,107 @@ +import inspect +from copy import deepcopy +from dataclasses import dataclass, make_dataclass +from importlib import import_module +from typing import Any, List + +from constructs import Construct + +from cdktf import ( + App, + LocalBackend, + TerraformProvider, + TerraformResource, + TerraformStack, +) + + +@dataclass +class CallyResourceAttributes: + id: str + provider: TerraformProvider + + +class CallyResource: + _cdktf_resource: Any # This is probably a callable TerraformResource + _instantiated_resource: TerraformResource + attributes: CallyResourceAttributes + provider: str + resource: str + defaults: dict + + def __init__(self, identifier: str, **kwargs) -> None: + module = import_module(f'cally.providers.{self.provider}.{self.resource}') + self._cdktf_resource = getattr(module, self.__class__.__name__) + self.attributes = self._build_attributes(identifier, **kwargs) + + def __str__(self) -> str: + return f'${{{self.resource}.{self.attributes.id}.id}}' + + def __getattr__(self, item: str) -> str: + if item.startswith('__jsii'): + return getattr(self._instantiated_resource, item) + return f'${{{self.resource}.{self.attributes.id}.{item}}}' + + def _get_attribute_default(self, name: str) -> Any: + if not hasattr(self, 'defaults'): + return None + return deepcopy(self.defaults.get(name, None)) + + def _build_attributes(self, identifier: str, **kwargs) -> CallyResourceAttributes: + func = self._cdktf_resource.__init__ # type: ignore + parameters = inspect.signature(func).parameters + fields = [ + (name, param.annotation, self._get_attribute_default(name)) + for name, param in parameters.items() + if param.annotation is not inspect._empty and name not in {'scope'} + ] + name = f'{self.__class__.__name__}CallyAttributes' + cls = make_dataclass(name, fields, bases=(CallyResourceAttributes,)) + return cls(**{'id_': identifier, **kwargs}) + + def construct_resource(self, scope: Construct, provider: TerraformProvider) -> None: + self.attributes.provider = provider + self._instantiated_resource = self._cdktf_resource( + scope, **self.attributes.__dict__ + ) + + +class CallyStack: + _resources: List[CallyResource] + name: str + + def __init__(self, name: str) -> None: + self.name = name + + def add_resource(self, resource: CallyResource) -> None: + self.resources.append(resource) + + def add_resources(self, resources: List[CallyResource]) -> None: + self.resources.extend(resources) + + @property + def resources(self) -> List[CallyResource]: + if getattr(self, '_resources', None) is None: + self._resources = [] + return self._resources + + def synth_stack(self, outdir='cdktf.out'): + stack = self + + class MyStack(TerraformStack): + + def __init__(self, scope: Construct) -> None: + super().__init__(scope, stack.name) + # TODO: Build provider loader + for resource in stack.resources: + provider = TerraformProvider(self, 'test') # type: ignore + resource.construct_resource(self, provider=provider) + + LocalBackend( + self, path=f'state/{stack.name}.tfstate' + ) # TODO: load this + + app = App(outdir=outdir) + MyStack(app) + + app.synth() diff --git a/cally/cdktf/resources/__init__.py b/cally/cdktf/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cally/cdktf/stacks/__init__.py b/cally/cdktf/stacks/__init__.py new file mode 100644 index 0000000..d3f40e2 --- /dev/null +++ b/cally/cdktf/stacks/__init__.py @@ -0,0 +1,18 @@ +import pkgutil +from importlib import import_module +from inspect import isclass + +from .. import CallyStack + +package = import_module(__package__) +for _, name, is_pkg in pkgutil.walk_packages(package.__path__): # noqa: B007 + full_name = f'{package.__name__}.{name}' + module = import_module(full_name) + for sub_module in dir(module): + if sub_module == 'CallyStack': + continue + attr = getattr(module, sub_module) + if not isclass(attr): + continue + if issubclass(attr, CallyStack): + globals()[sub_module] = attr diff --git a/cally/commands/tf.py b/cally/commands/tf.py new file mode 100644 index 0000000..43b9556 --- /dev/null +++ b/cally/commands/tf.py @@ -0,0 +1,19 @@ +import click + +from ..tools import terraform + + +@click.group() +def tf() -> None: + pass + + +@click.command(name='print') +@click.option('--stack-name') +@click.option('--stack-type') +def print_template(stack_name: str, stack_type: str): + with terraform.Action(stack_name=stack_name, stack_type=stack_type) as action: + click.secho(action.print()) + + +tf.add_command(print_template) diff --git a/cally/tools/terraform.py b/cally/tools/terraform.py new file mode 100644 index 0000000..8aaae27 --- /dev/null +++ b/cally/tools/terraform.py @@ -0,0 +1,55 @@ +import os +from pathlib import Path +from tempfile import TemporaryDirectory +from types import TracebackType +from typing import Type + +from ..cdktf import stacks + + +class Action: + _cwd: Path + _tmp_dir: TemporaryDirectory + stack_name: str + stack_type: str + + def __init__(self, stack_name: str, stack_type: str) -> None: + self.stack_name = stack_name + self.stack_type = stack_type + + def __enter__(self) -> 'Action': + self._tmp_dir = TemporaryDirectory() + self._cwd = Path().cwd() + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_value: BaseException, + traceback: TracebackType, + ) -> None: + os.chdir(self._cwd) + self._tmp_dir.cleanup() + + @property + def tmp_dir(self) -> str: + return self._tmp_dir.name + + @property + def output_path(self) -> Path: + return Path(self.tmp_dir, 'stacks', self.stack_name) + + @property + def output_file(self) -> Path: + return Path(self.output_path, 'cdk.tf.json') + + def synth_stack( + self, + ) -> None: + # TODO: fix typing here + cls = getattr(stacks, self.stack_type) + cls(self.stack_name).synth_stack(self.tmp_dir) + + def print(self) -> str: + self.synth_stack() + return self.output_file.read_text() diff --git a/pyproject.toml b/pyproject.toml index ed3c9d3..19b8bc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "setuptools.build_meta" name = "cally" dynamic = ["version"] dependencies = [ + "cdktf", "click", ] requires-python = ">=3.8" diff --git a/tests/__init__.py b/tests/__init__.py index f3520dd..47d5ccd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,11 @@ +import json import os from pathlib import Path from tempfile import TemporaryDirectory from unittest import TestCase, mock +from cally.cdktf import CallyStack + class CallyTestHarness(TestCase): working: TemporaryDirectory @@ -20,8 +23,27 @@ def setUp(self): clear=True, ) self.env_patcher.start() + os.chdir(self.working.name) def tearDown(self): self.env_patcher.stop() self.working.cleanup() os.chdir(self.current_working) + + @staticmethod + def get_test_file(filename) -> Path: + return Path(Path(__file__).parent, 'testdata', filename) + + def load_test_file(self, filename) -> str: + return self.get_test_file(filename).read_text() + + def load_json_file(self, filename) -> dict: + return json.loads(self.load_test_file(filename)) + + +class CallyTfTestHarness(CallyTestHarness): + + def synth_stack(self, stack: CallyStack) -> dict: + stack.synth_stack(self.working.name) + output_file = Path(self.working.name, 'stacks', stack.name, 'cdk.tf.json') + return json.loads(output_file.read_text()) diff --git a/tests/cdktf/__init__.py b/tests/cdktf/__init__.py new file mode 100644 index 0000000..51977b9 --- /dev/null +++ b/tests/cdktf/__init__.py @@ -0,0 +1,11 @@ +from cally.cdktf import CallyStack + +from .. import CallyTfTestHarness + + +class CallyStackTests(CallyTfTestHarness): + + def test_empty_synth(self): + stack = CallyStack('test') + result = self.synth_stack(stack) + self.assertDictEqual(result, self.load_json_file('cdktf/empty_synth.json')) diff --git a/tests/cli/test_tf.py b/tests/cli/test_tf.py new file mode 100644 index 0000000..760dfc7 --- /dev/null +++ b/tests/cli/test_tf.py @@ -0,0 +1,20 @@ +import json + +from click.testing import CliRunner + + +from cally.commands.tf import tf + +from .. import CallyTestHarness + + +class TfTests(CallyTestHarness): + + def test_empty_print(self): + result = CliRunner().invoke( + tf, ['print', '--stack-name', 'test-cli', '--stack-type', 'CallyStack'] + ) + self.assertEqual(result.exit_code, 0) + self.assertDictEqual( + json.loads(result.output), self.load_json_file('cli/empty_print.json') + ) diff --git a/tests/testdata/cdktf/empty_synth.json b/tests/testdata/cdktf/empty_synth.json new file mode 100644 index 0000000..1276bfd --- /dev/null +++ b/tests/testdata/cdktf/empty_synth.json @@ -0,0 +1,17 @@ +{ + "//": { + "metadata": { + "backend": "local", + "stackName": "test", + "version": "0.20.4" + }, + "outputs": {} + }, + "terraform": { + "backend": { + "local": { + "path": "state/test.tfstate" + } + } + } +} diff --git a/tests/testdata/cli/empty_print.json b/tests/testdata/cli/empty_print.json new file mode 100644 index 0000000..4ae6358 --- /dev/null +++ b/tests/testdata/cli/empty_print.json @@ -0,0 +1,18 @@ +{ + "//": { + "metadata": { + "backend": "local", + "stackName": "test-cli", + "version": "0.20.4" + }, + "outputs": { + } + }, + "terraform": { + "backend": { + "local": { + "path": "state/test-cli.tfstate" + } + } + } +}