majuna/app/terraform/terraform.py

152 lines
5.7 KiB
Python

import json
import subprocess # nosec
from abc import abstractmethod
from typing import Any, Optional, Tuple
from app.terraform import BaseAutomation
class TerraformAutomation(BaseAutomation):
"""
An abstract class to be extended by automation plugins using Terraform
providers to deploy resources.
"""
always_refresh: bool = False
"""
Force refresh even when not a full run.
"""
parallelism: int = 10
"""
Default parallelism for remote API calls.
"""
provider: str
"""
Short name for the provider used by this module.
"""
def automate(self, working_dir: str, full: bool = False) -> Tuple[bool, str]:
"""
Runs the Terraform automation module. The run will follow these steps:
1. The :func:`tf_prehook` hook is run.
2. Generate a Terraform configuration and write it to a single ``main.tf`` file in the working directory
(see :func:`working_directory <app.terraform.BaseAutomation.working_directory>`).
3. Run ``terraform init``.
4. Run ``terraform apply``. This will only include a refresh if *full* is **True**. The apply will wait up
to *lock_timeout* minutes for a lock to be released before failing. Up to *parallelism* requests will be
sent to remote APIs concurrently.
5. The :func:`tf_posthook` hook is run.
6. The logs from the apply step are returned as a string.
:param working_dir: temporary directory used to run the automation
:param full: include a Terraform refresh in the automation module run
:return: success status and Terraform apply logs
"""
prehook_result = self.tf_prehook() # pylint: disable=assignment-from-no-return
self.tf_generate(working_dir)
self.tf_init(working_dir)
returncode, logs = self.tf_apply(working_dir, refresh=self.always_refresh or full)
self.tf_posthook(prehook_result=prehook_result)
return returncode == 0, logs
def tf_apply(self, working_dir: str, *,
refresh: bool = True,
parallelism: Optional[int] = None,
lock_timeout: int = 15) -> Tuple[int, str]:
if not parallelism:
parallelism = self.parallelism
# The following subprocess call takes external input, but is providing
# the argument list as an array such that argument injection would be
# ineffective.
tfcmd = subprocess.run( # nosec
['terraform',
'apply',
'-auto-approve',
'-json',
f'-refresh={str(refresh).lower()}',
f'-parallelism={str(parallelism)}',
f'-lock-timeout={str(lock_timeout)}m',
],
cwd=working_dir,
stdout=subprocess.PIPE)
return tfcmd.returncode, tfcmd.stdout.decode('utf-8')
@abstractmethod
def tf_generate(self, working_dir) -> None:
raise NotImplementedError()
def tf_init(self, working_dir: str, *,
lock_timeout: int = 15) -> None:
# The init command does not support JSON output.
# The following subprocess call takes external input, but is providing
# the argument list as an array such that argument injection would be
# ineffective.
subprocess.run( # nosec
['terraform',
'init',
f'-lock-timeout={str(lock_timeout)}m',
],
cwd=working_dir)
def tf_output(self, working_dir) -> Any:
# The following subprocess call does not take any user input.
tfcmd = subprocess.run( # nosec
['terraform', 'output', '-json'],
cwd=working_dir,
stdout=subprocess.PIPE)
return json.loads(tfcmd.stdout)
def tf_plan(self, working_dir: str, *,
refresh: bool = True,
parallelism: Optional[int] = None,
lock_timeout: int = 15) -> Tuple[int, str]:
# The following subprocess call takes external input, but is providing
# the argument list as an array such that argument injection would be
# ineffective.
tfcmd = subprocess.run( # nosec
['terraform',
'plan',
'-json',
f'-refresh={str(refresh).lower()}',
f'-parallelism={str(parallelism)}',
f'-lock-timeout={str(lock_timeout)}m',
],
cwd=working_dir)
return tfcmd.returncode, tfcmd.stdout.decode('utf-8')
def tf_posthook(self, *, prehook_result: Any = None) -> None:
"""
This hook function is called as part of normal automation, after the
completion of :func:`tf_apply`.
The default, if not overridden by a subclass, is to do nothing.
:param prehook_result: the returned value of :func:`tf_prehook`
:return: None
"""
def tf_prehook(self) -> Optional[Any]:
"""
This hook function is called as part of normal automation, before generating
the terraform configuration file. The return value will be passed to
:func:`tf_posthook` but is otherwise ignored.
The default, if not overridden by a subclass, is to do nothing.
:return: state that is useful to :func:`tf_posthook`, if required
"""
def tf_show(self, working_dir) -> Any:
# This subprocess call doesn't take any user input.
terraform = subprocess.run( # nosec
['terraform', 'show', '-json'],
cwd=working_dir,
stdout=subprocess.PIPE)
return json.loads(terraform.stdout)
def tf_write(self, template: str, working_dir: str, **kwargs: Any) -> None:
self.tmpl_write("main.tf", template, working_dir, **kwargs)