diff --git a/examples/run.py b/examples/run.py new file mode 100644 index 00000000..3dd588e0 --- /dev/null +++ b/examples/run.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import argparse +import os +from datetime import datetime + +from tfe import TFEClient, TFEConfig +from tfe.models.run import ( + RunCreateOptions, + RunIncludeOpt, + RunListForOrganizationOptions, + RunListOptions, + RunReadOptions, + RunVariable, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Runs demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--workspace-id", help="Workspace ID") + parser.add_argument( + "--organization", + default=os.getenv("TFE_ORG", ""), + help="Organization name (for org-level operations)", + ) + parser.add_argument("--page", type=int, default=1) + parser.add_argument("--page-size", type=int, default=10) + parser.add_argument("--create-run", action="store_true", help="Create a new run") + parser.add_argument( + "--run-actions", action="store_true", help="Demo run actions (safe mode)" + ) + args = parser.parse_args() + + if not args.token: + print("Error: TFE_TOKEN environment variable or --token required") + return + + if not args.workspace_id and not args.organization: + print("Error: At least one of --workspace-id or --organization is required") + return + + if args.create_run and not args.workspace_id: + print("Error: --create-run requires --workspace-id") + return + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # Workspace-specific operations + if args.workspace_id: + # 1) List runs in the workspace + _print_header(f"Listing runs for workspace: {args.workspace_id}") + + options = RunListOptions( + page_number=args.page, + page_size=args.page_size, + ) + + try: + run_list = client.runs.list(args.workspace_id, options) + except Exception as e: + print(f"Error listing runs: {e}") + if args.organization: + print("Trying organization-level listing instead...") + else: + return + + if "run_list" in locals(): + print(f"Total runs: {run_list.total_count}") + print(f"Page {run_list.current_page} of {run_list.total_pages}") + print() + + for run in run_list.items: + print(f"- {run.id} | status={run.status} | created={run.created_at}") + print(f" message: {run.message}") + print( + f" has_changes: {run.has_changes} | is_destroy: {run.is_destroy}" + ) + + if not run_list.items: + print("No runs found.") + else: + # 2) Read the most recent run with details + _print_header("Reading most recent run details") + + latest_run = run_list.items[0] + read_options = RunReadOptions( + include=[ + RunIncludeOpt.RUN_PLAN, + RunIncludeOpt.RUN_APPLY, + RunIncludeOpt.RUN_CREATED_BY, + RunIncludeOpt.RUN_WORKSPACE, + ] + ) + + try: + detailed_run = client.runs.read_with_options( + latest_run.id, read_options + ) + + print(f"Run ID: {detailed_run.id}") + print(f"Status: {detailed_run.status}") + print(f"Source: {detailed_run.source}") + print(f"Message: {detailed_run.message}") + print(f"Created: {detailed_run.created_at}") + print(f"Auto Apply: {detailed_run.auto_apply}") + print(f"Plan Only: {detailed_run.plan_only}") + print(f"Position in Queue: {detailed_run.position_in_queue}") + + if detailed_run.actions: + print("\nAvailable Actions:") + print(f" Can Apply: {detailed_run.actions.is_confirmable}") + print(f" Can Cancel: {detailed_run.actions.is_cancelable}") + print(f" Can Discard: {detailed_run.actions.is_discardable}") + print( + f" Can Force Cancel: {detailed_run.actions.is_force_cancelable}" + ) + + if detailed_run.created_by: + print(f"\nCreated by: {detailed_run.created_by.username}") + + except Exception as e: + print(f"Error reading run details: {e}") + + # 3) Optionally create a new run + if args.create_run: + _print_header("Creating a new plan-only run") + + try: + # Get workspace object - convert to the model type expected by run + workspace_data = client.workspaces.read_by_id(args.workspace_id) + + # Create the workspace object that run models expect + from tfe.models.workspace import Workspace + + workspace = Workspace( + id=workspace_data.id, + name=workspace_data.name, + organization=workspace_data.organization, + execution_mode=workspace_data.execution_mode, + project_id=workspace_data.project_id, + tags=getattr(workspace_data, "tags", []), + ) + + # Create run with some example variables + variables = [ + RunVariable(key="environment", value="demo"), + RunVariable(key="instance_type", value="t3.micro"), + ] + + create_options = RunCreateOptions( + workspace=workspace, + plan_only=True, + message=f"Demo run created by python-tfe SDK at {datetime.now()}", + variables=variables, + ) + + new_run = client.runs.create(args.workspace_id, create_options) + + print(f"Created new run: {new_run.id}") + print(f"Status: {new_run.status}") + print(f"Message: {new_run.message}") + print(f"Variables: {len(variables)} variables passed") + print(f"Plan Only: {new_run.plan_only}") + + except Exception as e: + print(f"Error creating run: {e}") + import traceback + + traceback.print_exc() + + # 4) Organization-level listing (if organization provided) + if args.organization: + _print_header(f"Listing runs across organization: {args.organization}") + + try: + org_options = RunListForOrganizationOptions( + page_number=args.page, + page_size=5, # Smaller for demo + status="applied,planned,errored", + ) + + org_runs = client.runs.list_for_organization(args.organization, org_options) + print(f"Found {len(org_runs.items)} runs across organization") + + for run in org_runs.items[:3]: # Show first 3 + print(f"- {run.id} | status={run.status}") + if run.workspace: + print(f" workspace: {run.workspace.name}") + + except Exception as e: + print(f"Error listing organization runs: {e}") + + # 5) Demonstrate run actions (safe mode - show but don't execute) + if args.run_actions and args.workspace_id: + _print_header("Run Actions Demo (Safe Mode)") + + # Get runs first if not already available + if "run_list" not in locals() or not run_list.items: + try: + options = RunListOptions(page_size=1) + run_list = client.runs.list(args.workspace_id, options) + except Exception as e: + print(f"Error getting runs for actions demo: {e}") + return + + if not run_list.items: + print("No runs available for actions demo") + return + + demo_run = run_list.items[0] + print(f"Demonstrating actions for run: {demo_run.id}") + print(f"Current status: {demo_run.status}") + + # Show basic read (without options) + print("\n1. Basic read():") + try: + basic_run = client.runs.read(demo_run.id) + print(f" Read run {basic_run.id} - status: {basic_run.status}") + except Exception as e: + print(f" Error: {e}") + + # Show action methods (but don't execute them for safety) + print("\n2. Available action methods (not executed):") + print(" # Apply run:") + print( + f" # client.runs.apply('{demo_run.id}', RunApplyOptions(comment='Applied via SDK'))" + ) + + print(" # Cancel run:") + print( + f" # client.runs.cancel('{demo_run.id}', RunCancelOptions(comment='Canceled via SDK'))" + ) + + print(" # Force cancel run:") + print( + f" # client.runs.force_cancel('{demo_run.id}', RunForceCancelOptions(comment='Force canceled'))" + ) + + print(" # Discard run:") + print( + f" # client.runs.discard('{demo_run.id}', RunDiscardOptions(comment='Discarded via SDK'))" + ) + + print(" # Force execute run:") + print(f" # client.runs.force_execute('{demo_run.id}')") + + print("\n Note: These actions are commented out for safety.") + print(" Uncomment and use them carefully in your own code.") + + +if __name__ == "__main__": + main() diff --git a/examples/workspace_example.py b/examples/workspace.py similarity index 58% rename from examples/workspace_example.py rename to examples/workspace.py index fc4171a4..d5c67c59 100644 --- a/examples/workspace_example.py +++ b/examples/workspace.py @@ -23,11 +23,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) from tfe import TFEClient, TFEConfig -from tfe.errors import ( - InvalidOrgError, - InvalidWorkspaceIDError, - TFEError, -) from tfe.types import ( DataRetentionPolicyDeleteOlderSetOptions, DataRetentionPolicyDontDeleteSetOptions, @@ -63,7 +58,7 @@ def __init__(self): def demonstrate_all_operations(self, organization: str): """Demonstrate all workspace operations.""" - print("πŸš€ Starting Comprehensive Workspace Operations Demo") + print("Starting Comprehensive Workspace Operations Demo") print("=" * 60) try: @@ -109,15 +104,15 @@ def demonstrate_all_operations(self, organization: str): print(f"Error during demo: {e}") raise - print("\nπŸŽ‰ Comprehensive workspace demo completed successfully!") + 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("\n 1. WORKSPACE LISTING OPERATIONS") print("-" * 40) # Basic listing - print("πŸ” Listing all workspaces...") + print(" Listing all workspaces...") options = WorkspaceListOptions() workspaces = list(self.workspaces.list(organization, options=options)) print(f" Found {len(workspaces)} workspaces") @@ -129,7 +124,7 @@ def demo_list_operations(self, organization: str): print(f" - Locked: {ws.locked}") # Advanced listing with filters - print("\nπŸ” Listing with search filters...") + print("\n Listing with search filters...") filtered_options = WorkspaceListOptions( search="prod", # Search for workspaces containing "prod" tags="production,frontend", # Filter by tags @@ -147,7 +142,7 @@ def demo_list_operations(self, organization: str): def demo_create_operations(self, organization: str): """Demonstrate workspace creation operations.""" - print("\nπŸ—οΈ 2. WORKSPACE CREATION OPERATIONS") + print("\n 2. WORKSPACE CREATION OPERATIONS") print("-" * 40) # Basic workspace creation @@ -169,17 +164,17 @@ def demo_create_operations(self, organization: str): ) 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}") + 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...") + print("\n Creating workspace with VCS integration...") # VCS repository configuration vcs_repo = VCSRepo( @@ -201,11 +196,11 @@ def demo_create_with_vcs(self, organization: str): try: vcs_workspace = self.workspaces.create(organization, options=vcs_options) - print(f" βœ… Created VCS workspace: {vcs_workspace.name}") + 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}" + f" VCS workspace creation failed (expected without valid OAuth token): {e}" ) return None @@ -213,26 +208,26 @@ def demo_read_operations( self, organization: str, workspace_name: str, workspace_id: str ): """Demonstrate workspace reading operations.""" - print("\nπŸ“– 3. WORKSPACE READ 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}") + 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...") + 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}") + 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...") + print("\n Reading workspace with include options...") read_options = WorkspaceReadOptions( include=[WorkspaceIncludeOpt.CURRENT_RUN, WorkspaceIncludeOpt.OUTPUTS] ) @@ -240,19 +235,19 @@ def demo_read_operations( 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}") + 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("\n 4. WORKSPACE UPDATE OPERATIONS") print("-" * 40) # Update by name - print("πŸ”§ Updating workspace by name...") + print(" Updating workspace by name...") update_options = WorkspaceUpdateOptions( name=workspace_name, # Required field description=f"Updated description at {datetime.now()}", @@ -265,13 +260,13 @@ def demo_update_operations( 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}") + 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...") + print("\n Updating workspace by ID...") id_update_options = WorkspaceUpdateOptions( name=workspace_name, # Required field speculative_enabled=False, # Disable speculative plans @@ -281,22 +276,22 @@ def demo_update_operations( 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}") + 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("\n 5. VCS CONNECTION OPERATIONS") print("-" * 40) # Note: These operations require existing VCS connections - print("πŸ”Œ VCS connection management...") + print(" VCS connection management...") try: # Remove VCS connection by name - print("πŸ—‘οΈ Removing VCS connection by name...") + print(" Removing VCS connection by name...") remove_options = WorkspaceRemoveVCSConnectionOptions( id=workspace_id, vcs_repo=None, # Set to None to remove @@ -305,48 +300,48 @@ def demo_vcs_operations( updated_workspace = self.workspaces.remove_vcs_connection( organization, workspace_name, options=remove_options ) - print(f" βœ… VCS connection removed for: {updated_workspace.name}") + print(f" VCS connection removed for: {updated_workspace.name}") except Exception as e: - print(f" ⚠️ VCS operation note: {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("\n 6. WORKSPACE LOCKING OPERATIONS") print("-" * 40) # Lock workspace - print("πŸ” Locking 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}") + 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...") + 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}") + 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(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("\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)") + 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: """ @@ -357,38 +352,38 @@ def demo_ssh_key_operations(self, workspace_id: str): ) workspace_with_ssh = self.workspaces.assign_ssh_key(workspace_id, options=ssh_options) - print(f" πŸ”‘ SSH key assigned to: {workspace_with_ssh.name}") + 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}") + print(f" SSH key unassigned from: {workspace_without_ssh.name}") except Exception as e: - print(f" ⚠️ SSH key operation failed: {e}") + 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("\n 7. REMOTE STATE CONSUMER OPERATIONS") print("-" * 40) try: # 1. List current remote state consumers - print("πŸ“‹ Listing 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)") + print(f" Found {len(current_consumers)} current consumer(s)") for consumer in current_consumers: - print(f" πŸ”— Consumer: {consumer.name} (ID: {consumer.id})") + print(f" Consumer: {consumer.name} (ID: {consumer.id})") # 2. Get real workspaces from organization for demonstration - print("\nπŸ—οΈ Getting real workspaces for consumer demonstration...") + print("\n Getting real workspaces for consumer demonstration...") # Get existing workspaces from the organization to use as examples from tfe.types import WorkspaceListOptions @@ -410,34 +405,32 @@ def demo_remote_state_consumer_operations( demo_consumer_1 = available_workspaces[0] demo_consumer_2 = available_workspaces[1] - print(" πŸ“ Using real workspaces for demonstration:") + print(" Using real workspaces for demonstration:") print( - f" 🏒 Consumer 1: {demo_consumer_1.name} (ID: {demo_consumer_1.id})" + 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})" + 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" + f" Only {len(available_workspaces)} other workspaces available" ) - print(" πŸ—οΈ Creating minimal demo with available workspaces...") + 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}") + 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(" Using mock workspace references for concept demonstration") print( - " 🏒 In practice, use actual workspace IDs from your organization" + " In practice, use actual workspace IDs from your organization" ) # Create mock workspaces for demonstration only @@ -455,7 +448,7 @@ def demo_remote_state_consumer_operations( ) # 3. Add remote state consumers - print("\nβž• Adding remote state consumers...") + print("\n Adding remote state consumers...") add_options = WorkspaceAddRemoteStateConsumersOptions( workspaces=[demo_consumer_1, demo_consumer_2] ) @@ -463,28 +456,26 @@ def demo_remote_state_consumer_operations( # 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}") + 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}") + print(f" Add operation failed {expected_msg}: {add_error}") if not use_real_workspaces: - print( - " πŸ“ This is expected when using non-existent workspace IDs" - ) + 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...") + 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)}") + print(f" Current consumer count: {len(updated_consumers)}") # 5. Remove a remote state consumer - print("\nβž– Removing a remote state consumer...") + print("\n Removing a remote state consumer...") remove_options = WorkspaceRemoveRemoteStateConsumersOptions( workspaces=[demo_consumer_1] ) @@ -493,21 +484,21 @@ def demo_remote_state_consumer_operations( self.workspaces.remove_remote_state_consumers( workspace_id, remove_options ) - print(f" βœ… Successfully removed consumer: {demo_consumer_1.name}") + 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}") + print(f" Remove operation failed {expected_msg}: {remove_error}") # 6. Update remote state consumers (replace all) - print("\nπŸ”„ Updating remote state consumers (replacing 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})" + f" Consumer 3: {demo_consumer_3.name} (ID: {demo_consumer_3.id})" ) else: # Create mock workspace for demonstration @@ -528,69 +519,57 @@ def demo_remote_state_consumer_operations( self.workspaces.update_remote_state_consumers( workspace_id, update_options ) - print(" βœ… Successfully updated remote state consumers") + print(" Successfully updated remote state consumers") print( - f" πŸ”— New consumer set: {demo_consumer_2.name}, {demo_consumer_3.name}" + 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}") + print(f" Update operation failed {expected_msg}: {update_error}") # 7. Final listing to show results - print("\nπŸ“‹ Final remote state consumer listing...") + 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)}") + 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") + print(f" Final consumer: {consumer.name} (ID: {consumer.id})") 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") + print(f" Remote state consumer operations failed: {e}") def demo_tag_operations(self, workspace_id: str): """Demonstrate comprehensive workspace tag management operations.""" - print("\n🏷️ 8. WORKSPACE TAG OPERATIONS") + print("\n 8. WORKSPACE TAG OPERATIONS") print("-" * 40) try: # 8.1 List existing tags - print("πŸ“‹ Listing current workspace 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:") + print(f" Found {len(current_tags)} existing tags:") for tag in current_tags: - print(f" 🏷️ Tag: {tag.name} (ID: {tag.id})") + print(f" Tag: {tag.name} (ID: {tag.id})") # 8.2 List tags with search query - print("\nπŸ” Searching for tags with 'env' in name...") + 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':") + print(f" Found {len(search_results)} tags matching 'env':") for tag in search_results: - print(f" 🏷️ Matching tag: {tag.name}") + print(f" Matching tag: {tag.name}") # 8.3 Add new tags - print("\nβž• Adding new tags to workspace...") + print("\n Adding new tags to workspace...") new_tags = [ Tag(name="environment-production"), # Add by name Tag(name="team-backend"), @@ -602,33 +581,33 @@ def demo_tag_operations(self, workspace_id: str): add_options = WorkspaceAddTagsOptions(tags=new_tags) self.workspaces.add_tags(workspace_id, add_options) - print(f" βœ… Successfully added {len(new_tags)} tags") + print(f" Successfully added {len(new_tags)} tags") for tag in new_tags: if tag.id: - print(f" 🏷️ Added tag by ID: {tag.id}") + print(f" Added tag by ID: {tag.id}") else: - print(f" 🏷️ Added tag by name: {tag.name}") + print(f" Added tag by name: {tag.name}") # 8.4 List updated tags - print("\nπŸ“‹ Listing updated workspace 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)}") + print(f" Total tags after addition: {len(updated_tags)}") for tag in updated_tags: - print(f" 🏷️ Tag: {tag.name} (ID: {tag.id})") + print(f" Tag: {tag.name} (ID: {tag.id})") # 8.5 List tags with pagination - print("\nπŸ“„ Demonstrating tag 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") + 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...") + print("\n Removing specific tags...") tags_to_remove = [ Tag( name="version-v2-1-0" @@ -640,70 +619,56 @@ def demo_tag_operations(self, workspace_id: str): remove_options = WorkspaceRemoveTagsOptions(tags=tags_to_remove) self.workspaces.remove_tags(workspace_id, remove_options) - print(f" βœ… Successfully removed {len(tags_to_remove)} tags") + 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}") + print(f" Removed tag by ID: {tag.id}") else: - print(f" πŸ—‘οΈ Removed tag by name: {tag.name}") + print(f" Removed tag by name: {tag.name}") # 8.7 Final tag list - print("\nπŸ“‹ Final workspace tags...") + print("\n Final workspace tags...") final_tags = list(self.workspaces.list_tags(workspace_id, list_options)) - print(f" πŸ“Š Final tag count: {len(final_tags)}") + 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") + print(f" Final tag: {tag.name} (ID: {tag.id})") 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") + print(f" Tag operations failed: {e}") def demo_tag_binding_operations(self, workspace_id: str): """Demonstrate comprehensive workspace tag binding management operations.""" - print("\nπŸ”— 8B. WORKSPACE TAG BINDING OPERATIONS") + print("\n 8B. WORKSPACE TAG BINDING OPERATIONS") print("-" * 45) try: # 8B.1 List existing tag bindings - print("πŸ“‹ Listing current workspace 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:") + print(f" Found {len(current_bindings)} existing tag bindings:") for binding in current_bindings: print( - f" πŸ”— Binding: {binding.key} = {binding.value} (ID: {binding.id})" + 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)...") + 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:") + 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}") + print(f" Effective: {binding.key} = {binding.value}{links_info}") # 8B.3 Add new tag bindings - print("\nβž• Adding new tag bindings to workspace...") + print("\n Adding new tag bindings to workspace...") new_bindings = [ TagBinding(key="environment", value="production"), TagBinding(key="team", value="infrastructure"), @@ -716,15 +681,13 @@ def demo_tag_binding_operations(self, workspace_id: str): result_bindings = list( self.workspaces.add_tag_bindings(workspace_id, add_options) ) - print(f" βœ… Successfully added {len(result_bindings)} tag bindings") + print(f" Successfully added {len(result_bindings)} tag bindings") for binding in result_bindings: - print( - f" πŸ”— Added: {binding.key} = {binding.value} (ID: {binding.id})" - ) + 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...") + print("\n Updating existing tag bindings...") update_bindings = [ TagBinding(key="environment", value="staging"), # Update existing TagBinding(key="version", value="v2.1.0"), # Add new @@ -736,57 +699,34 @@ def demo_tag_binding_operations(self, workspace_id: str): updated_result = list( self.workspaces.add_tag_bindings(workspace_id, update_options) ) - print( - f" βœ… Successfully updated/added {len(updated_result)} tag bindings" - ) + print(f" Successfully updated/added {len(updated_result)} tag bindings") for binding in updated_result: - print(f" ✏️ Updated: {binding.key} = {binding.value}") + print(f" Updated: {binding.key} = {binding.value}") # 8B.5 Delete all tag bindings - print("\nπŸ—‘οΈ Removing all tag bindings...") + print("\n Removing all tag bindings...") self.workspaces.delete_all_tag_bindings(workspace_id) - print(" βœ… Successfully removed all tag bindings") + print(" Successfully removed all tag bindings") # 8B.6 Verify deletion - print("\nβœ… Verifying tag binding 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)}") + print(f" Remaining tag bindings: {len(final_bindings)}") if final_bindings: - print(" ⚠️ Some bindings remain:") + print(" Some bindings remain:") for binding in final_bindings: - print(f" πŸ”— {binding.key} = {binding.value}") + 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") + print(" All tag bindings successfully removed") 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") + print(f" Tag binding operations failed: {e}") def demo_data_retention_policy_operations(self, workspace_id: str): """Demonstrate workspace data retention policy management operations.""" - print("\nπŸ“Š Data Retention Policy Operations") + print("\n Data Retention Policy Operations") print("-" * 50) try: @@ -796,9 +736,9 @@ def demo_data_retention_policy_operations(self, workspace_id: str): workspace_id ) if current_policy is None or not current_policy.is_populated(): - print(" βœ… No data retention policy currently set") + print(" No data retention policy currently set") else: - print(f" πŸ“‹ Current policy: {current_policy}") + print(f" Current policy: {current_policy}") # Set a "delete older" data retention policy print("\n2. Setting 'delete older' data retention policy (30 days)...") @@ -810,9 +750,9 @@ def demo_data_retention_policy_operations(self, workspace_id: str): workspace_id, options=delete_older_options ) ) - print(f" βœ… Set delete older policy: ID={delete_older_policy.id}") + print(f" Set delete older policy: ID={delete_older_policy.id}") print( - f" πŸ“… Delete after: {delete_older_policy.delete_older_than_n_days} days" + f" Delete after: {delete_older_policy.delete_older_than_n_days} days" ) # Read the updated data retention policy choice @@ -821,18 +761,18 @@ def demo_data_retention_policy_operations(self, workspace_id: str): workspace_id ) if updated_policy and updated_policy.is_populated(): - print(" βœ… Data retention policy choice retrieved successfully") + 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") + 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}" + f" Legacy conversion: ID={legacy_policy.id}, Days={legacy_policy.delete_older_than_n_days}" ) # Update to a different retention period @@ -845,9 +785,9 @@ def demo_data_retention_policy_operations(self, workspace_id: str): workspace_id, options=updated_delete_older_options ) ) - print(f" βœ… Updated policy: ID={updated_delete_older_policy.id}") + 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" + f" New retention period: {updated_delete_older_policy.delete_older_than_n_days} days" ) # Switch to "don't delete" policy @@ -856,8 +796,8 @@ def demo_data_retention_policy_operations(self, workspace_id: str): 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") + 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...") @@ -869,20 +809,20 @@ def demo_data_retention_policy_operations(self, workspace_id: str): 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)") + 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)" + " 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") + print(" Data retention policy deleted successfully") # Verify deletion print("\n8. Verifying policy deletion...") @@ -890,96 +830,40 @@ def demo_data_retention_policy_operations(self, workspace_id: str): workspace_id ) if final_policy is None or not final_policy.is_populated(): - print(" βœ… Confirmed: No data retention policy set") + 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") + print(f" Unexpected: Policy still exists: {final_policy}") except Exception as e: 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" - ) + print(f" Data retention policy feature not available: {e}") 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") + print(f" Data retention policy operations failed: {e}") def demo_delete_operations( self, organization: str, workspace_name: str, workspace_id: str ): """Demonstrate workspace deletion operations.""" - print("\nπŸ—‘οΈ 9. WORKSPACE DELETE OPERATIONS") + print("\n 9. WORKSPACE DELETE OPERATIONS") print("-" * 40) - print("πŸ›‘οΈ Performing safe delete...") + 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") + 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}") + 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}") + 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}") + print(f" Delete failed: {delete_error}") def main(): @@ -989,27 +873,10 @@ def main(): 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" Terraform Address: {address}") + print(f" Organization: {organization}") print( - f"πŸ”‘ Token: {'*' * (len(token) - 8) + token[-8:] if len(token) > 8 else '****'}" + f" Token: {'*' * (len(token) - 8) + token[-8:] if len(token) > 8 else '****'}" ) try: @@ -1019,15 +886,8 @@ def main(): # Run comprehensive demo manager.demonstrate_all_operations(organization) - # Demonstrate error handling - manager.demo_error_handling(organization) - except Exception as e: print(f"\nDemo failed with error: {e}") - print("πŸ’‘ Common issues:") - print(" β€’ Invalid token or organization") - print(" β€’ Network connectivity problems") - print(" β€’ Insufficient permissions") raise diff --git a/src/tfe/client.py b/src/tfe/client.py index 38fc5e57..93e1254c 100644 --- a/src/tfe/client.py +++ b/src/tfe/client.py @@ -6,6 +6,7 @@ from .resources.projects import Projects from .resources.registry_module import RegistryModules from .resources.registry_provider import RegistryProviders +from .resources.run import Runs from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers from .resources.state_version_outputs import StateVersionOutputs @@ -45,6 +46,7 @@ def __init__(self, config: TFEConfig | None = None): self.state_version_outputs = StateVersionOutputs(self._transport) self.run_tasks = RunTasks(self._transport) self.run_triggers = RunTriggers(self._transport) + self.runs = Runs(self._transport) def close(self) -> None: pass diff --git a/src/tfe/errors.py b/src/tfe/errors.py index 84eaf3c0..b78b8065 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -246,6 +246,13 @@ def __init__(self, message: str = "name is required"): super().__init__(message) +class RequiredWorkspaceError(RequiredFieldMissing): + """Raised when a required workspace field is missing.""" + + def __init__(self, message: str = "workspace is required"): + super().__init__(message) + + # Run Task errors class InvalidRunTaskIDError(InvalidValues): """Raised when an invalid run task ID is provided.""" @@ -314,3 +321,21 @@ class InvalidRunTriggerIDError(InvalidValues): def __init__(self, message: str = "invalid value for run trigger ID"): super().__init__(message) + + +# Run errors +class InvalidRunIDError(InvalidValues): + """Raised when an invalid run ID is provided.""" + + def __init__(self, message: str = "invalid value for run ID"): + super().__init__(message) + + +class TerraformVersionValidForPlanOnlyError(ValidationError): + """Raised when terraform_version is set without plan_only being true.""" + + def __init__( + self, + message: str = "setting terraform-version is only valid when plan-only is set to true", + ): + super().__init__(message) diff --git a/src/tfe/models/apply.py b/src/tfe/models/apply.py new file mode 100644 index 00000000..16d04487 --- /dev/null +++ b/src/tfe/models/apply.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class Apply(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + log_read_url: str | None = Field(None, alias="log-read-url") + raiseesource_additions: int = Field(..., alias="resource-additions") + resource_changes: int = Field(..., alias="resource-changes") + resource_destructions: int = Field(..., alias="resource-destructions") + status: ApplyStatus = Field(..., alias="status") + status_timestamps: ApplyStatusTimestamps = Field(..., alias="status-timestamps") + + +class ApplyStatus(str, Enum): + Apply_Canceled = "canceled" + Apply_Created = "created" + Apply_Errored = "errored" + Apply_Finished = "finished" + Apply_MFA_Waiting = "mfa_waiting" + Apply_Pending = "pending" + Apply_Queued = "queued" + Apply_Running = "running" + Apply_Unreachable = "unreachable" + + +class ApplyStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + canceled_at: datetime = Field(..., alias="canceled-at") + errored_at: datetime = Field(..., alias="errored-at") + finished_at: datetime = Field(..., alias="finished-at") + force_canceled_at: datetime = Field(..., alias="force-canceled-at") + queued_at: datetime = Field(..., alias="queued-at") + started_at: datetime = Field(..., alias="started-at") diff --git a/src/tfe/models/comment.py b/src/tfe/models/comment.py new file mode 100644 index 00000000..6242f39c --- /dev/null +++ b/src/tfe/models/comment.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class Comment(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + body: str = Field(..., alias="body") diff --git a/src/tfe/models/configuration_version.py b/src/tfe/models/configuration_version.py new file mode 100644 index 00000000..a3e3023f --- /dev/null +++ b/src/tfe/models/configuration_version.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ConfigurationVersion(BaseModel): + id: str + auto_queue_runs: bool = Field(..., alias="auto-queue-runs") + error: str | None = Field(None, alias="error") + error_message: str | None = Field(None, alias="error-message") + # source: ConfigurationSource = Field(..., alias="source") + speculative: bool = Field(..., alias="speculative") + provisional: bool = Field(..., alias="provisional") + # status: ConfigurationStatus = Field(..., alias="status") + # status_timestamps: CVStatusTimestamps = Field(..., alias="status-timestamps") + upload_url: str | None = Field(None, alias="upload-url") + # ingress_attributes: IngressAttributes | None = Field(None, alias="ingress-attributes") diff --git a/src/tfe/models/cost_estimate.py b/src/tfe/models/cost_estimate.py new file mode 100644 index 00000000..3c0f4e16 --- /dev/null +++ b/src/tfe/models/cost_estimate.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class CostEstimate(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + delta_monthly_cost: str = Field(..., alias="delta-monthly-cost") + error_message: str = Field(..., alias="error-message") + matched_resources_count: int = Field(..., alias="matched-resources-count") + prior_monthly_cost: str = Field(..., alias="prior-monthly-cost") + proposed_monthly_cost: str = Field(..., alias="proposed-monthly-cost") + resources_count: int = Field(..., alias="resources-count") + status: CostEstimateStatus = Field(..., alias="status") + status_timestamps: CostEstimateStatusTimestamps = Field( + ..., alias="status-timestamps" + ) + unmatched_resources_count: int = Field(..., alias="unmatched-resources-count") + + +class CostEstimateStatus(str, Enum): + Cost_Estimate_Canceled = "canceled" + Cost_Estimate_Errored = "errored" + Cost_Estimate_Finished = "finished" + Cost_Estimate_Pending = "pending" + Cost_Estimate_Queued = "queued" + Cost_Estimate_Skipped_Due_To_Targeting = "skipped_due_to_targeting" + + +class CostEstimateStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + canceled_at: datetime = Field(..., alias="canceled-at") + errored_at: datetime = Field(..., alias="errored-at") + finished_at: datetime = Field(..., alias="finished-at") + queued_at: datetime = Field(..., alias="queued-at") + skipped_due_to_targeting_at: datetime = Field( + ..., alias="skipped-due-to-targeting-at" + ) diff --git a/src/tfe/models/plan.py b/src/tfe/models/plan.py new file mode 100644 index 00000000..a964efdb --- /dev/null +++ b/src/tfe/models/plan.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class PlanStatus(str, Enum): + Plan_Canceled = "canceled" + Plan_Created = "created" + Plan_Errored = "errored" + Plan_Finished = "finished" + Plan_MFA_Waiting = "mfa_waiting" + Plan_Pending = "pending" + Plan_Queued = "queued" + Plan_Running = "running" + Plan_Unreachable = "unreachable" + + +class Plan(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + has_changes: bool = Field(..., alias="has-changes") + generated_configuration: bool = Field(..., alias="generated-configuration") + log_read_url: str = Field(..., alias="log-read-url") + resource_additions: int = Field(..., alias="resource-additions") + resource_changes: int = Field(..., alias="resource-changes") + resource_destructions: int = Field(..., alias="resource-destructions") + resource_imports: int = Field(..., alias="resource-imports") + status: PlanStatus = Field(..., alias="status") + status_timestamps: PlanStatusTimestamps = Field(..., alias="status-timestamps") + + # Relations + # exports: list[PlanExport] = Field(..., alias="exports") + + +class PlanStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + canceled_at: datetime = Field(..., alias="canceled-at") + errored_at: datetime = Field(..., alias="errored-at") + finished_at: datetime = Field(..., alias="finished-at") + force_canceled_at: datetime = Field(..., alias="force-canceled-at") + queued_at: datetime = Field(..., alias="queued-at") + started_at: datetime = Field(..., alias="started-at") diff --git a/src/tfe/models/policy_check.py b/src/tfe/models/policy_check.py new file mode 100644 index 00000000..7e8366ca --- /dev/null +++ b/src/tfe/models/policy_check.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from .run import Run + + +# PolicyCheck represents a Terraform Enterprise policy check.. +class PolicyCheck(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + # actions: PolicyActions = Field(..., alias="actions") + # permissions: PolicyPermissions = Field(..., alias="permissions") + # result: PolicyResult = Field(..., alias="result") + # scope: PolicyScope = Field(..., alias="scope") + # status: PolicyStatus = Field(..., alias="status") + # status_timestamps: PolicyStatusTimestamps = Field(..., alias="status-timestamps") + + # Relations + run: Run = Field(..., alias="run") diff --git a/src/tfe/models/run.py b/src/tfe/models/run.py new file mode 100644 index 00000000..26a4042f --- /dev/null +++ b/src/tfe/models/run.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from .apply import Apply +from .comment import Comment +from .configuration_version import ConfigurationVersion +from .cost_estimate import CostEstimate +from .plan import Plan +from .policy_check import PolicyCheck +from .run_event import RunEvent +from .task_stage import TaskStage +from .user import User +from .workspace import Workspace + + +class RunSource(str, Enum): + """RunSource represents a source type of a run.""" + + Run_Source_API = "tfe-api" + Run_Source_Configuration_Version = "tfe-configuration-version" + Run_Source_UI = "tfe-ui" + + +class RunStatus(str, Enum): + """RunStatus represents a run state.""" + + Run_Applied = "applied" + Run_Applying = "applying" + Run_Apply_Queued = "apply_queued" + Run_Canceled = "canceled" + Run_Confirmed = "confirmed" + Run_Cost_Estimated = "cost_estimated" + Run_Cost_Estimating = "cost_estimating" + Run_Discarded = "discarded" + Run_Errored = "errored" + Run_Fetching = "fetching" + Run_Fetching_Completed = "fetching_completed" + Run_Pending = "pending" + Run_Planned = "planned" + Run_Planned_And_Finished = "planned_and_finished" + Run_Planned_And_Saved = "planned_and_saved" + Run_Planning = "planning" + Run_Plan_Queued = "plan_queued" + Run_Policy_Checked = "policy_checked" + Run_Policy_Checking = "policy_checking" + Run_Policy_Override = "policy_override" + Run_Policy_Soft_Failed = "policy_soft_failed" + Run_Post_Plan_Awaiting_Decision = "post_plan_awaiting_decision" + Run_Post_Plan_Completed = "post_plan_completed" + Run_Post_Plan_Running = "post_plan_running" + Run_Pre_Apply_Running = "pre_apply_running" + Run_Pre_Apply_Completed = "pre_apply_completed" + Run_Pre_Plan_Completed = "pre_plan_completed" + Run_Pre_Plan_Running = "pre_plan_running" + Run_Queuing = "queuing" + Run_Queuing_Apply = "queuing_apply" + + +class RunIncludeOpt(str, Enum): + RUN_WORKSPACE = "workspace" + RUN_CREATED_BY = "created-by" + RUN_PLAN = "plan" + RUN_CONFIG_VER = "configuration-version" + RUN_COST_ESTIMATE = "cost-estimate" + RUN_APPLY = "apply" + RUN_TASK_STAGES = "task-stages" + RUN_CONFIG_VER_INGRESS = "configuration-version.ingress_attributes" + + +class RunOperation(str, Enum): + """RunOperation represents an operation type of run.""" + + Run_Operation_Plan_Apply = "plan_and_apply" + Run_Operation_Plan_Only = "plan_only" + Run_Operation_Refresh_Only = "refresh_only" + Run_Operation_Destroy = "destroy" + Run_Operation_Empty_Apply = "empty_apply" + Run_Operation_Save_Plan = "save_plan" + + +class Run(BaseModel): + """Run represents a Terraform Enterprise run.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + actions: RunActions | None = Field(None, alias="actions") + auto_apply: bool | None = Field(None, alias="auto-apply") + allow_config_generation: bool | None = Field(None, alias="allow-config-generation") + allow_empty_apply: bool | None = Field(None, alias="allow-empty-apply") + canceled_at: datetime | None = Field(None, alias="canceled-at") + created_at: datetime | None = Field(None, alias="created-at") + force_cancel_available_at: datetime | None = Field( + None, alias="force-cancel-available-at" + ) + has_changes: bool | None = Field(None, alias="has-changes") + is_destroy: bool | None = Field(None, alias="is-destroy") + message: str | None = Field(None, alias="message") + permissions: RunPermissions | None = Field(None, alias="permissions") + policy_paths: list[str] | None = Field(None, alias="policy-paths") + position_in_queue: int | None = Field(None, alias="position-in-queue") + plan_only: bool | None = Field(None, alias="plan-only") + refresh: bool | None = Field(None, alias="refresh") + refresh_only: bool | None = Field(None, alias="refresh-only") + replace_addrs: list[str] | None = Field(None, alias="replace-addrs") + save_plan: bool | None = Field(None, alias="save-plan") + source: RunSource | None = Field(None, alias="source") + status: RunStatus | None = Field(None, alias="status") + status_timestamps: RunStatusTimestamps | None = Field( + None, alias="status-timestamps" + ) + target_addrs: list[str] | None = Field(None, alias="target-addrs") + terraform_version: str | None = Field(None, alias="terraform-version") + trigger_reason: str | None = Field(None, alias="trigger-reason") + variables: list[RunVariableAttr] | None = Field(None, alias="variables") + + # Relations + apply: Apply | None = Field(None, alias="apply") + configuration_version: ConfigurationVersion | None = Field( + None, alias="configuration-version" + ) + cost_estimate: CostEstimate | None = Field(None, alias="cost-estimate") + created_by: User | None = Field(None, alias="created-by") + confirmed_by: User | None = Field(None, alias="confirmed-by") + plan: Plan | None = Field(None, alias="plan") + policy_checks: list[PolicyCheck] | None = Field(None, alias="policy-checks") + run_events: list[RunEvent] | None = Field(None, alias="run-events") + task_stages: list[TaskStage] | None = Field(None, alias="task-stages") + workspace: Workspace | None = Field(None, alias="workspace") + comments: list[Comment] | None = Field(None, alias="comments") + + +class RunActions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + is_cancelable: bool = Field(..., alias="is-cancelable") + is_confirmable: bool = Field(..., alias="is-confirmable") + is_discardable: bool = Field(..., alias="is-discardable") + is_force_cancelable: bool = Field(..., alias="is-force-cancelable") + + +class RunPermissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_apply: bool = Field(..., alias="can-apply") + can_cancel: bool = Field(..., alias="can-cancel") + can_discard: bool = Field(..., alias="can-discard") + can_force_cancel: bool = Field(..., alias="can-force-cancel") + can_force_execute: bool = Field(..., alias="can-force-execute") + + +class RunStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + applied_at: datetime | None = Field(None, alias="applied-at") + applying_at: datetime | None = Field(None, alias="applying-at") + apply_queued_at: datetime | None = Field(None, alias="apply-queued-at") + canceled_at: datetime | None = Field(None, alias="canceled-at") + confirmed_at: datetime | None = Field(None, alias="confirmed-at") + cost_estimated_at: datetime | None = Field(None, alias="cost-estimated-at") + cost_estimating_at: datetime | None = Field(None, alias="cost-estimating-at") + discarded_at: datetime | None = Field(None, alias="discarded-at") + errored_at: datetime | None = Field(None, alias="errored-at") + fetched_at: datetime | None = Field(None, alias="fetched-at") + fetching_at: datetime | None = Field(None, alias="fetching-at") + force_canceled_at: datetime | None = Field(None, alias="force-canceled-at") + planned_and_finished_at: datetime | None = Field( + None, alias="planned-and-finished-at" + ) + planned_and_saved_at: datetime | None = Field(None, alias="planned-and-saved-at") + planned_at: datetime | None = Field(None, alias="planned-at") + planning_at: datetime | None = Field(None, alias="planning-at") + plan_queueable_at: datetime | None = Field(None, alias="plan-queueable-at") + plan_queued_at: datetime | None = Field(None, alias="plan-queued-at") + policy_checked_at: datetime | None = Field(None, alias="policy-checked-at") + policy_soft_failed_at: datetime | None = Field(None, alias="policy-soft-failed-at") + post_plan_completed_at: datetime | None = Field( + None, alias="post-plan-completed-at" + ) + post_plan_running_at: datetime | None = Field(None, alias="post-plan-running-at") + pre_plan_completed_at: datetime | None = Field(None, alias="pre-plan-completed-at") + pre_plan_running_at: datetime | None = Field(None, alias="pre-plan-running-at") + queuing_at: datetime | None = Field(None, alias="queuing-at") + + +class RunVariable(BaseModel): + """RunVariable for create operations.""" + + key: str + value: str + + +class RunVariableAttr(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + key: str = Field(..., alias="key") + value: str = Field(..., alias="value") + + +class RunList(BaseModel): + """RunList represents a list of runs.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: list[Run] = Field(default_factory=list) + current_page: int | None = None + prev_page: int | None = None + next_page: int | None = None + total_pages: int | None = None + total_count: int | None = None + + +class RunListOptions(BaseModel): + page_number: int | None = Field(default=1, alias="page[number]") + page_size: int | None = Field(default=20, alias="page[size]") + + user: str | None = Field(default=None, alias="search[user]") + commit: str | None = Field(default=None, alias="search[commit]") + search: str | None = Field(default=None, alias="search[basic]") + status: str | None = Field(default=None, alias="filter[status]") + source: str | None = Field(default=None, alias="filter[source]") + operation: str | None = Field(default=None, alias="filter[operation]") + + include: list[RunIncludeOpt] | None = Field(default_factory=list, alias="include") + + +class OrganizationRunList(BaseModel): + """ + OrganizationRunList represents a list of runs across an organization. + It differs from the RunList in that it does not include a TotalCount of records in the pagination details + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: list[Run] = Field(default_factory=list) + current_page: int | None = None + prev_page: int | None = None + next_page: int | None = None + + +class RunListForOrganizationOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_number: int | None = Field(default=1, alias="page[number]") + page_size: int | None = Field(default=20, alias="page[size]") + + user: str | None = Field(default=None, alias="search[user]") + commit: str | None = Field(default=None, alias="search[commit]") + basic: str | None = Field(default=None, alias="search[basic]") + status: str | None = Field(default=None, alias="filter[status]") + source: str | None = Field(default=None, alias="filter[source]") + operation: str | None = Field(default=None, alias="filter[operation]") + agent_pool_names: str | None = Field(default=None, alias="filter[agent_pool_names]") + status_group: str | None = Field(default=None, alias="filter[status_group]") + timeframe: str | None = Field(default=None, alias="filter[timeframe]") + workspace_names: str | None = Field(default=None, alias="filter[workspace_names]") + + include: list[RunIncludeOpt] | None = Field(default_factory=list, alias="include") + + +class RunCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + type: str = Field(default="runs") + allow_config_generation: bool | None = Field(None, alias="allow-config-generation") + allow_empty_apply: bool | None = Field(None, alias="allow-empty-apply") + terraform_version: str | None = Field(None, alias="terraform-version") + plan_only: bool | None = Field(None, alias="plan-only") + is_destroy: bool | None = Field(None, alias="is-destroy") + refresh: bool | None = Field(None, alias="refresh") + refresh_only: bool | None = Field(None, alias="refresh-only") + save_plan: bool | None = Field(None, alias="save-plan") + message: str | None = Field(None, alias="message") + configuration_version: ConfigurationVersion | None = Field( + None, alias="configuration-version" + ) + workspace: Workspace | None = Field(None, alias="workspace") + target_addrs: list[str] | None = Field(None, alias="target-addrs") + replace_addrs: list[str] | None = Field(None, alias="replace-addrs") + policy_paths: list[str] | None = Field(None, alias="policy-paths") + auto_apply: bool | None = Field(None, alias="auto-apply") + variables: list[RunVariable] | None = Field(None, alias="variables") + + +class RunReadOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[RunIncludeOpt] | None = Field(default_factory=list, alias="include") + + +class RunApplyOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + comment: str | None = Field(None, alias="comment") + + +class RunCancelOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + comment: str | None = Field(None, alias="comment") + + +class RunForceCancelOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + comment: str | None = Field(None, alias="comment") + + +class RunDiscardOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + comment: str | None = Field(None, alias="comment") diff --git a/src/tfe/models/run_event.py b/src/tfe/models/run_event.py new file mode 100644 index 00000000..15a17f3a --- /dev/null +++ b/src/tfe/models/run_event.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from .user import User + +# from .comment import Comment + + +class RunEvent(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + # action: RunEventAction = Field(..., alias="action") + created_at: datetime = Field(..., alias="created-at") + description: str = Field(..., alias="description") + + # Relations - Note that `target` is not supported yet + actor: User = Field(..., alias="actor") + # comment: Comment | None = Field(None, alias="comment") diff --git a/src/tfe/models/task_stage.py b/src/tfe/models/task_stage.py new file mode 100644 index 00000000..3f8b12fd --- /dev/null +++ b/src/tfe/models/task_stage.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +# TaskStage represents a HCP Terraform or Terraform Enterprise run's stage where run tasks can occur +class TaskStage(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + # stage: Stage = Field(..., alias="stage") + # status: TaskStageStatus = Field(..., alias="status") + # status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps") + # created_at: datetime = Field(..., alias="created-at") + # updated_at: datetime = Field(..., alias="updated-at") + # permissions: Permissions = Field(..., alias="permissions") + # actions: Actions = Field(..., alias="actions") + + # # Relations + # run: Run = Field(..., alias="run") + # task_results: list[TaskResult] = Field(..., alias="task-results") + # policy_evaluations: list[PolicyEvaluation] = Field(..., alias="policy-evaluations") diff --git a/src/tfe/models/user.py b/src/tfe/models/user.py new file mode 100644 index 00000000..69fe53d5 --- /dev/null +++ b/src/tfe/models/user.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class User(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str = Field(..., alias="id") + avatar_url: str = Field(..., alias="avatar-url") + email: str = Field(..., alias="email") + is_service_account: bool = Field(..., alias="is-service-account") + two_factor: dict = Field(..., alias="two-factor") + unconfirmed_email: str = Field(..., alias="unconfirmed-email") + username: str = Field(..., alias="username") + v2_only: bool = Field(..., alias="v2-only") + is_site_admin: bool = Field(..., alias="is-site-admin") # Deprecated + is_admin: bool = Field(..., alias="is-admin") + is_sso_login: bool = Field(..., alias="is-sso-login") + permissions: dict = Field(..., alias="permissions") + + # Relations + # authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens") diff --git a/src/tfe/resources/run.py b/src/tfe/resources/run.py new file mode 100644 index 00000000..eecccbd6 --- /dev/null +++ b/src/tfe/resources/run.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from typing import Any + +from ..errors import ( + InvalidOrgError, + InvalidRunIDError, + InvalidWorkspaceIDError, + RequiredWorkspaceError, + TerraformVersionValidForPlanOnlyError, +) +from ..models.run import ( + OrganizationRunList, + Run, + RunApplyOptions, + RunCancelOptions, + RunCreateOptions, + RunDiscardOptions, + RunForceCancelOptions, + RunList, + RunListForOrganizationOptions, + RunListOptions, + RunReadOptions, +) +from ..utils import _safe_str, valid_string, valid_string_id +from ._base import _Service + + +class Runs(_Service): + def list(self, workspace_id: str, options: RunListOptions | None = None) -> RunList: + """List all the runs of the given workspace.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + params = ( + options.model_dump(by_alias=True, exclude_none=True) if options else None + ) + r = self.t.request( + "GET", + f"/api/v2/workspaces/{workspace_id}/runs", + params=params, + ) + jd = r.json() + items = [] + meta = jd.get("meta", {}) + pagination = meta.get("pagination", {}) + for d in jd.get("data", []): + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") + items.append(Run.model_validate(attrs)) + return RunList( + items=items, + current_page=pagination.get("current-page"), + total_pages=pagination.get("total-pages"), + prev_page=pagination.get("prev-page"), + next_page=pagination.get("next-page"), + total_count=pagination.get("total-count"), + ) + + def list_for_organization( + self, organization: str, options: RunListForOrganizationOptions | None = None + ) -> OrganizationRunList: + """List all the runs of the given organization.""" + if not valid_string_id(organization): + raise InvalidOrgError() + params = ( + options.model_dump(by_alias=True, exclude_none=True) if options else None + ) + r = self.t.request( + "GET", + f"/api/v2/organizations/{organization}/runs", + params=params, + ) + jd = r.json() + items = [] + meta = jd.get("meta", {}) + pagination = meta.get("pagination", {}) + for d in jd.get("data", []): + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") + items.append(Run.model_validate(attrs)) + return OrganizationRunList( + items=items, + current_page=pagination.get("current-page"), + prev_page=pagination.get("prev-page"), + next_page=pagination.get("next-page"), + ) + + def create(self, options: RunCreateOptions) -> Run: + """Create a new run for the given workspace.""" + if options.workspace is None: + raise RequiredWorkspaceError() + if valid_string(options.terraform_version) and ( + options.plan_only is None or not options.plan_only + ): + raise TerraformVersionValidForPlanOnlyError() + attrs = options.model_dump(by_alias=True, exclude_none=True) + body: dict[str, Any] = { + "data": { + "attributes": attrs, + "type": "runs", + } + } + if options.workspace: + body["data"]["relationships"] = { + "workspace": { + "data": { + "type": "workspaces", + "id": options.workspace.id, + } + } + } + if options.configuration_version: + if "relationships" not in body["data"]: + body["data"]["relationships"] = {} + body["data"]["relationships"]["configuration-version"] = { + "data": { + "type": "configuration-versions", + "id": options.configuration_version.id, + } + } + r = self.t.request( + "POST", + "/api/v2/runs", + json_body=body, + ) + d = r.json().get("data", {}) + attrs = d.get("attributes", {}) + return Run( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attrs.items()}, + ) + + def read(self, run_id: str) -> Run: + """Read a run by its ID.""" + return self.read_with_options(run_id, None) + + def read_with_options( + self, run_id: str, options: RunReadOptions | None = None + ) -> Run: + """Read a run by its ID with the given options.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + params: dict[str, Any] = {} + if options and options.include: + params["include"] = ",".join(options.include) + r = self.t.request( + "GET", + f"/api/v2/runs/{run_id}", + params=params, + ) + d = r.json().get("data", {}) + attrs = d.get("attributes", {}) + return Run( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attrs.items()}, + ) + + def apply(self, run_id: str, options: RunApplyOptions | None = None) -> None: + """Apply a run by its ID.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + body = {"comment": options.comment} if options and options.comment else None + self.t.request("POST", f"/api/v2/runs/{run_id}/actions/apply", json_body=body) + + return None + + def cancel(self, run_id: str, options: RunCancelOptions | None = None) -> None: + """Cancel a run by its ID.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + body = {"comment": options.comment} if options and options.comment else None + self.t.request("POST", f"/api/v2/runs/{run_id}/actions/cancel", json_body=body) + return None + + def force_cancel( + self, run_id: str, options: RunForceCancelOptions | None = None + ) -> None: + """ForceCancel is used to forcefully cancel a run by its ID.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + body = {"comment": options.comment} if options and options.comment else None + self.t.request( + "POST", f"/api/v2/runs/{run_id}/actions/force-cancel", json_body=body + ) + return None + + def force_execute(self, run_id: str) -> None: + """ForceExecute is used to forcefully execute a run by its ID.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + self.t.request("POST", f"/api/v2/runs/{run_id}/actions/force-execute") + return None + + def discard(self, run_id: str, options: RunDiscardOptions | None = None) -> None: + """Discard a run by its ID.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + body = {"comment": options.comment} if options and options.comment else None + self.t.request("POST", f"/api/v2/runs/{run_id}/actions/discard", json_body=body) + return None diff --git a/tests/units/test_run.py b/tests/units/test_run.py new file mode 100644 index 00000000..b21c7371 --- /dev/null +++ b/tests/units/test_run.py @@ -0,0 +1,447 @@ +"""Unit tests for the run module.""" + +from unittest.mock import Mock, patch + +import pytest + +from tfe._http import HTTPTransport +from tfe.errors import ( + InvalidRunIDError, + RequiredWorkspaceError, + TerraformVersionValidForPlanOnlyError, +) +from tfe.models.run import ( + OrganizationRunList, + Run, + RunApplyOptions, + RunCancelOptions, + RunCreateOptions, + RunDiscardOptions, + RunForceCancelOptions, + RunIncludeOpt, + RunList, + RunListForOrganizationOptions, + RunListOptions, + RunReadOptions, + RunSource, + RunStatus, + RunVariable, +) +from tfe.models.workspace import Workspace +from tfe.resources.run import Runs + + +class TestRuns: + """Test the Runs service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def runs_service(self, mock_transport): + """Create a Runs service with mocked transport.""" + return Runs(mock_transport) + + def test_list_runs_success(self, runs_service): + """Test successful list operation.""" + + mock_response_data = { + "data": [ + { + "id": "run-123", + "attributes": { + "status": "applied", + "source": "tfe-configuration-version", + "message": "Test run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + "auto-apply": False, + "plan-only": False, + }, + }, + { + "id": "run-456", + "attributes": { + "status": "planned", + "source": "tfe-ui", + "message": "Another test run", + "created-at": "2023-01-02T14:00:00Z", + "has-changes": False, + "is-destroy": True, + "auto-apply": True, + "plan-only": True, + }, + }, + ], + "meta": { + "pagination": { + "current-page": 1, + "total-pages": 2, + "prev-page": None, + "next-page": 2, + "total-count": 10, + } + }, + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + # Test with custom page_size - use a print statement to debug what's actually sent + options = RunListOptions(page_number=1, page_size=5) + result = runs_service.list("ws-123", options) + + # Check what was actually called + call_args = mock_transport.request.call_args + actual_params = call_args[1]["params"] + + # Verify the basic structure + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/v2/workspaces/ws-123/runs" + assert actual_params["page[number]"] == 1 + + # Verify result structure + assert isinstance(result, RunList) + assert len(result.items) == 2 + assert result.current_page == 1 + assert result.total_pages == 2 + assert result.total_count == 10 + + # Verify run objects + run1 = result.items[0] + assert run1.id == "run-123" + assert run1.status == RunStatus.Run_Applied + assert run1.source == RunSource.Run_Source_Configuration_Version + assert run1.message == "Test run" + assert run1.has_changes is True + assert run1.is_destroy is False + + run2 = result.items[1] + assert run2.id == "run-456" + assert run2.status == RunStatus.Run_Planned + assert run2.source == RunSource.Run_Source_UI + assert run2.has_changes is False + assert run2.is_destroy is True + + def test_list_for_organization_success(self, runs_service): + """Test successful list_for_organization operation.""" + + mock_response_data = { + "data": [ + { + "id": "run-org-1", + "attributes": { + "status": "applied", + "source": "tfe-api", + "message": "Organization run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + }, + } + ], + "meta": { + "pagination": { + "current-page": 1, + "prev-page": None, + "next-page": None, + } + }, + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + options = RunListForOrganizationOptions(status="applied,planned") + result = runs_service.list_for_organization("test-org", options) + + # Verify request was made correctly (account for defaults and aliases) + expected_params = { + "page[number]": 1, + "page[size]": 20, + "filter[status]": "applied,planned", + "include": [], + } + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/test-org/runs", params=expected_params + ) + + # Verify result structure + assert isinstance(result, OrganizationRunList) + assert len(result.items) == 1 + assert result.current_page == 1 + assert result.items[0].id == "run-org-1" + + def test_create_run_validation_errors(self, runs_service): + """Test create method with validation errors.""" + + # Test missing workspace + options = RunCreateOptions() + with pytest.raises(RequiredWorkspaceError): + runs_service.create(options) + + # Test terraform_version with non-plan-only run + workspace = Workspace(id="ws-123", name="test", organization="test-org") + options = RunCreateOptions( + workspace=workspace, terraform_version="1.5.0", plan_only=False + ) + with pytest.raises(TerraformVersionValidForPlanOnlyError): + runs_service.create(options) + + # Test terraform_version with plan_only=None (defaults to False) + options = RunCreateOptions(workspace=workspace, terraform_version="1.5.0") + with pytest.raises(TerraformVersionValidForPlanOnlyError): + runs_service.create(options) + + def test_create_run_success(self, runs_service): + """Test successful create operation.""" + + mock_response_data = { + "data": { + "id": "run-new-123", + "attributes": { + "status": "pending", + "source": "tfe-api", + "message": "Created via API", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": False, + "is-destroy": False, + "auto-apply": False, + "plan-only": True, + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + workspace = Workspace(id="ws-123", name="test", organization="test-org") + variables = [ + RunVariable(key="env", value="test"), + RunVariable(key="region", value="us-east-1"), + ] + options = RunCreateOptions( + workspace=workspace, + message="Test run creation", + plan_only=True, + variables=variables, + ) + + result = runs_service.create(options) + + # Verify request was made correctly + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + + assert call_args[0][0] == "POST" # HTTP method + assert call_args[0][1] == "/api/v2/runs" # URL + + # Verify request body structure + json_body = call_args[1]["json_body"] + assert "data" in json_body + assert json_body["data"]["type"] == "runs" + assert "attributes" in json_body["data"] + assert json_body["data"]["attributes"]["message"] == "Test run creation" + assert json_body["data"]["attributes"]["plan-only"] is True + + # Verify relationships + assert "relationships" in json_body["data"] + assert "workspace" in json_body["data"]["relationships"] + workspace_data = json_body["data"]["relationships"]["workspace"]["data"] + assert workspace_data["id"] == "ws-123" + assert workspace_data["type"] == "workspaces" + + # Verify result + assert isinstance(result, Run) + assert result.id == "run-new-123" + assert result.status == RunStatus.Run_Pending + assert result.plan_only is True + + def test_read_run_validation_errors(self, runs_service): + """Test read method with invalid run ID.""" + + # Test empty run ID + with pytest.raises(InvalidRunIDError): + runs_service.read("") + + # Test None run ID + with pytest.raises(InvalidRunIDError): + runs_service.read(None) + + def test_read_run_success(self, runs_service): + """Test successful read operation.""" + + mock_response_data = { + "data": { + "id": "run-read-123", + "attributes": { + "status": "applied", + "source": "tfe-configuration-version", + "message": "Read test run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + result = runs_service.read("run-read-123") + + # Verify request was made correctly (read calls read_with_options with empty params) + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/runs/run-read-123", params={} + ) + + # Verify result + assert isinstance(result, Run) + assert result.id == "run-read-123" + assert result.status == RunStatus.Run_Applied + assert result.message == "Read test run" + + def test_read_with_options_success(self, runs_service): + """Test successful read_with_options operation.""" + + mock_response_data = { + "data": { + "id": "run-detailed-123", + "attributes": { + "status": "planned", + "source": "tfe-api", + "message": "Detailed read test", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + options = RunReadOptions( + include=[ + RunIncludeOpt.RUN_PLAN, + RunIncludeOpt.RUN_APPLY, + RunIncludeOpt.RUN_CREATED_BY, + ] + ) + result = runs_service.read_with_options("run-detailed-123", options) + + # Verify request was made correctly + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/runs/run-detailed-123", + params={"include": "plan,apply,created-by"}, + ) + + # Verify result + assert isinstance(result, Run) + assert result.id == "run-detailed-123" + + def test_apply_run_success(self, runs_service): + """Test successful apply operation.""" + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = Mock() + + options = RunApplyOptions(comment="Applying via API") + runs_service.apply("run-apply-123", options) + + # Verify request was made correctly + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + + assert call_args[0][0] == "POST" # HTTP method + assert call_args[0][1] == "/api/v2/runs/run-apply-123/actions/apply" # URL + + # Verify request body + json_body = call_args[1]["json_body"] + assert json_body["comment"] == "Applying via API" + + def test_cancel_run_success(self, runs_service): + """Test successful cancel operation.""" + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = Mock() + + options = RunCancelOptions(comment="Canceling run") + runs_service.cancel("run-cancel-123", options) + + # Verify request was made correctly + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/runs/run-cancel-123/actions/cancel" + assert call_args[1]["json_body"]["comment"] == "Canceling run" + + def test_force_cancel_run_success(self, runs_service): + """Test successful force_cancel operation.""" + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = Mock() + + options = RunForceCancelOptions(comment="Force canceling run") + runs_service.force_cancel("run-force-cancel-123", options) + + # Verify request was made correctly + call_args = mock_transport.request.call_args + assert ( + call_args[0][1] + == "/api/v2/runs/run-force-cancel-123/actions/force-cancel" + ) + assert call_args[1]["json_body"]["comment"] == "Force canceling run" + + def test_force_execute_run_success(self, runs_service): + """Test successful force_execute operation.""" + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = Mock() + + runs_service.force_execute("run-force-execute-123") + + # Verify request was made correctly - force_execute doesn't pass json_body + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] + == "/api/v2/runs/run-force-execute-123/actions/force-execute" + ) + # force_execute doesn't pass json_body parameter at all + assert "json_body" not in call_args[1] + + def test_discard_run_success(self, runs_service): + """Test successful discard operation.""" + + with patch.object(runs_service, "t") as mock_transport: + mock_transport.request.return_value = Mock() + + options = RunDiscardOptions(comment="Discarding run") + runs_service.discard("run-discard-123", options) + + # Verify request was made correctly + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/runs/run-discard-123/actions/discard" + assert call_args[1]["json_body"]["comment"] == "Discarding run"