Skip to content

AAP-46677-updated-terraform.py #194

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

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d36fba1
AAP-46677-updated-terraform.py
shvenkat-rh Jun 18, 2025
a21e750
AAP-46677-updated-terraform.py-conditions
shvenkat-rh Jun 19, 2025
f98b0f1
AAP-46677-fixed-black-errors
shvenkat-rh Jun 19, 2025
53ea46c
AAP-46677-samll fixes
shvenkat-rh Jun 19, 2025
138ad23
AAP-46677-isort-fixes
shvenkat-rh Jun 19, 2025
8b02b2e
Merge branch 'main' into AAP-46677-workspace
shvenkat-rh Jun 19, 2025
58bd19f
AAP-46677-updated changelogs
shvenkat-rh Jun 19, 2025
052940e
AAP-46677-updated-changelog.yaml
shvenkat-rh Jun 19, 2025
2e96f79
Merge branch 'main' into AAP-46677-workspace
shvenkat-rh Jun 19, 2025
1863897
AAP-46677-updated-docs
shvenkat-rh Jun 20, 2025
f8e2d4a
AAP-46677-Fixed-Lint-Errors
shvenkat-rh Jun 20, 2025
271a7f0
AAP-46677-updated-changelogs-fragments
shvenkat-rh Jun 20, 2025
78845ed
AAP-46677-fixed-cli-logic
shvenkat-rh Jun 20, 2025
a2a22f2
AAP-46677-added-test-cases
shvenkat-rh Jun 24, 2025
168b241
AAP-46677-fixed lint-errors
shvenkat-rh Jun 24, 2025
e4796c0
AAP-46677-updated-aliases
shvenkat-rh Jun 24, 2025
0f04838
AAP-46677-updated-test_terraform.py
shvenkat-rh Jun 24, 2025
e837497
AAP-46677-Updated-Terraform.py-test_terraform.py
shvenkat-rh Jun 24, 2025
a5deae3
AAP-46677-fixed-lint
shvenkat-rh Jun 24, 2025
27e3ddf
AAP-46677-fixed-lint
shvenkat-rh Jun 24, 2025
ea8be4c
AAP46677-push
shvenkat-rh Jun 24, 2025
c1fb892
AAP-46677-Fixed-Clean-TF-File
shvenkat-rh Jun 24, 2025
9d7a1c1
AAP-46677-updated-tests
shvenkat-rh Jun 24, 2025
b7d9e46
AAP-46677-Lint
shvenkat-rh Jun 24, 2025
c800602
AAP-46677-Updated-Regex
shvenkat-rh Jun 24, 2025
0ede33a
AAP-46677-Fixed-Pylint
shvenkat-rh Jun 24, 2025
3ddefb4
AAP-46677-Fixed-PEP8-Errors
shvenkat-rh Jun 24, 2025
7772ad8
AAP-46677-Simple-Fixes
shvenkat-rh Jun 24, 2025
76bb8be
AAP-46677-Added-tests-for-workspace-extraction
shvenkat-rh Jun 24, 2025
f4efa4b
AAP-46677-Changes
shvenkat-rh Jun 24, 2025
475fd6f
AAP-46677-Updated-no-workspace
shvenkat-rh Jun 24, 2025
8e316f9
AAP-46677-Paramaterized-Test-Cases
shvenkat-rh Jun 24, 2025
4e80e70
AAP-46677-revert
shvenkat-rh Jun 24, 2025
0586b14
AAP-46677-Added-Test-Case
shvenkat-rh Jun 24, 2025
6a4d300
AAP-46677-Fixed-Lints
shvenkat-rh Jun 24, 2025
a5b598d
AAP-46677-Parametrized-Extract-Workspace
shvenkat-rh Jun 24, 2025
6b091f9
AAP-46677-Revert
shvenkat-rh Jun 24, 2025
92d112d
AAP-46677-Reverted-Changelog.rst-change
shvenkat-rh Jun 24, 2025
8b96745
AAP-46677-Reverted_Change_Logs
shvenkat-rh Jun 24, 2025
578b3a5
AAP-46677-Updated-main.tf
shvenkat-rh Jun 25, 2025
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
4 changes: 2 additions & 2 deletions changelogs/changelog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
211 changes: 198 additions & 13 deletions plugins/modules/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand All @@ -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"

