From 3fe5592c0af61ccc63f72163a9d541e8b6bc18f8 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 12 Sep 2025 10:47:37 +0530 Subject: [PATCH 1/6] Workspace CRUD, VCS, Lock, SSH key Ops methods were added --- examples/workspace_example.py | 446 +++++++++++++++++++++ src/tfe/errors.py | 129 ++++++ src/tfe/resources/workspaces.py | 661 +++++++++++++++++++++++++++++-- src/tfe/types.py | 293 +++++++++++++- src/tfe/workspace_validation.py | 152 ++++++++ tests/units/test_workspaces.py | 670 ++++++++++++++++++++++++++++++++ 6 files changed, 2309 insertions(+), 42 deletions(-) create mode 100644 examples/workspace_example.py create mode 100644 src/tfe/workspace_validation.py create mode 100644 tests/units/test_workspaces.py diff --git a/examples/workspace_example.py b/examples/workspace_example.py new file mode 100644 index 00000000..aced02da --- /dev/null +++ b/examples/workspace_example.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +""" +Comprehensive Workspace Management Example + +This example demonstrates all available workspace operations in the Python TFE SDK, +including CRUD operations, VCS management, locking/unlocking, SSH key management, +and advanced configuration options. + +Usage: + python examples/workspace_comprehensive_example.py + +Requirements: + - TFE_TOKEN environment variable set + - TFE_ADDRESS environment variable set (optional, defaults to Terraform Cloud) + - An existing organization in your Terraform Cloud/Enterprise instance +""" + +import os +import sys +from datetime import datetime + +# Add the source directory to the path for direct execution +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from tfe import Client +from tfe.errors import ( + InvalidOrgError, + InvalidWorkspaceIDError, + TFEError, +) +from tfe.types import ( + ExecutionMode, + VCSRepo, + WorkspaceCreateOptions, + WorkspaceInclude, + WorkspaceListOptions, + WorkspaceLockOptions, + WorkspaceReadOptions, + WorkspaceRemoveVCSConnectionOptions, + WorkspaceUpdateOptions, +) + + +class WorkspaceManager: + """Comprehensive workspace management utility.""" + + def __init__(self, token: str, address: str = "https://app.terraform.io"): + """Initialize the workspace manager.""" + self.client = Client(token=token, address=address) + self.workspaces = self.client.workspaces + + def demonstrate_all_operations(self, organization: str): + """Demonstrate all workspace operations.""" + print("๐Ÿš€ Starting Comprehensive Workspace Operations Demo") + print("=" * 60) + + try: + # 1. List existing workspaces + self.demo_list_operations(organization) + + # 2. Create new workspace + workspace = self.demo_create_operations(organization) + workspace_id = workspace.id + workspace_name = workspace.name + + # 3. Read operations + self.demo_read_operations(organization, workspace_name, workspace_id) + + # 4. Update operations + self.demo_update_operations(organization, workspace_name, workspace_id) + + # 5. VCS operations + self.demo_vcs_operations(organization, workspace_name, workspace_id) + + # 6. Locking operations + self.demo_locking_operations(workspace_id) + + # 7. SSH key operations (commented out as it requires existing SSH keys) + # self.demo_ssh_key_operations(workspace_id) + + # 8. Cleanup - delete the test workspace + self.demo_delete_operations(organization, workspace_name, workspace_id) + + except Exception as e: + print(f"โŒ Error during demo: {e}") + raise + + print("\n๐ŸŽ‰ Comprehensive workspace demo completed successfully!") + + def demo_list_operations(self, organization: str): + """Demonstrate workspace listing operations.""" + print("\n๐Ÿ“‹ 1. WORKSPACE LISTING OPERATIONS") + print("-" * 40) + + # Basic listing + print("๐Ÿ” Listing all workspaces...") + options = WorkspaceListOptions() + workspaces = list(self.workspaces.list(organization, options=options)) + print(f" Found {len(workspaces)} workspaces") + + for ws in workspaces[:3]: # Show first 3 + print(f" โ€ข {ws.name} (ID: {ws.id[:10]}...)") + print(f" - Execution Mode: {ws.execution_mode}") + print(f" - Auto Apply: {ws.auto_apply}") + print(f" - Locked: {ws.locked}") + + # Advanced listing with filters + print("\n๐Ÿ” Listing with search filters...") + filtered_options = WorkspaceListOptions( + search="prod", # Search for workspaces containing "prod" + tags="production,frontend", # Filter by tags + include=[WorkspaceInclude.current_run], # Include current run info + page_size=5, # Limit results + ) + + try: + filtered_workspaces = list( + self.workspaces.list(organization, options=filtered_options) + ) + print(f" Found {len(filtered_workspaces)} workspaces matching filters") + except Exception as e: + print(f" Filter search failed (expected if no matching workspaces): {e}") + + def demo_create_operations(self, organization: str): + """Demonstrate workspace creation operations.""" + print("\n๐Ÿ—๏ธ 2. WORKSPACE CREATION OPERATIONS") + print("-" * 40) + + # Basic workspace creation + print("๐Ÿ”จ Creating basic workspace...") + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + workspace_name = f"demo-workspace-{timestamp}" + + basic_options = WorkspaceCreateOptions( + name=workspace_name, + description=f"Demo workspace created at {datetime.now()}", + auto_apply=False, + execution_mode=ExecutionMode.remote, + terraform_version="1.5.0", + working_directory="terraform/", + file_triggers_enabled=True, + queue_all_runs=False, + speculative_enabled=True, + operations=True, + trigger_prefixes=["modules/", "shared/"], + trigger_patterns=["**/*.tf", "**/*.tfvars"], + ) + + workspace = self.workspaces.create(organization, options=basic_options) + print(f" โœ… Created workspace: {workspace.name}") + print(f" ๐Ÿ“‹ ID: {workspace.id}") + print(f" ๐Ÿ“ Description: {workspace.description}") + print(f" โš™๏ธ Execution Mode: {workspace.execution_mode}") + print(f" ๐Ÿ”„ Auto Apply: {workspace.auto_apply}") + + return workspace + + def demo_create_with_vcs(self, organization: str): + """Demonstrate workspace creation with VCS integration.""" + print("\n๐Ÿ”— Creating workspace with VCS integration...") + + # VCS repository configuration + vcs_repo = VCSRepo( + identifier="your-org/your-repo", # Replace with actual repo + branch="main", + oauth_token_id="ot-your-token-id", # Replace with actual OAuth token + ingress_submodules=False, + tags_regex=r"v\d+\.\d+\.\d+", # Version tag pattern + ) + + vcs_options = WorkspaceCreateOptions( + name=f"vcs-demo-{datetime.now().strftime('%Y%m%d-%H%M%S')}", + description="Demo workspace with VCS integration", + vcs_repo=vcs_repo, + working_directory="terraform/production/", + trigger_prefixes=["terraform/production/"], + auto_apply=True, # Enable auto-apply for VCS-driven workflows + ) + + try: + vcs_workspace = self.workspaces.create(organization, options=vcs_options) + print(f" โœ… Created VCS workspace: {vcs_workspace.name}") + return vcs_workspace + except Exception as e: + print( + f" โš ๏ธ VCS workspace creation failed (expected without valid OAuth token): {e}" + ) + return None + + def demo_read_operations( + self, organization: str, workspace_name: str, workspace_id: str + ): + """Demonstrate workspace reading operations.""" + print("\n๐Ÿ“– 3. WORKSPACE READ OPERATIONS") + print("-" * 40) + + # Read by name + print("๐Ÿ“„ Reading workspace by name...") + workspace_by_name = self.workspaces.read(organization, workspace_name) + print(f" ๐Ÿ“‹ Name: {workspace_by_name.name}") + print(f" ๐Ÿ†” ID: {workspace_by_name.id}") + print(f" ๐Ÿ“… Created: {workspace_by_name.created_at}") + print(f" ๐Ÿ“… Updated: {workspace_by_name.updated_at}") + + # Read by ID + print("\n๐Ÿ“„ Reading workspace by ID...") + workspace_by_id = self.workspaces.read_by_id(workspace_id) + print(f" ๐Ÿ“‹ Name: {workspace_by_id.name}") + print(f" ๐Ÿ”ง Terraform Version: {workspace_by_id.terraform_version}") + print(f" ๐Ÿ“ Working Directory: {workspace_by_id.working_directory}") + + # Read with additional include options + print("\n๐Ÿ“„ Reading workspace with include options...") + read_options = WorkspaceReadOptions( + include=[WorkspaceInclude.current_run, WorkspaceInclude.outputs] + ) + + detailed_workspace = self.workspaces.read_with_options( + workspace_name, organization, options=read_options + ) + print(f" ๐Ÿƒ Current Run ID: {detailed_workspace.locked_by}") + print(f" ๐Ÿ“Š Resource Count: {detailed_workspace.resource_count}") + print(f" ๐Ÿท๏ธ Tag Names: {detailed_workspace.tag_names}") + + def demo_update_operations( + self, organization: str, workspace_name: str, workspace_id: str + ): + """Demonstrate workspace update operations.""" + print("\nโœ๏ธ 4. WORKSPACE UPDATE OPERATIONS") + print("-" * 40) + + # Update by name + print("๐Ÿ”ง Updating workspace by name...") + update_options = WorkspaceUpdateOptions( + description=f"Updated description at {datetime.now()}", + auto_apply=True, # Enable auto-apply + terraform_version="1.6.0", # Update Terraform version + queue_all_runs=True, # Enable queue all runs + working_directory="terraform/updated/", + ) + + updated_workspace = self.workspaces.update( + organization, workspace_name, options=update_options + ) + print(f" โœ… Updated workspace: {updated_workspace.name}") + print(f" ๐Ÿ“ New description: {updated_workspace.description}") + print(f" ๐Ÿ”„ Auto Apply: {updated_workspace.auto_apply}") + print(f" ๐Ÿ”ง Terraform Version: {updated_workspace.terraform_version}") + + # Update by ID + print("\n๐Ÿ”ง Updating workspace by ID...") + id_update_options = WorkspaceUpdateOptions( + speculative_enabled=False, # Disable speculative plans + operations=False, # Switch to local execution + ) + + updated_by_id = self.workspaces.update_by_id( + workspace_id, options=id_update_options + ) + print(f" โœ… Updated workspace operations: {updated_by_id.operations}") + print(f" ๐Ÿ” Speculative enabled: {updated_by_id.speculative_enabled}") + + def demo_vcs_operations( + self, organization: str, workspace_name: str, workspace_id: str + ): + """Demonstrate VCS connection operations.""" + print("\n๐Ÿ”— 5. VCS CONNECTION OPERATIONS") + print("-" * 40) + + # Note: These operations require existing VCS connections + print("๐Ÿ”Œ VCS connection management...") + + try: + # Remove VCS connection by name + print("๐Ÿ—‘๏ธ Removing VCS connection by name...") + remove_options = WorkspaceRemoveVCSConnectionOptions( + id=workspace_id, + vcs_repo=None, # Set to None to remove + ) + + updated_workspace = self.workspaces.remove_vcs_connection( + organization, workspace_name, options=remove_options + ) + print(f" โœ… VCS connection removed for: {updated_workspace.name}") + + except Exception as e: + print(f" โš ๏ธ VCS operation note: {e}") + print(" (VCS operations require existing VCS configurations)") + + def demo_locking_operations(self, workspace_id: str): + """Demonstrate workspace locking operations.""" + print("\n๐Ÿ”’ 6. WORKSPACE LOCKING OPERATIONS") + print("-" * 40) + + # Lock workspace + print("๐Ÿ” Locking workspace...") + lock_options = WorkspaceLockOptions( + reason="Demo: Maintenance in progress - testing locking functionality" + ) + + try: + locked_workspace = self.workspaces.lock(workspace_id, options=lock_options) + print(f" ๐Ÿ”’ Workspace locked: {locked_workspace.name}") + print(" ๐Ÿ“ Lock reason: Demo maintenance") + print(f" ๐Ÿ”“ Locked status: {locked_workspace.locked}") + + # Unlock workspace + print("\n๐Ÿ”“ Unlocking workspace...") + unlocked_workspace = self.workspaces.unlock(workspace_id) + print(f" ๐Ÿ”“ Workspace unlocked: {unlocked_workspace.name}") + print(f" ๐Ÿ”“ Locked status: {unlocked_workspace.locked}") + + except Exception as e: + print(f" โš ๏ธ Locking operation failed: {e}") + print(" (This may be expected if workspace has active runs)") + + def demo_ssh_key_operations(self, workspace_id: str): + """Demonstrate SSH key management operations.""" + print("\n๐Ÿ”‘ 7. SSH KEY MANAGEMENT OPERATIONS") + print("-" * 40) + + # Note: This requires existing SSH keys in the organization + print("๐Ÿ” SSH key management...") + print(" โš ๏ธ SSH key operations require existing SSH keys") + print(" ๐Ÿ“ Skipping SSH key demo (requires SSH key setup)") + + # Uncomment and modify when you have SSH keys configured: + """ + try: + # Assign SSH key + ssh_options = WorkspaceAssignSSHKeyOptions( + ssh_key_id="sshkey-your-key-id" # Replace with actual SSH key ID + ) + + workspace_with_ssh = self.workspaces.assign_ssh_key(workspace_id, options=ssh_options) + print(f" ๐Ÿ”‘ SSH key assigned to: {workspace_with_ssh.name}") + + # Unassign SSH key + workspace_without_ssh = self.workspaces.unassign_ssh_key(workspace_id) + print(f" ๐Ÿ”“ SSH key unassigned from: {workspace_without_ssh.name}") + + except Exception as e: + print(f" โš ๏ธ SSH key operation failed: {e}") + """ + + def demo_delete_operations( + self, organization: str, workspace_name: str, workspace_id: str + ): + """Demonstrate workspace deletion operations.""" + print("\n๐Ÿ—‘๏ธ 8. WORKSPACE DELETE OPERATIONS") + print("-" * 40) + + print("๐Ÿ›ก๏ธ Performing safe delete...") + try: + # Safe delete (recommended) + self.workspaces.safe_delete(organization, workspace_name) + print(f" โœ… Safe delete initiated for: {workspace_name}") + print(" ๐Ÿ“ Safe delete queues deletion after checking for dependencies") + + except Exception as e: + print(f" โš ๏ธ Safe delete failed, trying regular delete: {e}") + + # Regular delete (immediate) + try: + self.workspaces.delete(organization, workspace_name) + print(f" โœ… Workspace deleted: {workspace_name}") + except Exception as delete_error: + print(f" โŒ Delete failed: {delete_error}") + print(" ๐Ÿงน Manual cleanup may be required") + + def demo_error_handling(self, organization: str): + """Demonstrate error handling patterns.""" + print("\nโš ๏ธ ERROR HANDLING DEMONSTRATIONS") + print("-" * 40) + + # Invalid organization + try: + options = WorkspaceListOptions() + list(self.workspaces.list("", options=options)) + except InvalidOrgError: + print(" โœ… Caught InvalidOrgError for empty organization") + + # Invalid workspace ID + try: + self.workspaces.read_by_id("") + except InvalidWorkspaceIDError: + print(" โœ… Caught InvalidWorkspaceIDError for empty ID") + + # Nonexistent workspace + try: + self.workspaces.read(organization, "nonexistent-workspace-12345") + except TFEError as e: + print(f" โœ… Caught TFEError for nonexistent workspace: {e}") + + +def main(): + """Main execution function.""" + # Configuration + token = os.getenv("TFE_TOKEN") + address = os.getenv("TFE_ADDRESS", "https://app.terraform.io") + organization = os.getenv("TFE_ORG", "your-org-name") # Replace with your org + + if not token: + print("โŒ Error: TFE_TOKEN environment variable is required") + print("๐Ÿ“ Set it with: export TFE_TOKEN=your-token-here") + sys.exit(1) + + if organization == "your-org-name": + print("โš ๏ธ Warning: Using default organization name") + print("๐Ÿ“ Set TFE_ORG environment variable or update the script") + + # Allow user to input organization name + org_input = input("Enter your organization name: ").strip() + if org_input: + organization = org_input + else: + print("โŒ Organization name is required") + sys.exit(1) + + print(f"๐ŸŒ Terraform Address: {address}") + print(f"๐Ÿข Organization: {organization}") + print( + f"๐Ÿ”‘ Token: {'*' * (len(token) - 8) + token[-8:] if len(token) > 8 else '****'}" + ) + + try: + # Initialize workspace manager + manager = WorkspaceManager(token=token, address=address) + + # Run comprehensive demo + manager.demonstrate_all_operations(organization) + + # Demonstrate error handling + manager.demo_error_handling(organization) + + except Exception as e: + print(f"\nโŒ Demo failed with error: {e}") + print("๐Ÿ’ก Common issues:") + print(" โ€ข Invalid token or organization") + print(" โ€ข Network connectivity problems") + print(" โ€ข Insufficient permissions") + raise + + +if __name__ == "__main__": + main() diff --git a/src/tfe/errors.py b/src/tfe/errors.py index e524f321..528657af 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -57,3 +57,132 @@ class RequiredFieldMissing(TFEError): ... ERR_REQUIRED_NAME = "name is required" ERR_INVALID_ORG = "invalid organization name" ERR_REQUIRED_EMAIL = "email is required" + +class WorkspaceNotFound(NotFound): ... + + +class WorkspaceNameConflict(ValidationError): ... + + +class WorkspaceLocked(TFEError): ... + + +class WorkspaceLockedStateVersionStillPending(TFEError): ... + + +# Workspace validation errors +class WorkspaceValidationError(ValidationError): + """Base class for workspace validation errors.""" + + pass + + +class RequiredNameError(WorkspaceValidationError): + """Raised when workspace name is required but not provided.""" + + def __init__(self) -> None: + super().__init__("name is required") + + +class InvalidNameError(WorkspaceValidationError): + """Raised when workspace name is invalid.""" + + def __init__(self) -> None: + super().__init__("invalid value for name") + + +class UnsupportedOperationsError(WorkspaceValidationError): + """Raised when operations is specified with execution mode.""" + + def __init__(self) -> None: + super().__init__( + "operations is deprecated and cannot be specified when execution mode is used" + ) + + +class RequiredAgentModeError(WorkspaceValidationError): + """Raised when agent pool ID is specified without agent execution mode.""" + + def __init__(self) -> None: + super().__init__("specifying an agent pool ID requires 'agent' execution mode") + + +class RequiredAgentPoolIDError(WorkspaceValidationError): + """Raised when agent execution mode is specified without agent pool ID.""" + + def __init__(self) -> None: + super().__init__( + "'agent' execution mode requires an agent pool ID to be specified" + ) + + +class UnsupportedBothTriggerPatternsAndPrefixesError(WorkspaceValidationError): + """Raised when both trigger patterns and prefixes are specified.""" + + def __init__(self) -> None: + super().__init__( + '"TriggerPatterns" and "TriggerPrefixes" cannot be populated at the same time' + ) + + +class UnsupportedBothTagsRegexAndTriggerPatternsError(WorkspaceValidationError): + """Raised when both tags regex and trigger patterns are specified.""" + + def __init__(self) -> None: + super().__init__( + '"TagsRegex" and "TriggerPatterns" cannot be populated at the same time' + ) + + +class UnsupportedBothTagsRegexAndTriggerPrefixesError(WorkspaceValidationError): + """Raised when both tags regex and trigger prefixes are specified.""" + + def __init__(self) -> None: + super().__init__( + '"TagsRegex" and "TriggerPrefixes" cannot be populated at the same time' + ) + + +class UnsupportedBothTagsRegexAndFileTriggersEnabledError(WorkspaceValidationError): + """Raised when both tags regex and file triggers are enabled.""" + + def __init__(self) -> None: + super().__init__( + '"TagsRegex" cannot be populated when "FileTriggersEnabled" is true' + ) + + +# Parameter validation errors +class InvalidOrgError(WorkspaceValidationError): + """Raised when organization parameter is invalid.""" + + def __init__(self) -> None: + super().__init__("invalid value for organization") + + +class InvalidWorkspaceIDError(WorkspaceValidationError): + """Raised when workspace ID parameter is invalid.""" + + def __init__(self) -> None: + super().__init__("invalid value for workspace ID") + + +class InvalidWorkspaceValueError(WorkspaceValidationError): + """Raised when workspace name parameter is invalid.""" + + def __init__(self) -> None: + super().__init__("invalid value for workspace") + + +class RequiredSSHKeyIDError(WorkspaceValidationError): + """Raised when SSH key ID parameter is required but not provided.""" + + def __init__(self) -> None: + super().__init__("SSH key ID is required") + + +class InvalidSSHKeyIDError(WorkspaceValidationError): + """Raised when SSH key ID parameter is invalid.""" + + def __init__(self) -> None: + super().__init__("invalid value for SSH key ID") diff --git a/src/tfe/resources/workspaces.py b/src/tfe/resources/workspaces.py index 9f778eb8..0cb1ab07 100644 --- a/src/tfe/resources/workspaces.py +++ b/src/tfe/resources/workspaces.py @@ -4,7 +4,39 @@ from collections.abc import Iterator from typing import Any -from ..types import ExecutionMode, Workspace +from ..errors import ( + InvalidOrgError, + InvalidSSHKeyIDError, + InvalidWorkspaceIDError, + InvalidWorkspaceValueError, + RequiredSSHKeyIDError, + WorkspaceLockedStateVersionStillPending, +) +from ..types import ( + ExecutionMode, + LockedByChoice, + Tag, + VCSRepo, + Workspace, + WorkspaceActions, + WorkspaceAssignSSHKeyOptions, + WorkspaceCreateOptions, + WorkspaceListOptions, + WorkspaceLockOptions, + WorkspaceOutputs, + WorkspacePermissions, + WorkspaceReadOptions, + WorkspaceRemoveVCSConnectionOptions, + WorkspaceSettingOverwrites, + WorkspaceSource, + WorkspaceUpdateOptions, +) +from ..workspace_validation import ( + is_valid_string, + is_valid_string_id, + validate_workspace_create_options, + validate_workspace_update_options, +) from ._base import _Service @@ -35,8 +67,89 @@ def _ws_from(d: dict[str, Any], org: str | None = None) -> Workspace: if isinstance(proj, dict): proj_id = proj.get("id") if isinstance(proj.get("id"), str) else None + # Enhanced field mapping tags_val = attr.get("tags", []) or [] - tags_list: list[str] = list(tags_val) if isinstance(tags_val, list | tuple) else [] + tags_list: builtins.list[Tag] = [] + if isinstance(tags_val, builtins.list): + for tag_item in tags_val: + if isinstance(tag_item, dict): + tags_list.append( + Tag(id=tag_item.get("id"), name=tag_item.get("name", "")) + ) + elif isinstance(tag_item, str): + tags_list.append(Tag(name=tag_item)) + + # Map additional attributes + actions = None + if attr.get("actions"): + actions = WorkspaceActions( + is_destroyable=attr["actions"].get("is-destroyable", False) + ) + + permissions = None + if attr.get("permissions"): + perm_attr = attr["permissions"] + permissions = WorkspacePermissions( + can_destroy=perm_attr.get("can-destroy", False), + can_force_unlock=perm_attr.get("can-force-unlock", False), + can_lock=perm_attr.get("can-lock", False), + can_manage_run_tasks=perm_attr.get("can-manage-run-tasks", False), + can_queue_apply=perm_attr.get("can-queue-apply", False), + can_queue_destroy=perm_attr.get("can-queue-destroy", False), + can_queue_run=perm_attr.get("can-queue-run", False), + can_read_settings=perm_attr.get("can-read-settings", False), + can_unlock=perm_attr.get("can-unlock", False), + can_update=perm_attr.get("can-update", False), + can_update_variable=perm_attr.get("can-update-variable", False), + can_force_delete=perm_attr.get("can-force-delete"), + ) + + setting_overwrites = None + if attr.get("setting-overwrites"): + so_attr = attr["setting-overwrites"] + setting_overwrites = WorkspaceSettingOverwrites( + execution_mode=so_attr.get("execution-mode"), + agent_pool=so_attr.get("agent-pool"), + ) + + # Map VCS repo + vcs_repo = None + if attr.get("vcs-repo"): + vcs_attr = attr["vcs-repo"] + vcs_repo = VCSRepo( + branch=vcs_attr.get("branch"), + identifier=vcs_attr.get("identifier"), + ingress_submodules=vcs_attr.get("ingress-submodules"), + oauth_token_id=vcs_attr.get("oauth-token-id"), + gha_installation_id=vcs_attr.get("github-app-installation-id"), + ) + + # Map locked_by choice + locked_by = None + if d.get("relationships", {}).get("locked-by"): + lb_data = d["relationships"]["locked-by"]["data"] + if lb_data: + locked_by = LockedByChoice( + run=lb_data.get("run"), + user=lb_data.get("user"), + team=lb_data.get("team"), + ) + + # Map outputs + outputs = [] + if d.get("relationships", {}).get("outputs"): + for output_data in d["relationships"]["outputs"].get("data", []): + outputs.append( + WorkspaceOutputs( + id=output_data.get("id", ""), + name=output_data.get("attributes", {}).get("name", ""), + sensitive=output_data.get("attributes", {}).get("sensitive", False), + output_type=output_data.get("attributes", {}).get( + "output-type", "" + ), + value=output_data.get("attributes", {}).get("value"), + ) + ) return Workspace( id=id_str, @@ -45,68 +158,538 @@ def _ws_from(d: dict[str, Any], org: str | None = None) -> Workspace: execution_mode=em, project_id=proj_id, tags=tags_list, + # Core attributes + actions=actions, + allow_destroy_plan=attr.get("allow-destroy-plan", False), + assessments_enabled=attr.get("assessments-enabled", False), + auto_apply=attr.get("auto-apply", False), + auto_apply_run_trigger=attr.get("auto-apply-run-trigger", False), + auto_destroy_at=attr.get("auto-destroy-at"), + auto_destroy_activity_duration=attr.get("auto-destroy-activity-duration"), + can_queue_destroy_plan=attr.get("can-queue-destroy-plan", False), + created_at=attr.get("created-at"), + description=attr.get("description") or "", + environment=attr.get("environment", ""), + file_triggers_enabled=attr.get("file-triggers-enabled", False), + global_remote_state=attr.get("global-remote-state", False), + inherits_project_auto_destroy=attr.get("inherits-project-auto-destroy", False), + locked=attr.get("locked", False), + migration_environment=attr.get("migration-environment", ""), + no_code_upgrade_available=attr.get("no-code-upgrade-available", False), + operations=attr.get("operations", False), + permissions=permissions, + queue_all_runs=attr.get("queue-all-runs", False), + speculative_enabled=attr.get("speculative-enabled", False), + source=WorkspaceSource(attr.get("source")) if attr.get("source") else None, + source_name=attr.get("source-name") or "", + source_url=attr.get("source-url") or "", + structured_run_output_enabled=attr.get("structured-run-output-enabled", False), + terraform_version=attr.get("terraform-version") or "", + trigger_prefixes=attr.get("trigger-prefixes", []), + trigger_patterns=attr.get("trigger-patterns", []), + vcs_repo=vcs_repo, + working_directory=attr.get("working-directory") or "", + updated_at=attr.get("updated-at"), + resource_count=attr.get("resource-count", 0), + apply_duration_average=attr.get("apply-duration-average"), + plan_duration_average=attr.get("plan-duration-average"), + policy_check_failures=attr.get("policy-check-failures") or 0, + run_failures=attr.get("run-failures") or 0, + runs_count=attr.get("workspace-kpis-runs-count") or 0, + tag_names=attr.get("tag-names", []), + setting_overwrites=setting_overwrites, + # Relations + outputs=outputs, + locked_by=locked_by, ) class Workspaces(_Service): def list( - self, organization: str, *, search: str | None = None + self, + organization: str, + *, + options: WorkspaceListOptions, ) -> Iterator[Workspace]: + # Validate parameters + if not is_valid_string_id(organization): + raise InvalidOrgError() + params: dict[str, Any] = {} - if search: - params["search[name]"] = search + + # Use structured options + if options.search: + params["search[name]"] = options.search + if options.tags: + params["search[tags]"] = options.tags + if options.exclude_tags: + params["search[exclude-tags]"] = options.exclude_tags + if options.wildcard_name: + params["search[wildcard-name]"] = options.wildcard_name + if options.project_id: + params["filter[project][id]"] = options.project_id + if options.current_run_status: + params["filter[current-run][status]"] = options.current_run_status + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + if options.sort: + params["sort"] = options.sort + if options.page_number: + params["page[number]"] = options.page_number + if options.page_size: + params["page[size]"] = options.page_size + + # Handle tag binding filters + if options.tag_bindings: + for i, binding in enumerate(options.tag_bindings): + if binding.tag and binding.value: + params[f"search[tag-bindings][{i}][key]"] = binding.tag.name + params[f"search[tag-bindings][{i}][value]"] = binding.value + elif binding.tag: + params[f"search[tag-bindings][{i}][key]"] = binding.tag.name + path = f"/api/v2/organizations/{organization}/workspaces" for item in self._list(path, params=params): yield _ws_from(item, organization) - def get(self, id_or_name: str, organization: str | None = None) -> Workspace: - if organization: - r = self.t.request( - "GET", f"/api/v2/organizations/{organization}/workspaces/{id_or_name}" - ) - else: - r = self.t.request("GET", f"/api/v2/workspaces/{id_or_name}") + def read(self, organization: str, name: str) -> Workspace: + """Read workspace by organization and name.""" + return self.read_with_options( + name, organization=organization, options=WorkspaceReadOptions() + ) + + def read_with_options( + self, + name: str, + organization: str, + *, + options: WorkspaceReadOptions, + ) -> Workspace: + # Validate parameters + if not is_valid_string_id(organization): + raise InvalidOrgError() + if not is_valid_string_id(name): + raise InvalidWorkspaceValueError() + + params: dict[str, Any] = {} + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + r = self.t.request( + "GET", + f"/api/v2/organizations/{organization}/workspaces/{name}", + params=params, + ) return _ws_from(r.json()["data"], organization) + def read_by_id(self, id: str) -> Workspace: + """Read workspace by workspace ID.""" + return self.read_by_id_with_options(id, options=WorkspaceReadOptions()) + + def read_by_id_with_options( + self, id: str, *, options: WorkspaceReadOptions + ) -> Workspace: + # Validate parameters + if not is_valid_string_id(id): + raise InvalidWorkspaceIDError() + + params: dict[str, Any] = {} + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + r = self.t.request("GET", f"/api/v2/workspaces/{id}", params=params) + return _ws_from(r.json()["data"], None) + def create( self, organization: str, - name: str, *, - execution_mode: str | None = "remote", - project_id: str | None = None, - tags: builtins.list[str] | None = None, + options: WorkspaceCreateOptions, ) -> Workspace: - body: dict[str, Any] = { - "data": {"type": "workspaces", "attributes": {"name": name}} - } - if execution_mode: - body["data"]["attributes"]["execution-mode"] = execution_mode - if project_id: - body["data"].setdefault("relationships", {}) - body["data"]["relationships"]["project"] = { - "data": {"type": "projects", "id": project_id} - } - if tags: - body["data"]["attributes"]["tags"] = list(tags) + """Create a new workspace in the given organization.""" + # Validate parameters + if not is_valid_string_id(organization): + raise InvalidOrgError() + # Validate options before creating workspace + validate_workspace_create_options(options) + + body = self._build_workspace_payload(options, is_create=True) r = self.t.request( "POST", f"/api/v2/organizations/{organization}/workspaces", json_body=body ) return _ws_from(r.json()["data"], organization) - def update(self, id: str, **attrs: Any) -> Workspace: - body: dict[str, Any] = { - "data": {"type": "workspaces", "id": id, "attributes": {}} - } - for k, v in attrs.items(): - kk = k.replace("_", "-") - # Map enum back to string if provided - if kk == "execution-mode" and isinstance(v, ExecutionMode): - v = v.value - body["data"]["attributes"][kk] = v + # Convenience methods for org+name operations + def update( + self, organization: str, name: str, *, options: WorkspaceUpdateOptions + ) -> Workspace: + """Update workspace by organization and name.""" + # Validate parameters + if not is_valid_string_id(organization): + raise InvalidOrgError() + if not is_valid_string_id(name): + raise InvalidWorkspaceValueError() + + # Validate options before updating workspace + validate_workspace_update_options(options) + + body = self._build_workspace_payload(options, is_create=False) + r = self.t.request( + "PATCH", + f"/api/v2/organizations/{organization}/workspaces/{name}", + json_body=body, + ) + return _ws_from(r.json()["data"], organization) + + def update_by_id(self, id: str, *, options: WorkspaceUpdateOptions) -> Workspace: + """Update workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(id): + raise InvalidWorkspaceIDError() + + # Validate options before updating workspace + validate_workspace_update_options(options) + + body = self._build_workspace_payload(options, is_create=False) r = self.t.request("PATCH", f"/api/v2/workspaces/{id}", json_body=body) return _ws_from(r.json()["data"], None) - def delete(self, id: str) -> None: + def _build_workspace_payload( + self, + options: WorkspaceCreateOptions | WorkspaceUpdateOptions, + is_create: bool = False, + ) -> dict[str, Any]: + """Build the workspace payload from options following API specification. + + Args: + options: Either WorkspaceCreateOptions or WorkspaceUpdateOptions + is_create: True for create operations, False for update operations + """ + body: dict[str, Any] = {"data": {"type": "workspaces", "attributes": {}}} + + # Add attributes from options + attrs = body["data"]["attributes"] + + # Required field for both create and update: name + attrs["name"] = options.name + + # Common optional attributes + if options.agent_pool_id is not None: + attrs["agent-pool-id"] = options.agent_pool_id + if options.allow_destroy_plan is not None: + attrs["allow-destroy-plan"] = options.allow_destroy_plan + if options.assessments_enabled is not None: + attrs["assessments-enabled"] = options.assessments_enabled + if options.auto_apply is not None: + attrs["auto-apply"] = options.auto_apply + if options.auto_apply_run_trigger is not None: + attrs["auto-apply-run-trigger"] = options.auto_apply_run_trigger + if options.auto_destroy_at is not None: + # Format datetime as ISO8601 string as expected by the API + attrs["auto-destroy-at"] = options.auto_destroy_at.isoformat() + if options.auto_destroy_activity_duration is not None: + attrs["auto-destroy-activity-duration"] = ( + options.auto_destroy_activity_duration + ) + if options.description is not None: + attrs["description"] = options.description + if options.execution_mode is not None: + # Accepts either an enum (with .value) or a string; fallback to the value itself if neither + attrs["execution-mode"] = getattr( + options.execution_mode, "value", options.execution_mode + ) + if options.file_triggers_enabled is not None: + attrs["file-triggers-enabled"] = options.file_triggers_enabled + if options.global_remote_state is not None: + attrs["global-remote-state"] = options.global_remote_state + if options.queue_all_runs is not None: + attrs["queue-all-runs"] = options.queue_all_runs + if options.speculative_enabled is not None: + attrs["speculative-enabled"] = options.speculative_enabled + if options.terraform_version is not None: + attrs["terraform-version"] = options.terraform_version + if options.trigger_patterns: + attrs["trigger-patterns"] = options.trigger_patterns + if options.trigger_prefixes: + attrs["trigger-prefixes"] = options.trigger_prefixes + if options.working_directory is not None: + attrs["working-directory"] = options.working_directory + if options.allow_destroy_plan is not None: + attrs["allow-destroy-plan"] = options.allow_destroy_plan + if options.assessments_enabled is not None: + attrs["assessments-enabled"] = options.assessments_enabled + + # Create-specific attributes + if ( + is_create + and hasattr(options, "source_name") + and options.source_name is not None + ): + attrs["source-name"] = options.source_name + if ( + is_create + and hasattr(options, "source_url") + and options.source_url is not None + ): + attrs["source-url"] = options.source_url + if ( + is_create + and hasattr(options, "structured_run_output_enabled") + and options.structured_run_output_enabled is not None + ): + attrs["structured-run-output-enabled"] = ( + options.structured_run_output_enabled + ) + if ( + is_create + and hasattr(options, "hyok_enabled") + and options.hyok_enabled is not None + ): + attrs["hyok-enabled"] = options.hyok_enabled + + # VCS repository configuration + if hasattr(options, "vcs_repo") and options.vcs_repo is not None: + vcs_data: dict[str, Any] = {} + if options.vcs_repo.oauth_token_id is not None: + vcs_data["oauth-token-id"] = options.vcs_repo.oauth_token_id + if options.vcs_repo.identifier is not None: + vcs_data["identifier"] = options.vcs_repo.identifier + if options.vcs_repo.branch is not None: + vcs_data["branch"] = options.vcs_repo.branch + if options.vcs_repo.ingress_submodules is not None: + vcs_data["ingress-submodules"] = options.vcs_repo.ingress_submodules + if options.vcs_repo.tags_regex is not None: + vcs_data["tags-regex"] = options.vcs_repo.tags_regex + if options.vcs_repo.gha_installation_id is not None: + vcs_data["github-app-installation-id"] = ( + options.vcs_repo.gha_installation_id + ) + attrs["vcs-repo"] = vcs_data + + # Setting overwrites + if ( + hasattr(options, "setting_overwrites") + and options.setting_overwrites is not None + ): + setting_overwrites: dict[str, Any] = {} + if options.setting_overwrites.execution_mode is not None: + setting_overwrites["execution-mode"] = ( + options.setting_overwrites.execution_mode + ) + if options.setting_overwrites.agent_pool is not None: + setting_overwrites["agent-pool"] = options.setting_overwrites.agent_pool + attrs["setting-overwrites"] = setting_overwrites + + # Add relationships + relationships: dict[str, Any] = {} + + if hasattr(options, "project") and options.project and options.project.id: + relationships["project"] = { + "data": {"type": "projects", "id": options.project.id} + } + + if hasattr(options, "tag_bindings") and options.tag_bindings: + relationships["tag-bindings"] = {"data": []} + for binding in options.tag_bindings: + if binding.tag and binding.value: + tag_binding_data = { + "type": "tag-bindings", + "attributes": { + "key": binding.tag.name, + "value": binding.value, + }, + } + relationships["tag-bindings"]["data"].append(tag_binding_data) + + if relationships: + body["data"]["relationships"] = relationships + + return body + + def delete(self, organization: str, name: str) -> None: + """Delete workspace by organization and workspace name.""" + # Validate parameters (similar to Go implementation) + if not is_valid_string_id(organization): + raise InvalidOrgError() + if not is_valid_string_id(name): + raise InvalidWorkspaceValueError() + + self.t.request( + "DELETE", f"/api/v2/organizations/{organization}/workspaces/{name}" + ) + + def delete_by_id(self, id: str) -> None: + """Delete workspace by workspace ID.""" + # Validate parameters (similar to Go implementation) + if not is_valid_string_id(id): + raise InvalidWorkspaceIDError() + self.t.request("DELETE", f"/api/v2/workspaces/{id}") + + def safe_delete(self, organization: str, name: str) -> None: + """Safely delete workspace by organization and name.""" + # Validate parameters (similar to Go implementation) + if not is_valid_string_id(organization): + raise InvalidOrgError() + if not is_valid_string_id(name): + raise InvalidWorkspaceValueError() + + self.t.request( + "POST", + f"/api/v2/organizations/{organization}/workspaces/{name}/actions/safe-delete", + ) + + def safe_delete_by_id(self, id: str) -> None: + """Safely delete workspace by workspace ID.""" + # Validate parameters (similar to Go implementation) + if not is_valid_string_id(id): + raise InvalidWorkspaceIDError() + + self.t.request("POST", f"/api/v2/workspaces/{id}/actions/safe-delete") + + def remove_vcs_connection( + self, + organization: str, + name: str, + *, + options: WorkspaceRemoveVCSConnectionOptions, + ) -> Workspace: + """Remove VCS connection from workspace by organization and name.""" + # Validate parameters + if not is_valid_string_id(organization): + raise InvalidOrgError() + if not is_valid_string_id(name): + raise InvalidWorkspaceValueError() + + body = { + "data": { + "type": "workspaces", + "id": options.id, + "attributes": { + "vcs-repo": None # Setting to None removes the VCS connection + }, + } + } + + r = self.t.request( + "PATCH", + f"/api/v2/organizations/{organization}/workspaces/{name}", + json_body=body, + ) + return _ws_from(r.json()["data"], organization) + + def remove_vcs_connection_by_id( + self, id: str, *, options: WorkspaceRemoveVCSConnectionOptions + ) -> Workspace: + """Remove VCS connection from workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(id): + raise InvalidWorkspaceIDError() + + body = { + "data": { + "type": "workspaces", + "id": options.id, + "attributes": { + "vcs-repo": None # Setting to None removes the VCS connection + }, + } + } + + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{id}", + json_body=body, + ) + return _ws_from(r.json()["data"], None) + + def lock(self, workspace_id: str, *, options: WorkspaceLockOptions) -> Workspace: + """Lock a workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body = {"reason": options.reason} + + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/actions/lock", + json_body=body, + ) + return _ws_from(r.json()["data"], None) + + def unlock(self, workspace_id: str) -> Workspace: + """Unlock a workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + try: + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/actions/unlock", + ) + return _ws_from(r.json()["data"], None) + except Exception as e: + if "latest state version is still pending" in str(e): + raise WorkspaceLockedStateVersionStillPending(str(e)) from e + raise + + def force_unlock(self, workspace_id: str) -> Workspace: + """Force unlock a workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/actions/force-unlock", + ) + return _ws_from(r.json()["data"], None) + + def assign_ssh_key( + self, workspace_id: str, *, options: WorkspaceAssignSSHKeyOptions + ) -> Workspace: + """Assign an SSH key to a workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + if not is_valid_string(options.ssh_key_id): + raise RequiredSSHKeyIDError() + + if not is_valid_string_id(options.ssh_key_id): + raise InvalidSSHKeyIDError() + + body = { + "data": { + "type": "workspaces", + "attributes": {"id": options.ssh_key_id}, + } + } + + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/relationships/ssh-key", + json_body=body, + ) + return _ws_from(r.json()["data"], None) + + def unassign_ssh_key(self, workspace_id: str) -> Workspace: + """Unassign the SSH key from a workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body = { + "data": { + "type": "workspaces", + "attributes": {"id": None}, + } + } + + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/relationships/ssh-key", + json_body=body, + ) + + return _ws_from(r.json()["data"], None) diff --git a/src/tfe/types.py b/src/tfe/types.py index f5581e2b..d87b1f14 100644 --- a/src/tfe/types.py +++ b/src/tfe/types.py @@ -2,6 +2,7 @@ from datetime import datetime from enum import Enum +from typing import Any from pydantic import BaseModel, Field @@ -104,7 +105,7 @@ class Organization(BaseModel): class Project(BaseModel): id: str name: str - organization: str + organization: Organization | str | None = None class Workspace(BaseModel): @@ -113,8 +114,64 @@ class Workspace(BaseModel): organization: str execution_mode: ExecutionMode | None = None project_id: str | None = None - tags: list[str] = Field(default_factory=list) - + + # Core attributes + actions: WorkspaceActions | None = None + allow_destroy_plan: bool = False + assessments_enabled: bool = False + auto_apply: bool = False + auto_apply_run_trigger: bool = False + auto_destroy_at: datetime | None = None + auto_destroy_activity_duration: str | None = None + can_queue_destroy_plan: bool = False + created_at: datetime | None = None + description: str = "" + environment: str = "" + file_triggers_enabled: bool = False + global_remote_state: bool = False + inherits_project_auto_destroy: bool = False + locked: bool = False + migration_environment: str = "" + no_code_upgrade_available: bool = False + operations: bool = False + permissions: WorkspacePermissions | None = None + queue_all_runs: bool = False + speculative_enabled: bool = False + source: WorkspaceSource | None = None + source_name: str = "" + source_url: str = "" + structured_run_output_enabled: bool = False + terraform_version: str = "" + trigger_prefixes: list[str] = Field(default_factory=list) + trigger_patterns: list[str] = Field(default_factory=list) + vcs_repo: VCSRepo | None = None + working_directory: str = "" + updated_at: datetime | None = None + resource_count: int = 0 + apply_duration_average: float | None = None # in seconds + plan_duration_average: float | None = None # in seconds + policy_check_failures: int = 0 + run_failures: int = 0 + runs_count: int = 0 + tag_names: list[str] = Field(default_factory=list) + setting_overwrites: WorkspaceSettingOverwrites | None = None + + # Relations + agent_pool: Any | None = None # AgentPool object + current_run: Any | None = None # Run object + current_state_version: Any | None = None # StateVersion object + project: Project | None = None + ssh_key: Any | None = None # SSHKey object + outputs: list[WorkspaceOutputs] = Field(default_factory=list) + tags: list[Tag] = Field(default_factory=list) + current_configuration_version: Any | None = None # ConfigurationVersion object + locked_by: LockedByChoice | None = None + variables: list[Any] = Field(default_factory=list) # Variable objects + tag_bindings: list[TagBinding] = Field(default_factory=list) + effective_tag_bindings: list[EffectiveTagBinding] = Field(default_factory=list) + + # Links + links: dict[str, Any] = Field(default_factory=dict) class Capacity(BaseModel): organization: str @@ -222,3 +279,233 @@ class DataRetentionPolicyDeleteOlderSetOptions(BaseModel): class DataRetentionPolicyDontDeleteSetOptions(BaseModel): pass # No additional fields needed + +class Tag(BaseModel): + id: str | None = None + name: str = "" + + +class TagBinding(BaseModel): + id: str | None = None # Optional for new tag bindings + tag: Tag | None = None + value: str | None = None + + +class EffectiveTagBinding(BaseModel): + id: str + tag: Tag | None = None + value: str | None = None + inherited: bool = False + + +class WorkspaceIncludeOpt(str, Enum): + ORGANIZATION = "organization" + CURRENT_CONFIG_VER = "current_configuration_version" + CURRENT_CONFIG_VER_INGRESS = "current_configuration_version.ingress_attributes" + CURRENT_RUN = "current_run" + CURRENT_RUN_PLAN = "current_run.plan" + CURRENT_RUN_CONFIG_VER = "current_run.configuration_version" + CURRENT_RUN_CONFIG_VER_INGRESS = ( + "current_run.configuration_version.ingress_attributes" + ) + EFFECTIVE_TAG_BINDINGS = "effective_tag_bindings" + LOCKED_BY = "locked_by" + README = "readme" + OUTPUTS = "outputs" + CURRENT_STATE_VER = "current-state-version" + PROJECT = "project" + + +class VCSRepo(BaseModel): + branch: str | None = None + identifier: str | None = None + ingress_submodules: bool | None = None + oauth_token_id: str | None = None + tags_regex: str | None = None + gha_installation_id: str | None = None + + +class WorkspaceSource(str, Enum): + API = "tfe-api" + MODULE = "tfe-module" + UI = "tfe-ui" + TERRAFORM = "terraform" + + +class WorkspaceActions(BaseModel): + is_destroyable: bool = False + + +class WorkspacePermissions(BaseModel): + can_destroy: bool = False + can_force_unlock: bool = False + can_lock: bool = False + can_manage_run_tasks: bool = False + can_queue_apply: bool = False + can_queue_destroy: bool = False + can_queue_run: bool = False + can_read_settings: bool = False + can_unlock: bool = False + can_update: bool = False + can_update_variable: bool = False + can_force_delete: bool | None = None + + +class WorkspaceSettingOverwrites(BaseModel): + execution_mode: bool | None = None + agent_pool: bool | None = None + + +class WorkspaceOutputs(BaseModel): + id: str + name: str + sensitive: bool = False + output_type: str + value: Any | None = None + + +class LockedByChoice(BaseModel): + run: Any | None = None + user: Any | None = None + team: Any | None = None + + +class WorkspaceListOptions(BaseModel): + """Options for listing workspaces. + + Matches the Go-TFE WorkspaceListOptions struct. + """ + + # Pagination options (from ListOptions) + page_number: int | None = None + page_size: int | None = None + + # Search and filter options + search: str | None = None # search[name] - partial workspace name + tags: str | None = None # search[tags] - comma-separated tag names + exclude_tags: str | None = ( + None # search[exclude-tags] - comma-separated tag names to exclude + ) + wildcard_name: str | None = None # search[wildcard-name] - substring matching + project_id: str | None = None # filter[project][id] - project ID filter + current_run_status: str | None = ( + None # filter[current-run][status] - run status filter + ) + + # Tag binding filters (not URL encoded, handled specially) + tag_bindings: list[TagBinding] = Field(default_factory=list) + + # Include related resources + include: list[WorkspaceIncludeOpt] = Field(default_factory=list) + + # Sorting options + sort: str | None = ( + None # "name" (default) or "current-run.created-at", prepend "-" to reverse + ) + + +class WorkspaceReadOptions(BaseModel): + include: list[WorkspaceIncludeOpt] = Field(default_factory=list) + + +class WorkspaceCreateOptions(BaseModel): + name: str + type: str = "workspaces" + agent_pool_id: str | None = None + allow_destroy_plan: bool | None = None + assessments_enabled: bool | None = None + auto_apply: bool | None = None + auto_apply_run_trigger: bool | None = None + auto_destroy_at: datetime | None = None + auto_destroy_activity_duration: str | None = None + inherits_project_auto_destroy: bool | None = None + description: str | None = None + execution_mode: ExecutionMode | None = None + file_triggers_enabled: bool | None = None + global_remote_state: bool | None = None + migration_environment: str | None = None + operations: bool | None = None + queue_all_runs: bool | None = None + speculative_enabled: bool | None = None + source_name: str | None = None + source_url: str | None = None + structured_run_output_enabled: bool | None = None + terraform_version: str | None = None + trigger_prefixes: list[str] = Field(default_factory=list) + trigger_patterns: list[str] = Field(default_factory=list) + vcs_repo: VCSRepo | None = None + working_directory: str | None = None + hyok_enabled: bool | None = None + tags: list[Tag] = Field(default_factory=list) + setting_overwrites: WorkspaceSettingOverwrites | None = None + project: Project | None = None + tag_bindings: list[TagBinding] = Field(default_factory=list) + + +class WorkspaceUpdateOptions(BaseModel): + name: str + type: str = "workspaces" + agent_pool_id: str | None = None + allow_destroy_plan: bool | None = None + assessments_enabled: bool | None = None + auto_apply: bool | None = None + auto_apply_run_trigger: bool | None = None + auto_destroy_at: datetime | None = None + auto_destroy_activity_duration: str | None = None + inherits_project_auto_destroy: bool | None = None + description: str | None = None + execution_mode: ExecutionMode | None = None + file_triggers_enabled: bool | None = None + global_remote_state: bool | None = None + operations: bool | None = None + queue_all_runs: bool | None = None + speculative_enabled: bool | None = None + structured_run_output_enabled: bool | None = None + terraform_version: str | None = None + trigger_prefixes: list[str] = Field(default_factory=list) + trigger_patterns: list[str] = Field(default_factory=list) + vcs_repo: VCSRepo | None = None + working_directory: str | None = None + hyok_enabled: bool | None = None + setting_overwrites: WorkspaceSettingOverwrites | None = None + project: Project | None = None + tag_bindings: list[TagBinding] = Field(default_factory=list) + +class WorkspaceList(BaseModel): + items: list[Workspace] = Field(default_factory=list) + pagination: Pagination | None = None + + +class TagList(BaseModel): + items: list[Tag] = Field(default_factory=list) + pagination: Pagination | None = None + + +class WorkspaceRemoveVCSConnectionOptions(BaseModel): + """Options for removing VCS connection from a workspace.""" + + # Currently no options are defined, but this class can be extended in the future + id: str + vcs_repo: VCSRepo | None = None + + +class WorkspaceLockOptions(BaseModel): + """Options for locking a workspace.""" + + # Specifies the reason for locking the workspace. + reason: str + + +class WorkspaceAssignSSHKeyOptions(BaseModel): + """Options for assigning an SSH key to a workspace.""" + + ssh_key_id: str + type: str = "workspaces" + + +class workspaceUnassignSSHKeyOptions(BaseModel): + """Options for unassigning an SSH key from a workspace.""" + + # Must be nil to unset the currently assigned SSH key. + ssh_key_id: str + type: str = "workspaces" diff --git a/src/tfe/workspace_validation.py b/src/tfe/workspace_validation.py new file mode 100644 index 00000000..329a6473 --- /dev/null +++ b/src/tfe/workspace_validation.py @@ -0,0 +1,152 @@ +"""Workspace validation functions similar to Go implementation.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .types import VCSRepo, WorkspaceCreateOptions, WorkspaceUpdateOptions + +from .errors import ( + InvalidNameError, + RequiredAgentModeError, + RequiredAgentPoolIDError, + RequiredNameError, + UnsupportedBothTagsRegexAndFileTriggersEnabledError, + UnsupportedBothTagsRegexAndTriggerPatternsError, + UnsupportedBothTagsRegexAndTriggerPrefixesError, + UnsupportedBothTriggerPatternsAndPrefixesError, + UnsupportedOperationsError, +) + +# Regular expression used to validate common string ID patterns +# Matches strings that don't contain '/' or whitespace characters +STRING_ID_PATTERN = re.compile(r"^[^/\s]+$") + + +def is_valid_string(value: str | None) -> bool: + """Check if a string value is valid (not None and not empty).""" + return value is not None and value.strip() != "" + + +def is_valid_string_id(value: str | None) -> bool: + """ + Check if a string is a valid ID (similar to Go's validStringID). + Returns True if the string is non-null and contains a typical string identifier + (no slashes or whitespace). + """ + return value is not None and STRING_ID_PATTERN.match(value) is not None + + +def is_valid_workspace_name(name: str | None) -> bool: + """ + Check if a workspace name is valid. + Terraform Cloud workspace names must: + - Be between 1 and 90 characters + - Only contain letters, numbers, dashes, and underscores + - Cannot start or end with a dash + """ + if not is_valid_string(name): + return False + + if not name: + return False + + # Check length + if len(name) < 1 or len(name) > 90: + return False + + # Check format: alphanumeric, dashes, underscores, but not starting/ending with dash + if not re.match(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*[a-zA-Z0-9_]$|^[a-zA-Z0-9_]$", name): + return False + + return True + + +def has_tags_regex_defined(vcs_repo: VCSRepo | None) -> bool: + """Check if VCS repo has tags regex defined.""" + return vcs_repo is not None and is_valid_string(vcs_repo.tags_regex) + + +def validate_workspace_create_options(options: WorkspaceCreateOptions) -> None: + """ + Validate workspace create options similar to Go implementation. + Raises specific validation errors if validation fails. + """ + # Check required name + if not is_valid_string(options.name): + raise RequiredNameError() + + # Check name format + if not is_valid_workspace_name(options.name): + raise InvalidNameError() + + # Check operations and execution mode conflict + if options.operations is not None and options.execution_mode is not None: + raise UnsupportedOperationsError() + + # Check agent mode requirements + if options.agent_pool_id is not None and ( + options.execution_mode is None or options.execution_mode != "agent" + ): + raise RequiredAgentModeError() + + if ( + options.agent_pool_id is None + and options.execution_mode is not None + and options.execution_mode == "agent" + ): + raise RequiredAgentPoolIDError() + + # Check trigger patterns and prefixes conflict + if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: + raise UnsupportedBothTriggerPatternsAndPrefixesError() + + # Check tags regex conflicts + if has_tags_regex_defined(options.vcs_repo): + if len(options.trigger_patterns) > 0: + raise UnsupportedBothTagsRegexAndTriggerPatternsError() + + if len(options.trigger_prefixes) > 0: + raise UnsupportedBothTagsRegexAndTriggerPrefixesError() + + if options.file_triggers_enabled is not None and options.file_triggers_enabled: + raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() + + +def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: + """ + Validate workspace update options similar to Go implementation. + Raises specific validation errors if validation fails. + """ + # Check name format if provided + if options.name is not None and not is_valid_workspace_name(options.name): + raise InvalidNameError() + + # Check operations and execution mode conflict + if options.operations is not None and options.execution_mode is not None: + raise UnsupportedOperationsError() + + # Check agent mode requirements + if ( + options.agent_pool_id is None + and options.execution_mode is not None + and options.execution_mode == "agent" + ): + raise RequiredAgentPoolIDError() + + # Check trigger patterns and prefixes conflict + if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: + raise UnsupportedBothTriggerPatternsAndPrefixesError() + + # Check tags regex conflicts + if has_tags_regex_defined(options.vcs_repo): + if len(options.trigger_patterns) > 0: + raise UnsupportedBothTagsRegexAndTriggerPatternsError() + + if len(options.trigger_prefixes) > 0: + raise UnsupportedBothTagsRegexAndTriggerPrefixesError() + + if options.file_triggers_enabled is not None and options.file_triggers_enabled: + raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() diff --git a/tests/units/test_workspaces.py b/tests/units/test_workspaces.py new file mode 100644 index 00000000..c3582c59 --- /dev/null +++ b/tests/units/test_workspaces.py @@ -0,0 +1,670 @@ +""" +Comprehensive unit tests for workspace operations in the Python TFE SDK. + +This test suite covers all workspace methods including CRUD operations, +VCS management, locking/unlocking, SSH key management, and validation. +""" + +from unittest.mock import Mock + +import pytest + +from src.tfe.errors import ( + InvalidOrgError, + InvalidSSHKeyIDError, + InvalidWorkspaceIDError, + InvalidWorkspaceValueError, + RequiredSSHKeyIDError, +) +from src.tfe.resources.workspaces import Workspaces, _ws_from +from src.tfe.types import ( + ExecutionMode, + Project, + VCSRepo, + WorkspaceAssignSSHKeyOptions, + WorkspaceCreateOptions, + WorkspaceListOptions, + WorkspaceLockOptions, + WorkspaceReadOptions, + WorkspaceRemoveVCSConnectionOptions, + WorkspaceUpdateOptions, +) + + +class TestWorkspaceOperations: + """Test suite for workspace CRUD operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def workspaces_service(self, mock_transport): + """Create workspaces service with mocked transport.""" + return Workspaces(mock_transport) + + @pytest.fixture + def sample_workspace_response(self): + """Sample JSON:API workspace response.""" + return { + "data": { + "type": "workspaces", + "id": "ws-abc123def456", + "attributes": { + "name": "test-workspace", + "description": "Test workspace for unit tests", + "auto-apply": True, + "execution-mode": "remote", + "terraform-version": "1.5.0", + "working-directory": "terraform/", + "file-triggers-enabled": True, + "queue-all-runs": False, + "speculative-enabled": True, + "operations": True, + "locked": False, + "created-at": "2023-09-11T10:30:00.000Z", + "updated-at": "2023-09-11T15:45:00.000Z", + "resource-count": 25, + "trigger-prefixes": ["modules/"], + "trigger-patterns": ["**/*.tf", "**/*.tfvars"], + "tag-names": ["production", "frontend"], + "vcs-repo": { + "identifier": "org/repo", + "branch": "main", + "oauth-token-id": "ot-123", + "ingress-submodules": False, + "tags-regex": "v\\d+\\.\\d+\\.\\d+", + }, + }, + "relationships": { + "project": {"data": {"type": "projects", "id": "prj-xyz789"}}, + "current-run": {"data": {"type": "runs", "id": "run-def456"}}, + "locked-by": {"data": {"type": "users", "id": "user-123"}}, + }, + } + } + + @pytest.fixture + def sample_workspace_list_response(self): + """Sample JSON:API workspace list response.""" + return { + "data": [ + { + "type": "workspaces", + "id": "ws-123", + "attributes": { + "name": "workspace-1", + "description": "First workspace", + "auto-apply": False, + "execution-mode": "local", + "locked": False, + }, + }, + { + "type": "workspaces", + "id": "ws-456", + "attributes": { + "name": "workspace-2", + "description": "Second workspace", + "auto-apply": True, + "execution-mode": "remote", + "locked": True, + }, + }, + ] + } + + # ========================================== + # LIST OPERATIONS TESTS + # ========================================== + + def test_list_workspaces_basic( + self, workspaces_service, mock_transport, sample_workspace_list_response + ): + """Test basic workspace listing.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_list_response + ) + + options = WorkspaceListOptions() + workspaces = list(workspaces_service.list("test-org", options=options)) + + assert len(workspaces) == 2 + assert workspaces[0].name == "workspace-1" + assert workspaces[1].name == "workspace-2" + assert not workspaces[0].auto_apply + assert workspaces[1].auto_apply + + def test_list_workspaces_with_search(self, workspaces_service, mock_transport): + """Test workspace listing with search options.""" + mock_transport.request.return_value.json.return_value = {"data": []} + + options = WorkspaceListOptions( + search="production", + tags="frontend,backend", + exclude_tags="deprecated", + project_id="prj-123", + ) + + list(workspaces_service.list("test-org", options=options)) + + # Verify search parameters were passed correctly + call_args = mock_transport.request.call_args + params = call_args[1]["params"] + assert params["search[name]"] == "production" + assert params["search[tags]"] == "frontend,backend" + assert params["search[exclude-tags]"] == "deprecated" + assert params["filter[project][id]"] == "prj-123" + + def test_list_workspaces_invalid_org(self, workspaces_service): + """Test list with invalid organization.""" + options = WorkspaceListOptions() + + with pytest.raises(InvalidOrgError): + list(workspaces_service.list("", options=options)) + + with pytest.raises(InvalidOrgError): + list(workspaces_service.list("org/with/slash", options=options)) + + # ========================================== + # READ OPERATIONS TESTS + # ========================================== + + def test_read_workspace_by_name( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test reading workspace by organization and name.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + workspace = workspaces_service.read("test-org", "test-workspace") + + assert workspace.id == "ws-abc123def456" + assert workspace.name == "test-workspace" + assert workspace.description == "Test workspace for unit tests" + assert workspace.auto_apply + assert workspace.execution_mode == ExecutionMode.REMOTE + assert workspace.terraform_version == "1.5.0" + assert workspace.working_directory == "terraform/" + assert workspace.resource_count == 25 + assert workspace.trigger_prefixes == ["modules/"] + assert workspace.trigger_patterns == ["**/*.tf", "**/*.tfvars"] + assert workspace.tag_names == ["production", "frontend"] + + def test_read_workspace_by_id( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test reading workspace by ID.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + workspace = workspaces_service.read_by_id("ws-abc123def456") + + assert workspace.id == "ws-abc123def456" + assert workspace.name == "test-workspace" + + def test_read_workspace_with_options( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test reading workspace with include options.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + from src.tfe.types import WorkspaceIncludeOpt + + options = WorkspaceReadOptions( + include=[WorkspaceIncludeOpt.CURRENT_RUN, WorkspaceIncludeOpt.OUTPUTS] + ) + + workspace = workspaces_service.read_with_options( + "test-workspace", "test-org", options=options + ) + + assert workspace.id == "ws-abc123def456" + + # Verify include parameter was passed + call_args = mock_transport.request.call_args + params = call_args[1]["params"] + assert "include" in params + + def test_read_workspace_invalid_params(self, workspaces_service): + """Test read with invalid parameters.""" + with pytest.raises(InvalidOrgError): + workspaces_service.read("", "workspace-name") + + with pytest.raises(InvalidWorkspaceValueError): + workspaces_service.read("valid-org", "") + + with pytest.raises(InvalidWorkspaceIDError): + workspaces_service.read_by_id("") + + # ========================================== + # CREATE OPERATIONS TESTS + # ========================================== + + def test_create_workspace_basic( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test basic workspace creation.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceCreateOptions( + name="new-workspace", + description="A new test workspace", + auto_apply=True, + execution_mode=ExecutionMode.REMOTE, + terraform_version="1.5.0", + ) + + workspace = workspaces_service.create("test-org", options=options) + + assert workspace.id == "ws-abc123def456" + assert workspace.name == "test-workspace" + + # Verify POST request was made + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert "organizations/test-org/workspaces" in call_args[0][1] + + def test_create_workspace_with_vcs( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test workspace creation with VCS configuration.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + vcs_repo = VCSRepo( + identifier="myorg/myrepo", + branch="main", + oauth_token_id="ot-123456", + ingress_submodules=False, + tags_regex="v\\d+\\.\\d+\\.\\d+", + ) + + options = WorkspaceCreateOptions( + name="vcs-workspace", + vcs_repo=vcs_repo, + working_directory="terraform/", + # Remove trigger_prefixes to avoid conflict with tags_regex + ) + + workspace = workspaces_service.create("test-org", options=options) + + assert workspace.id == "ws-abc123def456" + + # Verify VCS configuration in payload + call_args = mock_transport.request.call_args + payload = call_args[1]["json_body"] + vcs_data = payload["data"]["attributes"]["vcs-repo"] + assert vcs_data["identifier"] == "myorg/myrepo" + assert vcs_data["oauth-token-id"] == "ot-123456" + assert vcs_data["tags-regex"] == "v\\d+\\.\\d+\\.\\d+" + + def test_create_workspace_with_project( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test workspace creation with project relationship.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + project = Project(id="prj-123", name="Test Project") + + options = WorkspaceCreateOptions(name="project-workspace", project=project) + + workspaces_service.create("test-org", options=options) + + # Verify project relationship in payload + call_args = mock_transport.request.call_args + payload = call_args[1]["json_body"] + project_rel = payload["data"]["relationships"]["project"] + assert project_rel["data"]["type"] == "projects" + assert project_rel["data"]["id"] == "prj-123" + + def test_create_workspace_invalid_org(self, workspaces_service): + """Test create with invalid organization.""" + options = WorkspaceCreateOptions(name="test-workspace") + + with pytest.raises(InvalidOrgError): + workspaces_service.create("", options=options) + + # ========================================== + # UPDATE OPERATIONS TESTS + # ========================================== + + def test_update_workspace_by_name( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test updating workspace by name.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceUpdateOptions( + name="test-workspace", # Required field + description="Updated description", + auto_apply=False, + terraform_version="1.6.0", + ) + + workspace = workspaces_service.update( + "test-org", "test-workspace", options=options + ) + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request was made + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert "organizations/test-org/workspaces/test-workspace" in call_args[0][1] + + def test_update_workspace_by_id( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test updating workspace by ID.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceUpdateOptions(name="dummy", auto_apply=True) + + workspace = workspaces_service.update_by_id("ws-123", options=options) + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request to workspace ID endpoint + call_args = mock_transport.request.call_args + assert "workspaces/ws-123" in call_args[0][1] + + # ========================================== + # DELETE OPERATIONS TESTS + # ========================================== + + def test_delete_workspace_by_name(self, workspaces_service, mock_transport): + """Test deleting workspace by name.""" + mock_transport.request.return_value = Mock() + + workspaces_service.delete("test-org", "test-workspace") + + # Verify DELETE request was made + call_args = mock_transport.request.call_args + assert call_args[0][0] == "DELETE" + assert "organizations/test-org/workspaces/test-workspace" in call_args[0][1] + + def test_delete_workspace_by_id(self, workspaces_service, mock_transport): + """Test deleting workspace by ID.""" + mock_transport.request.return_value = Mock() + + workspaces_service.delete_by_id("ws-123") + + # Verify DELETE request to workspace ID endpoint + call_args = mock_transport.request.call_args + assert "workspaces/ws-123" in call_args[0][1] + + def test_safe_delete_workspace(self, workspaces_service, mock_transport): + """Test safe delete workspace operations.""" + mock_transport.request.return_value = Mock() + + # Test safe delete by name + workspaces_service.safe_delete("test-org", "test-workspace") + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert "actions/safe-delete" in call_args[0][1] + + # Test safe delete by ID + workspaces_service.safe_delete_by_id("ws-123") + call_args = mock_transport.request.call_args + assert "workspaces/ws-123/actions/safe-delete" in call_args[0][1] + + # ========================================== + # VCS CONNECTION TESTS + # ========================================== + + def test_remove_vcs_connection_by_name( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test removing VCS connection by name.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceRemoveVCSConnectionOptions(id="ws-123") + workspace = workspaces_service.remove_vcs_connection( + "test-org", "test-workspace", options=options + ) + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request to remove VCS + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + payload = call_args[1]["json_body"] + assert payload["data"]["attributes"]["vcs-repo"] is None + + def test_remove_vcs_connection_by_id( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test removing VCS connection by ID.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceRemoveVCSConnectionOptions(id="ws-123") + workspace = workspaces_service.remove_vcs_connection_by_id( + "ws-123", options=options + ) + + assert workspace.id == "ws-abc123def456" + + # ========================================== + # LOCKING/UNLOCKING TESTS + # ========================================== + + def test_lock_workspace( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test locking a workspace.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceLockOptions(reason="Maintenance in progress") + workspace = workspaces_service.lock("ws-123", options=options) + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request to lock endpoint + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert "workspaces/ws-123/actions/lock" in call_args[0][1] + + payload = call_args[1]["json_body"] + assert payload["reason"] == "Maintenance in progress" + + def test_unlock_workspace( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test unlocking a workspace.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + workspace = workspaces_service.unlock("ws-123") + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request to unlock endpoint + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert "workspaces/ws-123/actions/unlock" in call_args[0][1] + + def test_force_unlock_workspace( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test force unlocking a workspace.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + workspace = workspaces_service.force_unlock("ws-123") + + assert workspace.id == "ws-abc123def456" + + # Verify POST request to force-unlock endpoint + call_args = mock_transport.request.call_args + assert "workspaces/ws-123/actions/force-unlock" in call_args[0][1] + + # ========================================== + # SSH KEY MANAGEMENT TESTS + # ========================================== + + def test_assign_ssh_key( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test assigning SSH key to workspace.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + options = WorkspaceAssignSSHKeyOptions(ssh_key_id="sshkey-123") + workspace = workspaces_service.assign_ssh_key("ws-123", options=options) + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request to SSH key relationship endpoint + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + # Note: There's a typo in the current implementation - "relastionships" should be "relationships" + assert "ssh-key" in call_args[0][1] + + payload = call_args[1]["json_body"] + assert payload["data"]["attributes"]["id"] == "sshkey-123" + + def test_assign_ssh_key_validation_errors(self, workspaces_service): + """Test SSH key assignment validation errors.""" + # Invalid workspace ID + options = WorkspaceAssignSSHKeyOptions(ssh_key_id="sshkey-123") + with pytest.raises(InvalidWorkspaceIDError): + workspaces_service.assign_ssh_key("", options=options) + + # Missing SSH key ID + options = WorkspaceAssignSSHKeyOptions(ssh_key_id="") + with pytest.raises(RequiredSSHKeyIDError): + workspaces_service.assign_ssh_key("ws-123", options=options) + + # Invalid SSH key ID format + options = WorkspaceAssignSSHKeyOptions(ssh_key_id="invalid/ssh/key") + with pytest.raises(InvalidSSHKeyIDError): + workspaces_service.assign_ssh_key("ws-123", options=options) + + def test_unassign_ssh_key( + self, workspaces_service, mock_transport, sample_workspace_response + ): + """Test unassigning SSH key from workspace.""" + mock_transport.request.return_value.json.return_value = ( + sample_workspace_response + ) + + workspace = workspaces_service.unassign_ssh_key("ws-123") + + assert workspace.id == "ws-abc123def456" + + # Verify PATCH request to unassign SSH key + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert "relationships/ssh-key" in call_args[0][1] + + payload = call_args[1]["json_body"] + assert payload["data"]["attributes"]["id"] is None + + # ========================================== + # HELPER FUNCTION TESTS + # ========================================== + + def test_ws_from_conversion(self, sample_workspace_response): + """Test _ws_from helper function conversion.""" + workspace_data = sample_workspace_response["data"] + workspace = _ws_from(workspace_data, "test-org") + + assert workspace.id == "ws-abc123def456" + assert workspace.name == "test-workspace" + assert workspace.organization == "test-org" + assert workspace.auto_apply + assert workspace.execution_mode == ExecutionMode.REMOTE + assert workspace.resource_count == 25 + assert len(workspace.trigger_prefixes) == 1 + assert len(workspace.trigger_patterns) == 2 + assert len(workspace.tag_names) == 2 + + # Test VCS repo conversion + assert workspace.vcs_repo is not None + assert workspace.vcs_repo.identifier == "org/repo" + assert workspace.vcs_repo.branch == "main" + assert workspace.vcs_repo.oauth_token_id == "ot-123" + + def test_ws_from_minimal_data(self): + """Test _ws_from with minimal data.""" + minimal_data = {"id": "ws-minimal", "attributes": {"name": "minimal-workspace"}} + + workspace = _ws_from(minimal_data, "test-org") + + assert workspace.id == "ws-minimal" + assert workspace.name == "minimal-workspace" + assert workspace.organization == "test-org" + assert not workspace.auto_apply # Default value + assert not workspace.locked # Default value + + # ========================================== + # EDGE CASES AND ERROR HANDLING + # ========================================== + + def test_empty_workspace_list(self, workspaces_service, mock_transport): + """Test handling empty workspace list.""" + mock_transport.request.return_value.json.return_value = {"data": []} + + options = WorkspaceListOptions() + workspaces = list(workspaces_service.list("test-org", options=options)) + + assert len(workspaces) == 0 + + def test_malformed_response_handling(self, workspaces_service, mock_transport): + """Test handling of malformed API responses.""" + # Test missing data field + mock_transport.request.return_value.json.return_value = {} + + options = WorkspaceListOptions() + workspaces = list(workspaces_service.list("test-org", options=options)) + assert len(workspaces) == 0 + + def test_none_values_handling(self): + """Test handling of None values in workspace data.""" + data_with_nones = { + "id": "ws-123", + "attributes": { + "name": "test-workspace", + "description": None, + "terraform-version": None, + "working-directory": None, + "vcs-repo": None, + }, + } + + workspace = _ws_from(data_with_nones, "test-org") + + assert workspace.description == "" # Should convert None to empty string + assert workspace.terraform_version == "" + assert workspace.working_directory == "" + assert workspace.vcs_repo is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From f9867e7077242176aed93f264b9fa0066fd72fd6 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 12 Sep 2025 12:05:41 +0530 Subject: [PATCH 2/6] ruff format fix --- src/tfe/errors.py | 1 + src/tfe/types.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tfe/errors.py b/src/tfe/errors.py index 528657af..6b6c02e4 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -58,6 +58,7 @@ class RequiredFieldMissing(TFEError): ... ERR_INVALID_ORG = "invalid organization name" ERR_REQUIRED_EMAIL = "email is required" + class WorkspaceNotFound(NotFound): ... diff --git a/src/tfe/types.py b/src/tfe/types.py index d87b1f14..19237122 100644 --- a/src/tfe/types.py +++ b/src/tfe/types.py @@ -114,7 +114,7 @@ class Workspace(BaseModel): organization: str execution_mode: ExecutionMode | None = None project_id: str | None = None - + # Core attributes actions: WorkspaceActions | None = None allow_destroy_plan: bool = False @@ -173,6 +173,7 @@ class Workspace(BaseModel): # Links links: dict[str, Any] = Field(default_factory=dict) + class Capacity(BaseModel): organization: str pending: int @@ -280,6 +281,7 @@ class DataRetentionPolicyDeleteOlderSetOptions(BaseModel): class DataRetentionPolicyDontDeleteSetOptions(BaseModel): pass # No additional fields needed + class Tag(BaseModel): id: str | None = None name: str = "" @@ -471,6 +473,7 @@ class WorkspaceUpdateOptions(BaseModel): project: Project | None = None tag_bindings: list[TagBinding] = Field(default_factory=list) + class WorkspaceList(BaseModel): items: list[Workspace] = Field(default_factory=list) pagination: Pagination | None = None From be8098cff21eaaff1c34b8ce5a7bc5ef5e1b297a Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 12 Sep 2025 16:00:05 +0530 Subject: [PATCH 3/6] Import Client handled at examples --- examples/workspace_example.py | 34 ++++++++++++++++----------------- src/tfe/resources/workspaces.py | 6 +++--- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/workspace_example.py b/examples/workspace_example.py index aced02da..d8f2a307 100644 --- a/examples/workspace_example.py +++ b/examples/workspace_example.py @@ -22,7 +22,7 @@ # Add the source directory to the path for direct execution sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from tfe import Client +from tfe import TFEClient, TFEConfig from tfe.errors import ( InvalidOrgError, InvalidWorkspaceIDError, @@ -32,7 +32,7 @@ ExecutionMode, VCSRepo, WorkspaceCreateOptions, - WorkspaceInclude, + WorkspaceIncludeOpt, WorkspaceListOptions, WorkspaceLockOptions, WorkspaceReadOptions, @@ -44,9 +44,9 @@ class WorkspaceManager: """Comprehensive workspace management utility.""" - def __init__(self, token: str, address: str = "https://app.terraform.io"): + def __init__(self): """Initialize the workspace manager.""" - self.client = Client(token=token, address=address) + self.client = TFEClient(TFEConfig.from_env()) self.workspaces = self.client.workspaces def demonstrate_all_operations(self, organization: str): @@ -82,7 +82,7 @@ def demonstrate_all_operations(self, organization: str): self.demo_delete_operations(organization, workspace_name, workspace_id) except Exception as e: - print(f"โŒ Error during demo: {e}") + print(f"Error during demo: {e}") raise print("\n๐ŸŽ‰ Comprehensive workspace demo completed successfully!") @@ -109,7 +109,7 @@ def demo_list_operations(self, organization: str): filtered_options = WorkspaceListOptions( search="prod", # Search for workspaces containing "prod" tags="production,frontend", # Filter by tags - include=[WorkspaceInclude.current_run], # Include current run info + include=[WorkspaceIncludeOpt.CURRENT_RUN], # Include current run info page_size=5, # Limit results ) @@ -135,15 +135,13 @@ def demo_create_operations(self, organization: str): name=workspace_name, description=f"Demo workspace created at {datetime.now()}", auto_apply=False, - execution_mode=ExecutionMode.remote, + execution_mode=ExecutionMode.REMOTE, terraform_version="1.5.0", working_directory="terraform/", file_triggers_enabled=True, queue_all_runs=False, speculative_enabled=True, - operations=True, trigger_prefixes=["modules/", "shared/"], - trigger_patterns=["**/*.tf", "**/*.tfvars"], ) workspace = self.workspaces.create(organization, options=basic_options) @@ -212,7 +210,7 @@ def demo_read_operations( # Read with additional include options print("\n๐Ÿ“„ Reading workspace with include options...") read_options = WorkspaceReadOptions( - include=[WorkspaceInclude.current_run, WorkspaceInclude.outputs] + include=[WorkspaceIncludeOpt.CURRENT_RUN, WorkspaceIncludeOpt.OUTPUTS] ) detailed_workspace = self.workspaces.read_with_options( @@ -232,6 +230,7 @@ def demo_update_operations( # Update by name print("๐Ÿ”ง Updating workspace by name...") update_options = WorkspaceUpdateOptions( + name=workspace_name, # Required field description=f"Updated description at {datetime.now()}", auto_apply=True, # Enable auto-apply terraform_version="1.6.0", # Update Terraform version @@ -250,6 +249,7 @@ def demo_update_operations( # Update by ID print("\n๐Ÿ”ง Updating workspace by ID...") id_update_options = WorkspaceUpdateOptions( + name=workspace_name, # Required field speculative_enabled=False, # Disable speculative plans operations=False, # Switch to local execution ) @@ -401,20 +401,20 @@ def main(): organization = os.getenv("TFE_ORG", "your-org-name") # Replace with your org if not token: - print("โŒ Error: TFE_TOKEN environment variable is required") - print("๐Ÿ“ Set it with: export TFE_TOKEN=your-token-here") + print("Error: TFE_TOKEN environment variable is required") + print("Set it with: export TFE_TOKEN=your-token-here") sys.exit(1) if organization == "your-org-name": - print("โš ๏ธ Warning: Using default organization name") - print("๐Ÿ“ Set TFE_ORG environment variable or update the script") + print("Warning: Using default organization name") + print("Set TFE_ORG environment variable or update the script") # Allow user to input organization name org_input = input("Enter your organization name: ").strip() if org_input: organization = org_input else: - print("โŒ Organization name is required") + print("Organization name is required") sys.exit(1) print(f"๐ŸŒ Terraform Address: {address}") @@ -425,7 +425,7 @@ def main(): try: # Initialize workspace manager - manager = WorkspaceManager(token=token, address=address) + manager = WorkspaceManager() # Run comprehensive demo manager.demonstrate_all_operations(organization) @@ -434,7 +434,7 @@ def main(): manager.demo_error_handling(organization) except Exception as e: - print(f"\nโŒ Demo failed with error: {e}") + print(f"\nDemo failed with error: {e}") print("๐Ÿ’ก Common issues:") print(" โ€ข Invalid token or organization") print(" โ€ข Network connectivity problems") diff --git a/src/tfe/resources/workspaces.py b/src/tfe/resources/workspaces.py index 0cb1ab07..d2299011 100644 --- a/src/tfe/resources/workspaces.py +++ b/src/tfe/resources/workspaces.py @@ -611,7 +611,7 @@ def lock(self, workspace_id: str, *, options: WorkspaceLockOptions) -> Workspace body = {"reason": options.reason} r = self.t.request( - "PATCH", + "POST", f"/api/v2/workspaces/{workspace_id}/actions/lock", json_body=body, ) @@ -624,7 +624,7 @@ def unlock(self, workspace_id: str) -> Workspace: raise InvalidWorkspaceIDError() try: r = self.t.request( - "PATCH", + "POST", f"/api/v2/workspaces/{workspace_id}/actions/unlock", ) return _ws_from(r.json()["data"], None) @@ -640,7 +640,7 @@ def force_unlock(self, workspace_id: str) -> Workspace: raise InvalidWorkspaceIDError() r = self.t.request( - "PATCH", + "POST", f"/api/v2/workspaces/{workspace_id}/actions/force-unlock", ) return _ws_from(r.json()["data"], None) From ec0e8ec4a2c5ecae748b620ce5314eacf31145d4 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 12 Sep 2025 16:22:34 +0530 Subject: [PATCH 4/6] testcase modified --- tests/units/test_workspaces.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/units/test_workspaces.py b/tests/units/test_workspaces.py index c3582c59..5af88ec8 100644 --- a/tests/units/test_workspaces.py +++ b/tests/units/test_workspaces.py @@ -484,7 +484,7 @@ def test_lock_workspace( # Verify PATCH request to lock endpoint call_args = mock_transport.request.call_args - assert call_args[0][0] == "PATCH" + assert call_args[0][0] == "POST" assert "workspaces/ws-123/actions/lock" in call_args[0][1] payload = call_args[1]["json_body"] @@ -504,7 +504,7 @@ def test_unlock_workspace( # Verify PATCH request to unlock endpoint call_args = mock_transport.request.call_args - assert call_args[0][0] == "PATCH" + assert call_args[0][0] == "POST" assert "workspaces/ws-123/actions/unlock" in call_args[0][1] def test_force_unlock_workspace( From 3c5123b181c0d5551a95a7523619a1bfabd2c68a Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 15 Sep 2025 12:03:05 +0530 Subject: [PATCH 5/6] Workspace remote state consumers, tags, tagbindings, data retention policy were added --- examples/workspace_example.py | 574 ++++++++++++++++++++- src/tfe/errors.py | 28 ++ src/tfe/resources/workspaces.py | 469 +++++++++++++++++- src/tfe/types.py | 66 ++- tests/units/test_workspaces.py | 848 ++++++++++++++++++++++++++++++++ 5 files changed, 1971 insertions(+), 14 deletions(-) diff --git a/examples/workspace_example.py b/examples/workspace_example.py index d8f2a307..e69c5940 100644 --- a/examples/workspace_example.py +++ b/examples/workspace_example.py @@ -29,15 +29,27 @@ TFEError, ) from tfe.types import ( + DataRetentionPolicyDeleteOlderSetOptions, + DataRetentionPolicyDontDeleteSetOptions, ExecutionMode, + Tag, + TagBinding, VCSRepo, + WorkspaceAddRemoteStateConsumersOptions, + WorkspaceAddTagBindingsOptions, + WorkspaceAddTagsOptions, WorkspaceCreateOptions, WorkspaceIncludeOpt, WorkspaceListOptions, + WorkspaceListRemoteStateConsumersOptions, WorkspaceLockOptions, WorkspaceReadOptions, + WorkspaceRemoveRemoteStateConsumersOptions, + WorkspaceRemoveTagsOptions, WorkspaceRemoveVCSConnectionOptions, + WorkspaceTagListOptions, WorkspaceUpdateOptions, + WorkspaceUpdateRemoteStateConsumersOptions, ) @@ -78,7 +90,19 @@ def demonstrate_all_operations(self, organization: str): # 7. SSH key operations (commented out as it requires existing SSH keys) # self.demo_ssh_key_operations(workspace_id) - # 8. Cleanup - delete the test workspace + # 8. Remote state consumer operations + self.demo_remote_state_consumer_operations(organization, workspace_id) + + # 9. Tag operations + self.demo_tag_operations(workspace_id) + + # 9B. Tag binding operations + self.demo_tag_binding_operations(workspace_id) + + # 9C. Data retention policy operations + self.demo_data_retention_policy_operations(workspace_id) + + # 10. Cleanup - delete the test workspace self.demo_delete_operations(organization, workspace_name, workspace_id) except Exception as e: @@ -343,11 +367,557 @@ def demo_ssh_key_operations(self, workspace_id: str): print(f" โš ๏ธ SSH key operation failed: {e}") """ + def demo_remote_state_consumer_operations( + self, organization: str, workspace_id: str + ): + """Demonstrate remote state consumer management operations.""" + print("\n๐Ÿ”— 7. REMOTE STATE CONSUMER OPERATIONS") + print("-" * 40) + + try: + # 1. List current remote state consumers + print("๐Ÿ“‹ Listing current remote state consumers...") + list_options = WorkspaceListRemoteStateConsumersOptions(page_size=10) + + current_consumers = list( + self.workspaces.list_remote_state_consumers(workspace_id, list_options) + ) + print(f" ๐Ÿ“Š Found {len(current_consumers)} current consumer(s)") + + for consumer in current_consumers: + print(f" ๐Ÿ”— Consumer: {consumer.name} (ID: {consumer.id})") + + # 2. Get real workspaces from organization for demonstration + print("\n๐Ÿ—๏ธ Getting real workspaces for consumer demonstration...") + + # Get existing workspaces from the organization to use as examples + from tfe.types import WorkspaceListOptions + + org_list_options = WorkspaceListOptions(page_size=5) + + try: + # Get list of existing workspaces (excluding the current one) + all_workspaces = list( + self.workspaces.list(organization, options=org_list_options) + ) + + # Filter out the current workspace and get up to 2 others for demo + available_workspaces = [ + ws for ws in all_workspaces if ws.id != workspace_id + ] + + if len(available_workspaces) >= 2: + demo_consumer_1 = available_workspaces[0] + demo_consumer_2 = available_workspaces[1] + + print(" ๐Ÿ“ Using real workspaces for demonstration:") + print( + f" ๐Ÿข Consumer 1: {demo_consumer_1.name} (ID: {demo_consumer_1.id})" + ) + print( + f" ๐Ÿข Consumer 2: {demo_consumer_2.name} (ID: {demo_consumer_2.id})" + ) + + use_real_workspaces = True + else: + print( + f" โš ๏ธ Only {len(available_workspaces)} other workspaces available" + ) + print( + " ๐Ÿ“ Need at least 2 other workspaces for full demonstration" + ) + print(" ๐Ÿ—๏ธ Creating minimal demo with available workspaces...") + use_real_workspaces = False + + except Exception as ws_error: + print(f" โŒ Could not fetch organization workspaces: {ws_error}") + use_real_workspaces = False + + if not use_real_workspaces: + # Fallback to showing the concept with mock data + print(" ๐Ÿ“ Using mock workspace references for concept demonstration") + print( + " ๐Ÿข In practice, use actual workspace IDs from your organization" + ) + + # Create mock workspaces for demonstration only + from tfe.types import Workspace + + demo_consumer_1 = Workspace( + id="ws-demo-consumer-1", + name="demo-consumer-1", + organization="demo-org", + ) + demo_consumer_2 = Workspace( + id="ws-demo-consumer-2", + name="demo-consumer-2", + organization="demo-org", + ) + + # 3. Add remote state consumers + print("\nโž• Adding remote state consumers...") + add_options = WorkspaceAddRemoteStateConsumersOptions( + workspaces=[demo_consumer_1, demo_consumer_2] + ) + + # Note: This will fail in demo since we're using mock workspaces + try: + self.workspaces.add_remote_state_consumers(workspace_id, add_options) + print(" โœ… Successfully added remote state consumers") + print(f" ๐Ÿ”— Added consumer: {demo_consumer_1.name}") + print(f" ๐Ÿ”— Added consumer: {demo_consumer_2.name}") + except Exception as add_error: + expected_msg = ( + "(expected with mock data)" if not use_real_workspaces else "" + ) + print(f" โš ๏ธ Add operation failed {expected_msg}: {add_error}") + if not use_real_workspaces: + print( + " ๐Ÿ“ This is expected when using non-existent workspace IDs" + ) + + # 4. List consumers after adding (would show updated list in real scenario) + print("\n๐Ÿ“‹ Listing consumers after adding...") + updated_consumers = list( + self.workspaces.list_remote_state_consumers(workspace_id, list_options) + ) + print(f" ๐Ÿ“Š Current consumer count: {len(updated_consumers)}") + + # 5. Remove a remote state consumer + print("\nโž– Removing a remote state consumer...") + remove_options = WorkspaceRemoveRemoteStateConsumersOptions( + workspaces=[demo_consumer_1] + ) + + try: + self.workspaces.remove_remote_state_consumers( + workspace_id, remove_options + ) + print(f" โœ… Successfully removed consumer: {demo_consumer_1.name}") + except Exception as remove_error: + expected_msg = ( + "(expected with mock data)" if not use_real_workspaces else "" + ) + print(f" โš ๏ธ Remove operation failed {expected_msg}: {remove_error}") + + # 6. Update remote state consumers (replace all) + print("\n๐Ÿ”„ Updating remote state consumers (replacing all)...") + + if use_real_workspaces and len(available_workspaces) >= 3: + # Use a third real workspace if available + demo_consumer_3 = available_workspaces[2] + print( + f" ๐Ÿข Consumer 3: {demo_consumer_3.name} (ID: {demo_consumer_3.id})" + ) + else: + # Create mock workspace for demonstration + demo_consumer_3 = Workspace( + id="ws-demo-consumer-3", + name="demo-consumer-3", + organization="demo-org", + ) + + update_options = WorkspaceUpdateRemoteStateConsumersOptions( + workspaces=[ + demo_consumer_2, + demo_consumer_3, + ] # Keep consumer 2, add consumer 3 + ) + + try: + self.workspaces.update_remote_state_consumers( + workspace_id, update_options + ) + print(" โœ… Successfully updated remote state consumers") + print( + f" ๐Ÿ”— New consumer set: {demo_consumer_2.name}, {demo_consumer_3.name}" + ) + except Exception as update_error: + expected_msg = ( + "(expected with mock data)" if not use_real_workspaces else "" + ) + print(f" โš ๏ธ Update operation failed {expected_msg}: {update_error}") + + # 7. Final listing to show results + print("\n๐Ÿ“‹ Final remote state consumer listing...") + final_consumers = list( + self.workspaces.list_remote_state_consumers(workspace_id, list_options) + ) + print(f" ๐Ÿ“Š Final consumer count: {len(final_consumers)}") + + for consumer in final_consumers: + print(f" ๐Ÿ”— Final consumer: {consumer.name} (ID: {consumer.id})") + + # Best practices and tips + print("\n๐Ÿ’ก REMOTE STATE CONSUMER BEST PRACTICES:") + print(" ๐Ÿ”’ Use remote state sharing carefully - it creates dependencies") + print(" ๐Ÿ“‹ Regularly audit consumer lists to maintain security") + print(" ๐Ÿ—๏ธ Consider workspace organization structure when sharing state") + print(" โšก Use specific workspace IDs rather than names for reliability") + print(" ๐Ÿ”„ Test state consumer changes in development environments first") + + except Exception as e: + print(f" โŒ Remote state consumer operations failed: {e}") + print(" ๐Ÿ’ก This may be due to:") + print(" โ€ข Insufficient permissions for workspace relationships") + print(" โ€ข Network connectivity issues") + print(" โ€ข Invalid workspace references") + + def demo_tag_operations(self, workspace_id: str): + """Demonstrate comprehensive workspace tag management operations.""" + print("\n๐Ÿท๏ธ 8. WORKSPACE TAG OPERATIONS") + print("-" * 40) + + try: + # 8.1 List existing tags + print("๐Ÿ“‹ Listing current workspace tags...") + list_options = WorkspaceTagListOptions(page_size=20) + current_tags = list(self.workspaces.list_tags(workspace_id, list_options)) + + print(f" ๐Ÿ“Š Found {len(current_tags)} existing tags:") + for tag in current_tags: + print(f" ๐Ÿท๏ธ Tag: {tag.name} (ID: {tag.id})") + + # 8.2 List tags with search query + print("\n๐Ÿ” Searching for tags with 'env' in name...") + search_options = WorkspaceTagListOptions(query="env", page_size=10) + search_results = list( + self.workspaces.list_tags(workspace_id, search_options) + ) + + print(f" ๐Ÿ” Found {len(search_results)} tags matching 'env':") + for tag in search_results: + print(f" ๐Ÿท๏ธ Matching tag: {tag.name}") + + # 8.3 Add new tags + print("\nโž• Adding new tags to workspace...") + new_tags = [ + Tag(name="environment-production"), # Add by name + Tag(name="team-backend"), + Tag(name="version-v2-1-0"), # Fixed: no dots, use hyphens + Tag(id="tag-existing-123") + if current_tags + else Tag(name="cost-center-engineering"), # Add by ID if exists + ] + + add_options = WorkspaceAddTagsOptions(tags=new_tags) + self.workspaces.add_tags(workspace_id, add_options) + print(f" โœ… Successfully added {len(new_tags)} tags") + + for tag in new_tags: + if tag.id: + print(f" ๐Ÿท๏ธ Added tag by ID: {tag.id}") + else: + print(f" ๐Ÿท๏ธ Added tag by name: {tag.name}") + + # 8.4 List updated tags + print("\n๐Ÿ“‹ Listing updated workspace tags...") + updated_tags = list(self.workspaces.list_tags(workspace_id, list_options)) + print(f" ๐Ÿ“Š Total tags after addition: {len(updated_tags)}") + + for tag in updated_tags: + print(f" ๐Ÿท๏ธ Tag: {tag.name} (ID: {tag.id})") + + # 8.5 List tags with pagination + print("\n๐Ÿ“„ Demonstrating tag pagination...") + paginated_options = WorkspaceTagListOptions(page_number=1) + page_tags = list(self.workspaces.list_tags(workspace_id, paginated_options)) + + print(f" ๐Ÿ“„ Page 1 results: {len(page_tags)} tags") + for i, tag in enumerate(page_tags, 1): + print(f" {i}. {tag.name}") + + # 8.6 Remove specific tags + print("\nโž– Removing specific tags...") + tags_to_remove = [ + Tag( + name="version-v2-1-0" + ), # Fixed: Remove by name (matching what we added) + Tag(id=updated_tags[0].id) + if updated_tags + else Tag(name="team-backend"), # Remove by ID + ] + + remove_options = WorkspaceRemoveTagsOptions(tags=tags_to_remove) + self.workspaces.remove_tags(workspace_id, remove_options) + print(f" โœ… Successfully removed {len(tags_to_remove)} tags") + + for tag in tags_to_remove: + if tag.id: + print(f" ๐Ÿ—‘๏ธ Removed tag by ID: {tag.id}") + else: + print(f" ๐Ÿ—‘๏ธ Removed tag by name: {tag.name}") + + # 8.7 Final tag list + print("\n๐Ÿ“‹ Final workspace tags...") + final_tags = list(self.workspaces.list_tags(workspace_id, list_options)) + print(f" ๐Ÿ“Š Final tag count: {len(final_tags)}") + + for tag in final_tags: + print(f" ๐Ÿท๏ธ Final tag: {tag.name} (ID: {tag.id})") + + # Best practices and tips + print("\n๐Ÿ’ก TAG MANAGEMENT BEST PRACTICES:") + print( + " ๐Ÿ—๏ธ Use consistent naming conventions (e.g., 'environment-production')" + ) + print(" ๐Ÿ“Š Use tags for filtering and organizing workspaces") + print(" ๐Ÿ” Leverage tag search for quick workspace discovery") + print(" ๐Ÿท๏ธ Prefer adding by name for new tags, by ID for existing ones") + + except Exception as e: + print(f" โŒ Tag operations failed: {e}") + print(" ๐Ÿ’ก This may be due to:") + print(" โ€ข Insufficient permissions for workspace tag management") + print(" โ€ข Invalid tag names or IDs") + print(" โ€ข Network connectivity issues") + print(" โ€ข Workspace not found or inaccessible") + + def demo_tag_binding_operations(self, workspace_id: str): + """Demonstrate comprehensive workspace tag binding management operations.""" + print("\n๐Ÿ”— 8B. WORKSPACE TAG BINDING OPERATIONS") + print("-" * 45) + + try: + # 8B.1 List existing tag bindings + print("๐Ÿ“‹ Listing current workspace tag bindings...") + current_bindings = list(self.workspaces.list_tag_bindings(workspace_id)) + + print(f" ๐Ÿ“Š Found {len(current_bindings)} existing tag bindings:") + for binding in current_bindings: + print( + f" ๐Ÿ”— Binding: {binding.key} = {binding.value} (ID: {binding.id})" + ) + + # 8B.2 List effective tag bindings (including inherited) + print("\n๐ŸŒ Listing effective tag bindings (including inherited)...") + effective_bindings = list( + self.workspaces.list_effective_tag_bindings(workspace_id) + ) + + print(f" ๐Ÿ“Š Found {len(effective_bindings)} effective tag bindings:") + for binding in effective_bindings: + links_info = ( + f" (Links: {len(binding.links)} entries)" if binding.links else "" + ) + print(f" ๐ŸŒ Effective: {binding.key} = {binding.value}{links_info}") + + # 8B.3 Add new tag bindings + print("\nโž• Adding new tag bindings to workspace...") + new_bindings = [ + TagBinding(key="environment", value="production"), + TagBinding(key="team", value="infrastructure"), + TagBinding(key="cost-center", value="engineering"), + TagBinding(key="project", value="terraform-automation"), + TagBinding(key="owner", value="devops-team"), + ] + + add_options = WorkspaceAddTagBindingsOptions(tag_bindings=new_bindings) + result_bindings = list( + self.workspaces.add_tag_bindings(workspace_id, add_options) + ) + print(f" โœ… Successfully added {len(result_bindings)} tag bindings") + + for binding in result_bindings: + print( + f" ๐Ÿ”— Added: {binding.key} = {binding.value} (ID: {binding.id})" + ) + + # 8B.4 Update existing tag bindings (same key, new value) + print("\nโœ๏ธ Updating existing tag bindings...") + update_bindings = [ + TagBinding(key="environment", value="staging"), # Update existing + TagBinding(key="version", value="v2.1.0"), # Add new + ] + + update_options = WorkspaceAddTagBindingsOptions( + tag_bindings=update_bindings + ) + updated_result = list( + self.workspaces.add_tag_bindings(workspace_id, update_options) + ) + print( + f" โœ… Successfully updated/added {len(updated_result)} tag bindings" + ) + + for binding in updated_result: + print(f" โœ๏ธ Updated: {binding.key} = {binding.value}") + + # 8B.5 Delete all tag bindings + print("\n๐Ÿ—‘๏ธ Removing all tag bindings...") + self.workspaces.delete_all_tag_bindings(workspace_id) + print(" โœ… Successfully removed all tag bindings") + + # 8B.6 Verify deletion + print("\nโœ… Verifying tag binding deletion...") + final_bindings = list(self.workspaces.list_tag_bindings(workspace_id)) + print(f" ๐Ÿ“Š Remaining tag bindings: {len(final_bindings)}") + + if final_bindings: + print(" โš ๏ธ Some bindings remain:") + for binding in final_bindings: + print(f" ๐Ÿ”— {binding.key} = {binding.value}") + else: + print(" โœ… All tag bindings successfully removed") + + # Best practices and tips + print("\n๐Ÿ’ก TAG BINDING MANAGEMENT BEST PRACTICES:") + print( + " ๐Ÿ—๏ธ Use consistent key naming conventions (e.g., 'environment', 'team')" + ) + print(" ๐Ÿ“Š Tag bindings enable fine-grained resource categorization") + print( + " ๐Ÿ” Use effective bindings to see the complete inheritance hierarchy" + ) + print(" โœ๏ธ Update bindings by adding with same key and new value") + print(" ๐ŸŒ Leverage inherited bindings for organization-wide standards") + print(" ๐Ÿ—‘๏ธ Use delete_all_tag_bindings to reset workspace bindings") + + except Exception as e: + print(f" โŒ Tag binding operations failed: {e}") + print(" ๐Ÿ’ก This may be due to:") + print( + " โ€ข Insufficient permissions for workspace tag binding management" + ) + print(" โ€ข Invalid tag binding keys or values") + print(" โ€ข Network connectivity issues") + print(" โ€ข Workspace not found or inaccessible") + print(" โ€ข Organization-level tag binding restrictions") + + def demo_data_retention_policy_operations(self, workspace_id: str): + """Demonstrate workspace data retention policy management operations.""" + print("\n๐Ÿ“Š Data Retention Policy Operations") + print("-" * 50) + + try: + # Read current data retention policy choice (should be None initially) + print("1. Reading current data retention policy...") + current_policy = self.workspaces.read_data_retention_policy_choice( + workspace_id + ) + if current_policy is None or not current_policy.is_populated(): + print(" โœ… No data retention policy currently set") + else: + print(f" ๐Ÿ“‹ Current policy: {current_policy}") + + # Set a "delete older" data retention policy + print("\n2. Setting 'delete older' data retention policy (30 days)...") + delete_older_options = DataRetentionPolicyDeleteOlderSetOptions( + delete_older_than_n_days=30 + ) + delete_older_policy = ( + self.workspaces.set_data_retention_policy_delete_older( + workspace_id, options=delete_older_options + ) + ) + print(f" โœ… Set delete older policy: ID={delete_older_policy.id}") + print( + f" ๐Ÿ“… Delete after: {delete_older_policy.delete_older_than_n_days} days" + ) + + # Read the updated data retention policy choice + print("\n3. Reading updated data retention policy choice...") + updated_policy = self.workspaces.read_data_retention_policy_choice( + workspace_id + ) + if updated_policy and updated_policy.is_populated(): + print(" โœ… Data retention policy choice retrieved successfully") + if updated_policy.data_retention_policy_delete_older: + drp = updated_policy.data_retention_policy_delete_older + print(" ๐Ÿ—ƒ๏ธ Policy Type: Delete Older") + print(f" ๐Ÿ†” Policy ID: {drp.id}") + print(f" ๐Ÿ“… Delete after: {drp.delete_older_than_n_days} days") + + # Test legacy conversion + legacy_policy = updated_policy.convert_to_legacy_struct() + if legacy_policy: + print( + f" ๐Ÿ”„ Legacy conversion: ID={legacy_policy.id}, Days={legacy_policy.delete_older_than_n_days}" + ) + + # Update to a different retention period + print("\n4. Updating retention period to 60 days...") + updated_delete_older_options = DataRetentionPolicyDeleteOlderSetOptions( + delete_older_than_n_days=60 + ) + updated_delete_older_policy = ( + self.workspaces.set_data_retention_policy_delete_older( + workspace_id, options=updated_delete_older_options + ) + ) + print(f" โœ… Updated policy: ID={updated_delete_older_policy.id}") + print( + f" ๐Ÿ“… New retention period: {updated_delete_older_policy.delete_older_than_n_days} days" + ) + + # Switch to "don't delete" policy + print("\n5. Switching to 'don't delete' data retention policy...") + dont_delete_options = DataRetentionPolicyDontDeleteSetOptions() + dont_delete_policy = self.workspaces.set_data_retention_policy_dont_delete( + workspace_id, options=dont_delete_options + ) + print(f" โœ… Set don't delete policy: ID={dont_delete_policy.id}") + print(" โ™พ๏ธ Data will never be automatically deleted") + + # Read the don't delete policy + print("\n6. Reading 'don't delete' policy...") + dont_delete_choice = self.workspaces.read_data_retention_policy_choice( + workspace_id + ) + if ( + dont_delete_choice + and dont_delete_choice.data_retention_policy_dont_delete + ): + dnd = dont_delete_choice.data_retention_policy_dont_delete + print(f" โœ… Don't delete policy confirmed: ID={dnd.id}") + print(" โ™พ๏ธ Data retention: Indefinite (never delete)") + + # Test legacy conversion (should return None for don't delete policies) + legacy_policy = dont_delete_choice.convert_to_legacy_struct() + if legacy_policy is None: + print( + " ๐Ÿ”„ Legacy conversion: None (don't delete policies can't be represented as legacy)" + ) + + # Clean up - delete the data retention policy + print("\n7. Cleaning up - deleting data retention policy...") + self.workspaces.delete_data_retention_policy(workspace_id) + print(" โœ… Data retention policy deleted successfully") + + # Verify deletion + print("\n8. Verifying policy deletion...") + final_policy = self.workspaces.read_data_retention_policy_choice( + workspace_id + ) + if final_policy is None or not final_policy.is_populated(): + print(" โœ… Confirmed: No data retention policy set") + else: + print(f" โš ๏ธ Unexpected: Policy still exists: {final_policy}") + + print("\nโœ… Data Retention Policy Operations Summary:") + print(" ๐Ÿ—ƒ๏ธ Created 'delete older' policy with 30-day retention") + print(" ๐Ÿ“… Updated retention period to 60 days") + print(" โ™พ๏ธ Switched to 'don't delete' policy") + print(" ๐Ÿ”„ Tested legacy policy conversion methods") + print(" ๐Ÿ—‘๏ธ Successfully deleted policy") + + except Exception as e: + print(f" โŒ Data retention policy operations failed: {e}") + print(" ๐Ÿ’ก This may be due to:") + print( + " โ€ข Insufficient permissions for data retention policy management" + ) + print(" โ€ข Terraform Enterprise license requirements") + print(" โ€ข Network connectivity issues") + print(" โ€ข Workspace not found or inaccessible") + print(" โ€ข Organization-level policy restrictions") + print(" โ€ข Feature not available in Terraform Cloud") + def demo_delete_operations( self, organization: str, workspace_name: str, workspace_id: str ): """Demonstrate workspace deletion operations.""" - print("\n๐Ÿ—‘๏ธ 8. WORKSPACE DELETE OPERATIONS") + print("\n๐Ÿ—‘๏ธ 9. WORKSPACE DELETE OPERATIONS") print("-" * 40) print("๐Ÿ›ก๏ธ Performing safe delete...") diff --git a/src/tfe/errors.py b/src/tfe/errors.py index 6b6c02e4..db33c60a 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -187,3 +187,31 @@ class InvalidSSHKeyIDError(WorkspaceValidationError): def __init__(self) -> None: super().__init__("invalid value for SSH key ID") + + +class WorkspaceRequiredError(WorkspaceValidationError): + """Raised when workspace parameter is required but not provided.""" + + def __init__(self) -> None: + super().__init__("workspace is required") + + +class WorkspaceMinimumLimitError(WorkspaceValidationError): + """Raised when at least one workspace is required but not provided.""" + + def __init__(self) -> None: + super().__init__("must provide at least one workspace") + + +class MissingTagIdentifierError(WorkspaceValidationError): + """Raised when tag identifier is missing.""" + + def __init__(self) -> None: + super().__init__("must specify at least one tag by ID or name") + + +class MissingTagBindingIdentifierError(WorkspaceValidationError): + """Raised when tag binding identifier is missing.""" + + def __init__(self) -> None: + super().__init__("TagBindings are required") diff --git a/src/tfe/resources/workspaces.py b/src/tfe/resources/workspaces.py index d2299011..db44f12c 100644 --- a/src/tfe/resources/workspaces.py +++ b/src/tfe/resources/workspaces.py @@ -9,27 +9,48 @@ InvalidSSHKeyIDError, InvalidWorkspaceIDError, InvalidWorkspaceValueError, + MissingTagBindingIdentifierError, + MissingTagIdentifierError, RequiredSSHKeyIDError, WorkspaceLockedStateVersionStillPending, + WorkspaceMinimumLimitError, + WorkspaceRequiredError, ) from ..types import ( + DataRetentionPolicy, + DataRetentionPolicyChoice, + DataRetentionPolicyDeleteOlder, + DataRetentionPolicyDeleteOlderSetOptions, + DataRetentionPolicyDontDelete, + DataRetentionPolicyDontDeleteSetOptions, + DataRetentionPolicySetOptions, + EffectiveTagBinding, ExecutionMode, LockedByChoice, Tag, + TagBinding, VCSRepo, Workspace, WorkspaceActions, + WorkspaceAddRemoteStateConsumersOptions, + WorkspaceAddTagBindingsOptions, + WorkspaceAddTagsOptions, WorkspaceAssignSSHKeyOptions, WorkspaceCreateOptions, WorkspaceListOptions, + WorkspaceListRemoteStateConsumersOptions, WorkspaceLockOptions, WorkspaceOutputs, WorkspacePermissions, WorkspaceReadOptions, + WorkspaceRemoveRemoteStateConsumersOptions, + WorkspaceRemoveTagsOptions, WorkspaceRemoveVCSConnectionOptions, WorkspaceSettingOverwrites, WorkspaceSource, + WorkspaceTagListOptions, WorkspaceUpdateOptions, + WorkspaceUpdateRemoteStateConsumersOptions, ) from ..workspace_validation import ( is_valid_string, @@ -151,6 +172,36 @@ def _ws_from(d: dict[str, Any], org: str | None = None) -> Workspace: ) ) + data_retention_policy_choice: DataRetentionPolicyChoice | None = None + if d.get("relationships", {}).get("data-retention-policy-choice"): + drp_data = d["relationships"]["data-retention-policy-choice"]["data"] + if drp_data: + if drp_data.get("type") == "data-retention-policy-delete-olders": + data_retention_policy_choice = DataRetentionPolicyChoice( + data_retention_policy_delete_older=DataRetentionPolicyDeleteOlder( + id=drp_data.get("id"), + delete_older_than_n_days=drp_data.get("attributes", {}).get( + "delete-older-than-n-days", 0 + ), + ) + ) + elif drp_data.get("type") == "data-retention-policy-dont-deletes": + data_retention_policy_choice = DataRetentionPolicyChoice( + data_retention_policy_dont_delete=DataRetentionPolicyDontDelete( + id=drp_data.get("id") + ) + ) + elif drp_data.get("type") == "data-retention-policies": + # Legacy data retention policy + data_retention_policy_choice = DataRetentionPolicyChoice( + data_retention_policy=DataRetentionPolicy( + id=drp_data.get("id"), + delete_older_than_n_days=drp_data.get("attributes", {}).get( + "delete-older-than-n-days", 0 + ), + ) + ) + return Workspace( id=id_str, name=name_str, @@ -201,6 +252,9 @@ def _ws_from(d: dict[str, Any], org: str | None = None) -> Workspace: # Relations outputs=outputs, locked_by=locked_by, + data_retention_policy_choice=data_retention_policy_choice + if data_retention_policy_choice + else None, ) @@ -242,11 +296,11 @@ def list( # Handle tag binding filters if options.tag_bindings: for i, binding in enumerate(options.tag_bindings): - if binding.tag and binding.value: - params[f"search[tag-bindings][{i}][key]"] = binding.tag.name + if binding.key and binding.value: + params[f"search[tag-bindings][{i}][key]"] = binding.key params[f"search[tag-bindings][{i}][value]"] = binding.value - elif binding.tag: - params[f"search[tag-bindings][{i}][key]"] = binding.tag.name + elif binding.key: + params[f"search[tag-bindings][{i}][key]"] = binding.key path = f"/api/v2/organizations/{organization}/workspaces" for item in self._list(path, params=params): @@ -279,7 +333,13 @@ def read_with_options( f"/api/v2/organizations/{organization}/workspaces/{name}", params=params, ) - return _ws_from(r.json()["data"], organization) + ws = _ws_from(r.json()["data"], organization) + ws.data_retention_policy = ( + ws.data_retention_policy_choice.convert_to_legacy_struct() + if ws.data_retention_policy_choice + else None + ) + return ws def read_by_id(self, id: str) -> Workspace: """Read workspace by workspace ID.""" @@ -296,7 +356,12 @@ def read_by_id_with_options( if options.include: params["include"] = ",".join([i.value for i in options.include]) r = self.t.request("GET", f"/api/v2/workspaces/{id}", params=params) - return _ws_from(r.json()["data"], None) + ws = _ws_from(r.json()["data"], None) + if ws.data_retention_policy_choice is not None: + ws.data_retention_policy = ( + ws.data_retention_policy_choice.convert_to_legacy_struct() + ) + return ws def create( self, @@ -490,11 +555,11 @@ def _build_workspace_payload( if hasattr(options, "tag_bindings") and options.tag_bindings: relationships["tag-bindings"] = {"data": []} for binding in options.tag_bindings: - if binding.tag and binding.value: + if binding.key and binding.value: tag_binding_data = { "type": "tag-bindings", "attributes": { - "key": binding.tag.name, + "key": binding.key, "value": binding.value, }, } @@ -693,3 +758,391 @@ def unassign_ssh_key(self, workspace_id: str) -> Workspace: ) return _ws_from(r.json()["data"], None) + + def list_remote_state_consumers( + self, workspace_id: str, options: WorkspaceListRemoteStateConsumersOptions + ) -> Iterator[Workspace]: + """List remote state consumers of a workspace by workspace ID.""" + # Validate parameters + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + params: dict[str, Any] = {} + + # Use structured options + if options.page_number: + params["page[number]"] = options.page_number + if options.page_size: + params["page[size]"] = options.page_size + + path = f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers" + for item in self._list(path, params=params): + yield _ws_from(item, None) + + def add_remote_state_consumers( + self, workspace_id: str, options: WorkspaceAddRemoteStateConsumersOptions + ) -> None: + """Add remote state consumers to a workspace by workspace ID.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if options.workspaces is None: + raise WorkspaceRequiredError() + if len(options.workspaces) == 0: + raise WorkspaceMinimumLimitError() + + body = { + "data": [{"type": "workspaces", "id": ws.id} for ws in options.workspaces] + } + self.t.request( + "POST", + f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers", + json_body=body, + ) + + def remove_remote_state_consumers( + self, workspace_id: str, options: WorkspaceRemoveRemoteStateConsumersOptions + ) -> None: + """Remove remote state consumers from a workspace by workspace ID.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if options.workspaces is None: + raise WorkspaceRequiredError() + if len(options.workspaces) == 0: + raise WorkspaceMinimumLimitError() + body = { + "data": [{"type": "workspaces", "id": ws.id} for ws in options.workspaces] + } + self.t.request( + "DELETE", + f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers", + json_body=body, + ) + + def update_remote_state_consumers( + self, workspace_id: str, options: WorkspaceUpdateRemoteStateConsumersOptions + ) -> None: + """Update remote state consumers of a workspace by workspace ID.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if options.workspaces is None: + raise WorkspaceRequiredError() + if len(options.workspaces) == 0: + raise WorkspaceMinimumLimitError() + body = { + "data": [{"type": "workspaces", "id": ws.id} for ws in options.workspaces] + } + self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers", + json_body=body, + ) + + def list_tags( + self, workspace_id: str, options: WorkspaceTagListOptions + ) -> Iterator[Tag]: + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + params: dict[str, Any] = {} + if options.query is not None: + params["name"] = options.query + if options.page_number is not None: + params["page[number]"] = options.page_number + if options.page_size is not None: + params["page[size]"] = options.page_size + + path = f"/api/v2/workspaces/{workspace_id}/relationships/tags" + for item in self._list(path, params=params): + attr = item.get("attributes", {}) or {} + yield Tag(id=item.get("id"), name=attr.get("name", "")) + + def add_tags(self, workspace_id: str, options: WorkspaceAddTagsOptions) -> None: + """AddTags adds a list of tags to a workspace.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if len(options.tags) == 0: + raise MissingTagIdentifierError() + for tag in options.tags: + if tag.id == "" and tag.name == "": + raise MissingTagIdentifierError() + data: list[dict[str, Any]] = [] + for tag in options.tags: + if tag.id: + data.append({"type": "tags", "id": tag.id}) + else: + data.append({"type": "tags", "attributes": {"name": tag.name}}) + body = {"data": data} + self.t.request( + "POST", + f"/api/v2/workspaces/{workspace_id}/relationships/tags", + json_body=body, + ) + + def remove_tags( + self, workspace_id: str, options: WorkspaceRemoveTagsOptions + ) -> None: + """RemoveTags removes a list of tags from a workspace.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if len(options.tags) == 0: + raise MissingTagIdentifierError() + for tag in options.tags: + if tag.id == "" and tag.name == "": + raise MissingTagIdentifierError() + data: list[dict[str, Any]] = [] + for tag in options.tags: + if tag.id: + data.append({"type": "tags", "id": tag.id}) + else: + data.append({"type": "tags", "attributes": {"name": tag.name}}) + body = {"data": data} + self.t.request( + "DELETE", + f"/api/v2/workspaces/{workspace_id}/relationships/tags", + json_body=body, + ) + + def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + path = f"/api/v2/workspaces/{workspace_id}/tag-bindings" + for item in self._list(path): + attr = item.get("attributes", {}) or {} + yield TagBinding( + id=item.get("id"), + key=attr.get("key", ""), + value=attr.get("value", ""), + ) + + def list_effective_tag_bindings( + self, workspace_id: str + ) -> Iterator[EffectiveTagBinding]: + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + path = f"/api/v2/workspaces/{workspace_id}/effective-tag-bindings" + for item in self._list(path): + attr = item.get("attributes", {}) or {} + yield EffectiveTagBinding( + id=item.get("id", ""), + key=attr.get("key", ""), + value=attr.get("value", ""), + links=attr.get("links", {}), + ) + + def add_tag_bindings( + self, workspace_id: str, options: WorkspaceAddTagBindingsOptions + ) -> Iterator[TagBinding]: + """AddTagBindings adds or modifies the value of existing tag binding keys for a workspace.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if len(options.tag_bindings) == 0: + raise MissingTagBindingIdentifierError() + data: list[dict[str, Any]] = [] + for binding in options.tag_bindings: + data.append( + { + "type": "tag-bindings", + "attributes": {"key": binding.key, "value": binding.value}, + } + ) + body = {"data": data} + r = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/tag-bindings", + json_body=body, + ) + out: builtins.list[TagBinding] = [] + for item in r.json().get("data", []): + attr = item.get("attributes", {}) or {} + out.append( + TagBinding( + id=item.get("id"), + key=attr.get("key", ""), + value=attr.get("value", ""), + ) + ) + return iter(out) + + def delete_all_tag_bindings(self, workspace_id: str) -> None: + """DeleteAllTagBindings removes all tag bindings associated with a workspace.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body = { + "data": { + "type": "workspaces", + "id": workspace_id, + "relationships": {"tag-bindings": {"data": []}}, + } + } + self.t.request("PATCH", f"/api/v2/workspaces/{workspace_id}", json_body=body) + + def read_data_retention_policy( + self, workspace_id: str + ) -> DataRetentionPolicy | None: + """Read a workspace's data retention policy (deprecated: use read_data_retention_policy_choice instead).""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + try: + r = self.t.request("GET", self._data_retention_policy_link(workspace_id)) + d = r.json().get("data") + if not d: + return None + + return DataRetentionPolicy( + id=d.get("id"), + delete_older_than_n_days=d.get("attributes", {}).get( + "delete-older-than-n-days" + ), + ) + except Exception as e: + # Handle the case where TFE >= 202401 and direct user towards the V2 function + if "data-retention-policies" in str(e) and "does not match" in str(e): + raise ValueError( + "error reading deprecated DataRetentionPolicy, use read_data_retention_policy_choice instead" + ) from e + raise + + def read_data_retention_policy_choice( + self, workspace_id: str + ) -> DataRetentionPolicyChoice | None: + """Read a workspace's data retention policy choice (polymorphic).""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + # First, read the workspace to determine the type of data retention policy + ws = self.read_by_id(workspace_id) + + # If there's no data retention policy choice or it's not populated, return it as-is + if ( + ws.data_retention_policy_choice is None + or not ws.data_retention_policy_choice.is_populated() + ): + return ws.data_retention_policy_choice + + # Get the specific data retention policy data from the relationships endpoint + r = self.t.request("GET", self._data_retention_policy_link(workspace_id)) + drp_data = r.json().get("data") + + if not drp_data: + return None + + data_retention_policy_choice = DataRetentionPolicyChoice() + if ( + ws.data_retention_policy_choice.data_retention_policy_delete_older + is not None + ): + data_retention_policy_choice.data_retention_policy_delete_older = ( + DataRetentionPolicyDeleteOlder( + id=drp_data.get("id"), + delete_older_than_n_days=drp_data.get("attributes", {}).get( + "delete-older-than-n-days" + ), + ) + ) + elif ( + ws.data_retention_policy_choice.data_retention_policy_dont_delete + is not None + ): + data_retention_policy_choice.data_retention_policy_dont_delete = ( + DataRetentionPolicyDontDelete(id=drp_data.get("id")) + ) + elif ws.data_retention_policy_choice.data_retention_policy is not None: + data_retention_policy_choice.data_retention_policy = DataRetentionPolicy( + id=drp_data.get("id"), + delete_older_than_n_days=drp_data.get("attributes", {}).get( + "delete-older-than-n-days" + ), + ) + + return data_retention_policy_choice + + def set_data_retention_policy( + self, workspace_id: str, *, options: DataRetentionPolicySetOptions + ) -> DataRetentionPolicy: + """Set a workspace's data retention policy (deprecated: use set_data_retention_policy_delete_older instead).""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body = { + "data": { + "type": "data-retention-policies", + "attributes": { + "delete-older-than-n-days": options.delete_older_than_n_days + }, + } + } + + r = self.t.request( + "PATCH", self._data_retention_policy_link(workspace_id), json_body=body + ) + d = r.json()["data"] + + return DataRetentionPolicy( + id=d.get("id"), + delete_older_than_n_days=d.get("attributes", {}).get( + "delete-older-than-n-days" + ), + ) + + def _data_retention_policy_link(self, workspace_id: str) -> str: + """Helper method to generate the data retention policy relationships URL.""" + return f"/api/v2/workspaces/{workspace_id}/relationships/data-retention-policy" + + def set_data_retention_policy_delete_older( + self, workspace_id: str, *, options: DataRetentionPolicyDeleteOlderSetOptions + ) -> DataRetentionPolicyDeleteOlder: + """Set a workspace's data retention policy to delete data older than a certain number of days.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body = { + "data": { + "type": "data-retention-policy-delete-olders", + "attributes": { + "delete-older-than-n-days": options.delete_older_than_n_days + }, + } + } + + r = self.t.request( + "POST", self._data_retention_policy_link(workspace_id), json_body=body + ) + d = r.json()["data"] + + return DataRetentionPolicyDeleteOlder( + id=d.get("id"), + delete_older_than_n_days=d.get("attributes", {}).get( + "delete-older-than-n-days" + ), + ) + + def set_data_retention_policy_dont_delete( + self, workspace_id: str, *, options: DataRetentionPolicyDontDeleteSetOptions + ) -> DataRetentionPolicyDontDelete: + """Set a workspace's data retention policy to explicitly not delete data.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body = { + "data": { + "type": "data-retention-policy-dont-deletes", + } + } + + r = self.t.request( + "POST", self._data_retention_policy_link(workspace_id), json_body=body + ) + d = r.json()["data"] + + return DataRetentionPolicyDontDelete(id=d.get("id")) + + def delete_data_retention_policy(self, workspace_id: str) -> None: + """Delete a workspace's data retention policy.""" + if not is_valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + self.t.request("DELETE", self._data_retention_policy_link(workspace_id)) diff --git a/src/tfe/types.py b/src/tfe/types.py index 19237122..5eb3bca7 100644 --- a/src/tfe/types.py +++ b/src/tfe/types.py @@ -172,6 +172,8 @@ class Workspace(BaseModel): # Links links: dict[str, Any] = Field(default_factory=dict) + data_retention_policy: DataRetentionPolicy | None = None + data_retention_policy_choice: DataRetentionPolicyChoice | None = None class Capacity(BaseModel): @@ -208,6 +210,9 @@ class Run(BaseModel): class Pagination(BaseModel): current_page: int total_count: int + previous_page: int | None = None + next_page: int | None = None + total_pages: int | None = None # Add other pagination fields as needed @@ -288,16 +293,16 @@ class Tag(BaseModel): class TagBinding(BaseModel): - id: str | None = None # Optional for new tag bindings - tag: Tag | None = None + id: str | None = None + key: str value: str | None = None class EffectiveTagBinding(BaseModel): id: str - tag: Tag | None = None + key: str value: str | None = None - inherited: bool = False + links: dict[str, Any] = Field(default_factory=dict) class WorkspaceIncludeOpt(str, Enum): @@ -512,3 +517,56 @@ class workspaceUnassignSSHKeyOptions(BaseModel): # Must be nil to unset the currently assigned SSH key. ssh_key_id: str type: str = "workspaces" + + +class WorkspaceListRemoteStateConsumersOptions(BaseModel): + """Options for listing remote state consumers of a workspace.""" + + # Pagination options (from ListOptions) + page_number: int | None = None + page_size: int | None = None + + +class WorkspaceAddRemoteStateConsumersOptions(BaseModel): + """Options for adding remote state consumers to a workspace.""" + + workspaces: list[Workspace] = Field(default_factory=list) + + +class WorkspaceRemoveRemoteStateConsumersOptions(BaseModel): + """Options for removing remote state consumers from a workspace.""" + + workspaces: list[Workspace] = Field(default_factory=list) + + +class WorkspaceUpdateRemoteStateConsumersOptions(BaseModel): + """Options for updating remote state consumers of a workspace.""" + + workspaces: list[Workspace] = Field(default_factory=list) + + +class WorkspaceTagListOptions(BaseModel): + """Options for listing tags of a workspace.""" + + # Pagination options (from ListOptions) + page_number: int | None = None + page_size: int | None = None + query: str | None = None + + +class WorkspaceAddTagsOptions(BaseModel): + """Options for adding tags to a workspace.""" + + tags: list[Tag] = Field(default_factory=list) + + +class WorkspaceRemoveTagsOptions(BaseModel): + """Options for removing tags from a workspace.""" + + tags: list[Tag] = Field(default_factory=list) + + +class WorkspaceAddTagBindingsOptions(BaseModel): + """Options for adding tag bindings to a workspace.""" + + tag_bindings: list[TagBinding] = Field(default_factory=list) diff --git a/tests/units/test_workspaces.py b/tests/units/test_workspaces.py index 5af88ec8..a7c943bf 100644 --- a/tests/units/test_workspaces.py +++ b/tests/units/test_workspaces.py @@ -14,20 +14,38 @@ InvalidSSHKeyIDError, InvalidWorkspaceIDError, InvalidWorkspaceValueError, + MissingTagBindingIdentifierError, + MissingTagIdentifierError, RequiredSSHKeyIDError, + WorkspaceMinimumLimitError, ) from src.tfe.resources.workspaces import Workspaces, _ws_from from src.tfe.types import ( + DataRetentionPolicyDeleteOlderSetOptions, + DataRetentionPolicyDontDeleteSetOptions, + DataRetentionPolicySetOptions, + EffectiveTagBinding, ExecutionMode, Project, + Tag, + TagBinding, VCSRepo, + Workspace, + WorkspaceAddRemoteStateConsumersOptions, + WorkspaceAddTagBindingsOptions, + WorkspaceAddTagsOptions, WorkspaceAssignSSHKeyOptions, WorkspaceCreateOptions, WorkspaceListOptions, + WorkspaceListRemoteStateConsumersOptions, WorkspaceLockOptions, WorkspaceReadOptions, + WorkspaceRemoveRemoteStateConsumersOptions, + WorkspaceRemoveTagsOptions, WorkspaceRemoveVCSConnectionOptions, + WorkspaceTagListOptions, WorkspaceUpdateOptions, + WorkspaceUpdateRemoteStateConsumersOptions, ) @@ -665,6 +683,836 @@ def test_none_values_handling(self): assert workspace.working_directory == "" assert workspace.vcs_repo is None + # ========================================== + # REMOTE STATE CONSUMER OPERATIONS TESTS + # ========================================== + + @pytest.fixture + def sample_remote_state_consumers_response(self): + """Sample JSON:API remote state consumers response.""" + return { + "data": [ + { + "type": "workspaces", + "id": "ws-consumer-1", + "attributes": { + "name": "consumer-workspace-1", + "description": "First consumer workspace", + "auto-apply": False, + "execution-mode": "remote", + "locked": False, + }, + }, + { + "type": "workspaces", + "id": "ws-consumer-2", + "attributes": { + "name": "consumer-workspace-2", + "description": "Second consumer workspace", + "auto-apply": True, + "execution-mode": "local", + "locked": False, + }, + }, + ] + } + + def test_list_remote_state_consumers_basic( + self, workspaces_service, mock_transport, sample_remote_state_consumers_response + ): + """Test basic remote state consumers listing.""" + mock_transport.request.return_value.json.return_value = ( + sample_remote_state_consumers_response + ) + + options = WorkspaceListRemoteStateConsumersOptions(page_size=10) + consumers = list( + workspaces_service.list_remote_state_consumers("ws-123", options) + ) + + assert len(consumers) == 2 + assert consumers[0].name == "consumer-workspace-1" + assert consumers[1].name == "consumer-workspace-2" + assert not consumers[0].auto_apply + assert consumers[1].auto_apply + + # Verify the correct HTTP request was made + mock_transport.request.assert_called() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert ( + "/api/v2/workspaces/ws-123/relationships/remote-state-consumers" + in call_args[0][1] + ) + + def test_list_remote_state_consumers_with_pagination( + self, workspaces_service, mock_transport + ): + """Test remote state consumers listing with pagination options.""" + mock_transport.request.return_value.json.return_value = {"data": []} + + options = WorkspaceListRemoteStateConsumersOptions(page_number=2, page_size=5) + + list(workspaces_service.list_remote_state_consumers("ws-123", options)) + + # Verify pagination parameters were passed + call_args = mock_transport.request.call_args + params = call_args[1]["params"] + assert params["page[number]"] == 2 + assert params["page[size]"] == 5 + + def test_add_remote_state_consumers_basic(self, workspaces_service, mock_transport): + """Test adding remote state consumers.""" + consumer_workspaces = [ + Workspace(id="ws-consumer-1", name="consumer-1", organization="test-org"), + Workspace(id="ws-consumer-2", name="consumer-2", organization="test-org"), + ] + + options = WorkspaceAddRemoteStateConsumersOptions( + workspaces=consumer_workspaces + ) + + workspaces_service.add_remote_state_consumers("ws-123", options) + + # Verify POST request was made with correct data + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert ( + "/api/v2/workspaces/ws-123/relationships/remote-state-consumers" + in call_args[0][1] + ) + + # Verify request body + body = call_args[1]["json_body"] + assert body["data"] == [ + {"type": "workspaces", "id": "ws-consumer-1"}, + {"type": "workspaces", "id": "ws-consumer-2"}, + ] + + def test_add_remote_state_consumers_validation_errors(self, workspaces_service): + """Test add remote state consumers validation errors.""" + # Test invalid workspace ID + options = WorkspaceAddRemoteStateConsumersOptions(workspaces=[]) + + with pytest.raises(InvalidWorkspaceIDError): + workspaces_service.add_remote_state_consumers("", options) + + # Test empty workspaces list + options = WorkspaceAddRemoteStateConsumersOptions(workspaces=[]) + + with pytest.raises(WorkspaceMinimumLimitError): + workspaces_service.add_remote_state_consumers("ws-123", options) + + # Test invalid workspace ID format (with slash) + options = WorkspaceAddRemoteStateConsumersOptions( + workspaces=[Workspace(id="ws-valid", name="valid", organization="test-org")] + ) + + with pytest.raises(InvalidWorkspaceIDError): + workspaces_service.add_remote_state_consumers("invalid/id", options) + + def test_remove_remote_state_consumers_basic( + self, workspaces_service, mock_transport + ): + """Test removing remote state consumers.""" + consumer_workspaces = [ + Workspace(id="ws-consumer-1", name="consumer-1", organization="test-org"), + ] + + options = WorkspaceRemoveRemoteStateConsumersOptions( + workspaces=consumer_workspaces + ) + + workspaces_service.remove_remote_state_consumers("ws-123", options) + + # Verify DELETE request was made with correct data + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "DELETE" + assert ( + "/api/v2/workspaces/ws-123/relationships/remote-state-consumers" + in call_args[0][1] + ) + + # Verify request body + body = call_args[1]["json_body"] + assert body["data"] == [{"type": "workspaces", "id": "ws-consumer-1"}] + + def test_update_remote_state_consumers_basic( + self, workspaces_service, mock_transport + ): + """Test updating (replacing) remote state consumers.""" + consumer_workspaces = [ + Workspace(id="ws-consumer-3", name="consumer-3", organization="test-org"), + Workspace(id="ws-consumer-4", name="consumer-4", organization="test-org"), + ] + + options = WorkspaceUpdateRemoteStateConsumersOptions( + workspaces=consumer_workspaces + ) + + workspaces_service.update_remote_state_consumers("ws-123", options) + + # Verify PATCH request was made with correct data + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert ( + "/api/v2/workspaces/ws-123/relationships/remote-state-consumers" + in call_args[0][1] + ) + + # Verify request body + body = call_args[1]["json_body"] + assert body["data"] == [ + {"type": "workspaces", "id": "ws-consumer-3"}, + {"type": "workspaces", "id": "ws-consumer-4"}, + ] + + # ========================================== + # TAG OPERATIONS TESTS + # ========================================== + + @pytest.fixture + def sample_tags_response(self): + """Sample JSON:API tags response.""" + return { + "data": [ + { + "type": "tags", + "id": "tag-123", + "attributes": { + "name": "environment", + }, + }, + { + "type": "tags", + "id": "tag-456", + "attributes": { + "name": "team", + }, + }, + ] + } + + def test_list_tags_basic( + self, workspaces_service, mock_transport, sample_tags_response + ): + """Test basic tag listing.""" + mock_transport.request.return_value.json.return_value = sample_tags_response + + options = WorkspaceTagListOptions(page_size=10) + tags = list(workspaces_service.list_tags("ws-123", options)) + + assert len(tags) == 2 + assert tags[0].name == "environment" + assert tags[1].name == "team" + + # Verify the correct HTTP request was made + mock_transport.request.assert_called() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert "/api/v2/workspaces/ws-123/relationships/tags" in call_args[0][1] + + def test_list_tags_with_query_and_pagination( + self, workspaces_service, mock_transport + ): + """Test tag listing with query and pagination options.""" + mock_transport.request.return_value.json.return_value = {"data": []} + + options = WorkspaceTagListOptions(query="env", page_number=2, page_size=5) + + list(workspaces_service.list_tags("ws-123", options)) + + # Verify query and pagination parameters were passed + call_args = mock_transport.request.call_args + params = call_args[1]["params"] + assert params["name"] == "env" + assert params["page[number]"] == 2 + assert params["page[size]"] == 5 + + def test_add_tags_basic(self, workspaces_service, mock_transport): + """Test adding tags to a workspace.""" + tags = [ + Tag(id="tag-123"), + Tag(name="environment"), + Tag(id="tag-456", name="team"), # Both ID and name provided + ] + + options = WorkspaceAddTagsOptions(tags=tags) + + workspaces_service.add_tags("ws-123", options) + + # Verify POST request was made with correct data + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert "/api/v2/workspaces/ws-123/relationships/tags" in call_args[0][1] + + # Verify request body + body = call_args[1]["json_body"] + expected_data = [ + {"type": "tags", "id": "tag-123"}, # ID takes precedence + {"type": "tags", "attributes": {"name": "environment"}}, # Name only + {"type": "tags", "id": "tag-456"}, # ID takes precedence when both provided + ] + assert body["data"] == expected_data + + def test_add_tags_validation_errors(self, workspaces_service): + """Test add tags validation errors.""" + # Test invalid workspace ID + options = WorkspaceAddTagsOptions(tags=[]) + + with pytest.raises(InvalidWorkspaceIDError): + workspaces_service.add_tags("", options) + + # Test empty tags list + options = WorkspaceAddTagsOptions(tags=[]) + + with pytest.raises(MissingTagIdentifierError): + workspaces_service.add_tags("ws-123", options) + + # Test tag with no ID or name + options = WorkspaceAddTagsOptions(tags=[Tag(id="", name="")]) + + with pytest.raises(MissingTagIdentifierError): + workspaces_service.add_tags("ws-123", options) + + # Test invalid workspace ID format + options = WorkspaceAddTagsOptions(tags=[Tag(id="tag-123")]) + + with pytest.raises(InvalidWorkspaceIDError): + workspaces_service.add_tags("ws 123", options) + + def test_remove_tags_basic(self, workspaces_service, mock_transport): + """Test removing tags from a workspace.""" + tags = [ + Tag(id="tag-123"), + Tag(name="environment"), + ] + + options = WorkspaceRemoveTagsOptions(tags=tags) + + workspaces_service.remove_tags("ws-123", options) + + # Verify DELETE request was made with correct data + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "DELETE" + assert "/api/v2/workspaces/ws-123/relationships/tags" in call_args[0][1] + + # Verify request body + body = call_args[1]["json_body"] + expected_data = [ + {"type": "tags", "id": "tag-123"}, + {"type": "tags", "attributes": {"name": "environment"}}, + ] + assert body["data"] == expected_data + + # ========================================== + # TAG BINDING OPERATIONS TESTS + # ========================================== + + def test_list_tag_bindings_basic(self, workspaces_service, mock_transport): + """Test listing tag bindings for a workspace.""" + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-123", + "type": "tag-bindings", + "attributes": {"key": "environment", "value": "production"}, + }, + { + "id": "tb-456", + "type": "tag-bindings", + "attributes": {"key": "team", "value": "infrastructure"}, + }, + ] + } + mock_transport.request.return_value = mock_response + + # Call the method + tag_bindings = list(workspaces_service.list_tag_bindings("ws-123")) + + # Verify API call + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-123/tag-bindings", + params={"page[number]": 1, "page[size]": 100}, + ) + + # Verify returned data + assert len(tag_bindings) == 2 + assert isinstance(tag_bindings[0], TagBinding) + assert tag_bindings[0].id == "tb-123" + assert tag_bindings[0].key == "environment" + assert tag_bindings[0].value == "production" + assert tag_bindings[1].id == "tb-456" + assert tag_bindings[1].key == "team" + assert tag_bindings[1].value == "infrastructure" + + def test_list_effective_tag_bindings_basic( + self, workspaces_service, mock_transport + ): + """Test listing effective tag bindings for a workspace.""" + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "etb-123", + "type": "effective-tag-bindings", + "attributes": { + "key": "environment", + "value": "production", + "links": { + "self": "/api/v2/workspaces/ws-123/effective-tag-bindings/etb-123" + }, + }, + }, + { + "id": "etb-456", + "type": "effective-tag-bindings", + "attributes": { + "key": "cost-center", + "value": "engineering", + "links": { + "self": "/api/v2/workspaces/ws-123/effective-tag-bindings/etb-456" + }, + }, + }, + ] + } + mock_transport.request.return_value = mock_response + + # Call the method + effective_bindings = list( + workspaces_service.list_effective_tag_bindings("ws-123") + ) + + # Verify API call + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-123/effective-tag-bindings", + params={"page[number]": 1, "page[size]": 100}, + ) + + # Verify returned data + assert len(effective_bindings) == 2 + assert isinstance(effective_bindings[0], EffectiveTagBinding) + assert effective_bindings[0].id == "etb-123" + assert effective_bindings[0].key == "environment" + assert effective_bindings[0].value == "production" + assert effective_bindings[0].links == { + "self": "/api/v2/workspaces/ws-123/effective-tag-bindings/etb-123" + } + + def test_add_tag_bindings_basic(self, workspaces_service, mock_transport): + """Test adding tag bindings to a workspace.""" + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-123", + "type": "tag-bindings", + "attributes": {"key": "environment", "value": "staging"}, + }, + { + "id": "tb-456", + "type": "tag-bindings", + "attributes": {"key": "team", "value": "backend"}, + }, + ] + } + mock_transport.request.return_value = mock_response + + # Create tag binding options + options = WorkspaceAddTagBindingsOptions( + tag_bindings=[ + TagBinding(key="environment", value="staging"), + TagBinding(key="team", value="backend"), + ] + ) + + # Call the method + result_bindings = list(workspaces_service.add_tag_bindings("ws-123", options)) + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert "/api/v2/workspaces/ws-123/tag-bindings" in call_args[0][1] + + # Verify request body + body = call_args[1]["json_body"] + expected_data = [ + { + "type": "tag-bindings", + "attributes": {"key": "environment", "value": "staging"}, + }, + {"type": "tag-bindings", "attributes": {"key": "team", "value": "backend"}}, + ] + assert body["data"] == expected_data + + # Verify returned data + assert len(result_bindings) == 2 + assert isinstance(result_bindings[0], TagBinding) + assert result_bindings[0].id == "tb-123" + assert result_bindings[0].key == "environment" + assert result_bindings[0].value == "staging" + + def test_add_tag_bindings_validation_errors(self, workspaces_service): + """Test add tag bindings validation errors.""" + + # Test empty tag bindings + empty_options = WorkspaceAddTagBindingsOptions(tag_bindings=[]) + with pytest.raises(MissingTagBindingIdentifierError): + list(workspaces_service.add_tag_bindings("ws-123", empty_options)) + + def test_add_tag_bindings_update_existing(self, workspaces_service, mock_transport): + """Test updating existing tag bindings.""" + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-123", + "type": "tag-bindings", + "attributes": { + "key": "environment", + "value": "production", # Updated value + }, + } + ] + } + mock_transport.request.return_value = mock_response + + # Create options to update existing tag binding + options = WorkspaceAddTagBindingsOptions( + tag_bindings=[TagBinding(key="environment", value="production")] + ) + + # Call the method + result_bindings = list(workspaces_service.add_tag_bindings("ws-123", options)) + + # Verify returned data shows updated value + assert len(result_bindings) == 1 + assert result_bindings[0].value == "production" + + def test_delete_all_tag_bindings_basic(self, workspaces_service, mock_transport): + """Test deleting all tag bindings from a workspace.""" + # Mock successful response + mock_response = Mock() + mock_transport.request.return_value = mock_response + + # Call the method + workspaces_service.delete_all_tag_bindings("ws-123") + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert "/api/v2/workspaces/ws-123" in call_args[0][1] + + # Verify request body + body = call_args[1]["json_body"] + expected_body = { + "data": { + "type": "workspaces", + "id": "ws-123", + "relationships": {"tag-bindings": {"data": []}}, + } + } + assert body == expected_body + + def test_read_data_retention_policy_legacy( + self, workspaces_service, mock_transport + ): + """Test reading a workspace's data retention policy (legacy method).""" + # Mock successful response + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "drp-legacy123", + "type": "data-retention-policies", + "attributes": {"delete-older-than-n-days": 30}, + } + } + mock_transport.request.return_value = mock_response + + # Call the method + result = workspaces_service.read_data_retention_policy("ws-123") + + # Verify API call + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/workspaces/ws-123/relationships/data-retention-policy" + ) + + # Verify result + assert result.id == "drp-legacy123" + assert result.delete_older_than_n_days == 30 + + def test_read_data_retention_policy_choice_delete_older( + self, workspaces_service, mock_transport + ): + """Test reading a workspace's data retention policy choice (delete older type).""" + # Mock the read_by_id call first + workspace_mock_response = Mock() + workspace_mock_response.json.return_value = { + "data": { + "id": "ws-123", + "type": "workspaces", + "attributes": {"name": "test-workspace"}, + "relationships": { + "data-retention-policy-choice": { + "data": { + "id": "drp-delete123", + "type": "data-retention-policy-delete-olders", + "attributes": {"delete-older-than-n-days": 45}, + } + } + }, + } + } + + # Mock the relationships endpoint call + drp_mock_response = Mock() + drp_mock_response.json.return_value = { + "data": { + "id": "drp-delete123", + "type": "data-retention-policy-delete-olders", + "attributes": {"delete-older-than-n-days": 45}, + } + } + + # Configure mock to return different responses for different URLs + def side_effect(*args, **kwargs): + if "relationships/data-retention-policy" in args[1]: + return drp_mock_response + else: + return workspace_mock_response + + mock_transport.request.side_effect = side_effect + + # Call the method + result = workspaces_service.read_data_retention_policy_choice("ws-123") + + # Verify API calls + assert mock_transport.request.call_count == 2 + + # Verify result + assert result is not None + assert result.data_retention_policy_delete_older is not None + assert result.data_retention_policy_delete_older.id == "drp-delete123" + assert result.data_retention_policy_delete_older.delete_older_than_n_days == 45 + assert result.data_retention_policy_dont_delete is None + assert result.data_retention_policy is None + + def test_read_data_retention_policy_choice_dont_delete( + self, workspaces_service, mock_transport + ): + """Test reading a workspace's data retention policy choice (don't delete type).""" + # Mock the read_by_id call first + workspace_mock_response = Mock() + workspace_mock_response.json.return_value = { + "data": { + "id": "ws-123", + "type": "workspaces", + "attributes": {"name": "test-workspace"}, + "relationships": { + "data-retention-policy-choice": { + "data": { + "id": "drp-dontdelete123", + "type": "data-retention-policy-dont-deletes", + "attributes": {}, + } + } + }, + } + } + + # Mock the relationships endpoint call + drp_mock_response = Mock() + drp_mock_response.json.return_value = { + "data": { + "id": "drp-dontdelete123", + "type": "data-retention-policy-dont-deletes", + "attributes": {}, + } + } + + # Configure mock to return different responses for different URLs + def side_effect(*args, **kwargs): + if "relationships/data-retention-policy" in args[1]: + return drp_mock_response + else: + return workspace_mock_response + + mock_transport.request.side_effect = side_effect + + # Call the method + result = workspaces_service.read_data_retention_policy_choice("ws-123") + + # Verify result + assert result is not None + assert result.data_retention_policy_dont_delete is not None + assert result.data_retention_policy_dont_delete.id == "drp-dontdelete123" + assert result.data_retention_policy_delete_older is None + assert result.data_retention_policy is None + + def test_set_data_retention_policy_delete_older( + self, workspaces_service, mock_transport + ): + """Test setting a workspace's data retention policy to delete older.""" + + # Mock successful response + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "drp-new123", + "type": "data-retention-policy-delete-olders", + "attributes": {"delete-older-than-n-days": 60}, + } + } + mock_transport.request.return_value = mock_response + + # Create options + options = DataRetentionPolicyDeleteOlderSetOptions(delete_older_than_n_days=60) + + # Call the method + result = workspaces_service.set_data_retention_policy_delete_older( + "ws-123", options=options + ) + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert ( + "/api/v2/workspaces/ws-123/relationships/data-retention-policy" + in call_args[0][1] + ) + + # Verify request body + body = call_args[1]["json_body"] + expected_body = { + "data": { + "type": "data-retention-policy-delete-olders", + "attributes": {"delete-older-than-n-days": 60}, + } + } + assert body == expected_body + + # Verify result + assert result.id == "drp-new123" + assert result.delete_older_than_n_days == 60 + + def test_set_data_retention_policy_dont_delete( + self, workspaces_service, mock_transport + ): + """Test setting a workspace's data retention policy to don't delete.""" + + # Mock successful response + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "drp-dontdelete456", + "type": "data-retention-policy-dont-deletes", + "attributes": {}, + } + } + mock_transport.request.return_value = mock_response + + # Create options + options = DataRetentionPolicyDontDeleteSetOptions() + + # Call the method + result = workspaces_service.set_data_retention_policy_dont_delete( + "ws-123", options=options + ) + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert ( + "/api/v2/workspaces/ws-123/relationships/data-retention-policy" + in call_args[0][1] + ) + + # Verify request body + body = call_args[1]["json_body"] + expected_body = { + "data": { + "type": "data-retention-policy-dont-deletes", + } + } + assert body == expected_body + + # Verify result + assert result.id == "drp-dontdelete456" + + def test_set_data_retention_policy_legacy(self, workspaces_service, mock_transport): + """Test setting a workspace's data retention policy (legacy method).""" + + # Mock successful response + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "drp-legacy789", + "type": "data-retention-policies", + "attributes": {"delete-older-than-n-days": 90}, + } + } + mock_transport.request.return_value = mock_response + + # Create options + options = DataRetentionPolicySetOptions(delete_older_than_n_days=90) + + # Call the method + result = workspaces_service.set_data_retention_policy("ws-123", options=options) + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert ( + "/api/v2/workspaces/ws-123/relationships/data-retention-policy" + in call_args[0][1] + ) + + # Verify request body + body = call_args[1]["json_body"] + expected_body = { + "data": { + "type": "data-retention-policies", + "attributes": {"delete-older-than-n-days": 90}, + } + } + assert body == expected_body + + # Verify result + assert result.id == "drp-legacy789" + assert result.delete_older_than_n_days == 90 + + def test_delete_data_retention_policy(self, workspaces_service, mock_transport): + """Test deleting a workspace's data retention policy.""" + # Mock successful response + mock_response = Mock() + mock_transport.request.return_value = mock_response + + # Call the method + workspaces_service.delete_data_retention_policy("ws-123") + + # Verify API call + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/workspaces/ws-123/relationships/data-retention-policy" + ) + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 8cffc82942dd0bc4efec5fd399a995d7e8c571a5 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 15 Sep 2025 15:00:49 +0530 Subject: [PATCH 6/6] Moved common methods in Utilities and added readme method --- examples/workspace_example.py | 39 +++++--- src/tfe/resources/workspaces.py | 117 +++++++++++++++--------- src/tfe/utils.py | 126 ++++++++++++++++++++++++++ src/tfe/workspace_validation.py | 152 -------------------------------- 4 files changed, 228 insertions(+), 206 deletions(-) delete mode 100644 src/tfe/workspace_validation.py diff --git a/examples/workspace_example.py b/examples/workspace_example.py index e69c5940..fc4171a4 100644 --- a/examples/workspace_example.py +++ b/examples/workspace_example.py @@ -902,16 +902,35 @@ def demo_data_retention_policy_operations(self, workspace_id: str): print(" ๐Ÿ—‘๏ธ Successfully deleted policy") except Exception as e: - print(f" โŒ Data retention policy operations failed: {e}") - print(" ๐Ÿ’ก This may be due to:") - print( - " โ€ข Insufficient permissions for data retention policy management" - ) - print(" โ€ข Terraform Enterprise license requirements") - print(" โ€ข Network connectivity issues") - print(" โ€ข Workspace not found or inaccessible") - print(" โ€ข Organization-level policy restrictions") - print(" โ€ข Feature not available in Terraform Cloud") + error_msg = str(e).lower() + if "not found" in error_msg: + print(f" โš ๏ธ Data retention policy feature not available: {e}") + print( + "\n ๐Ÿ’ก IMPORTANT: Data retention policies are a Terraform Enterprise feature" + ) + print( + " ๐Ÿ“‹ This feature is NOT available in Terraform Cloud (app.terraform.io)" + ) + print(" ๐Ÿข To use data retention policies, you need:") + print(" โ€ข Terraform Enterprise (self-hosted)") + print(" โ€ข Terraform Business tier or higher") + print(" โ€ข Admin permissions on the organization") + print( + "\n โœ… This is expected behavior when running against Terraform Cloud" + ) + print( + " ๐Ÿ“ The implementation is correct and will work with Terraform Enterprise" + ) + else: + print(f" โŒ Data retention policy operations failed: {e}") + print(" ๐Ÿ’ก This may be due to:") + print( + " โ€ข Insufficient permissions for data retention policy management" + ) + print(" โ€ข Terraform Enterprise license requirements") + print(" โ€ข Network connectivity issues") + print(" โ€ข Workspace not found or inaccessible") + print(" โ€ข Organization-level policy restrictions") def demo_delete_operations( self, organization: str, workspace_name: str, workspace_id: str diff --git a/src/tfe/resources/workspaces.py b/src/tfe/resources/workspaces.py index db44f12c..1d3e6c0b 100644 --- a/src/tfe/resources/workspaces.py +++ b/src/tfe/resources/workspaces.py @@ -52,9 +52,9 @@ WorkspaceUpdateOptions, WorkspaceUpdateRemoteStateConsumersOptions, ) -from ..workspace_validation import ( - is_valid_string, - is_valid_string_id, +from ..utils import ( + valid_string, + valid_string_id, validate_workspace_create_options, validate_workspace_update_options, ) @@ -266,7 +266,7 @@ def list( options: WorkspaceListOptions, ) -> Iterator[Workspace]: # Validate parameters - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() params: dict[str, Any] = {} @@ -320,9 +320,9 @@ def read_with_options( options: WorkspaceReadOptions, ) -> Workspace: # Validate parameters - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() - if not is_valid_string_id(name): + if not valid_string_id(name): raise InvalidWorkspaceValueError() params: dict[str, Any] = {} @@ -349,7 +349,7 @@ def read_by_id_with_options( self, id: str, *, options: WorkspaceReadOptions ) -> Workspace: # Validate parameters - if not is_valid_string_id(id): + if not valid_string_id(id): raise InvalidWorkspaceIDError() params: dict[str, Any] = {} @@ -371,7 +371,7 @@ def create( ) -> Workspace: """Create a new workspace in the given organization.""" # Validate parameters - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() # Validate options before creating workspace @@ -389,9 +389,9 @@ def update( ) -> Workspace: """Update workspace by organization and name.""" # Validate parameters - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() - if not is_valid_string_id(name): + if not valid_string_id(name): raise InvalidWorkspaceValueError() # Validate options before updating workspace @@ -408,7 +408,7 @@ def update( def update_by_id(self, id: str, *, options: WorkspaceUpdateOptions) -> Workspace: """Update workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(id): + if not valid_string_id(id): raise InvalidWorkspaceIDError() # Validate options before updating workspace @@ -573,9 +573,9 @@ def _build_workspace_payload( def delete(self, organization: str, name: str) -> None: """Delete workspace by organization and workspace name.""" # Validate parameters (similar to Go implementation) - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() - if not is_valid_string_id(name): + if not valid_string_id(name): raise InvalidWorkspaceValueError() self.t.request( @@ -585,7 +585,7 @@ def delete(self, organization: str, name: str) -> None: def delete_by_id(self, id: str) -> None: """Delete workspace by workspace ID.""" # Validate parameters (similar to Go implementation) - if not is_valid_string_id(id): + if not valid_string_id(id): raise InvalidWorkspaceIDError() self.t.request("DELETE", f"/api/v2/workspaces/{id}") @@ -593,9 +593,9 @@ def delete_by_id(self, id: str) -> None: def safe_delete(self, organization: str, name: str) -> None: """Safely delete workspace by organization and name.""" # Validate parameters (similar to Go implementation) - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() - if not is_valid_string_id(name): + if not valid_string_id(name): raise InvalidWorkspaceValueError() self.t.request( @@ -606,7 +606,7 @@ def safe_delete(self, organization: str, name: str) -> None: def safe_delete_by_id(self, id: str) -> None: """Safely delete workspace by workspace ID.""" # Validate parameters (similar to Go implementation) - if not is_valid_string_id(id): + if not valid_string_id(id): raise InvalidWorkspaceIDError() self.t.request("POST", f"/api/v2/workspaces/{id}/actions/safe-delete") @@ -620,9 +620,9 @@ def remove_vcs_connection( ) -> Workspace: """Remove VCS connection from workspace by organization and name.""" # Validate parameters - if not is_valid_string_id(organization): + if not valid_string_id(organization): raise InvalidOrgError() - if not is_valid_string_id(name): + if not valid_string_id(name): raise InvalidWorkspaceValueError() body = { @@ -647,7 +647,7 @@ def remove_vcs_connection_by_id( ) -> Workspace: """Remove VCS connection from workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(id): + if not valid_string_id(id): raise InvalidWorkspaceIDError() body = { @@ -670,7 +670,7 @@ def remove_vcs_connection_by_id( def lock(self, workspace_id: str, *, options: WorkspaceLockOptions) -> Workspace: """Lock a workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() body = {"reason": options.reason} @@ -685,7 +685,7 @@ def lock(self, workspace_id: str, *, options: WorkspaceLockOptions) -> Workspace def unlock(self, workspace_id: str) -> Workspace: """Unlock a workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() try: r = self.t.request( @@ -701,7 +701,7 @@ def unlock(self, workspace_id: str) -> Workspace: def force_unlock(self, workspace_id: str) -> Workspace: """Force unlock a workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() r = self.t.request( @@ -715,13 +715,13 @@ def assign_ssh_key( ) -> Workspace: """Assign an SSH key to a workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - if not is_valid_string(options.ssh_key_id): + if not valid_string(options.ssh_key_id): raise RequiredSSHKeyIDError() - if not is_valid_string_id(options.ssh_key_id): + if not valid_string_id(options.ssh_key_id): raise InvalidSSHKeyIDError() body = { @@ -741,7 +741,7 @@ def assign_ssh_key( def unassign_ssh_key(self, workspace_id: str) -> Workspace: """Unassign the SSH key from a workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() body = { @@ -764,7 +764,7 @@ def list_remote_state_consumers( ) -> Iterator[Workspace]: """List remote state consumers of a workspace by workspace ID.""" # Validate parameters - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() params: dict[str, Any] = {} @@ -783,7 +783,7 @@ def add_remote_state_consumers( self, workspace_id: str, options: WorkspaceAddRemoteStateConsumersOptions ) -> None: """Add remote state consumers to a workspace by workspace ID.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.workspaces is None: raise WorkspaceRequiredError() @@ -803,7 +803,7 @@ def remove_remote_state_consumers( self, workspace_id: str, options: WorkspaceRemoveRemoteStateConsumersOptions ) -> None: """Remove remote state consumers from a workspace by workspace ID.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.workspaces is None: raise WorkspaceRequiredError() @@ -822,7 +822,7 @@ def update_remote_state_consumers( self, workspace_id: str, options: WorkspaceUpdateRemoteStateConsumersOptions ) -> None: """Update remote state consumers of a workspace by workspace ID.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if options.workspaces is None: raise WorkspaceRequiredError() @@ -840,7 +840,7 @@ def update_remote_state_consumers( def list_tags( self, workspace_id: str, options: WorkspaceTagListOptions ) -> Iterator[Tag]: - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() params: dict[str, Any] = {} @@ -858,7 +858,7 @@ def list_tags( def add_tags(self, workspace_id: str, options: WorkspaceAddTagsOptions) -> None: """AddTags adds a list of tags to a workspace.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if len(options.tags) == 0: raise MissingTagIdentifierError() @@ -882,7 +882,7 @@ def remove_tags( self, workspace_id: str, options: WorkspaceRemoveTagsOptions ) -> None: """RemoveTags removes a list of tags from a workspace.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if len(options.tags) == 0: raise MissingTagIdentifierError() @@ -903,7 +903,7 @@ def remove_tags( ) def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() path = f"/api/v2/workspaces/{workspace_id}/tag-bindings" @@ -918,7 +918,7 @@ def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: def list_effective_tag_bindings( self, workspace_id: str ) -> Iterator[EffectiveTagBinding]: - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() path = f"/api/v2/workspaces/{workspace_id}/effective-tag-bindings" @@ -935,7 +935,7 @@ def add_tag_bindings( self, workspace_id: str, options: WorkspaceAddTagBindingsOptions ) -> Iterator[TagBinding]: """AddTagBindings adds or modifies the value of existing tag binding keys for a workspace.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() if len(options.tag_bindings) == 0: raise MissingTagBindingIdentifierError() @@ -967,7 +967,7 @@ def add_tag_bindings( def delete_all_tag_bindings(self, workspace_id: str) -> None: """DeleteAllTagBindings removes all tag bindings associated with a workspace.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() body = { @@ -983,7 +983,7 @@ def read_data_retention_policy( self, workspace_id: str ) -> DataRetentionPolicy | None: """Read a workspace's data retention policy (deprecated: use read_data_retention_policy_choice instead).""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() try: @@ -1010,7 +1010,7 @@ def read_data_retention_policy_choice( self, workspace_id: str ) -> DataRetentionPolicyChoice | None: """Read a workspace's data retention policy choice (polymorphic).""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() # First, read the workspace to determine the type of data retention policy @@ -1064,7 +1064,7 @@ def set_data_retention_policy( self, workspace_id: str, *, options: DataRetentionPolicySetOptions ) -> DataRetentionPolicy: """Set a workspace's data retention policy (deprecated: use set_data_retention_policy_delete_older instead).""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() body = { @@ -1096,7 +1096,7 @@ def set_data_retention_policy_delete_older( self, workspace_id: str, *, options: DataRetentionPolicyDeleteOlderSetOptions ) -> DataRetentionPolicyDeleteOlder: """Set a workspace's data retention policy to delete data older than a certain number of days.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() body = { @@ -1124,7 +1124,7 @@ def set_data_retention_policy_dont_delete( self, workspace_id: str, *, options: DataRetentionPolicyDontDeleteSetOptions ) -> DataRetentionPolicyDontDelete: """Set a workspace's data retention policy to explicitly not delete data.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() body = { @@ -1142,7 +1142,36 @@ def set_data_retention_policy_dont_delete( def delete_data_retention_policy(self, workspace_id: str) -> None: """Delete a workspace's data retention policy.""" - if not is_valid_string_id(workspace_id): + if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() self.t.request("DELETE", self._data_retention_policy_link(workspace_id)) + + def readme(self, workspace_id: str) -> str | None: + """Get the README content of a workspace by its ID.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + r = self.t.request( + "GET", f"/api/v2/workspaces/{workspace_id}", params={"include": "readme"} + ) + payload = r.json() + + # First check if workspace has a readme relationship + data = payload.get("data", {}) + relationships = data.get("relationships", {}) + readme_rel = relationships.get("readme", {}) + readme_data = readme_rel.get("data") + + # If no readme relationship or it's null, return None + if not readme_data: + return None + + # Look for the readme in included section + readme_id = readme_data.get("id") + included = payload.get("included") or [] + + for inc in included: + if inc.get("type") == "workspace-readme" and inc.get("id") == readme_id: + return (inc.get("attributes") or {}).get("raw-markdown") + + return None diff --git a/src/tfe/utils.py b/src/tfe/utils.py index 303431a4..a942ffcf 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -4,6 +4,19 @@ import time from collections.abc import Callable +from .errors import ( + InvalidNameError, + RequiredAgentModeError, + RequiredAgentPoolIDError, + RequiredNameError, + UnsupportedBothTagsRegexAndFileTriggersEnabledError, + UnsupportedBothTagsRegexAndTriggerPatternsError, + UnsupportedBothTagsRegexAndTriggerPrefixesError, + UnsupportedBothTriggerPatternsAndPrefixesError, + UnsupportedOperationsError, +) +from .types import VCSRepo, WorkspaceCreateOptions, WorkspaceUpdateOptions + _STRING_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$") @@ -29,3 +42,116 @@ def valid_string(v: str | None) -> bool: def valid_string_id(v: str | None) -> bool: return v is not None and _STRING_ID_PATTERN.match(str(v)) is not None + + +def is_valid_workspace_name(name: str | None) -> bool: + """ + Check if a workspace name is valid. + Terraform Cloud workspace names must: + - Be between 1 and 90 characters + - Only contain letters, numbers, dashes, and underscores + - Cannot start or end with a dash + """ + if not valid_string(name): + return False + + if not name: + return False + + # Check length + if len(name) < 1 or len(name) > 90: + return False + + # Check format: alphanumeric, dashes, underscores, but not starting/ending with dash + if not re.match(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*[a-zA-Z0-9_]$|^[a-zA-Z0-9_]$", name): + return False + + return True + + +def has_tags_regex_defined(vcs_repo: VCSRepo | None) -> bool: + """Check if VCS repo has tags regex defined.""" + return vcs_repo is not None and valid_string(vcs_repo.tags_regex) + + +def validate_workspace_create_options(options: WorkspaceCreateOptions) -> None: + """ + Validate workspace create options similar to Go implementation. + Raises specific validation errors if validation fails. + """ + # Check required name + if not valid_string(options.name): + raise RequiredNameError() + + # Check name format + if not is_valid_workspace_name(options.name): + raise InvalidNameError() + + # Check operations and execution mode conflict + if options.operations is not None and options.execution_mode is not None: + raise UnsupportedOperationsError() + + # Check agent mode requirements + if options.agent_pool_id is not None and ( + options.execution_mode is None or options.execution_mode != "agent" + ): + raise RequiredAgentModeError() + + if ( + options.agent_pool_id is None + and options.execution_mode is not None + and options.execution_mode == "agent" + ): + raise RequiredAgentPoolIDError() + + # Check trigger patterns and prefixes conflict + if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: + raise UnsupportedBothTriggerPatternsAndPrefixesError() + + # Check tags regex conflicts + if has_tags_regex_defined(options.vcs_repo): + if len(options.trigger_patterns) > 0: + raise UnsupportedBothTagsRegexAndTriggerPatternsError() + + if len(options.trigger_prefixes) > 0: + raise UnsupportedBothTagsRegexAndTriggerPrefixesError() + + if options.file_triggers_enabled is not None and options.file_triggers_enabled: + raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() + + +def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: + """ + Validate workspace update options similar to Go implementation. + Raises specific validation errors if validation fails. + """ + # Check name format if provided + if options.name is not None and not is_valid_workspace_name(options.name): + raise InvalidNameError() + + # Check operations and execution mode conflict + if options.operations is not None and options.execution_mode is not None: + raise UnsupportedOperationsError() + + # Check agent mode requirements + if ( + options.agent_pool_id is None + and options.execution_mode is not None + and options.execution_mode == "agent" + ): + raise RequiredAgentPoolIDError() + + # Check trigger patterns and prefixes conflict + if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: + raise UnsupportedBothTriggerPatternsAndPrefixesError() + + # Check tags regex conflicts + if has_tags_regex_defined(options.vcs_repo): + if len(options.trigger_patterns) > 0: + raise UnsupportedBothTagsRegexAndTriggerPatternsError() + + if len(options.trigger_prefixes) > 0: + raise UnsupportedBothTagsRegexAndTriggerPrefixesError() + + if options.file_triggers_enabled is not None and options.file_triggers_enabled: + raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() diff --git a/src/tfe/workspace_validation.py b/src/tfe/workspace_validation.py deleted file mode 100644 index 329a6473..00000000 --- a/src/tfe/workspace_validation.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Workspace validation functions similar to Go implementation.""" - -from __future__ import annotations - -import re -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .types import VCSRepo, WorkspaceCreateOptions, WorkspaceUpdateOptions - -from .errors import ( - InvalidNameError, - RequiredAgentModeError, - RequiredAgentPoolIDError, - RequiredNameError, - UnsupportedBothTagsRegexAndFileTriggersEnabledError, - UnsupportedBothTagsRegexAndTriggerPatternsError, - UnsupportedBothTagsRegexAndTriggerPrefixesError, - UnsupportedBothTriggerPatternsAndPrefixesError, - UnsupportedOperationsError, -) - -# Regular expression used to validate common string ID patterns -# Matches strings that don't contain '/' or whitespace characters -STRING_ID_PATTERN = re.compile(r"^[^/\s]+$") - - -def is_valid_string(value: str | None) -> bool: - """Check if a string value is valid (not None and not empty).""" - return value is not None and value.strip() != "" - - -def is_valid_string_id(value: str | None) -> bool: - """ - Check if a string is a valid ID (similar to Go's validStringID). - Returns True if the string is non-null and contains a typical string identifier - (no slashes or whitespace). - """ - return value is not None and STRING_ID_PATTERN.match(value) is not None - - -def is_valid_workspace_name(name: str | None) -> bool: - """ - Check if a workspace name is valid. - Terraform Cloud workspace names must: - - Be between 1 and 90 characters - - Only contain letters, numbers, dashes, and underscores - - Cannot start or end with a dash - """ - if not is_valid_string(name): - return False - - if not name: - return False - - # Check length - if len(name) < 1 or len(name) > 90: - return False - - # Check format: alphanumeric, dashes, underscores, but not starting/ending with dash - if not re.match(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*[a-zA-Z0-9_]$|^[a-zA-Z0-9_]$", name): - return False - - return True - - -def has_tags_regex_defined(vcs_repo: VCSRepo | None) -> bool: - """Check if VCS repo has tags regex defined.""" - return vcs_repo is not None and is_valid_string(vcs_repo.tags_regex) - - -def validate_workspace_create_options(options: WorkspaceCreateOptions) -> None: - """ - Validate workspace create options similar to Go implementation. - Raises specific validation errors if validation fails. - """ - # Check required name - if not is_valid_string(options.name): - raise RequiredNameError() - - # Check name format - if not is_valid_workspace_name(options.name): - raise InvalidNameError() - - # Check operations and execution mode conflict - if options.operations is not None and options.execution_mode is not None: - raise UnsupportedOperationsError() - - # Check agent mode requirements - if options.agent_pool_id is not None and ( - options.execution_mode is None or options.execution_mode != "agent" - ): - raise RequiredAgentModeError() - - if ( - options.agent_pool_id is None - and options.execution_mode is not None - and options.execution_mode == "agent" - ): - raise RequiredAgentPoolIDError() - - # Check trigger patterns and prefixes conflict - if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: - raise UnsupportedBothTriggerPatternsAndPrefixesError() - - # Check tags regex conflicts - if has_tags_regex_defined(options.vcs_repo): - if len(options.trigger_patterns) > 0: - raise UnsupportedBothTagsRegexAndTriggerPatternsError() - - if len(options.trigger_prefixes) > 0: - raise UnsupportedBothTagsRegexAndTriggerPrefixesError() - - if options.file_triggers_enabled is not None and options.file_triggers_enabled: - raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() - - -def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: - """ - Validate workspace update options similar to Go implementation. - Raises specific validation errors if validation fails. - """ - # Check name format if provided - if options.name is not None and not is_valid_workspace_name(options.name): - raise InvalidNameError() - - # Check operations and execution mode conflict - if options.operations is not None and options.execution_mode is not None: - raise UnsupportedOperationsError() - - # Check agent mode requirements - if ( - options.agent_pool_id is None - and options.execution_mode is not None - and options.execution_mode == "agent" - ): - raise RequiredAgentPoolIDError() - - # Check trigger patterns and prefixes conflict - if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: - raise UnsupportedBothTriggerPatternsAndPrefixesError() - - # Check tags regex conflicts - if has_tags_regex_defined(options.vcs_repo): - if len(options.trigger_patterns) > 0: - raise UnsupportedBothTagsRegexAndTriggerPatternsError() - - if len(options.trigger_prefixes) > 0: - raise UnsupportedBothTagsRegexAndTriggerPrefixesError() - - if options.file_triggers_enabled is not None and options.file_triggers_enabled: - raise UnsupportedBothTagsRegexAndFileTriggersEnabledError()