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 `). 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)