Check warning on line 422 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L422

Added line #L422 was not covered by tests

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):

Check warning on line 450 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L450

Added line #L450 was not covered by tests
# Skip files that can't be read, continue with others
continue

Check warning on line 452 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L452

Added line #L452 was not covered by tests

except OSError:
pass

Check warning on line 455 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L454-L455

Added lines #L454 - L455 were not covered by tests

return None, "cli"


def is_attribute_sensitive_in_providers_schema(
schemas: TerraformProviderSchemaCollection, resource: TerraformModuleResource, attribute: str
) -> bool:
Expand Down Expand Up @@ -493,6 +638,43 @@
else:
terraform_binary = module.get_bin_path("terraform", required=True)

cloud_workspace, terraform_offering = extract_workspace_from_terraform_config(project_path)

Check warning on line 641 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L641

Added line #L641 was not covered by tests

if terraform_offering == "cloud":
if cloud_workspace is None and workspace == "default":
module.fail_json(

Check warning on line 645 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L645

Added line #L645 was not covered by tests
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(

Check warning on line 653 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L653

Added line #L653 was not covered by tests
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")

Check warning on line 662 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L661-L662

Added lines #L661 - L662 were not covered by tests
elif workspace != "default":
final_workspace = workspace
module.log(f"Using explicitly provided workspace '{final_workspace}' for Terraform cloud")

Check warning on line 665 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L664-L665

Added lines #L664 - L665 were not covered by tests
else:
final_workspace = "default"
module.log("Using default workspace for Terraform cloud configuration")

Check warning on line 668 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L667-L668

Added lines #L667 - L668 were not covered by tests
else:
final_workspace = workspace

Check warning on line 670 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L670

Added line #L670 was not covered by tests
if workspace == "default":
module.log("Using default workspace for Terraform CLI")

Check warning on line 672 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L672

Added line #L672 was not covered by tests
else:
module.log(f"Using explicitly provided workspace '{final_workspace}' for Terraform CLI")

Check warning on line 674 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L674

Added line #L674 was not covered by tests

workspace = final_workspace

Check warning on line 676 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L676

Added line #L676 was not covered by tests

terraform = TerraformCommands(module.run_command, project_path, terraform_binary, computed_check_mode)

checked_version = terraform.version()
Expand Down Expand Up @@ -525,11 +707,14 @@
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")

Check warning on line 711 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L711

Added line #L711 was not covered by tests
else:
if workspace_ctx.current != workspace:
if workspace not in workspace_ctx.all:
terraform.workspace(WorkspaceCommand.NEW, workspace)

Check warning on line 715 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L715

Added line #L715 was not covered by tests
else:
terraform.workspace(WorkspaceCommand.SELECT, workspace)

Check warning on line 717 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L717

Added line #L717 was not covered by tests

variables_args = []
if complex_vars:
Expand Down Expand Up @@ -607,7 +792,7 @@
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
Expand All @@ -631,11 +816,11 @@
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)

Check warning on line 821 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L821

Added line #L821 was not covered by tests
if computed_state == "absent" and workspace != "default" and purge_workspace is True:
terraform.workspace(WorkspaceCommand.DELETE, workspace)

Check warning on line 823 in plugins/modules/terraform.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/terraform.py#L823

Added line #L823 was not covered by tests

diff = dict(
before=dataclasses.asdict(initial_state) if initial_state is not None else {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 2 additions & 0 deletions tests/integration/targets/test_tfc/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
disabled # Require a HCP configuration account
cloud/aws
40 changes: 40 additions & 0 deletions tests/integration/targets/test_tfc/files/main.tf.j2
Original file line number Diff line number Diff line change
@@ -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
}
Loading