Skip to content

Commit

Permalink
feat: Initial Terraform Action + Cli
Browse files Browse the repository at this point in the history
This adds a Terraform Action processor, along with an initial cli
printing the generated Stack output.
  • Loading branch information
techman83 committed Mar 10, 2024
1 parent ec0b8e8 commit fa1e837
Show file tree
Hide file tree
Showing 11 changed files with 288 additions and 0 deletions.
107 changes: 107 additions & 0 deletions cally/cdktf/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
Empty file.
18 changes: 18 additions & 0 deletions cally/cdktf/stacks/__init__.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions cally/commands/tf.py
Original file line number Diff line number Diff line change
@@ -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)
55 changes: 55 additions & 0 deletions cally/tools/terraform.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ build-backend = "setuptools.build_meta"
name = "cally"
dynamic = ["version"]
dependencies = [
"cdktf",
"click",
]
requires-python = ">=3.8"
Expand Down
22 changes: 22 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())
11 changes: 11 additions & 0 deletions tests/cdktf/__init__.py
Original file line number Diff line number Diff line change
@@ -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'))
20 changes: 20 additions & 0 deletions tests/cli/test_tf.py
Original file line number Diff line number Diff line change
@@ -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')
)
17 changes: 17 additions & 0 deletions tests/testdata/cdktf/empty_synth.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"//": {
"metadata": {
"backend": "local",
"stackName": "test",
"version": "0.20.4"
},
"outputs": {}
},
"terraform": {
"backend": {
"local": {
"path": "state/test.tfstate"
}
}
}
}
18 changes: 18 additions & 0 deletions tests/testdata/cli/empty_print.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"//": {
"metadata": {
"backend": "local",
"stackName": "test-cli",
"version": "0.20.4"
},
"outputs": {
}
},
"terraform": {
"backend": {
"local": {
"path": "state/test-cli.tfstate"
}
}
}
}

0 comments on commit fa1e837

Please sign in to comment.