1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# (c) 2017, Ryan Scott Brown <ryansb@redhat.com> 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11DOCUMENTATION = r''' 12--- 13module: terraform 14short_description: Manages a Terraform deployment (and plans) 15description: 16 - Provides support for deploying resources with Terraform and pulling 17 resource information back into Ansible. 18options: 19 state: 20 choices: ['planned', 'present', 'absent'] 21 description: 22 - Goal state of given stage/project 23 type: str 24 default: present 25 binary_path: 26 description: 27 - The path of a terraform binary to use, relative to the 'service_path' 28 unless you supply an absolute path. 29 type: path 30 project_path: 31 description: 32 - The path to the root of the Terraform directory with the 33 vars.tf/main.tf/etc to use. 34 type: path 35 required: true 36 plugin_paths: 37 description: 38 - List of paths containing Terraform plugin executable files. 39 - Plugin executables can be downloaded from U(https://releases.hashicorp.com/). 40 - When set, the plugin discovery and auto-download behavior of Terraform is disabled. 41 - The directory structure in the plugin path can be tricky. The Terraform docs 42 U(https://learn.hashicorp.com/tutorials/terraform/automate-terraform#pre-installed-plugins) 43 show a simple directory of files, but actually, the directory structure 44 has to follow the same structure you would see if Terraform auto-downloaded the plugins. 45 See the examples below for a tree output of an example plugin directory. 46 type: list 47 elements: path 48 version_added: 3.0.0 49 workspace: 50 description: 51 - The terraform workspace to work with. 52 type: str 53 default: default 54 purge_workspace: 55 description: 56 - Only works with state = absent 57 - If true, the workspace will be deleted after the "terraform destroy" action. 58 - The 'default' workspace will not be deleted. 59 default: false 60 type: bool 61 plan_file: 62 description: 63 - The path to an existing Terraform plan file to apply. If this is not 64 specified, Ansible will build a new TF plan and execute it. 65 Note that this option is required if 'state' has the 'planned' value. 66 type: path 67 state_file: 68 description: 69 - The path to an existing Terraform state file to use when building plan. 70 If this is not specified, the default `terraform.tfstate` will be used. 71 - This option is ignored when plan is specified. 72 type: path 73 variables_files: 74 description: 75 - The path to a variables file for Terraform to fill into the TF 76 configurations. This can accept a list of paths to multiple variables files. 77 - Up until Ansible 2.9, this option was usable as I(variables_file). 78 type: list 79 elements: path 80 aliases: [ 'variables_file' ] 81 variables: 82 description: 83 - A group of key-values to override template variables or those in 84 variables files. 85 type: dict 86 targets: 87 description: 88 - A list of specific resources to target in this plan/application. The 89 resources selected here will also auto-include any dependencies. 90 type: list 91 elements: str 92 lock: 93 description: 94 - Enable statefile locking, if you use a service that accepts locks (such 95 as S3+DynamoDB) to store your statefile. 96 type: bool 97 default: true 98 lock_timeout: 99 description: 100 - How long to maintain the lock on the statefile, if you use a service 101 that accepts locks (such as S3+DynamoDB). 102 type: int 103 force_init: 104 description: 105 - To avoid duplicating infra, if a state file can't be found this will 106 force a `terraform init`. Generally, this should be turned off unless 107 you intend to provision an entirely new Terraform deployment. 108 default: false 109 type: bool 110 overwrite_init: 111 description: 112 - Run init even if C(.terraform/terraform.tfstate) already exists in I(project_path). 113 default: true 114 type: bool 115 version_added: '3.2.0' 116 backend_config: 117 description: 118 - A group of key-values to provide at init stage to the -backend-config parameter. 119 type: dict 120 backend_config_files: 121 description: 122 - The path to a configuration file to provide at init state to the -backend-config parameter. 123 This can accept a list of paths to multiple configuration files. 124 type: list 125 elements: path 126 version_added: '0.2.0' 127 init_reconfigure: 128 description: 129 - Forces backend reconfiguration during init. 130 default: false 131 type: bool 132 version_added: '1.3.0' 133 check_destroy: 134 description: 135 - Apply only when no resources are destroyed. Note that this only prevents "destroy" actions, 136 but not "destroy and re-create" actions. This option is ignored when I(state=absent). 137 type: bool 138 default: false 139 version_added: '3.3.0' 140 parallelism: 141 description: 142 - Restrict concurrent operations when Terraform applies the plan. 143 type: int 144 version_added: '3.8.0' 145notes: 146 - To just run a `terraform plan`, use check mode. 147requirements: [ "terraform" ] 148author: "Ryan Scott Brown (@ryansb)" 149''' 150 151EXAMPLES = """ 152- name: Basic deploy of a service 153 community.general.terraform: 154 project_path: '{{ project_dir }}' 155 state: present 156 157- name: Define the backend configuration at init 158 community.general.terraform: 159 project_path: 'project/' 160 state: "{{ state }}" 161 force_init: true 162 backend_config: 163 region: "eu-west-1" 164 bucket: "some-bucket" 165 key: "random.tfstate" 166 167- name: Define the backend configuration with one or more files at init 168 community.general.terraform: 169 project_path: 'project/' 170 state: "{{ state }}" 171 force_init: true 172 backend_config_files: 173 - /path/to/backend_config_file_1 174 - /path/to/backend_config_file_2 175 176- name: Disable plugin discovery and auto-download by setting plugin_paths 177 community.general.terraform: 178 project_path: 'project/' 179 state: "{{ state }}" 180 force_init: true 181 plugin_paths: 182 - /path/to/plugins_dir_1 183 - /path/to/plugins_dir_2 184 185### Example directory structure for plugin_paths example 186# $ tree /path/to/plugins_dir_1 187# /path/to/plugins_dir_1/ 188# └── registry.terraform.io 189# └── hashicorp 190# └── vsphere 191# ├── 1.24.0 192# │ └── linux_amd64 193# │ └── terraform-provider-vsphere_v1.24.0_x4 194# └── 1.26.0 195# └── linux_amd64 196# └── terraform-provider-vsphere_v1.26.0_x4 197""" 198 199RETURN = """ 200outputs: 201 type: complex 202 description: A dictionary of all the TF outputs by their assigned name. Use `.outputs.MyOutputName.value` to access the value. 203 returned: on success 204 sample: '{"bukkit_arn": {"sensitive": false, "type": "string", "value": "arn:aws:s3:::tf-test-bukkit"}' 205 contains: 206 sensitive: 207 type: bool 208 returned: always 209 description: Whether Terraform has marked this value as sensitive 210 type: 211 type: str 212 returned: always 213 description: The type of the value (string, int, etc) 214 value: 215 type: str 216 returned: always 217 description: The value of the output as interpolated by Terraform 218stdout: 219 type: str 220 description: Full `terraform` command stdout, in case you want to display it or examine the event log 221 returned: always 222 sample: '' 223command: 224 type: str 225 description: Full `terraform` command built by this module, in case you want to re-run the command outside the module or debug a problem. 226 returned: always 227 sample: terraform apply ... 228""" 229 230import os 231import json 232import tempfile 233from distutils.version import LooseVersion 234from ansible.module_utils.six.moves import shlex_quote 235 236from ansible.module_utils.basic import AnsibleModule 237 238module = None 239 240 241def get_version(bin_path): 242 extract_version = module.run_command([bin_path, 'version', '-json']) 243 terraform_version = (json.loads(extract_version[1]))['terraform_version'] 244 return terraform_version 245 246 247def preflight_validation(bin_path, project_path, version, variables_args=None, plan_file=None): 248 if project_path is None or '/' not in project_path: 249 module.fail_json(msg="Path for Terraform project can not be None or ''.") 250 if not os.path.exists(bin_path): 251 module.fail_json(msg="Path for Terraform binary '{0}' doesn't exist on this host - check the path and try again please.".format(bin_path)) 252 if not os.path.isdir(project_path): 253 module.fail_json(msg="Path for Terraform project '{0}' doesn't exist on this host - check the path and try again please.".format(project_path)) 254 if LooseVersion(version) < LooseVersion('0.15.0'): 255 rc, out, err = module.run_command([bin_path, 'validate'] + variables_args, check_rc=True, cwd=project_path) 256 else: 257 rc, out, err = module.run_command([bin_path, 'validate'], check_rc=True, cwd=project_path) 258 259 260def _state_args(state_file): 261 if state_file and os.path.exists(state_file): 262 return ['-state', state_file] 263 if state_file and not os.path.exists(state_file): 264 module.fail_json(msg='Could not find state_file "{0}", check the path and try again.'.format(state_file)) 265 return [] 266 267 268def init_plugins(bin_path, project_path, backend_config, backend_config_files, init_reconfigure, plugin_paths): 269 command = [bin_path, 'init', '-input=false'] 270 if backend_config: 271 for key, val in backend_config.items(): 272 command.extend([ 273 '-backend-config', 274 shlex_quote('{0}={1}'.format(key, val)) 275 ]) 276 if backend_config_files: 277 for f in backend_config_files: 278 command.extend(['-backend-config', f]) 279 if init_reconfigure: 280 command.extend(['-reconfigure']) 281 if plugin_paths: 282 for plugin_path in plugin_paths: 283 command.extend(['-plugin-dir', plugin_path]) 284 rc, out, err = module.run_command(command, check_rc=True, cwd=project_path) 285 286 287def get_workspace_context(bin_path, project_path): 288 workspace_ctx = {"current": "default", "all": []} 289 command = [bin_path, 'workspace', 'list', '-no-color'] 290 rc, out, err = module.run_command(command, cwd=project_path) 291 if rc != 0: 292 module.warn("Failed to list Terraform workspaces:\r\n{0}".format(err)) 293 for item in out.split('\n'): 294 stripped_item = item.strip() 295 if not stripped_item: 296 continue 297 elif stripped_item.startswith('* '): 298 workspace_ctx["current"] = stripped_item.replace('* ', '') 299 else: 300 workspace_ctx["all"].append(stripped_item) 301 return workspace_ctx 302 303 304def _workspace_cmd(bin_path, project_path, action, workspace): 305 command = [bin_path, 'workspace', action, workspace, '-no-color'] 306 rc, out, err = module.run_command(command, check_rc=True, cwd=project_path) 307 return rc, out, err 308 309 310def create_workspace(bin_path, project_path, workspace): 311 _workspace_cmd(bin_path, project_path, 'new', workspace) 312 313 314def select_workspace(bin_path, project_path, workspace): 315 _workspace_cmd(bin_path, project_path, 'select', workspace) 316 317 318def remove_workspace(bin_path, project_path, workspace): 319 _workspace_cmd(bin_path, project_path, 'delete', workspace) 320 321 322def build_plan(command, project_path, variables_args, state_file, targets, state, plan_path=None): 323 if plan_path is None: 324 f, plan_path = tempfile.mkstemp(suffix='.tfplan') 325 326 plan_command = [command[0], 'plan', '-input=false', '-no-color', '-detailed-exitcode', '-out', plan_path] 327 328 for t in targets: 329 plan_command.extend(['-target', t]) 330 331 plan_command.extend(_state_args(state_file)) 332 333 rc, out, err = module.run_command(plan_command + variables_args, cwd=project_path) 334 335 if rc == 0: 336 # no changes 337 return plan_path, False, out, err, plan_command if state == 'planned' else command 338 elif rc == 1: 339 # failure to plan 340 module.fail_json(msg='Terraform plan could not be created\r\nSTDOUT: {0}\r\n\r\nSTDERR: {1}'.format(out, err)) 341 elif rc == 2: 342 # changes, but successful 343 return plan_path, True, out, err, plan_command if state == 'planned' else command 344 345 module.fail_json(msg='Terraform plan failed with unexpected exit code {0}. \r\nSTDOUT: {1}\r\n\r\nSTDERR: {2}'.format(rc, out, err)) 346 347 348def main(): 349 global module 350 module = AnsibleModule( 351 argument_spec=dict( 352 project_path=dict(required=True, type='path'), 353 binary_path=dict(type='path'), 354 plugin_paths=dict(type='list', elements='path'), 355 workspace=dict(type='str', default='default'), 356 purge_workspace=dict(type='bool', default=False), 357 state=dict(default='present', choices=['present', 'absent', 'planned']), 358 variables=dict(type='dict'), 359 variables_files=dict(aliases=['variables_file'], type='list', elements='path'), 360 plan_file=dict(type='path'), 361 state_file=dict(type='path'), 362 targets=dict(type='list', elements='str', default=[]), 363 lock=dict(type='bool', default=True), 364 lock_timeout=dict(type='int',), 365 force_init=dict(type='bool', default=False), 366 backend_config=dict(type='dict'), 367 backend_config_files=dict(type='list', elements='path'), 368 init_reconfigure=dict(type='bool', default=False), 369 overwrite_init=dict(type='bool', default=True), 370 check_destroy=dict(type='bool', default=False), 371 parallelism=dict(type='int'), 372 ), 373 required_if=[('state', 'planned', ['plan_file'])], 374 supports_check_mode=True, 375 ) 376 377 project_path = module.params.get('project_path') 378 bin_path = module.params.get('binary_path') 379 plugin_paths = module.params.get('plugin_paths') 380 workspace = module.params.get('workspace') 381 purge_workspace = module.params.get('purge_workspace') 382 state = module.params.get('state') 383 variables = module.params.get('variables') or {} 384 variables_files = module.params.get('variables_files') 385 plan_file = module.params.get('plan_file') 386 state_file = module.params.get('state_file') 387 force_init = module.params.get('force_init') 388 backend_config = module.params.get('backend_config') 389 backend_config_files = module.params.get('backend_config_files') 390 init_reconfigure = module.params.get('init_reconfigure') 391 overwrite_init = module.params.get('overwrite_init') 392 check_destroy = module.params.get('check_destroy') 393 394 if bin_path is not None: 395 command = [bin_path] 396 else: 397 command = [module.get_bin_path('terraform', required=True)] 398 399 checked_version = get_version(command[0]) 400 401 if LooseVersion(checked_version) < LooseVersion('0.15.0'): 402 DESTROY_ARGS = ('destroy', '-no-color', '-force') 403 APPLY_ARGS = ('apply', '-no-color', '-input=false', '-auto-approve=true') 404 else: 405 DESTROY_ARGS = ('destroy', '-no-color', '-auto-approve') 406 APPLY_ARGS = ('apply', '-no-color', '-input=false', '-auto-approve') 407 408 if force_init: 409 if overwrite_init or not os.path.isfile(os.path.join(project_path, ".terraform", "terraform.tfstate")): 410 init_plugins(command[0], project_path, backend_config, backend_config_files, init_reconfigure, plugin_paths) 411 412 workspace_ctx = get_workspace_context(command[0], project_path) 413 if workspace_ctx["current"] != workspace: 414 if workspace not in workspace_ctx["all"]: 415 create_workspace(command[0], project_path, workspace) 416 else: 417 select_workspace(command[0], project_path, workspace) 418 419 if state == 'present': 420 command.extend(APPLY_ARGS) 421 elif state == 'absent': 422 command.extend(DESTROY_ARGS) 423 424 if state == 'present' and module.params.get('parallelism') is not None: 425 command.append('-parallelism=%d' % module.params.get('parallelism')) 426 427 variables_args = [] 428 for k, v in variables.items(): 429 variables_args.extend([ 430 '-var', 431 '{0}={1}'.format(k, v) 432 ]) 433 if variables_files: 434 for f in variables_files: 435 variables_args.extend(['-var-file', f]) 436 437 preflight_validation(command[0], project_path, checked_version, variables_args) 438 439 if module.params.get('lock') is not None: 440 if module.params.get('lock'): 441 command.append('-lock=true') 442 else: 443 command.append('-lock=false') 444 if module.params.get('lock_timeout') is not None: 445 command.append('-lock-timeout=%ds' % module.params.get('lock_timeout')) 446 447 for t in (module.params.get('targets') or []): 448 command.extend(['-target', t]) 449 450 # we aren't sure if this plan will result in changes, so assume yes 451 needs_application, changed = True, False 452 453 out, err = '', '' 454 455 if state == 'absent': 456 command.extend(variables_args) 457 elif state == 'present' and plan_file: 458 if any([os.path.isfile(project_path + "/" + plan_file), os.path.isfile(plan_file)]): 459 command.append(plan_file) 460 else: 461 module.fail_json(msg='Could not find plan_file "{0}", check the path and try again.'.format(plan_file)) 462 else: 463 plan_file, needs_application, out, err, command = build_plan(command, project_path, variables_args, state_file, 464 module.params.get('targets'), state, plan_file) 465 if state == 'present' and check_destroy and '- destroy' in out: 466 module.fail_json(msg="Aborting command because it would destroy some resources. " 467 "Consider switching the 'check_destroy' to false to suppress this error") 468 command.append(plan_file) 469 470 if needs_application and not module.check_mode and state != 'planned': 471 rc, out, err = module.run_command(command, check_rc=False, cwd=project_path) 472 if rc != 0: 473 if workspace_ctx["current"] != workspace: 474 select_workspace(command[0], project_path, workspace_ctx["current"]) 475 module.fail_json(msg=err.rstrip(), rc=rc, stdout=out, 476 stdout_lines=out.splitlines(), stderr=err, 477 stderr_lines=err.splitlines(), 478 cmd=' '.join(command)) 479 # checks out to decide if changes were made during execution 480 if ' 0 added, 0 changed' not in out and not state == "absent" or ' 0 destroyed' not in out: 481 changed = True 482 483 outputs_command = [command[0], 'output', '-no-color', '-json'] + _state_args(state_file) 484 rc, outputs_text, outputs_err = module.run_command(outputs_command, cwd=project_path) 485 if rc == 1: 486 module.warn("Could not get Terraform outputs. This usually means none have been defined.\nstdout: {0}\nstderr: {1}".format(outputs_text, outputs_err)) 487 outputs = {} 488 elif rc != 0: 489 module.fail_json( 490 msg="Failure when getting Terraform outputs. " 491 "Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, outputs_text, outputs_err), 492 command=' '.join(outputs_command)) 493 else: 494 outputs = json.loads(outputs_text) 495 496 # Restore the Terraform workspace found when running the module 497 if workspace_ctx["current"] != workspace: 498 select_workspace(command[0], project_path, workspace_ctx["current"]) 499 if state == 'absent' and workspace != 'default' and purge_workspace is True: 500 remove_workspace(command[0], project_path, workspace) 501 502 module.exit_json(changed=changed, state=state, workspace=workspace, outputs=outputs, stdout=out, stderr=err, command=' '.join(command)) 503 504 505if __name__ == '__main__': 506 main() 507