diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..cf223dbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,54 @@ +--- +name: Bug report +about: Let us know about an unexpected error, a crash, or an incorrect behavior. +labels: bug +--- + + + +#### python-tfe version + +```plaintext +... +``` + +## Description + + +## Testing plan + +```plaintext +... +``` + +#### Expected Behavior + + +#### Actual Behavior + + +#### Additional Context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..f6782d3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: HCP Terraform and Terraform Enterprise Troubleshooting and Feature Requests + url: https://support.hashicorp.com/hc/en-us/requests/new + about: For issues and feature requests concerning the HCP Terraform and Terraform Enterprise platform itself, please submit a HashiCorp support request or email tf-cloud@hashicorp.support + - name: Terraform Language or Workflow Questions + url: https://discuss.hashicorp.com + about: Please ask Terraform language or workflow related questions through the HashiCorp Discuss forum diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..07f602ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,43 @@ +--- +name: Feature request +about: Suggest a new feature or other enhancement. +labels: feature-request +--- + + + + +#### Use-cases + + +#### Attempted Solutions + + +#### Proposal + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0534f448 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +--- +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..9d614dc2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,64 @@ + + +## Description + + + +## Testing plan + + + +## External links + + + +## Output from tests +Including output from tests may require access to a TFE instance. Ignore this section if you have no environment to test against. + + +``` +... +``` + + +## Rollback Plan + + + +## Changes to Security Controls + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..60a7c8f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,136 @@ +# Mac specific +.DS_Store + +# python specific +__pycache__ +.coverage +coverage_results +.coverage* +coverage.xml +htmlcov +.venv +.pyre +.pytest_cache/ + +# Visual Studio Code +.vscode/ +.env + +# build artifacts +**/test-results/** +blu-server-coverage.tgz +.artifacts/** +dist/** +**/sqlite_test.db +.container-id +docker-artifacts/** +portal.zip +analyzer.zip +ngrok.log + +# portal copy +static/server-version.txt + +# other cloned repos +.repos/** + + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/ +*.iml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# Database stuff +database/postgresql-*.jar + +# OS stuff +*.pid + +# Profile files +profiles + +# Pyenv +# .python-version is needed by setup-python GH action +# .python-version + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Cached build artifacts from the Go build system +.cache + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# OS-specific files +.DS_Store + +# Repo-specific +bin/ + +# Generated protoset files +*.protoset + +# Root vendor directory generated from the Go build system +/vendor + +### Visual Studio Code ### +.vscode/* +!.vscode/settings.json +!.vscode/launch.json +!.vscode/extensions.json + +# Ignore code-workspaces +*.code-workspace +/app/__pycache__/ + +# Terraform +terraform/local/.terraform/** +poc-terraform/.terraform +poc-terraform/terraform.tfstate* + +# Proto bin generated files +protos.bin + +# Local RSA keys +.local/* diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..d61ea531 --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.13" diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..4baa44c0 --- /dev/null +++ b/__init__.py @@ -0,0 +1,30 @@ +""" +PyTFE - Python SDK for Terraform Enterprise/Cloud API + +This package provides a Python client for interacting with the Terraform Enterprise +and Terraform Cloud APIs, similar to HashiCorp's go-tfe SDK. +""" + +__version__ = "0.1.0" +__author__ = "Ansible-Tfe-Development-Team" +__email__ = "sivaselvan.i@hasicorp.com" + +from .client import Client +from .config import Config +from .exceptions import ( + PyTFEException, + AuthenticationError, + NotFoundError, + ValidationError, + ServerError, +) + +__all__ = [ + "Client", + "Config", + "PyTFEException", + "AuthenticationError", + "NotFoundError", + "ValidationError", + "ServerError", +] diff --git a/client.py b/client.py new file mode 100644 index 00000000..44b7e895 --- /dev/null +++ b/client.py @@ -0,0 +1,265 @@ +""" +Main client class for the PyTFE SDK. + +This module provides the main Client class that serves as the entry point +for interacting with the Terraform Enterprise/Cloud API. +""" + +import time +from typing import Optional, Dict, Any, Union +from urllib.parse import urljoin + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from .config import Config +from .exceptions import ( + PyTFEException, + AuthenticationError, + AuthorizationError, + NotFoundError, + ValidationError, + ConflictError, + ServerError, + RateLimitError, + ConnectionError as PyTFEConnectionError, +) + + +class Client: + """Main client for interacting with Terraform Enterprise/Cloud API.""" + + def __init__(self, config: Optional[Config] = None) -> None: + """ + Initialize the PyTFE client. + + Args: + config: Configuration object. If None, uses default configuration + with environment variables. + """ + self.config = config or Config() + self._session = self._create_session() + + # Initialize service clients + from .services.organizations import OrganizationService + self.organizations = OrganizationService(self) + + def _create_session(self) -> requests.Session: + """Create and configure the HTTP session.""" + session = requests.Session() + + # Set default headers + session.headers.update(self.config.auth_headers) + + # Configure retries if enabled + if self.config.retry_server_errors: + retry_strategy = Retry( + total=self.config.max_retries, + status_forcelist=[500, 502, 503, 504], + backoff_factor=self.config.retry_backoff_factor, + allowed_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + + return session + + def _make_request( + self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + json_data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> requests.Response: + """ + Make an HTTP request to the API. + + Args: + method: HTTP method (GET, POST, PUT, PATCH, DELETE). + endpoint: API endpoint (relative to base API URL). + params: Query parameters. + json_data: JSON data for request body. + headers: Additional headers. + **kwargs: Additional arguments passed to requests. + + Returns: + Response object. + + Raises: + PyTFEException: For various API errors. + """ + url = urljoin(self.config.api_url + "/", endpoint.lstrip("/")) + print("url", url, self.config.api_url) + + # Merge headers + request_headers = self.config.auth_headers.copy() + if headers: + request_headers.update(headers) + + try: + response = self._session.request( + method=method, + url=url, + params=params, + json=json_data, + headers=request_headers, + timeout=self.config.timeout, + **kwargs, + ) + + # Handle rate limiting + if response.status_code == 429: + retry_after = response.headers.get("Retry-After") + if retry_after: + time.sleep(int(retry_after)) + # Retry once after rate limit + response = self._session.request( + method=method, + url=url, + params=params, + json=json_data, + headers=request_headers, + timeout=self.config.timeout, + **kwargs, + ) + + self._handle_response_errors(response) + return response + + except requests.exceptions.ConnectionError as e: + raise PyTFEConnectionError(f"Connection error: {e}") from e + except requests.exceptions.Timeout as e: + raise PyTFEConnectionError(f"Request timeout: {e}") from e + except requests.exceptions.RequestException as e: + raise PyTFEException(f"Request failed: {e}") from e + + def _handle_response_errors(self, response: requests.Response) -> None: + """ + Handle HTTP response errors by raising appropriate exceptions. + + Args: + response: HTTP response object. + + Raises: + Appropriate PyTFEException subclass based on status code. + """ + if response.status_code < 400: + return + + try: + error_data = response.json() + except ValueError: + error_data = {"message": response.text or "Unknown error"} + + error_message = self._extract_error_message(error_data) + + if response.status_code == 401: + raise AuthenticationError( + error_message, response.status_code, error_data + ) + elif response.status_code == 403: + raise AuthorizationError( + error_message, response.status_code, error_data + ) + elif response.status_code == 404: + raise NotFoundError( + error_message, response.status_code, error_data + ) + elif response.status_code == 409: + raise ConflictError( + error_message, response.status_code, error_data + ) + elif response.status_code == 422: + raise ValidationError( + error_message, response.status_code, error_data + ) + elif response.status_code == 429: + raise RateLimitError( + error_message, response.status_code, error_data + ) + elif response.status_code >= 500: + raise ServerError( + error_message, response.status_code, error_data + ) + else: + raise PyTFEException( + error_message, response.status_code, error_data + ) + + def _extract_error_message(self, error_data: Dict[str, Any]) -> str: + """ + Extract error message from API error response. + + Args: + error_data: Error response data. + + Returns: + Formatted error message. + """ + # Try different possible error message formats + if isinstance(error_data, dict): + # JSON API format + if "errors" in error_data and isinstance(error_data["errors"], list): + errors = error_data["errors"] + if errors: + first_error = errors[0] + if isinstance(first_error, dict): + return first_error.get("detail", first_error.get("title", "Unknown error")) + + # Simple message format + if "message" in error_data: + return str(error_data["message"]) + + # Error field + if "error" in error_data: + return str(error_data["error"]) + + return "Unknown error" + + def get( + self, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + **kwargs: Any + ) -> requests.Response: + """Make a GET request.""" + return self._make_request("GET", endpoint, params=params, **kwargs) + + def post( + self, + endpoint: str, + json_data: Optional[Dict[str, Any]] = None, + **kwargs: Any + ) -> requests.Response: + """Make a POST request.""" + return self._make_request("POST", endpoint, json_data=json_data, **kwargs) + + def put( + self, + endpoint: str, + json_data: Optional[Dict[str, Any]] = None, + **kwargs: Any + ) -> requests.Response: + """Make a PUT request.""" + return self._make_request("PUT", endpoint, json_data=json_data, **kwargs) + + def patch( + self, + endpoint: str, + json_data: Optional[Dict[str, Any]] = None, + **kwargs: Any + ) -> requests.Response: + """Make a PATCH request.""" + return self._make_request("PATCH", endpoint, json_data=json_data, **kwargs) + + def delete( + self, + endpoint: str, + **kwargs: Any + ) -> requests.Response: + """Make a DELETE request.""" + return self._make_request("DELETE", endpoint, **kwargs) diff --git a/config.py b/config.py new file mode 100644 index 00000000..f1160e3d --- /dev/null +++ b/config.py @@ -0,0 +1,121 @@ +""" +Configuration management for the PyTFE SDK. + +This module provides configuration options for connecting to Terraform Enterprise +or Terraform Cloud APIs, including authentication and connection settings. +""" + +import os +from typing import Optional, Dict, Any +from urllib.parse import urlparse + + +class Config: + """Configuration class for PyTFE client.""" + + def __init__( + self, + address: Optional[str] = None, + token: Optional[str] = None, + hostname: Optional[str] = None, + retry_server_errors: bool = True, + max_retries: int = 3, + retry_backoff_factor: float = 0.3, + timeout: int = 30, + headers: Optional[Dict[str, str]] = None, + ) -> None: + """ + Initialize configuration. + + Args: + address: Full URL of the Terraform Enterprise/Cloud instance. + Falls back to TFE_ADDRESS environment variable. + token: API token for authentication. + Falls back to TFE_TOKEN environment variable. + hostname: Hostname of the TFE instance (alternative to address). + Falls back to TFE_HOSTNAME environment variable. + retry_server_errors: Whether to retry on server errors (5xx). + max_retries: Maximum number of retries for failed requests. + retry_backoff_factor: Backoff factor for retries. + timeout: Request timeout in seconds. + headers: Additional headers to include in requests. + """ + self.address = self._resolve_address(address, hostname) + self.token = token or os.getenv("TFE_TOKEN") + self.retry_server_errors = retry_server_errors + self.max_retries = max_retries + self.retry_backoff_factor = retry_backoff_factor + self.timeout = timeout + self.headers = headers or {} + print(self.address, self.token, self.retry_server_errors, self.max_retries, self.retry_backoff_factor, self.timeout, self.headers) + + self._validate_config() + + def _resolve_address(self, address: Optional[str], hostname: Optional[str]) -> str: + """Resolve the API address from various sources.""" + # Priority: address parameter -> TFE_ADDRESS env -> hostname parameter -> TFE_HOSTNAME env + if address: + return self._normalize_address(address) + + env_address = os.getenv("TFE_ADDRESS") + if env_address: + return self._normalize_address(env_address) + + if hostname: + return f"https://{hostname}" + + env_hostname = os.getenv("TFE_HOSTNAME") + if env_hostname: + return f"https://{env_hostname}" + + # Default to Terraform Cloud + return "https://app.terraform.io" + + def _normalize_address(self, address: str) -> str: + """Normalize the address to ensure it's a valid URL.""" + if not address.startswith(("http://", "https://")): + address = f"https://{address}" + + # Remove trailing slash + return address.rstrip("/") + + def _validate_config(self) -> None: + """Validate the configuration.""" + if not self.token: + raise ValueError( + "API token is required. Set TFE_TOKEN environment variable " + "or provide token parameter." + ) + + # Validate URL format + try: + parsed = urlparse(self.address) + if not parsed.scheme or not parsed.netloc: + raise ValueError(f"Invalid address format: {self.address}") + except Exception as e: + raise ValueError(f"Invalid address format: {self.address}") from e + + @property + def api_url(self) -> str: + """Get the base API URL.""" + return f"{self.address}/api/v2" + + @property + def auth_headers(self) -> Dict[str, str]: + """Get authentication headers.""" + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/vnd.api+json", + "Accept": "application/vnd.api+json", + } + headers.update(self.headers) + return headers + + def __repr__(self) -> str: + """String representation of config (without sensitive data).""" + return ( + f"Config(address='{self.address}', " + f"token='***', " + f"retry_server_errors={self.retry_server_errors}, " + f"max_retries={self.max_retries})" + ) diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 00000000..9dae44bd --- /dev/null +++ b/exceptions.py @@ -0,0 +1,63 @@ +""" +Exceptions for the PyTFE SDK. + +This module defines custom exceptions used throughout the PyTFE SDK +to provide meaningful error handling for different types of API errors. +""" + +from typing import Optional, Dict, Any + + +class PyTFEException(Exception): + """Base exception for all PyTFE SDK errors.""" + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response_data: Optional[Dict[str, Any]] = None + ) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + self.response_data = response_data or {} + + +class AuthenticationError(PyTFEException): + """Raised when authentication fails (401 Unauthorized).""" + pass + + +class AuthorizationError(PyTFEException): + """Raised when authorization fails (403 Forbidden).""" + pass + + +class NotFoundError(PyTFEException): + """Raised when a resource is not found (404 Not Found).""" + pass + + +class ValidationError(PyTFEException): + """Raised when request validation fails (422 Unprocessable Entity).""" + pass + + +class ConflictError(PyTFEException): + """Raised when there's a conflict (409 Conflict).""" + pass + + +class ServerError(PyTFEException): + """Raised when there's a server error (5xx status codes).""" + pass + + +class RateLimitError(PyTFEException): + """Raised when rate limit is exceeded (429 Too Many Requests).""" + pass + + +class ConnectionError(PyTFEException): + """Raised when there's a connection error.""" + pass diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 00000000..bbe59064 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,20 @@ +""" +Data models for the PyTFE SDK. + +This module contains Pydantic models that represent the various entities +returned by the Terraform Enterprise/Cloud API. +""" + +from .organization import ( + Organization, + OrganizationCreateRequest, + OrganizationUpdateRequest, + OrganizationListOptions, +) + +__all__ = [ + "Organization", + "OrganizationCreateRequest", + "OrganizationUpdateRequest", + "OrganizationListOptions", +] diff --git a/models/organization.py b/models/organization.py new file mode 100644 index 00000000..0cdb30b6 --- /dev/null +++ b/models/organization.py @@ -0,0 +1,215 @@ +""" +Organization models for the PyTFE SDK. + +This module contains Pydantic models for organization-related entities +in the Terraform Enterprise/Cloud API. +""" + +from datetime import datetime +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field, ConfigDict + + +class OrganizationPermissions(BaseModel): + """Organization permissions model.""" + model_config = ConfigDict(extra="allow") + + can_update: bool = Field(alias="can-update") + can_destroy: bool = Field(alias="can-destroy") + can_access_via_teams: bool = Field(alias="can-access-via-teams") + can_create_module: bool = Field(alias="can-create-module") + can_create_team: bool = Field(alias="can-create-team") + can_create_workspace: bool = Field(alias="can-create-workspace") + can_manage_users: bool = Field(alias="can-manage-users") + can_manage_subscription: bool = Field(alias="can-manage-subscription") + can_manage_sso: bool = Field(alias="can-manage-sso") + can_update_oauth: bool = Field(alias="can-update-oauth") + can_update_sentinel: bool = Field(alias="can-update-sentinel") + can_update_ssh_keys: bool = Field(alias="can-update-ssh-keys") + can_update_api_token: bool = Field(alias="can-update-api-token") + can_traverse: bool = Field(alias="can-traverse") + can_start_trial: Optional[bool] = Field(None, alias="can-start-trial") + can_update_agent_pools: Optional[bool] = Field(None, alias="can-update-agent-pools") + can_manage_tags: Optional[bool] = Field(None, alias="can-manage-tags") + can_manage_varsets: Optional[bool] = Field(None, alias="can-manage-varsets") + can_read_varsets: Optional[bool] = Field(None, alias="can-read-varsets") + can_manage_public_providers: Optional[bool] = Field(None, alias="can-manage-public-providers") + can_create_provider: Optional[bool] = Field(None, alias="can-create-provider") + can_manage_public_modules: Optional[bool] = Field(None, alias="can-manage-public-modules") + can_manage_custom_providers: Optional[bool] = Field(None, alias="can-manage-custom-providers") + can_manage_run_tasks: Optional[bool] = Field(None, alias="can-manage-run-tasks") + can_read_run_tasks: Optional[bool] = Field(None, alias="can-read-run-tasks") + can_manage_membership: Optional[bool] = Field(None, alias="can-manage-membership") + can_manage_owners: Optional[bool] = Field(None, alias="can-manage-owners") + + +class Organization(BaseModel): + """Organization model representing a Terraform Enterprise/Cloud organization.""" + model_config = ConfigDict(extra="allow") + + id: str + type: str = "organizations" + + # Attributes + name: str + email: Optional[str] = None + session_timeout: Optional[int] = Field(None, alias="session-timeout") + session_remember: Optional[int] = Field(None, alias="session-remember") + collaborator_auth_policy: Optional[str] = Field(None, alias="collaborator-auth-policy") + plan_expired: Optional[bool] = Field(None, alias="plan-expired") + plan_expires_at: Optional[datetime] = Field(None, alias="plan-expires-at") + plan_is_trial: Optional[bool] = Field(None, alias="plan-is-trial") + plan_is_enterprise: Optional[bool] = Field(None, alias="plan-is-enterprise") + plan_identifier: Optional[str] = Field(None, alias="plan-identifier") + cost_estimation_enabled: Optional[bool] = Field(None, alias="cost-estimation-enabled") + send_passing_statuses_for_untriggered_speculative_plans: Optional[bool] = Field( + None, alias="send-passing-statuses-for-untriggered-speculative-plans" + ) + aggregated_commit_status_enabled: Optional[bool] = Field( + None, alias="aggregated-commit-status-enabled" + ) + assessments_enforced: Optional[bool] = Field(None, alias="assessments-enforced") + public_providers: Optional[bool] = Field(None, alias="public-providers") + public_modules: Optional[bool] = Field(None, alias="public-modules") + fair_run_queuing_enabled: Optional[bool] = Field(None, alias="fair-run-queuing-enabled") + default_execution_mode: Optional[str] = Field(None, alias="default-execution-mode") + permissions: Optional[OrganizationPermissions] = None + saml_enabled: Optional[bool] = Field(None, alias="saml-enabled") + owners_team_saml_role_id: Optional[str] = Field(None, alias="owners-team-saml-role-id") + two_factor_conformant: Optional[bool] = Field(None, alias="two-factor-conformant") + external_id: Optional[str] = Field(None, alias="external-id") + created_at: Optional[datetime] = Field(None, alias="created-at") + + +class OrganizationCreateRequest(BaseModel): + """Request model for creating an organization.""" + model_config = ConfigDict(extra="forbid") + + data: Dict[str, Any] = Field(...) + + @classmethod + def create( + cls, + name: str, + email: str, + session_timeout: Optional[int] = None, + session_remember: Optional[int] = None, + collaborator_auth_policy: Optional[str] = None, + cost_estimation_enabled: Optional[bool] = None, + owners_team_saml_role_id: Optional[str] = None, + send_passing_statuses_for_untriggered_speculative_plans: Optional[bool] = None, + aggregated_commit_status_enabled: Optional[bool] = None, + assessments_enforced: Optional[bool] = None, + **kwargs: Any, + ) -> "OrganizationCreateRequest": + """Create an organization creation request.""" + attributes = { + "name": name, + "email": email, + } + + # Add optional attributes + if session_timeout is not None: + attributes["session-timeout"] = session_timeout + if session_remember is not None: + attributes["session-remember"] = session_remember + if collaborator_auth_policy is not None: + attributes["collaborator-auth-policy"] = collaborator_auth_policy + if cost_estimation_enabled is not None: + attributes["cost-estimation-enabled"] = cost_estimation_enabled + if owners_team_saml_role_id is not None: + attributes["owners-team-saml-role-id"] = owners_team_saml_role_id + if send_passing_statuses_for_untriggered_speculative_plans is not None: + attributes["send-passing-statuses-for-untriggered-speculative-plans"] = ( + send_passing_statuses_for_untriggered_speculative_plans + ) + if aggregated_commit_status_enabled is not None: + attributes["aggregated-commit-status-enabled"] = aggregated_commit_status_enabled + if assessments_enforced is not None: + attributes["assessments-enforced"] = assessments_enforced + + # Add any additional attributes + attributes.update(kwargs) + + data = { + "type": "organizations", + "attributes": attributes, + } + + return cls(data=data) + + +class OrganizationUpdateRequest(BaseModel): + """Request model for updating an organization.""" + model_config = ConfigDict(extra="forbid") + + data: Dict[str, Any] = Field(...) + + @classmethod + def create( + cls, + name: Optional[str] = None, + email: Optional[str] = None, + session_timeout: Optional[int] = None, + session_remember: Optional[int] = None, + collaborator_auth_policy: Optional[str] = None, + cost_estimation_enabled: Optional[bool] = None, + owners_team_saml_role_id: Optional[str] = None, + send_passing_statuses_for_untriggered_speculative_plans: Optional[bool] = None, + aggregated_commit_status_enabled: Optional[bool] = None, + assessments_enforced: Optional[bool] = None, + **kwargs: Any, + ) -> "OrganizationUpdateRequest": + """Create an organization update request.""" + attributes = {} + + # Add provided attributes + if name is not None: + attributes["name"] = name + if email is not None: + attributes["email"] = email + if session_timeout is not None: + attributes["session-timeout"] = session_timeout + if session_remember is not None: + attributes["session-remember"] = session_remember + if collaborator_auth_policy is not None: + attributes["collaborator-auth-policy"] = collaborator_auth_policy + if cost_estimation_enabled is not None: + attributes["cost-estimation-enabled"] = cost_estimation_enabled + if owners_team_saml_role_id is not None: + attributes["owners-team-saml-role-id"] = owners_team_saml_role_id + if send_passing_statuses_for_untriggered_speculative_plans is not None: + attributes["send-passing-statuses-for-untriggered-speculative-plans"] = ( + send_passing_statuses_for_untriggered_speculative_plans + ) + if aggregated_commit_status_enabled is not None: + attributes["aggregated-commit-status-enabled"] = aggregated_commit_status_enabled + if assessments_enforced is not None: + attributes["assessments-enforced"] = assessments_enforced + + # Add any additional attributes + attributes.update(kwargs) + + data = { + "type": "organizations", + "attributes": attributes, + } + + return cls(data=data) + + +class OrganizationListOptions(BaseModel): + """Options for listing organizations.""" + model_config = ConfigDict(extra="forbid") + + page_number: Optional[int] = Field(None, alias="page[number]") + page_size: Optional[int] = Field(None, alias="page[size]") + + def to_params(self) -> Dict[str, Any]: + """Convert to query parameters.""" + params = {} + if self.page_number is not None: + params["page[number]"] = self.page_number + if self.page_size is not None: + params["page[size]"] = self.page_size + return params diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 00000000..948033d0 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,12 @@ +""" +Service modules for the PyTFE SDK. + +This module contains service classes that handle API interactions +for different Terraform Enterprise/Cloud resources. +""" + +from .organizations import OrganizationService + +__all__ = [ + "OrganizationService", +] diff --git a/services/organizations.py b/services/organizations.py new file mode 100644 index 00000000..6901e7e7 --- /dev/null +++ b/services/organizations.py @@ -0,0 +1,247 @@ +""" +Organization service for the PyTFE SDK. + +This module provides methods for interacting with organization-related +endpoints in the Terraform Enterprise/Cloud API. +""" + +from typing import List, Optional, Dict, Any, TYPE_CHECKING + +from ..models.organization import ( + Organization, + OrganizationCreateRequest, + OrganizationUpdateRequest, + OrganizationListOptions, +) +from ..exceptions import NotFoundError + +if TYPE_CHECKING: + from ..client import Client + + +class OrganizationService: + """Service for managing organizations.""" + + def __init__(self, client: "Client") -> None: + """ + Initialize the organization service. + + Args: + client: The PyTFE client instance. + """ + self._client = client + + def list(self, options: Optional[OrganizationListOptions] = None) -> List[Organization]: + """ + List all organizations. + + Args: + options: Options for listing organizations (pagination, etc.). + + Returns: + List of organizations. + + Raises: + PyTFEException: If the request fails. + """ + params = options.to_params() if options else {} + response = self._client.get("organizations", params=params) + + data = response.json() + organizations = [] + + if "data" in data: + for org_data in data["data"]: + # Convert attributes to the format expected by the model + org_dict = { + "id": org_data["id"], + "type": org_data["type"], + **org_data.get("attributes", {}), + } + + # Handle permissions if present (same logic as read method) + if "attributes" in org_data and "permissions" in org_data["attributes"]: + org_dict["permissions"] = org_data["attributes"]["permissions"] + + # Handle relationships if present + if "relationships" in org_data: + # Add relationships to the dict if needed + pass + + organizations.append(Organization(**org_dict)) + + return organizations + + def read(self, organization_name: str) -> Organization: + """ + Read a specific organization. + + Args: + organization_name: The name of the organization. + + Returns: + The organization. + + Raises: + NotFoundError: If the organization doesn't exist. + PyTFEException: If the request fails. + """ + response = self._client.get(f"organizations/{organization_name}") + data = response.json() + + if "data" not in data: + raise NotFoundError(f"Organization '{organization_name}' not found") + + org_data = data["data"] + org_dict = { + "id": org_data["id"], + "type": org_data["type"], + **org_data.get("attributes", {}), + } + + # Handle permissions if present + if "attributes" in org_data and "permissions" in org_data["attributes"]: + org_dict["permissions"] = org_data["attributes"]["permissions"] + + return Organization(**org_dict) + + def create(self, request: OrganizationCreateRequest) -> Organization: + """ + Create a new organization. + + Args: + request: The organization creation request. + + Returns: + The created organization. + + Raises: + ValidationError: If the request data is invalid. + PyTFEException: If the request fails. + """ + response = self._client.post("organizations", json_data=request.model_dump()) + data = response.json() + + org_data = data["data"] + org_dict = { + "id": org_data["id"], + "type": org_data["type"], + **org_data.get("attributes", {}), + } + + return Organization(**org_dict) + + def update( + self, + organization_name: str, + request: OrganizationUpdateRequest + ) -> Organization: + """ + Update an existing organization. + + Args: + organization_name: The name of the organization to update. + request: The organization update request. + + Returns: + The updated organization. + + Raises: + NotFoundError: If the organization doesn't exist. + ValidationError: If the request data is invalid. + PyTFEException: If the request fails. + """ + response = self._client.patch( + f"organizations/{organization_name}", + json_data=request.model_dump() + ) + data = response.json() + + org_data = data["data"] + org_dict = { + "id": org_data["id"], + "type": org_data["type"], + **org_data.get("attributes", {}), + } + + return Organization(**org_dict) + + def delete(self, organization_name: str) -> None: + """ + Delete an organization. + + Args: + organization_name: The name of the organization to delete. + + Raises: + NotFoundError: If the organization doesn't exist. + PyTFEException: If the request fails. + """ + self._client.delete(f"organizations/{organization_name}") + + def entitlements(self, organization_name: str) -> Dict[str, Any]: + """ + Get organization entitlements. + + Args: + organization_name: The name of the organization. + + Returns: + Dictionary containing entitlement information. + + Raises: + NotFoundError: If the organization doesn't exist. + PyTFEException: If the request fails. + """ + response = self._client.get(f"organizations/{organization_name}/entitlement-set") + return response.json() + + def capacity(self, organization_name: str) -> Dict[str, Any]: + """ + Get organization capacity information. + + Args: + organization_name: The name of the organization. + + Returns: + Dictionary containing capacity information. + + Raises: + NotFoundError: If the organization doesn't exist. + PyTFEException: If the request fails. + """ + response = self._client.get(f"organizations/{organization_name}/capacity") + return response.json() + + def run_queue( + self, + organization_name: str, + page_number: Optional[int] = None, + page_size: Optional[int] = None + ) -> Dict[str, Any]: + """ + Get organization run queue. + + Args: + organization_name: The name of the organization. + page_number: Page number for pagination. + page_size: Number of items per page. + + Returns: + Dictionary containing run queue information. + + Raises: + NotFoundError: If the organization doesn't exist. + PyTFEException: If the request fails. + """ + params = {} + if page_number is not None: + params["page[number]"] = page_number + if page_size is not None: + params["page[size]"] = page_size + + response = self._client.get( + f"organizations/{organization_name}/runs/queue", + params=params + ) + return response.json()