"""Core CloudSSO role creation orchestration.""" from __future__ import annotations import logging import time from typing import Optional from .config import AppConfig, PolicyConfig, RoleConfig MAX_RETRIES = 3 RETRY_BASE_DELAY = 1 RETRY_BACKOFF_FACTOR = 2 class ApiError(Exception): """API-level failure while processing roles.""" def __init__(self, message: str, role_name: str = None, policy_name: str = None): self.role_name = role_name self.policy_name = policy_name super().__init__(message) def _load_cloudsso_models(): from alibabacloud_cloudsso20210515 import models as cloudsso_models return cloudsso_models def _get_attr(obj, *names, default=None): for name in names: if hasattr(obj, name): return getattr(obj, name) return default def _extract_error_code(exc: Exception) -> str: code = _get_attr(exc, "code", "error_code", default=None) return str(code) if code else "" def _is_retryable_error(exc: Exception) -> bool: if isinstance(exc, (ConnectionError, TimeoutError)): return True code = _extract_error_code(exc).lower() status_code = _get_attr(exc, "status_code", default=None) if isinstance(status_code, int) and 400 <= status_code < 500: return False non_retryable_codes = { "invalidpolicyname", "accessconfigurationnamealreadyexists", "forbidden", "accessdenied", "invalidparameter", } if code in non_retryable_codes: return False retryable_tokens = ( "throttling", "serviceunavailable", "internalerror", "timeout", "connection", ) if any(token in code for token in retryable_tokens): return True message = str(exc).lower() retryable_message_tokens = ( "throttling", "service unavailable", "timed out", "timeout", "connection", ) return any(token in message for token in retryable_message_tokens) def _retry_on_infra_error( func, *, logger: logging.Logger, action: str, role_name: str = None, policy_name: str = None, ): attempt = 0 while True: try: return func() except Exception as exc: if not _is_retryable_error(exc): raise if attempt >= MAX_RETRIES: raise delay = RETRY_BASE_DELAY * (RETRY_BACKOFF_FACTOR**attempt) logger.warning( "Retryable error during %s (role=%s, policy=%s). retry %d/%d in %ss: %s", action, role_name, policy_name, attempt + 1, MAX_RETRIES, delay, exc, ) time.sleep(delay) attempt += 1 def _check_existing( client, directory_id: str, configuration_name: str, logger: logging.Logger, ) -> Optional[str]: models = _load_cloudsso_models() next_token = None try: while True: request_kwargs = { "directory_id": directory_id, } if next_token: request_kwargs["next_token"] = next_token request = models.ListAccessConfigurationsRequest(**request_kwargs) response = _retry_on_infra_error( lambda: client.list_access_configurations(request), logger=logger, action="ListAccessConfigurations", role_name=configuration_name, ) body = _get_attr(response, "body", default=response) configs = _get_attr(body, "access_configurations", default=[]) or [] for item in configs: remote_name = _get_attr( item, "access_configuration_name", "AccessConfigurationName", default=None, ) if remote_name == configuration_name: return _get_attr( item, "access_configuration_id", "AccessConfigurationId", default=None, ) next_token = _get_attr(body, "next_token", "NextToken", default=None) if not next_token: return None except ApiError: raise except Exception as exc: raise ApiError( f"failed to query existing access configurations: {exc}", role_name=configuration_name, ) from exc def _create_access_configuration( client, directory_id: str, role: RoleConfig, logger: logging.Logger, ) -> str: models = _load_cloudsso_models() request = models.CreateAccessConfigurationRequest( directory_id=directory_id, access_configuration_name=role.configuration_name, description=role.description, session_duration=role.session_duration, ) try: response = _retry_on_infra_error( lambda: client.create_access_configuration(request), logger=logger, action="CreateAccessConfiguration", role_name=role.configuration_name, ) body = _get_attr(response, "body", default=response) access_configuration = _get_attr( body, "access_configuration", "AccessConfiguration", default=body ) access_configuration_id = _get_attr( access_configuration, "access_configuration_id", "AccessConfigurationId", default=None, ) if not access_configuration_id: raise ApiError( "missing access configuration id in API response", role_name=role.configuration_name, ) return access_configuration_id except ApiError: raise except Exception as exc: raise ApiError( f"failed to create access configuration: {exc}", role_name=role.configuration_name, ) from exc def _attach_policy( client, directory_id: str, access_configuration_id: str, policy: PolicyConfig, logger: logging.Logger, role_name: str, ) -> None: models = _load_cloudsso_models() policy_type = "Inline" if policy.policy_type == "Custom" else "System" request = models.AddPermissionPolicyToAccessConfigurationRequest( directory_id=directory_id, access_configuration_id=access_configuration_id, permission_policy_type=policy_type, permission_policy_name=policy.policy_name, ) try: _retry_on_infra_error( lambda: client.add_permission_policy_to_access_configuration(request), logger=logger, action="AddPermissionPolicyToAccessConfiguration", role_name=role_name, policy_name=policy.policy_name, ) except ApiError: raise except Exception as exc: raise ApiError( f"failed to attach policy: {exc}", role_name=role_name, policy_name=policy.policy_name, ) from exc def process_roles(client, config: AppConfig, logger: logging.Logger) -> dict: """Process all configured roles with idempotency and retries.""" started = time.monotonic() summary = { "total": len(config.roles), "created": 0, "skipped": 0, "policies_attached": 0, "elapsed": 0.0, } for role in config.roles: existing_id = _check_existing( client, config.directory_id, role.configuration_name, logger, ) if existing_id: logger.info( "Access configuration already exists. Skip role=%s id=%s", role.configuration_name, existing_id, ) summary["skipped"] += 1 continue access_configuration_id = _create_access_configuration( client, config.directory_id, role, logger, ) summary["created"] += 1 for policy in role.policies: _attach_policy( client, config.directory_id, access_configuration_id, policy, logger, role.configuration_name, ) summary["policies_attached"] += 1 summary["elapsed"] = round(time.monotonic() - started, 3) return summary