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