Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New module plan_stash #113

Merged
merged 8 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ output/
.collection_root/*

tests/integration/inventory
tests/integration/cloud-config-*.ini
terraform

**/.terraform/*
*.tfstate
Expand Down
67 changes: 67 additions & 0 deletions plugins/action/plan_stash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-

# Copyright: Contributors to the Ansible project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)


from ansible.plugins.action import ActionBase
from ansible.utils.vars import isidentifier
from ansible_collections.cloud.terraform.plugins.module_utils.plan_stash_args import PLAN_STASH_ARG_SPEC


class ActionModule(ActionBase): # type: ignore # mypy ignore
def run(self, tmp=None, task_vars=None): # type: ignore # mypy ignore
if task_vars is None:
task_vars = dict()

result = super(ActionModule, self).run(tmp, task_vars)
del tmp # tmp no longer has any effect

validation_result, new_module_args = self.validate_argument_spec(PLAN_STASH_ARG_SPEC)

# Validate that 'var_name' is a valid variable name
var_name = new_module_args.get("var_name")
binary_data = new_module_args.get("binary_data")
if var_name:
if not isidentifier(var_name):
result["failed"] = True
result["msg"] = (
"The variable name '%s' is not valid. Variables must start with a letter or underscore character, and contain only "
"letters, numbers and underscores." % var_name
)
return result

state = new_module_args.get("state")
if state == "load":
if var_name is not None and binary_data is not None:
result["failed"] = True
result["msg"] = "You cannot specify both 'var_name' and 'binary_data' to load the terraform plan file."
return result

if binary_data is None:
var_name = new_module_args.get("var_name") or "terraform_plan"
try:
value = task_vars[var_name]
except KeyError:
try:
value = task_vars["hostvars"][task_vars["inventory_hostname"]][var_name]
except KeyError:
result["failed"] = True
result["msg"] = "No variable found with this name: %s" % var_name
return result

new_module_args.pop("var_name")
new_module_args["binary_data"] = value
elif state == "stash":
var_name = new_module_args.get("var_name") or "terraform_plan"
new_module_args.update({"var_name": var_name})

# Execute the plan_stash module.
module_return = self._execute_module(
module_name=self._task.action,
module_args=new_module_args,
task_vars=task_vars,
)

result.update(module_return)
return result
7 changes: 7 additions & 0 deletions plugins/module_utils/plan_stash_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
PLAN_STASH_ARG_SPEC = {
"path": {"required": True, "type": "path"},
"var_name": {},
"per_host": {"type": "bool", "default": False},
"state": {"choices": ["stash", "load"], "default": "stash"},
"binary_data": {"type": "raw"},
}
160 changes: 160 additions & 0 deletions plugins/modules/plan_stash.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this, I'm realizing we may want to make this module work both for stashing and for loading the plan file. The only way to safely write the b64 encoded data to a zip file would be for the user to have a shell task that calls out to base64, which is kind of ugly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gravesm it makes sense to support the load of the plan file, but currently, a shell task is not the only way to do that, we can do it using ansible.builtin.copy module

- copy:
    dest: /path/to/plan/file
    content: "{{ terraform_plan | b64decode }}"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear to me that this is always going to work, though. The documentation for the b64decode filter pretty clearly says this is likely to corrupt the binary data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I am going to update the module to work like this

Stash the terraform plan file

- cloud.terraform.plan_stash:
     mode: stash
     path: terraform.tfplan
     var_name: terraform_plan

Load the terraform plan file

- cloud.terraform.plan_stash
    mode: load
    path: terraform.tfplan
    var_name: terraform_plan

WDYT ? @gravesm @hakbailey

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is along the lines of what I'm thinking. My only suggestion would be to use state instead of mode.

Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2024, Aubin Bikouo <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

DOCUMENTATION = r"""
---
module: plan_stash
version_added: 2.1.0
short_description: Handle the base64 encoding or decoding of a terraform plan file
description:
abikouo marked this conversation as resolved.
Show resolved Hide resolved
- This module performs base64-encoding of a terraform plan file and saves it into playbook execution stats similar
to M(ansible.builtin.set_stats) module.
- The module also performs base64-decoding of a terraform plan file from a variable defined into ansible facts and writes them
into a file specified by the user.
author:
- "Aubin Bikouo (@abikouo)"
options:
state:
description:
- "O(state=stash): base64-encodes the terraform plan file and saves it into ansible stats like using the M(ansible.builtin.set_stats) module."
- "O(state=load): base64-decodes data from variable specified in O(var_name) and writes them into terraform plan file."
choices: [stash, load]
default: stash
type: str
path:
description:
- The path to the terraform plan file.
type: path
required: true
var_name:
description:
- When O(state=stash), this parameter defines the variable name to be set into stats.
- When O(state=load), this parameter defines the variable from ansible facts containing
the base64-encoded data of the terraform plan file.
- Variables must start with a letter or underscore character, and contain only letters,
numbers and underscores.
- The module will use V(terraform_plan) as default variable name if not specified.
type: str
binary_data:
description:
- When O(state=load), this parameter defines the base64-encoded data of the terraform plan file.
- Mutually exclusive with V(var_name).
- Ignored when O(state=stash).
type: raw
per_host:
description:
- Whether the stats are per host or for all hosts in the run.
- Ignored when O(state=load).
type: bool
default: false
notes:
- For security reasons, this module should be used with I(no_log=true) and I(register) functionalities
as the plan file can contain unencrypted secrets.
"""

EXAMPLES = r"""
abikouo marked this conversation as resolved.
Show resolved Hide resolved
# Encode terraform plan file into default variable 'terraform_plan'
- name: Encode a terraform plan file into terraform_plan variable
cloud.terraform.plan_stash:
path: /path/to/terraform_plan_file
state: stash
no_log: true

# Encode terraform plan file into variable 'stashed_plan'
- name: Encode a terraform plan file into terraform_plan variable
cloud.terraform.plan_stash:
path: /path/to/terraform_plan_file
var_name: stashed_plan
state: stash
no_log: true

# Load terraform plan file from variable 'stashed_plan'
- name: Load a terraform plan file data from variable 'stashed_plan' into file 'tfplan'
cloud.terraform.plan_stash:
path: tfplan
var_name: stashed_plan
state: load
no_log: true

# Load terraform plan file from binary data
- name: Load a terraform plan file data from binary data
cloud.terraform.plan_stash:
path: tfplan
binary_data: "{{ terraform_binary_data }}"
state: load
no_log: true
"""

RETURN = r"""
"""

import base64

from ansible.module_utils.basic import AnsibleModule
from ansible_collections.cloud.terraform.plugins.module_utils.plan_stash_args import PLAN_STASH_ARG_SPEC


def read_file_content(file_path: str, module: AnsibleModule, failed_on_error: bool = True) -> bytes:
data = b""
try:
with open(file_path, "rb") as f:
data = f.read()
except FileNotFoundError:
if failed_on_error:
module.fail_json(msg="The following file '{0}' does not exist.".format(file_path))
return data


def main() -> None:
module = AnsibleModule(
argument_spec=PLAN_STASH_ARG_SPEC,
supports_check_mode=True,
)

terrafom_plan_file = module.params.get("path")
var_name = module.params.get("var_name")
per_host = module.params.get("per_host")
state = module.params.get("state")

result = {}
if state == "stash":
# Stash: base64-encode the terraform plan file and set stats
data = read_file_content(terrafom_plan_file, module)
# encode binary data
try:
encoded_data = base64.b64encode(data)
except Exception as e:
module.fail_json(msg="Cannot encode data from file {0} due to: {1}".format(terrafom_plan_file, e))

stats = {"data": {var_name: encoded_data}, "per_host": per_host}
result = {"ansible_stats": stats, "changed": False}
else:
# Load: Decodes the data from the variable name and write into terraform plan file
binary_data = module.params.get("binary_data")
try:
data = base64.b64decode(binary_data)
except Exception as e:
module.fail_json(msg="Failed to decode binary data due to: {0}".format(e))

current_content = read_file_content(terrafom_plan_file, module, failed_on_error=False)
changed = False
if current_content != data:
changed = True
if not module.check_mode:
try:
with open(terrafom_plan_file, "wb") as f:
f.write(data)
result.update({"msg": "data successfully decoded into file %s" % terrafom_plan_file})
except Exception as e:
module.fail_json(msg="Failed to write data into file due to: {0}".format(e))

result.update({"changed": changed})

module.exit_json(**result)


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion plugins/modules/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
plan_file:
description:
- The path to a Terraform plan file to apply or generate.
- When 'check_mode' is set to I(True) or I(state=planned), a Terraform plan file with be generated and
- When 'check_mode' is set to C(True) or I(state=planned), a Terraform plan file will be generated and
saved into the specified location.
- When 'check_mode' is set to I(False) and I(state) is set to either C(present) or C(absent),
The existing Terraform plan file will be applied.
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/targets/plan_stash/files/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "3.6.0"
}
}
}

resource "random_string" "random" {
length = 16
special = true
}
5 changes: 5 additions & 0 deletions tests/integration/targets/plan_stash/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- name: Validate plan_stash module
ansible.builtin.include_tasks: 'tasks/{{ item }}.yml'
with_items:
- validate_args
- run
Loading
Loading