diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 23f064a5..fcce7e19 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -154,8 +154,8 @@ releases: - inventory/terraform_provider - Remove custom `read_config_data()` method (https://github.com/ansible-collections/cloud.terraform/pull/181). - inventory/terraform_state - Remove custom `read_config_data()` method (https://github.com/ansible-collections/cloud.terraform/pull/181). - inventory/terraform_state - Support for custom Terraform providers (https://github.com/ansible-collections/cloud.terraform/pull/146). - release_summary: This release includes bug fixes and new feature for the ``terraform_state`` - inventory plugin. + release_summary: This release includes bug fixes and new feature for the ``terraform_state`` inventory plugin and ``terraform`` module plugin. + fragments: - 161-bump-ansible-lint-version.yml - 20240527-roles-add-description.yml diff --git a/changelogs/fragments/20250620-modules-terraform-update-workspace-logic.yml b/changelogs/fragments/20250620-modules-terraform-update-workspace-logic.yml new file mode 100644 index 00000000..752bb803 --- /dev/null +++ b/changelogs/fragments/20250620-modules-terraform-update-workspace-logic.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - modules/terraform - Updated Workspace Logic for TFC/TFE and CLI (https://github.com/ansible-collections/cloud.terraform/pull/194) diff --git a/plugins/modules/terraform.py b/plugins/modules/terraform.py index c528a19a..2b281eee 100644 --- a/plugins/modules/terraform.py +++ b/plugins/modules/terraform.py @@ -49,7 +49,11 @@ version_added: 1.0.0 workspace: description: - - The terraform workspace to work with. + - If specified, this workspace will be used for all operations, provided it matches the workspace defined in the Terraform Cloud configuration. + - If the specified workspace does not match the one in the Terraform Cloud configuration, an error will be raised. + - If not specified, the module will attempt to determine the workspace from the Terraform Cloud configuration. + - If a workspace is set in the playbook but not defined in the Terraform Cloud configuration, an error will be raised. + - If no workspace is specified in both the playbook and the Terraform Cloud configuration, the module will default to using the Terraform CLI mode. type: str default: default version_added: 1.0.0 @@ -240,6 +244,18 @@ unit_number: 3 force_init: true +- name: Using workspace from playbook for Terraform Cloud/Enterprise + cloud.terraform.terraform: + project_path: '{{ project_dir }}' + state: present + workspace: 'my_workspace' # workspace must match and exist in Terraform cloud configuration. + +- name: Auto-detect workspace from Terraform cloud configuration for Terraform Cloud/Enterprise + cloud.terraform.terraform: + project_path: '{{ project_dir }}' + state: present + # workspace parameter omitted - will be auto-detected from .tf files + ### Example directory structure for plugin_paths example # $ tree /path/to/plugins_dir_1 # /path/to/plugins_dir_1/ @@ -288,8 +304,9 @@ import dataclasses import os +import re import tempfile -from typing import List +from typing import List, Optional, Tuple from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six import integer_types @@ -312,6 +329,134 @@ ) +def clean_tf_file(tf_content: str) -> str: + """ + Removes all comments (inline '//' and '#', and block '/* ... */') and empty lines from Terraform file content. + + Args: + tf_content (str): The raw content of a Terraform file. + + Returns: + str: The cleaned Terraform file content with comments and empty lines removed. + """ + + def remove_multiline_comments(s: str) -> str: + """ + Removes all multiline comments (/* ... */) from the given string. + + Args: + s (str): The input string potentially containing multiline comments. + + Returns: + str: The input string with all multiline comments removed. + """ + pattern = re.compile(r"/\*.*?\*/", re.DOTALL) + return re.sub(pattern, "", s) + + def remove_inline_comments(line: str) -> str: + """ + Removes inline comments from a given line of text, preserving quoted strings. + + This function scans the input line and removes any content following a '#' or '//' comment marker, + unless the marker appears within a quoted string (single or double quotes). Quoted strings are + preserved as-is, including any comment markers inside them. + + Args: + line (str): The input line from which to remove inline comments. + + Returns: + str: The line with inline comments removed, preserving quoted strings. + """ + quote_open: Optional[str] = None # None when no quote is open + result = "" + i = 0 + length = len(line) + while i < length: + char = line[i] + if char in ('"', "'"): + if quote_open is None: + quote_open = char # opening quote + elif quote_open == char: + quote_open = None # closing quote + result += char + elif quote_open is None: + if i + 1 < length and line[i] == "/" and line[i + 1] == "/": + break # Start of '//' comment + elif line[i] == "#": + break # Start of '#' comment + else: + result += char + else: + result += char + i += 1 + return result.rstrip() + + no_multiline = remove_multiline_comments(tf_content) + + cleaned_lines = [] + for line in no_multiline.splitlines(): + stripped = remove_inline_comments(line) + if stripped.strip(): + cleaned_lines.append(stripped) + + return "\n".join(cleaned_lines) + + +def extract_workspace_from_terraform_config(project_path: str) -> Tuple[Optional[str], str]: + """ + Extract workspace configuration from Terraform files. + + Returns: + Tuple of (workspace_name, terraform_offering) + - workspace_name: The workspace name found in cloud configuration, or None + - terraform_offering: "cloud" if cloud block found, "cli" otherwise + """ + cloud_block_pattern = re.compile(r"cloud\s*{([^}]+)}", re.DOTALL | re.IGNORECASE) + workspaces_block_pattern = re.compile(r"workspaces\s*{([^}]+)}", re.DOTALL | re.IGNORECASE) + name_attr_pattern = re.compile(r'name\s*=\s*"([^"]+)"', re.IGNORECASE) + + exclude_files = {"vars.tf", "var.tf", "provider.tf", "variables.tf", "outputs.tf"} + + try: + if not os.path.exists(project_path): + return None, "cli" + + for filename in os.listdir(project_path): + if filename.endswith(".tf") and filename not in exclude_files: + filepath = os.path.join(project_path, filename) + try: + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + content = clean_tf_file(content) + # Find the cloud {} block + cloud_match = re.search(cloud_block_pattern, content) + if cloud_match: + cloud_content = cloud_match.group() + + # Find the workspaces {} block within the cloud block + workspaces_match = re.search(workspaces_block_pattern, cloud_content) + if workspaces_match: + workspaces_content = workspaces_match.group() + + # Find the name attribute within the workspaces block + name_match = re.search(name_attr_pattern, workspaces_content) + if name_match: + workspace_name = name_match.group(1) + return workspace_name, "cloud" + + # If cloud block exists but no workspace name found, it's still cloud + return None, "cloud" + + except (IOError, UnicodeDecodeError): + # Skip files that can't be read, continue with others + continue + + except OSError: + pass + + return None, "cli" + + def is_attribute_sensitive_in_providers_schema( schemas: TerraformProviderSchemaCollection, resource: TerraformModuleResource, attribute: str ) -> bool: @@ -493,6 +638,43 @@ def main() -> None: else: terraform_binary = module.get_bin_path("terraform", required=True) + cloud_workspace, terraform_offering = extract_workspace_from_terraform_config(project_path) + + if terraform_offering == "cloud": + if cloud_workspace is None and workspace == "default": + module.fail_json( + msg=( + "Terraform cloud configuration found, but no workspace defined. " + "Please ensure the workspace is defined in your Terraform cloud configuration." + ) + ) + if cloud_workspace: + if workspace != "default" and workspace != cloud_workspace: + module.fail_json( + msg=( + f"Workspace configuration conflict: The playbook specifies workspace " + f"'{workspace}', but the Terraform cloud configuration " + f"specifies '{cloud_workspace}'. Please ensure they match or remove " + f"the workspace parameter from the playbook to use the cloud configuration." + ) + ) + final_workspace = cloud_workspace + module.log(f"Using workspace '{final_workspace}' from Terraform cloud configuration") + elif workspace != "default": + final_workspace = workspace + module.log(f"Using explicitly provided workspace '{final_workspace}' for Terraform cloud") + else: + final_workspace = "default" + module.log("Using default workspace for Terraform cloud configuration") + else: + final_workspace = workspace + if workspace == "default": + module.log("Using default workspace for Terraform CLI") + else: + module.log(f"Using explicitly provided workspace '{final_workspace}' for Terraform CLI") + + workspace = final_workspace + terraform = TerraformCommands(module.run_command, project_path, terraform_binary, computed_check_mode) checked_version = terraform.version() @@ -525,11 +707,14 @@ def main() -> None: module.warn(e.message) workspace_ctx = TerraformWorkspaceContext(current="default", all=[]) - if workspace_ctx.current != workspace: - if workspace not in workspace_ctx.all: - terraform.workspace(WorkspaceCommand.NEW, workspace) - else: - terraform.workspace(WorkspaceCommand.SELECT, workspace) + if terraform_offering == "cloud": + module.log("Terraform Cloud detected - workspace management handled by cloud configuration") + else: + if workspace_ctx.current != workspace: + if workspace not in workspace_ctx.all: + terraform.workspace(WorkspaceCommand.NEW, workspace) + else: + terraform.workspace(WorkspaceCommand.SELECT, workspace) variables_args = [] if complex_vars: @@ -607,7 +792,7 @@ def main() -> None: needs_application=plan_file_needs_application, ) except TerraformError as e: - if not computed_check_mode: + if not computed_check_mode and terraform_offering != "cloud": if workspace_ctx.current != workspace: terraform.workspace(WorkspaceCommand.SELECT, workspace_ctx.current) raise e @@ -631,11 +816,11 @@ def main() -> None: workspace=workspace, ) - # Restore the Terraform workspace found when running the module - if workspace_ctx.current != workspace: - terraform.workspace(WorkspaceCommand.SELECT, workspace_ctx.current) - if computed_state == "absent" and workspace != "default" and purge_workspace is True: - terraform.workspace(WorkspaceCommand.DELETE, workspace) + if terraform_offering != "cloud": + if workspace_ctx.current != workspace: + terraform.workspace(WorkspaceCommand.SELECT, workspace_ctx.current) + if computed_state == "absent" and workspace != "default" and purge_workspace is True: + terraform.workspace(WorkspaceCommand.DELETE, workspace) diff = dict( before=dataclasses.asdict(initial_state) if initial_state is not None else {}, diff --git a/tests/integration/targets/inventory_terraform_state_aws/templates/main.child.tf.j2 b/tests/integration/targets/inventory_terraform_state_aws/templates/main.child.tf.j2 index 3c7b03bf..8d1c6890 100644 --- a/tests/integration/targets/inventory_terraform_state_aws/templates/main.child.tf.j2 +++ b/tests/integration/targets/inventory_terraform_state_aws/templates/main.child.tf.j2 @@ -24,7 +24,7 @@ variable "child_group_id" { resource "aws_instance" "test_tiny" { ami = var.child_ami_id - instance_type = "t3.micro" + instance_type = "t2.micro" subnet_id = var.child_subnet_id vpc_security_group_ids = [var.child_group_id] diff --git a/tests/integration/targets/test_tfc/aliases b/tests/integration/targets/test_tfc/aliases new file mode 100644 index 00000000..64cd16eb --- /dev/null +++ b/tests/integration/targets/test_tfc/aliases @@ -0,0 +1,2 @@ +disabled # Require a HCP configuration account +cloud/aws diff --git a/tests/integration/targets/test_tfc/files/main.tf.j2 b/tests/integration/targets/test_tfc/files/main.tf.j2 new file mode 100644 index 00000000..25cef642 --- /dev/null +++ b/tests/integration/targets/test_tfc/files/main.tf.j2 @@ -0,0 +1,40 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.16" + } + } + required_version = ">= 1.10.0" + cloud { + organization = "{{ tfc_organization }}" + hostname = "app.terraform.io" + token = "{{ hcp_token }}" + workspaces { + name = "{{ tfc_workspace }}" + } + } +} + +provider "aws" { + region = "{{ aws_region }}" + {% if aws_access_key and aws_secret_key %} + access_key = "{{ aws_access_key }}" + secret_key = "{{ aws_secret_key }}" + {% else %} + # Uses environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY + {% endif %} +} + +resource "aws_instance" "app_server_tf" { + ami = "{{ ami_id }}" + instance_type = "t2.micro" + + tags = { + Name = "Instance_Cloud_TF" + } +} + +output "my_output" { + value = aws_instance.app_server_tf +} \ No newline at end of file diff --git a/tests/integration/targets/test_tfc/tasks/main.yml b/tests/integration/targets/test_tfc/tasks/main.yml new file mode 100644 index 00000000..5d593568 --- /dev/null +++ b/tests/integration/targets/test_tfc/tasks/main.yml @@ -0,0 +1,137 @@ +- name: Render and test Terraform with AWS + hosts: localhost + gather_facts: false + vars_files: + - ../vars/main.yml + + tasks: + - block: + - name: Set test path + set_fact: + test_basedir: "{{ test_basedir | default(output_dir) }}" + resource_id: "vpc" + + - name: Render Terraform template + ansible.builtin.template: + src: ../files/main.tf.j2 + dest: "{{ test_basedir }}/main.tf" + + - name: Terraform in check mode + cloud.terraform.terraform: + project_path: "{{ test_basedir }}" + state: present + workspace: "{{ tfc_workspace }}" + force_init: true + check_mode: true + register: terraform_result + + - name: Check instance doesn't exist + amazon.aws.ec2_instance_info: + region: "{{ aws_region }}" + filters: + "tag:Name": Instance_Cloud_TF + "instance-state-name": running + register: instance_info + + - assert: + that: + - instance_info.instances | length == 0 + fail_msg: "Instance_Cloud_TF should not exist in check mode" + + - name: Deploy Terraform + cloud.terraform.terraform: + project_path: "{{ test_basedir }}" + state: present + force_init: true + workspace: "{{ tfc_workspace }}" + register: terraform_result1 + + - name: Check instance exists after deploy + amazon.aws.ec2_instance_info: + region: "{{ aws_region }}" + filters: + "tag:Name": Instance_Cloud_TF + "instance-state-name": running + register: instance_info2 + + - assert: + that: + - instance_info2.instances | length == 1 + fail_msg: "Instance_Cloud_TF should exist after deployment" + + - name: Destroy Terraform + cloud.terraform.terraform: + project_path: "{{ test_basedir }}" + state: absent + force_init: true + workspace: "{{ tfc_workspace }}" + register: terraform_result3 + + - name: Verify destruction + amazon.aws.ec2_instance_info: + region: "{{ aws_region }}" + filters: + "tag:Name": Instance_Cloud_TF + "instance-state-name": running + register: instance_info3 + + - assert: + that: + - instance_info3.instances | length == 0 + fail_msg: "Instance_Cloud_TF should be destroyed" + + - name: Deploy using default workspace from .tf + cloud.terraform.terraform: + project_path: "{{ test_basedir }}" + state: present + force_init: true + register: terraform_result4 + + - name: Check instance exists after deploy + amazon.aws.ec2_instance_info: + region: "{{ aws_region }}" + filters: + "tag:Name": Instance_Cloud_TF + "instance-state-name": running + register: instance_info4 + + - assert: + that: + - instance_info4.instances | length == 1 + fail_msg: "Instance_Cloud_TF should exist after deployment" + + - name: Destroy again + cloud.terraform.terraform: + project_path: "{{ test_basedir }}" + state: absent + force_init: true + register: terraform_result4_destroy + + - name: Confirm instance is destroyed + amazon.aws.ec2_instance_info: + region: "{{ aws_region }}" + filters: + "tag:Name": Instance_Cloud_TF + "instance-state-name": running + register: instance_info4_post_destroy + + - assert: + that: + - instance_info4_post_destroy.instances | length == 0 + fail_msg: "Instance_Cloud_TF should be destroyed" + + - name: Deploy with mismatched workspace + cloud.terraform.terraform: + project_path: "{{ test_basedir }}" + state: present + workspace: "nonexistent_workspace" + force_init: true + register: terraform_negative_test + failed_when: false + + - name: Expect failure due to invalid workspace + assert: + that: + - "'Workspace configuration conflict' in terraform_negative_test.msg" + success_msg: "Terraform failed as expected for invalid workspace" + fail_msg: "Terraform unexpectedly succeeded for invalid workspace" diff --git a/tests/integration/targets/test_tfc/vars/main.yml b/tests/integration/targets/test_tfc/vars/main.yml new file mode 100644 index 00000000..aa48a9ff --- /dev/null +++ b/tests/integration/targets/test_tfc/vars/main.yml @@ -0,0 +1,8 @@ +--- +tfc_organization: "Ansible-BU-TFC" +tfc_workspace: "my_tf_project_default" +aws_region: "us-west-2" +aws_access_key: "{{ lookup('env', 'AWS_ACCESS_KEY_ID') }}" +aws_secret_key: "{{ lookup('env', 'AWS_SECRET_ACCESS_KEY') }}" +ami_id: "ami-830c94e3" +hcp_token: "{{ lookup('env', 'HCP_TOKEN') }}" diff --git a/tests/unit/plugins/modules/test_terraform.py b/tests/unit/plugins/modules/test_terraform.py index d3dc23d0..9b77c2bd 100644 --- a/tests/unit/plugins/modules/test_terraform.py +++ b/tests/unit/plugins/modules/test_terraform.py @@ -3,6 +3,9 @@ # # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +import os +import tempfile + import pytest from ansible_collections.cloud.terraform.plugins.module_utils.models import ( TerraformAttributeSpec, @@ -19,6 +22,8 @@ TerraformSimpleAttributeSpec, ) from ansible_collections.cloud.terraform.plugins.modules.terraform import ( + clean_tf_file, + extract_workspace_from_terraform_config, filter_outputs, filter_resource_attributes, is_attribute_in_sensitive_values, @@ -271,7 +276,8 @@ def state_contents(root_module_resource, sensitive_root_module_resource): "my_sensitive_output": TerraformOutput(sensitive=True, value="my_sensitive_value", type="string"), }, root_module=TerraformRootModule( - resources=[root_module_resource, sensitive_root_module_resource], child_modules=[] + resources=[root_module_resource, sensitive_root_module_resource], + child_modules=[], ), ), ) @@ -298,7 +304,13 @@ def test_not_sensitive_attributes(self, provider_schemas, root_module_resource, ("resource_block", True), ], ) - def test_sensitive_attributes(self, provider_schemas, sensitive_root_module_resource, attribute, expected_result): + def test_sensitive_attributes( + self, + provider_schemas, + sensitive_root_module_resource, + attribute, + expected_result, + ): result = is_attribute_sensitive_in_providers_schema( provider_schemas, sensitive_root_module_resource, attribute=attribute ) @@ -593,3 +605,221 @@ def test_from_json_nested(self): terraform_attribute_spec = TerraformAttributeSpec.from_json(resource) assert terraform_attribute_spec == expected_terraform_attribute_spec + + +class TestCleanTfFile: + """Test cases for the clean_tf_file function.""" + + @pytest.mark.parametrize( + "tf_content,expected", + [ + ( + """ +resource "aws_instance" "example" { + # This is a comment + ami = "ami-12345678" + instance_type = "t2.micro" + # Another comment +} +""", + """resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" +}""", + ), + ( + """ +resource "aws_instance" "example" { + // This is a comment + ami = "ami-12345678" + instance_type = "t2.micro" + // Another comment +} +""", + """resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" +}""", + ), + ( + """ +resource "aws_instance" "example" { + /* This is a + multiline comment */ + ami = "ami-12345678" + instance_type = "t2.micro" + /* Another multiline + comment here */ +} +""", + """resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" +}""", + ), + ( + """ +# Top level comment +resource "aws_instance" "example" { + /* Multiline comment + spanning multiple lines */ + ami = "ami-12345678" # Inline hash comment + instance_type = "t2.micro" // Inline double slash comment + # Another hash comment +} +// Bottom comment +""", + """resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" +}""", + ), + ( + """ +resource "aws_instance" "example" { + ami = "ami-12345678" + user_data = "#!/bin/bash\\necho 'Hello # World // Test'" + instance_type = "t2.micro" +} +""", + """resource "aws_instance" "example" { + ami = "ami-12345678" + user_data = "#!/bin/bash\\necho 'Hello # World // Test'" + instance_type = "t2.micro" +}""", + ), + ( + """ + +resource "aws_instance" "example" { + + ami = "ami-12345678" + + instance_type = "t2.micro" + +} + +""", + """resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" +}""", + ), + ( + "", + "", + ), + ( + """ +# This is a comment +// Another comment +/* Multiline + comment */ +""", + "", + ), + ], + ) + def test_clean_tf_file(self, tf_content, expected): + result = clean_tf_file(tf_content) + assert result == expected + + +class TestExtractWorkspaceFromTerraformConfig: + """Test cases for the extract_workspace_from_terraform_config function using real files.""" + + def test_terraform_files_no_cloud_block(self): + """Test .tf files without cloud block.""" + tf_content = """ + resource "aws_instance" "example" { + ami = "ami-12345678" + instance_type = "t2.micro" + } + """ + with tempfile.TemporaryDirectory() as tmpdir: + file_path = os.path.join(tmpdir, "main.tf") + with open(file_path, "w") as f: + f.write(tf_content) + + result = extract_workspace_from_terraform_config(tmpdir) + assert result == (None, "cli") + + def test_cloud_block_with_workspace_name(self): + """Test cloud block with workspace name.""" + tf_content = """ + terraform { + cloud { + organization = "my-org" + workspaces { + name = "my-workspace" + } + } + } + """ + with tempfile.TemporaryDirectory() as tmpdir: + file_path = os.path.join(tmpdir, "main.tf") + with open(file_path, "w") as f: + f.write(tf_content) + + result = extract_workspace_from_terraform_config(tmpdir) + assert result == ("my-workspace", "cloud") + + def test_cloud_block_with_comments(self): + """Test cloud block with comments that should be cleaned.""" + tf_content = """ + terraform { + # This is a comment + cloud { + organization = "my-org" + /* This is a multiline + comment */ + workspaces { + name = "production-workspace" // Inline comment + } + } + } + """ + with tempfile.TemporaryDirectory() as tmpdir: + file_path = os.path.join(tmpdir, "main.tf") + with open(file_path, "w") as f: + f.write(tf_content) + + result = extract_workspace_from_terraform_config(tmpdir) + assert result == ("production-workspace", "cloud") + + def test_workspace_with_special_characters(self): + """Test workspace names with special characters.""" + tf_content = """ + terraform { + cloud { + organization = "my-org" + workspaces { + name = "my-workspace-123_test" + } + } + } + """ + with tempfile.TemporaryDirectory() as tmpdir: + file_path = os.path.join(tmpdir, "main.tf") + with open(file_path, "w") as f: + f.write(tf_content) + + result = extract_workspace_from_terraform_config(tmpdir) + assert result == ("my-workspace-123_test", "cloud") + + def test_cloud_block_without_workspace(self): + """Test workspace names with special characters.""" + tf_content = """ + terraform { + cloud { + organization = "my-org" + } + } + """ + with tempfile.TemporaryDirectory() as tmpdir: + file_path = os.path.join(tmpdir, "main.tf") + with open(file_path, "w") as f: + f.write(tf_content) + + result = extract_workspace_from_terraform_config(tmpdir) + assert result == (None, "cloud")