diff --git a/README.md b/README.md index a47fc1f..709ccd6 100644 --- a/README.md +++ b/README.md @@ -103,3 +103,4 @@ Please refer to Ansible Automation Platform Documentation for further documentat [John Westcott](https://github.com/john-westcott-iv) [Jessica Steurer](https://github.com/jay-steurer) [Bryan Havenstein](https://github.com/bhavenst) + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..98c0956 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,698 @@ +# Ansible Platform Collection - Architecture Documentation + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture Goals](#architecture-goals) +3. [System Architecture](#system-architecture) +4. [Component Details](#component-details) +5. [Data Flow](#data-flow) +6. [Key Design Decisions](#key-design-decisions) +7. [Migration from Legacy Architecture](#migration-from-legacy-architecture) + +--- + +## Overview + +The Ansible Platform Collection provides action plugins for managing Ansible Automation Platform (AAP) Gateway resources (users, organizations, teams, etc.) with automatic API version adaptation and code generation capabilities. + +### Key Features + +- **Persistent Connections**: Manager service maintains HTTP sessions across playbook tasks (50-75% faster execution) +- **Manager-Side Transformations**: All data transformations happen in the persistent manager, not in action plugins +- **Round-Trip Data Contract**: Output format always matches input format (single DOCUMENTATION source) +- **Generic Manager**: Resource-agnostic manager works for all modules +- **Dynamic Version Management**: Filesystem-based version discovery, no hardcoded version lists +- **Code Generation**: Automated dataclass generation from docstrings and OpenAPI specs + +--- + +## Architecture Goals + +### 1. User-Facing Stability +Ansible playbook interface remains stable across API versions. Users don't need to change playbooks when the platform API changes. + +### 2. Code Generation +Minimize manual coding through automated generation from docstrings and OpenAPI specs. Target: 80% automated, 20% manual. + +### 3. Version Flexibility +Support multiple API versions dynamically without hardcoding. Automatic version detection and fallback. + +### 4. Performance +Maintain persistent platform connections for faster playbook execution. Reuse HTTP sessions across tasks. + +### 5. Type Safety +Strong typing throughout with validation at multiple layers (input, transformation, output). + +--- + +## System Architecture + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Ansible Playbook │ +│ - Stable YAML interface │ +│ - Version-agnostic │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER (Action Plugins) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ BaseResourceActionPlugin │ │ +│ │ - Input validation (ArgumentSpec) │ │ +│ │ - Create Ansible dataclass │ │ +│ │ - Manager spawning/connection │ │ +│ │ - Output validation │ │ +│ │ - Format return dict │ │ +│ │ │ │ +│ │ NO transformations │ │ +│ │ NO API knowledge │ │ +│ │ NO version resolution │ │ +│ └──────────────────┬───────────────────────────────────┘ │ +└──────────────────────┼──────────────────────────────────────┘ + │ + │ RPC (Ansible dataclasses only) + │ +┌──────────────────────▼──────────────────────────────────────┐ +│ MANAGER LAYER (Persistent Service) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ PlatformService (PlatformManager) │ │ +│ │ - Persistent HTTP session │ │ +│ │ - API version detection & caching │ │ +│ │ - Dynamic class loading │ │ +│ │ - FORWARD TRANSFORM (Ansible → API) │ │ +│ │ - API calls (multi-endpoint support) │ │ +│ │ - REVERSE TRANSFORM (API → Ansible) │ │ +│ │ - Lookup helpers (names ↔ IDs) │ │ +│ │ │ │ +│ │ Generic: Works for ALL resources │ │ +│ └──────────────────┬───────────────────────────────────┘ │ +└──────────────────────┼──────────────────────────────────────┘ + │ + │ HTTP/HTTPS + │ +┌──────────────────────▼──────────────────────────────────────┐ +│ Platform API (AAP Gateway) │ +│ - REST API endpoints │ +│ - Version-specific schemas │ +│ - Authentication (Basic/OAuth) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Component Layers + +#### Layer 1: Client (Action Plugins) +- **Location**: `plugins/action/` +- **Responsibility**: Thin client that validates, sends, receives, and validates +- **Key File**: `base_action.py` - Base class for all resource action plugins +- **Characteristics**: + - Stateless + - No API knowledge + - No transformations + - Manager lifecycle management + +#### Layer 2: Manager (Persistent Service) +- **Location**: `plugins/plugin_utils/manager/` +- **Responsibility**: Heavy lifting - transformations, API calls, version management +- **Key Files**: + - `platform_manager.py` - PlatformService and PlatformManager + - `rpc_client.py` - Client-side RPC communication + - `process_manager.py` - Generic process management (Platform SDK) +- **Characteristics**: + - Stateful (persistent session) + - Resource-agnostic + - All transformations + - Version-aware + - Uses Platform SDK for generic process management + +#### Layer 3: Platform Framework (Platform SDK) +- **Location**: `plugins/plugin_utils/platform/` +- **Responsibility**: Core transformation, version management, and generic platform SDK +- **Key Files**: + - `base_transform.py` - BaseTransformMixin (universal transformation) + - `types.py` - Shared types (EndpointOperation, TransformContext) + - `config.py` - GatewayConfig and gateway configuration extraction (Platform SDK) + - `registry.py` - APIVersionRegistry (version discovery) + - `loader.py` - DynamicClassLoader (runtime class loading) +- **Characteristics**: + - Generic, reusable (Platform SDK - not Ansible-specific) + - No resource-specific code + - Filesystem-based discovery + - Can be used by CLI, MCP, or other entry points + +#### Layer 4: Data Models +- **Location**: `plugins/plugin_utils/ansible_models/` and `plugins/plugin_utils/api/` +- **Responsibility**: Type-safe data structures +- **Key Files**: + - `ansible_models/` - User-facing dataclasses (stable) + - `api/v1/` - API dataclasses (versioned) + - `docs/` - DOCUMENTATION strings (source of truth) +- **Characteristics**: + - Generated from docstrings/OpenAPI + - Type-safe + - Round-trip contract + +--- + +## Component Details + +### 1. BaseTransformMixin + +**Purpose**: Universal transformation logic inherited by all dataclasses. + +**Location**: `plugins/plugin_utils/platform/base_transform.py` + +**Key Methods**: +- `to_api(context)` - Transform Ansible → API format +- `to_ansible(context)` - Transform API → Ansible format +- `_apply_forward_mapping()` - Apply forward transformations +- `_apply_reverse_mapping()` - Apply reverse transformations + +**How It Works**: +1. Subclasses define `_field_mapping` dict +2. Subclasses define `_transform_registry` dict +3. BaseTransformMixin applies mappings and transformations generically +4. Supports nested fields (dot notation) +5. Context-aware (can access manager for lookups) + +**Example**: +```python +class UserTransformMixin_v1(BaseTransformMixin): + _field_mapping = { + 'username': 'username', # 1:1 mapping + 'organizations': { # Complex mapping + 'api_field': 'organization_ids', + 'forward_transform': 'names_to_ids', + 'reverse_transform': 'ids_to_names', + } + } + _transform_registry = { + 'names_to_ids': lambda names, ctx: ctx.manager.lookup_org_ids(names), + 'ids_to_names': lambda ids, ctx: ctx.manager.lookup_org_names(ids), + } +``` + +**Note**: Context is now a `TransformContext` dataclass (not a dict) for better type safety and mypy support. The context provides type-safe access to `manager`, `session`, `cache`, and `api_version`. + +### 2. APIVersionRegistry + +**Purpose**: Discover available API versions by scanning filesystem. + +**Location**: `plugins/plugin_utils/platform/registry.py` + +**Key Methods**: +- `get_supported_versions()` - List all discovered versions +- `get_versions_for_module(module_name)` - Versions supporting a module +- `find_best_version(requested, module)` - Find best match with fallback + +**How It Works**: +1. Scans `api/` directory for version directories (v1/, v2/, etc.) +2. Discovers module implementations in each version +3. Builds version × module matrix +4. Provides fallback logic (exact → lower → higher) + +**Example**: +``` +api/ +├── v1/ +│ ├── user.py +│ └── organization.py +└── v2/ + ├── user.py + └── team.py + +Registry discovers: +- Versions: ['1', '2'] +- user: ['1', '2'] +- organization: ['1'] +- team: ['2'] +``` + +### 3. DynamicClassLoader + +**Purpose**: Load version-appropriate classes at runtime. + +**Location**: `plugins/plugin_utils/platform/loader.py` + +**Key Methods**: +- `load_classes_for_module(module_name, api_version)` - Load all classes for a module + +**How It Works**: +1. Uses registry to find best version match +2. Dynamically imports Ansible dataclass +3. Dynamically imports API dataclass and mixin +4. Caches loaded classes for performance +5. Pattern matching for class discovery + +**Returns**: Tuple of (AnsibleClass, APIClass, MixinClass) + +### 4. PlatformService + +**Purpose**: Persistent service that handles all API communication and transformations. + +**Location**: `plugins/plugin_utils/manager/platform_manager.py` + +**Key Methods**: +- `execute(operation, module_name, ansible_data_dict)` - Main entry point +- `_create_resource()` - Create with transformations +- `_update_resource()` - Update with transformations +- `_delete_resource()` - Delete resource +- `_find_resource()` - Find resource +- `_execute_operations()` - Multi-endpoint orchestration + +**How It Works**: +1. Maintains persistent `requests.Session` +2. Detects and caches API version on startup +3. Loads version-specific classes via DynamicClassLoader +4. Performs forward transform (Ansible → API) +5. Executes API calls (potentially multiple endpoints) +6. Performs reverse transform (API → Ansible) +7. Returns Ansible-format data + +**Authentication**: +- Supports Basic Auth (username/password) +- Supports OAuth Token (Bearer token) +- Authenticates once on service creation +- Session persists across requests + +### 5. PlatformManager + +**Purpose**: Multiprocessing Manager for sharing PlatformService across processes. + +**Location**: `plugins/plugin_utils/manager/platform_manager.py` + +**How It Works**: +1. Extends `BaseManager` with `ThreadingMixIn` +2. Registers `get_platform_service()` method +3. Uses Unix domain socket for communication +4. Thread-safe (multiple concurrent clients) +5. Daemon threads (cleanup on exit) + +### 6. ManagerRPCClient + +**Purpose**: Client-side interface for communicating with PlatformManager. + +**Location**: `plugins/plugin_utils/manager/rpc_client.py` + +**Key Methods**: +- `execute(operation, module_name, ansible_data)` - Execute operation via manager + +**How It Works**: +1. Connects to manager via Unix socket +2. Gets proxy to PlatformService +3. Serializes dataclass to dict +4. Calls manager method via proxy +5. Returns result dict + +### 7. Platform SDK Components + +**Purpose**: Generic platform SDK modules that are not Ansible-specific and can be reused by CLI, MCP, or other entry points. + +#### 7a. GatewayConfig (`platform/config.py`) + +**Purpose**: Gateway connection configuration extraction and normalization. + +**Location**: `plugins/plugin_utils/platform/config.py` + +**Key Components**: +- `GatewayConfig` dataclass - Type-safe configuration object +- `extract_gateway_config()` - Extract config from task_args and host_vars + +**Characteristics**: +- Not Ansible-specific (generic dict input) +- URL normalization +- Auth parameter extraction +- Type-safe with dataclass + +#### 7b. ProcessManager (`manager/process_manager.py`) + +**Purpose**: Generic process management utilities for spawning and connecting to manager processes. + +**Location**: `plugins/plugin_utils/manager/process_manager.py` + +**Key Methods**: +- `generate_connection_info()` - Generate socket path and authkey +- `spawn_manager_process()` - Spawn manager process +- `wait_for_process_startup()` - Wait for process to be ready +- `cleanup_old_socket()` - Clean up old socket files + +**Characteristics**: +- Not Ansible-specific (generic process management) +- Reusable for CLI, MCP, or other entry points +- Type-safe with dataclasses + +#### 7c. TransformContext (`platform/types.py`) + +**Purpose**: Type-safe context for data transformations (replaces Dict[str, Any]). + +**Location**: `plugins/plugin_utils/platform/types.py` + +**Key Components**: +- `TransformContext` dataclass - Type-safe context with manager, session, cache, api_version + +**Benefits**: +- Better mypy type checking +- IDE autocomplete support +- Clear structure instead of dict keys + +### 8. BaseResourceActionPlugin + +**Purpose**: Base class for all resource action plugins. + +**Location**: `plugins/action/base_action.py` + +**Key Methods**: +- `_get_or_spawn_manager(task_vars)` - Get or spawn manager (returns tuple: client, facts_dict) +- `_build_argspec_from_docs(documentation)` - Parse DOCUMENTATION +- `_validate_data(data, argspec, direction)` - Validate input/output +- `_detect_operation(args)` - Detect create/update/delete/find + +**How It Works**: +1. Uses Platform SDK (`extract_gateway_config`) to get gateway configuration +2. Checks hostvars for existing manager +3. If found, connects to existing manager +4. If not found, uses Platform SDK (`ProcessManager`) to spawn new manager process +5. Returns tuple: (ManagerRPCClient, facts_dict) where facts_dict contains connection info +6. Action plugin sets facts in result dict for reuse +7. Validates input before sending to manager +8. Validates output after receiving from manager +9. Formats return dict for Ansible + +**Manager Lifecycle**: +- First task spawns manager (via Platform SDK ProcessManager) +- Facts stored in result dict (not via set_fact module) +- Subsequent tasks reuse same manager from hostvars +- Manager persists for playbook duration +- Cleanup on playbook completion + +**Separation of Concerns**: +- Ansible-specific: task_vars, AnsibleError, result dict formatting +- Platform SDK: Gateway config extraction, process management + +--- + +## Data Flow + +### Complete Request Flow + +``` +1. PLAYBOOK TASK + └─> Action Plugin (user.py) + │ + ├─> 2. Validate Input (ArgumentSpec) + │ └─> DOCUMENTATION string + │ + ├─> 3. Create AnsibleUser dataclass + │ └─> organizations=['Engineering', 'DevOps'] # Names + │ + ├─> 4. Get/Connect to Manager + │ └─> ManagerRPCClient + │ + └─> 5. Send to Manager (RPC) + └─> execute('create', 'user', ansible_user_dict) + │ + ▼ +6. PLATFORM MANAGER (PlatformService) + │ + ├─> 7. Load Version-Specific Classes + │ └─> AnsibleUser, APIUser_v1, UserTransformMixin_v1 + │ + ├─> 8. Reconstruct AnsibleUser from dict + │ └─> Why? RPC serializes dataclasses as dicts, but transformation + │ methods (to_api()) require dataclass instances. Reconstruction + │ also validates types and runs __post_init__(). + │ + ├─> 9. FORWARD TRANSFORM (Ansible → API) + │ └─> UserTransformMixin_v1.to_api(context) + │ │ + │ ├─> Apply field mappings + │ │ username → username + │ │ organizations → organization_ids + │ │ + │ └─> Apply transformations + │ organizations=['Engineering'] → lookup_org_ids() + │ → organization_ids=[1] + │ + ├─> 10. Execute API Calls + │ │ + │ ├─> POST /api/gateway/v1/users/ + │ │ └─> {username: 'jdoe', email: 'jdoe@example.com'} + │ │ └─> Response: {id: 123, username: 'jdoe', ...} + │ │ + │ └─> POST /api/gateway/v1/users/123/organizations/ + │ └─> {organization_ids: [1, 2]} + │ └─> Response: {success: true} + │ + ├─> 11. REVERSE TRANSFORM (API → Ansible) + │ └─> APIUser_v1.to_ansible(context) + │ │ + │ ├─> Apply reverse mappings + │ │ organization_ids → organizations + │ │ + │ └─> Apply reverse transformations + │ organization_ids=[1, 2] → lookup_org_names() + │ → organizations=['Engineering', 'DevOps'] + │ + └─> 12. Return AnsibleUser dict + └─> {username: 'jdoe', organizations: ['Engineering', 'DevOps'], ...} + │ + ▼ +13. ACTION PLUGIN + │ + ├─> 14. Validate Output (ArgumentSpec) + │ └─> Same spec as input validation + │ + └─> 15. Format Return Dict + └─> {changed: True, failed: False, user: {...}} + │ + ▼ +16. ANSIBLE PLAYBOOK + └─> Task completes, result available +``` + +### Round-Trip Data Contract + +**Key Principle**: Output format matches input format. + +**Input** (from playbook): +```yaml +username: jdoe +organizations: + - Engineering + - DevOps +``` + +**Output** (to playbook): +```yaml +username: jdoe +organizations: + - Engineering + - DevOps +id: 123 +created: '2025-01-15T10:30:00Z' +``` + +**Internal** (never exposed to client): +```json +{ + "username": "jdoe", + "organization_ids": [1, 2] // API format +} +``` + +--- + +## Key Design Decisions + +### 1. Manager-Side Transformations + +**Decision**: All transformations happen in the persistent manager, not in action plugins. + +**Rationale**: +- Manager has API connection for lookups (names ↔ IDs) +- Manager knows API version +- Manager has persistent cache +- Client stays thin and version-agnostic +- Clean RPC protocol (only Ansible format crosses boundary) + +**Benefits**: +- Client doesn't need API knowledge +- Version changes don't affect client code +- Transformations have full context (session, cache, version) + +### 1.5. Dataclass Reconstruction at Manager Boundary + +**Decision**: Manager reconstructs Ansible dataclass instances from dicts received via RPC. + +**Rationale**: +- **RPC Serialization Limitation**: Python's multiprocessing RPC can only serialize simple types (dicts, lists, primitives), not dataclass instances. The action plugin must convert the dataclass to a dict before sending: `asdict(ansible_user)`. +- **Transformation Requires Instances**: The transformation methods (`to_api()`, `to_ansible()`) are instance methods that need the dataclass instance to: + - Access fields via `getattr(self, field)` + - Call `__post_init__()` for validation/normalization + - Maintain type safety throughout the transformation pipeline +- **Type Safety**: Reconstructing ensures data types are validated and normalized (e.g., `organizations` list normalization in `__post_init__()`) + +**Why Not Work with Dicts Directly?**: +- Would lose type safety and validation +- Would need to duplicate `__post_init__()` logic +- Would make transformation code more error-prone +- Would break the clean separation between data models and transformations + +**Flow**: +1. Action plugin: `AnsibleUser(...)` → `asdict()` → dict +2. RPC: dict crosses process boundary +3. Manager: `AnsibleUser(**dict)` → reconstructs instance +4. Manager: `ansible_instance.to_api(context)` → transformation works + +**Benefits**: +- Maintains type safety across process boundaries +- Validates and normalizes data at manager entry point +- Keeps transformation code clean and type-safe +- Single source of truth for data structure (dataclass definition) + +### 2. Round-Trip Data Contract + +**Decision**: Output format always matches input format. Single DOCUMENTATION defines both. + +**Rationale**: +- Predictable interface for users +- No separate RETURN section needed +- Consistent across all API versions +- Easier to understand and use + +**Benefits**: +- Users get what they put in (same field names, types) +- API format details hidden from users +- Single source of truth (DOCUMENTATION) + +### 3. Generic Manager + +**Decision**: Manager is resource-agnostic. Resource logic lives in dataclass mixins. + +**Rationale**: +- One manager works for all resources +- Easy to add new resources +- Consistent behavior across resources +- Less code duplication + +**Benefits**: +- Manager code doesn't need updates for new resources +- Resource-specific logic isolated in mixins +- Manager is reusable and maintainable + +### 4. Dynamic Version Discovery + +**Decision**: Filesystem-based version discovery, no hardcoded version lists. + +**Rationale**: +- Easy to add new API versions (just create directory) +- No code changes needed for version support +- Automatic discovery on startup +- Flexible version fallback + +**Benefits**: +- No configuration files to maintain +- Version support is declarative (directory structure) +- Easy to see what versions are supported + +### 5. Persistent Connections + +**Decision**: Manager maintains persistent HTTP session across playbook tasks. + +**Rationale**: +- Authentication overhead only once +- Connection reuse is faster +- Follows existing multiprocess pattern (weather service) + +**Benefits**: +- 50-75% faster playbook execution +- Better resource utilization +- Consistent with Ansible patterns + +--- + +## Migration from Legacy Architecture + +### Legacy Architecture (Current) + +**Structure**: +``` +plugins/ +├── modules/ +│ └── user.py # Direct AnsibleModule subclass +└── module_utils/ + ├── aap_module.py # Base module with session + └── aap_user.py # Resource-specific logic +``` + +**Characteristics**: +- Each module creates its own session +- No persistent connections +- Transformations in module code +- No version management +- Manual field mapping + +### New Architecture (This Implementation) + +**Structure**: +``` +plugins/ +├── action/ +│ ├── base_action.py # Base action plugin +│ └── user.py # Thin action plugin +└── plugin_utils/ + ├── manager/ + │ ├── platform_manager.py # Persistent service + │ └── rpc_client.py # RPC client + ├── platform/ + │ ├── base_transform.py # Universal transforms + │ ├── registry.py # Version discovery + │ └── loader.py # Dynamic loading + ├── ansible_models/ + │ └── user.py # Ansible dataclass + ├── api/ + │ └── v1/ + │ └── user.py # Transform mixin + └── docs/ + └── user.py # DOCUMENTATION +``` + +**Characteristics**: +- Persistent manager service +- Reused connections +- Manager-side transformations +- Dynamic version management +- Automated field mapping + +### Migration Strategy + +1. **Keep legacy modules** - Don't break existing playbooks +2. **Add new action plugins** - New architecture alongside old +3. **Gradual migration** - Migrate resources one at a time +4. **Feature flag** - Allow choosing old vs new architecture +5. **Deprecation path** - Eventually deprecate legacy modules + +--- + +## Next Steps + +1. **Code Generation Tools** - Automate dataclass generation +2. **First Resource Migration** - Migrate user module as example +3. **Testing Framework** - Unit and integration tests +4. **Documentation** - User-facing documentation +5. **CI/CD Integration** - Automated testing and validation + +--- + +## Related Documentation + +- `FLOW_EXPLANATION.md` - High-level flow explanation +- `CODE_WALKTHROUGH.md` - Detailed step-by-step code walkthrough with line numbers +- `IMPLEMENTATION_GUIDE.md` - Step-by-step implementation guide +- `DEVELOPER_GUIDE.md` - How to add new resources +- `API_REFERENCE.md` - API documentation for components + + diff --git a/docs/ARCHITECTURE_DIAGRAMS.md b/docs/ARCHITECTURE_DIAGRAMS.md new file mode 100644 index 0000000..a7506a4 --- /dev/null +++ b/docs/ARCHITECTURE_DIAGRAMS.md @@ -0,0 +1,632 @@ +# Architecture and Sequence Diagrams - Ansible Platform Collection + +This document contains comprehensive architecture and sequence diagrams for the Ansible Platform Collection. + +## Table of Contents + +1. [High-Level Architecture](#high-level-architecture) +2. [Component Architecture](#component-architecture) +3. [Data Flow Architecture](#data-flow-architecture) +4. [Manager Lifecycle](#manager-lifecycle) +5. [Sequence Diagrams](#sequence-diagrams) + - [First Task: Spawning Manager](#first-task-spawning-manager) + - [Subsequent Task: Reusing Manager](#subsequent-task-reusing-manager) + - [Complete Create Operation](#complete-create-operation) + - [Data Transformation Flow](#data-transformation-flow) + - [Version Discovery and Class Loading](#version-discovery-and-class-loading) + - [Multi-Endpoint Operation](#multi-endpoint-operation) + +--- + +## High-Level Architecture + +```mermaid +graph TB + subgraph "Layer 1: Ansible Playbook" + PB[Playbook YAML
Stable Interface] + end + + subgraph "Layer 2: Action Plugins (Client)" + AP[Action Plugin
BaseResourceActionPlugin] + AP --> |Validates| IV[Input Validation] + AP --> |Creates| DC[Ansible Dataclass] + AP --> |Connects| MC[ManagerRPCClient] + AP --> |Validates| OV[Output Validation] + end + + subgraph "Layer 3: Platform Manager (Service)" + PM[PlatformManager
Unix Socket Server] + PS[PlatformService
Persistent HTTP Session] + PS --> |Detects| AV[API Version] + PS --> |Loads| CL[DynamicClassLoader] + PS --> |Transforms| FT[Forward Transform
Ansible → API] + PS --> |Executes| AC[API Calls] + PS --> |Transforms| RT[Reverse Transform
API → Ansible] + PM --> |Manages| PS + end + + subgraph "Layer 4: Platform Framework (Platform SDK)" + BT[BaseTransformMixin
Universal Transform Logic] + VR[APIVersionRegistry
Version Discovery] + DL[DynamicClassLoader
Runtime Class Loading] + GC[GatewayConfig
Config Extraction] + PM[ProcessManager
Process Management] + TC[TransformContext
Type-Safe Context] + FT --> BT + RT --> BT + CL --> VR + CL --> DL + AP --> |Uses| GC + AP --> |Uses| PM + BT --> |Uses| TC + end + + subgraph "Layer 5: AAP Gateway API" + API[REST API
Versioned Endpoints] + end + + PB --> |Task Execution| AP + AP --> |RPC via Unix Socket| PM + PM --> |HTTP/HTTPS| API + + style PB fill:#e1f5ff + style AP fill:#fff4e1 + style PM fill:#ffe1f5 + style PS fill:#ffe1f5 + style BT fill:#e1ffe1 + style VR fill:#e1ffe1 + style DL fill:#e1ffe1 + style API fill:#ffe1e1 +``` + +--- + +## Component Architecture + +```mermaid +graph LR + subgraph "Action Plugin Layer" + BA[BaseResourceActionPlugin] + BA --> |Inherits| AB[ActionBase] + BA --> |Uses| MRC[ManagerRPCClient] + BA --> |Validates| ASV[ArgumentSpecValidator] + BA --> |Parses| DOC[DOCUMENTATION] + end + + subgraph "Manager Layer" + MRC --> |Connects via| US[Unix Socket] + US --> |RPC| PM[PlatformManager] + PM --> |Manages| PS[PlatformService] + PS --> |Uses| RS[requests.Session] + PS --> |Caches| VC[Version Cache] + PS --> |Caches| LC[Lookup Cache] + end + + subgraph "Platform Framework" + PS --> |Uses| DL[DynamicClassLoader] + DL --> |Uses| VR[APIVersionRegistry] + VR --> |Scans| FS[FileSystem
api/v1/, api/v2/] + PS --> |Uses| BT[BaseTransformMixin] + BT --> |Applied by| TM[Transform Mixins
UserTransformMixin_v1] + end + + subgraph "Data Models" + AD[Ansible Dataclasses
ansible_models/] + APD[API Dataclasses
api/v1/generated/] + TM --> |Transforms| AD + TM --> |Transforms| APD + end + + style BA fill:#fff4e1 + style PS fill:#ffe1f5 + style BT fill:#e1ffe1 + style AD fill:#e1f5ff + style APD fill:#ffe1e1 +``` + +--- + +## Data Flow Architecture + +```mermaid +flowchart TD + Start[Playbook Task] --> Input[User Input
organizations: ['Engineering']] + + Input --> Validate1[Action Plugin:
Validate Input] + Validate1 --> CreateDC[Create AnsibleUser
organizations: ['Engineering']] + + CreateDC --> RPC[RPC Call via Unix Socket] + RPC --> Manager[PlatformService] + + Manager --> LoadClasses[Load Version Classes
AnsibleUser, APIUser_v1, UserTransformMixin_v1] + + LoadClasses --> Forward[Forward Transform
to_api(context)] + + Forward --> Lookup[Lookup Org IDs
lookup_org_ids(['Engineering'])] + Lookup --> API1[API Call:
GET /organizations/?name=Engineering] + API1 --> OrgID[Returns: org_id=1] + + OrgID --> Transform1[Transform:
organizations → organization_ids
['Engineering'] → [1]] + + Transform1 --> APICall[API Call:
POST /users/
organization_ids: [1]] + APICall --> APIResp[API Response:
id: 123, organization_ids: [1]] + + APIResp --> Reverse[Reverse Transform
to_ansible(context)] + + Reverse --> Lookup2[Lookup Org Names
lookup_org_names([1])] + Lookup2 --> API2[API Call:
GET /organizations/1/] + API2 --> OrgName[Returns: name='Engineering'] + + OrgName --> Transform2[Transform:
organization_ids → organizations
[1] → ['Engineering']] + + Transform2 --> CreateResult[Create AnsibleUser Result
organizations: ['Engineering']] + + CreateResult --> RPC2[RPC Return via Unix Socket] + RPC2 --> Validate2[Action Plugin:
Validate Output] + Validate2 --> Output[Return to Playbook
organizations: ['Engineering']] + + style Input fill:#e1f5ff + style CreateDC fill:#e1f5ff + style Transform1 fill:#fff4e1 + style APICall fill:#ffe1e1 + style Transform2 fill:#fff4e1 + style CreateResult fill:#e1f5ff + style Output fill:#e1f5ff +``` + +--- + +## Manager Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> CheckManager: First Task + + CheckManager --> SpawnManager: Manager Not Found + CheckManager --> ConnectManager: Manager Found + + SpawnManager --> ExtractConfig: Extract Gateway Config
(Platform SDK) + ExtractConfig --> GenerateConnInfo: Generate Connection Info
(Platform SDK ProcessManager) + GenerateConnInfo --> StartProcess: Spawn Manager Process
(Platform SDK) + StartProcess --> InitService: Initialize PlatformService + InitService --> CreateSession: Create HTTP Session + CreateSession --> Authenticate: Authenticate with AAP + Authenticate --> DetectVersion: Detect API Version + DetectVersion --> InitRegistry: Initialize Registry + InitRegistry --> StartServer: Start Manager Server + StartServer --> WaitSocket: Wait for Socket
(Platform SDK) + WaitSocket --> SetFactsInResult: Set Facts in Result Dict
(ansible_facts, _ansible_facts_cacheable) + SetFactsInResult --> ConnectManager: Connect to Manager + + ConnectManager --> Ready: Manager Ready + + Ready --> ExecuteTask: Execute Task + ExecuteTask --> Ready: Task Complete + + Ready --> [*]: Playbook Complete + + note right of Ready + Manager persists for + entire playbook duration + Reused by all tasks + end note +``` + +--- + +## Sequence Diagrams + +### First Task: Spawning Manager + +```mermaid +sequenceDiagram + participant PB as Playbook + participant AP as Action Plugin + participant HV as HostVars + participant PM as Manager Process + participant PS as PlatformService + participant API as AAP Gateway + + PB->>AP: Execute Task + AP->>AP: Extract gateway config (Platform SDK) + AP->>HV: Check for existing manager + HV-->>AP: No manager found + + AP->>AP: Generate connection info (Platform SDK ProcessManager) + AP->>PM: Spawn manager process (Platform SDK) + + PM->>PS: Create PlatformService + PS->>PS: Create requests.Session + PS->>API: Authenticate (Basic/OAuth) + API-->>PS: Authentication success + PS->>API: Detect API version (/ping) + API-->>PS: Version: v1 + PS->>PS: Initialize APIVersionRegistry + PS->>PS: Initialize DynamicClassLoader + PM->>PM: Start Unix socket server + + PM-->>AP: Socket ready + AP->>AP: Set facts in result dict
(ansible_facts, _ansible_facts_cacheable) + AP->>AP: Connect via ManagerRPCClient + AP-->>PB: Manager ready (result includes facts) +``` + +### Subsequent Task: Reusing Manager + +```mermaid +sequenceDiagram + participant PB as Playbook + participant AP as Action Plugin + participant HV as HostVars + participant MRC as ManagerRPCClient + participant PM as PlatformManager + participant PS as PlatformService + + PB->>AP: Execute Task + AP->>HV: Check for existing manager + HV-->>AP: Manager found (socket_path, authkey) + + AP->>AP: Verify socket exists + AP->>MRC: Create ManagerRPCClient + MRC->>PM: Connect via Unix socket + PM-->>MRC: Connection established + MRC->>PM: get_platform_service() + PM-->>MRC: Service proxy + MRC-->>AP: Client ready + + Note over PS: Persistent session reused
No re-authentication needed + + AP-->>PB: Manager ready (reused) +``` + +### Complete Create Operation + +```mermaid +sequenceDiagram + participant PB as Playbook + participant AP as Action Plugin + participant MRC as ManagerRPCClient + participant PS as PlatformService + participant DL as DynamicClassLoader + participant BT as BaseTransformMixin + participant API as AAP Gateway + + PB->>AP: Create user task + AP->>AP: Validate input (ArgumentSpec) + AP->>AP: Create AnsibleUser dataclass + Note over AP: organizations: ['Engineering', 'DevOps'] + + AP->>MRC: execute('create', 'user', ansible_user_dict) + MRC->>PS: execute(operation, module_name, data_dict) + + PS->>DL: load_classes_for_module('user', '1') + DL->>DL: Find best version match + DL->>DL: Import AnsibleUser + DL->>DL: Import APIUser_v1 + DL->>DL: Import UserTransformMixin_v1 + DL-->>PS: (AnsibleUser, APIUser_v1, UserTransformMixin_v1) + + PS->>PS: Reconstruct AnsibleUser from dict + + PS->>PS: Create TransformContext dataclass
(manager, session, cache, api_version) + PS->>BT: Forward Transform: to_api(context) + Note over BT: context is TransformContext
(type-safe, not dict) + BT->>PS: lookup_org_ids(['Engineering', 'DevOps']) + PS->>API: GET /organizations/?name=Engineering + API-->>PS: {id: 1, name: 'Engineering'} + PS->>API: GET /organizations/?name=DevOps + API-->>PS: {id: 2, name: 'DevOps'} + PS-->>BT: [1, 2] + BT->>BT: Apply field mapping + Note over BT: organizations → organization_ids
['Engineering', 'DevOps'] → [1, 2] + BT-->>PS: APIUser_v1 instance + + PS->>PS: Get endpoint operations + PS->>API: POST /api/gateway/v1/users/ + Note over API: {username: 'jdoe', email: 'jdoe@example.com'} + API-->>PS: {id: 123, username: 'jdoe', ...} + + PS->>API: POST /api/gateway/v1/users/123/organizations/ + Note over API: {organization_ids: [1, 2]} + API-->>PS: {success: true} + + PS->>BT: Reverse Transform: to_ansible(context) + Note over BT: context is TransformContext
(type-safe, not dict) + BT->>PS: lookup_org_names([1, 2]) + PS->>API: GET /organizations/1/ + API-->>PS: {id: 1, name: 'Engineering'} + PS->>API: GET /organizations/2/ + API-->>PS: {id: 2, name: 'DevOps'} + PS-->>BT: ['Engineering', 'DevOps'] + BT->>BT: Apply reverse mapping + Note over BT: organization_ids → organizations
[1, 2] → ['Engineering', 'DevOps'] + BT-->>PS: AnsibleUser instance + + PS-->>MRC: AnsibleUser dict + MRC-->>AP: Result dict + AP->>AP: Validate output (ArgumentSpec) + AP-->>PB: {changed: True, user: {...}} + Note over PB: organizations: ['Engineering', 'DevOps'] +``` + +### Data Transformation Flow + +```mermaid +sequenceDiagram + participant AD as AnsibleUser
(Input) + participant BT as BaseTransformMixin + participant TM as UserTransformMixin_v1 + participant PS as PlatformService + participant API as AAP Gateway + participant APD as APIUser_v1
(API Format) + participant AD2 as AnsibleUser
(Output) + + Note over AD: User Input
organizations: ['Engineering'] + + AD->>BT: to_api(context) + Note over BT: context is TransformContext
(type-safe dataclass, not dict) + BT->>TM: _apply_forward_mapping() + TM->>TM: Check _field_mapping + Note over TM: organizations → organization_ids
forward_transform: names_to_ids + + TM->>PS: lookup_org_ids(['Engineering']) + PS->>API: GET /organizations/?name=Engineering + API-->>PS: {id: 1, name: 'Engineering'} + PS-->>TM: [1] + + TM->>TM: Apply transform + Note over TM: ['Engineering'] → [1] + TM->>APD: Create APIUser_v1 + Note over APD: organization_ids: [1] + + APD->>API: POST /users/ (with organization_ids: [1]) + API-->>APD: Response: {id: 123, organization_ids: [1]} + + APD->>BT: to_ansible(context) + Note over BT: context is TransformContext
(type-safe dataclass, not dict) + BT->>TM: _apply_reverse_mapping() + TM->>TM: Check _field_mapping + Note over TM: organization_ids → organizations
reverse_transform: ids_to_names + + TM->>PS: lookup_org_names([1]) + PS->>API: GET /organizations/1/ + API-->>PS: {id: 1, name: 'Engineering'} + PS-->>TM: ['Engineering'] + + TM->>TM: Apply reverse transform + Note over TM: [1] → ['Engineering'] + TM->>AD2: Create AnsibleUser + Note over AD2: organizations: ['Engineering'] + + Note over AD,AD2: Round-Trip Contract:
Output matches Input +``` + +### Version Discovery and Class Loading + +```mermaid +sequenceDiagram + participant PS as PlatformService + participant VR as APIVersionRegistry + participant FS as FileSystem + participant DL as DynamicClassLoader + participant IM as Import Module + participant CC as Class Cache + + PS->>VR: Initialize APIVersionRegistry() + VR->>FS: Scan api/ directory + FS-->>VR: Found: v1/, v2/ + + VR->>FS: Scan v1/ directory + FS-->>VR: Found: user.py, organization.py + + VR->>FS: Scan v2/ directory + FS-->>VR: Found: user.py, team.py + + VR->>VR: Build version matrix + Note over VR: Versions: ['1', '2']
user: ['1', '2']
organization: ['1']
team: ['2'] + + PS->>PS: Detect API version from API + PS-->>PS: api_version = '1' + + PS->>DL: load_classes_for_module('user', '1') + DL->>VR: find_best_version('1', 'user') + VR-->>DL: '1' (exact match) + + DL->>CC: Check cache + CC-->>DL: Not cached + + DL->>IM: Import ansible_models.user + IM-->>DL: AnsibleUser class + + DL->>IM: Import api.v1.user + IM-->>DL: APIUser_v1, UserTransformMixin_v1 + + DL->>CC: Cache classes + DL-->>PS: (AnsibleUser, APIUser_v1, UserTransformMixin_v1) + + Note over PS: Classes loaded and cached
Ready for transformation +``` + +### Multi-Endpoint Operation + +```mermaid +sequenceDiagram + participant PS as PlatformService + participant TM as UserTransformMixin_v1 + participant EO as EndpointOperations + participant API as AAP Gateway + + PS->>TM: get_endpoint_operations() + TM-->>PS: Operations dict + + Note over EO: Operation 1: create
path: /users/
order: 1
fields: ['username', 'email'] + + Note over EO: Operation 2: assign_orgs
path: /users/{id}/organizations/
order: 2
depends_on: 'create'
fields: ['organization_ids'] + + PS->>PS: Sort operations by dependencies & order + Note over PS: Execution order:
1. create (order=1)
2. assign_orgs (order=2, depends_on='create') + + PS->>API: POST /api/gateway/v1/users/ + Note over API: {username: 'jdoe', email: 'jdoe@example.com'} + API-->>PS: {id: 123, username: 'jdoe', ...} + PS->>PS: Store id=123 for next operation + + PS->>PS: Build path with {id} parameter + Note over PS: /users/{id}/organizations/
→ /users/123/organizations/ + + PS->>API: POST /api/gateway/v1/users/123/organizations/ + Note over API: {organization_ids: [1, 2]} + API-->>PS: {success: true} + + PS->>PS: Combine results + PS-->>PS: Return main result +``` + +--- + +## Component Interaction Matrix + +```mermaid +graph TB + subgraph "Action Plugin Components" + BA[BaseResourceActionPlugin] + MRC[ManagerRPCClient] + end + + subgraph "Manager Components" + PM[PlatformManager] + PS[PlatformService] + end + + subgraph "Platform Framework" + BT[BaseTransformMixin] + VR[APIVersionRegistry] + DL[DynamicClassLoader] + EO[EndpointOperation] + end + + subgraph "Data Models" + AD[Ansible Dataclasses] + APD[API Dataclasses] + TM[Transform Mixins] + end + + BA -->|uses| MRC + MRC -->|RPC via| PM + PM -->|manages| PS + PS -->|uses| DL + PS -->|uses| BT + DL -->|uses| VR + AD -->|transforms via| BT + APD -->|transforms via| BT + TM -->|inherits| BT + TM -->|defines| EO + + style BA fill:#fff4e1 + style PS fill:#ffe1f5 + style BT fill:#e1ffe1 + style AD fill:#e1f5ff + style APD fill:#ffe1e1 +``` + +--- + +## File Structure and Dependencies + +```mermaid +graph TD + ROOT[ansible.platform/] + + ROOT --> PLUGINS[plugins/] + PLUGINS --> ACTION[action/] + PLUGINS --> MODULES[modules/] + PLUGINS --> PLUGIN_UTILS[plugin_utils/] + + ACTION --> BA[base_action.py
BaseResourceActionPlugin] + ACTION --> USER_ACT[user.py
ActionModule] + + PLUGIN_UTILS --> MANAGER[manager/] + PLUGIN_UTILS --> PLATFORM[platform/] + PLUGIN_UTILS --> ANSIBLE_MODELS[ansible_models/] + PLUGIN_UTILS --> API[api/] + PLUGIN_UTILS --> DOCS[docs/] + + MANAGER --> PM[platform_manager.py
PlatformService, PlatformManager] + MANAGER --> RPC[rpc_client.py
ManagerRPCClient] + + PLATFORM --> BT[base_transform.py
BaseTransformMixin] + PLATFORM --> REG[registry.py
APIVersionRegistry] + PLATFORM --> LOAD[loader.py
DynamicClassLoader] + PLATFORM --> TYPES[types.py
EndpointOperation] + + ANSIBLE_MODELS --> USER_AM[user.py
AnsibleUser] + + API --> V1[v1/] + V1 --> USER_API[user.py
APIUser_v1, UserTransformMixin_v1] + V1 --> GEN[generated/
models.py] + + DOCS --> USER_DOC[user.py
DOCUMENTATION] + + BA -->|inherits| ACTION_BASE[ActionBase] + USER_ACT -->|inherits| BA + USER_ACT -->|uses| USER_DOC + USER_ACT -->|uses| USER_AM + + BA -->|uses| RPC + RPC -->|connects to| PM + PM -->|uses| BT + PM -->|uses| LOAD + LOAD -->|uses| REG + USER_API -->|inherits| BT + USER_API -->|inherits| GEN + + style BA fill:#fff4e1 + style PM fill:#ffe1f5 + style BT fill:#e1ffe1 + style USER_AM fill:#e1f5ff + style USER_API fill:#ffe1e1 +``` + +--- + +## Legend + +### Color Coding + +- **Blue** (`#e1f5ff`): User-facing components (Playbook, Ansible dataclasses) +- **Orange** (`#fff4e1`): Client layer (Action plugins) +- **Pink** (`#ffe1f5`): Service layer (Manager, PlatformService) +- **Green** (`#e1ffe1`): Framework layer (Transform, Registry, Loader) +- **Red** (`#ffe1e1`): API layer (API dataclasses, Gateway API) + +### Diagram Types + +1. **Graph Diagrams**: Show component relationships and architecture +2. **Flowchart Diagrams**: Show data flow and transformations +3. **State Diagrams**: Show state transitions and lifecycle +4. **Sequence Diagrams**: Show temporal interactions between components + +--- + +## Notes + +- All diagrams use **Mermaid syntax** and can be rendered in: + - GitHub/GitLab markdown viewers + - VS Code with Mermaid extension + - Online Mermaid editors (mermaid.live) + - Documentation tools (MkDocs, Docusaurus, etc.) + +- **Sequence diagrams** show the temporal flow of operations +- **Architecture diagrams** show component relationships +- **Flow diagrams** show data transformation paths +- **State diagrams** show lifecycle and state transitions + +--- + +## Related Documentation + +- `ARCHITECTURE.md` - Detailed architecture documentation +- `FLOW_EXPLANATION.md` - Complete flow explanation +- `API_REFERENCE.md` - Component API reference +- `IMPLEMENTATION_GUIDE.md` - Implementation details + diff --git a/docs/CODE_WALKTHROUGH.md b/docs/CODE_WALKTHROUGH.md new file mode 100644 index 0000000..8fcabde --- /dev/null +++ b/docs/CODE_WALKTHROUGH.md @@ -0,0 +1,1052 @@ +# Detailed Code Walkthrough: User Module Execution + +This document provides a step-by-step walkthrough of what happens when you run a user module task in an Ansible playbook. + +## Visual Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PLAYBOOK: ansible.platform.user │ +│ username: demo777, email: demo006@example.com, state: present │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PHASE 1: Ansible Core │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 1. Loads action plugin: user.py │ │ +│ │ 2. Instantiates ActionModule class │ │ +│ │ 3. Calls ActionModule.run(task_vars) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PHASE 2: Action Plugin (user.py) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Step 1: Build argspec from DOCUMENTATION │ │ +│ │ Step 2: Validate input (ArgumentSpecValidator) │ │ +│ │ Step 3: Get/spawn manager (_get_or_spawn_manager) │ │ +│ │ Step 4: Create AnsibleUser dataclass │ │ +│ │ Step 5: Detect operation (create/update/delete) │ │ +│ │ Step 6: Execute via manager (RPC call) │ │ +│ │ Step 7: Validate output │ │ +│ │ Step 8: Format return dict │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + │ RPC (Unix Socket) + │ +┌──────────────────────────▼──────────────────────────────────────┐ +│ PHASE 3: Manager Process (platform_manager.py) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Step 9: Receive RPC call (execute()) │ │ +│ │ Step 10: Load version-specific classes │ │ +│ │ Step 11: Forward Transform (AnsibleUser → APIUser_v1) │ │ +│ │ Step 12: Execute API call (HTTP POST) │ │ +│ │ Step 13: Reverse Transform (API response → Ansible) │ │ +│ │ Step 14: Return result (via RPC) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + │ HTTP/HTTPS + │ +┌──────────────────────────▼──────────────────────────────────────┐ +│ PHASE 4: Platform API (AAP Gateway) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ POST /api/gateway/v1/users/ │ │ +│ │ Body: {"username": "demo777", "email": "..."} │ │ +│ │ Response: {"id": 7, "username": "demo777", ...} │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Example Playbook + +```yaml +- name: Ensure user demo777 exists + ansible.platform.user: + gateway_hostname: "https://18.205.116.155/" + gateway_token: "{{ lookup('env', 'AAP_TOKEN') }}" + gateway_validate_certs: false + gateway_username: admin + gateway_password: Admin!Password!Gw + username: demo777 + email: demo006@example.com + state: present +``` + +--- + +## Step-by-Step Execution Flow + +### Phase 1: Ansible Task Execution + +#### Step 1: Ansible Core Loads Action Plugin + +**Location**: Ansible core (not our code) + +**What happens**: +1. Ansible sees `ansible.platform.user` in the task +2. Looks for action plugin at: `ansible_collections/ansible/platform/plugins/action/user.py` +3. Instantiates `ActionModule` class from `user.py` + +**Code**: `user.py:20-27` +```python +class ActionModule(BaseResourceActionPlugin): + MODULE_NAME = 'user' +``` + +--- + +### Phase 2: Action Plugin Initialization + +#### Step 2: Action Plugin `run()` Method Called + +**Location**: `plugins/action/user.py:29` + +**What happens**: +```python +def run(self, tmp=None, task_vars=None): + result = super(ActionModule, self).run(tmp, task_vars) # Base class setup + # ... our code ... +``` + +**Input**: +- `self._task.args` = `{'username': 'demo777', 'email': 'demo006@example.com', 'state': 'present', 'gateway_hostname': '...', ...}` +- `task_vars` = Ansible variables (inventory, facts, etc.) + +--- + +### Phase 3: Input Validation + +#### Step 3: Build Argument Spec from DOCUMENTATION + +**Location**: `plugins/action/user.py:52-60` + +**Code Flow**: +```python +# Step 1: Build argspec from DOCUMENTATION +argspec = self._build_argspec_from_docs(DOCUMENTATION) +``` + +**What `_build_argspec_from_docs()` does** (`base_action.py:365-400`): +1. Parses `DOCUMENTATION` string (YAML format) +2. Extracts `options` section +3. Builds Ansible `ArgumentSpec` format: + ```python + { + 'argument_spec': { + 'username': {'type': 'str', 'required': True}, + 'email': {'type': 'str'}, + 'state': {'type': 'str', 'default': 'present'}, + # ... etc + }, + 'mutually_exclusive': [], + 'required_together': [], + # ... etc + } + ``` + +**Output**: `argspec` dict ready for validation + +--- + +#### Step 4: Validate Input Parameters + +**Location**: `plugins/action/user.py:77-89` + +**Code Flow**: +```python +# Step 2: Validate input +validated_input = self._validate_data( + module_args, # From self._task.args + argspec, + 'input' +) +``` + +**What `_validate_data()` does** (`base_action.py:402-430`): +1. Creates `ArgumentSpecValidator` with argspec +2. Validates all parameters against spec +3. Normalizes types (e.g., converts strings to bools) +4. Checks required fields, mutually exclusive, etc. +5. Returns validated dict + +**Input**: +```python +{ + 'username': 'demo777', + 'email': 'demo006@example.com', + 'state': 'present', + 'gateway_hostname': 'https://18.205.116.155/', + # ... auth params ... +} +``` + +**Output**: Same dict, but validated and normalized + +--- + +### Phase 4: Manager Connection + +#### Step 5: Get or Spawn Manager + +**Location**: `plugins/action/user.py:92-108` + +**Code Flow**: +```python +# Step 3: Get or spawning manager +manager = self._get_or_spawn_manager(task_vars) +``` + +**What `_get_or_spawn_manager()` does** (`base_action.py:169-318`): + +**5a. Extract Gateway Config (Platform SDK)**: +```python +# Uses Platform SDK for generic config extraction +from ...platform.config import extract_gateway_config + +gateway_config = extract_gateway_config( + task_args=self._task.args, + host_vars=host_vars, + required=True +) +# Returns GatewayConfig dataclass +``` + +**5b. Check for Existing Manager**: +```python +# Check hostvars and task_vars for existing manager +socket_path = ( + host_vars.get('platform_manager_socket') or + task_vars.get('platform_manager_socket') +) +authkey_b64 = ( + host_vars.get('platform_manager_authkey') or + task_vars.get('platform_manager_authkey') +) + +if socket_path and authkey_b64 and Path(socket_path).exists(): + # Connect to existing manager + authkey = base64.b64decode(authkey_b64) + client = ManagerRPCClient(gateway_config.base_url, socket_path, authkey) + return client, None # Returns tuple: (client, facts_dict) +``` + +**5c. Spawn New Manager** (if not found, uses Platform SDK): +```python +# Uses Platform SDK ProcessManager for generic process management +from ...manager.process_manager import ProcessManager + +# Generate connection info +conn_info = ProcessManager.generate_connection_info( + identifier=inventory_hostname, + socket_dir=socket_dir +) + +# Spawn manager process +process = ProcessManager.spawn_manager_process( + script_path=script_path, + socket_path=conn_info.socket_path, + socket_dir=str(socket_dir), + identifier=inventory_hostname, + gateway_config=gateway_config, + authkey_b64=conn_info.authkey_b64, + sys_path=parent_sys_path +) + +# Wait for process startup +ProcessManager.wait_for_process_startup(...) + +# Return tuple: (client, facts_dict) +return client, { + 'platform_manager_socket': socket_path, + 'platform_manager_authkey': authkey_b64, + 'gateway_url': gateway_config.base_url +} +``` + +**5d. Set Facts in Result** (`user.py:101-106`): +```python +manager, facts_to_set = self._get_or_spawn_manager(task_vars) + +if facts_to_set: + result['ansible_facts'] = facts_to_set + result['_ansible_facts_cacheable'] = True + # Facts will be available in hostvars for next task +``` + +**Manager Process Startup** (`manager_process.py:16-195`): +1. Reads arguments and environment variables +2. Restores `sys.path` from parent +3. Imports `PlatformService` and `PlatformManager` +4. Creates `PlatformService` (authenticates, detects API version) +5. Registers service with `PlatformManager` +6. Starts BaseManager server: `server.serve_forever()` + +**Output**: `ManagerRPCClient` instance connected to manager + +--- + +### Phase 5: Data Preparation + +#### Step 6: Create Ansible Dataclass + +**Location**: `plugins/action/user.py:110-117` + +**Code Flow**: +```python +# Step 4: Create dataclass from validated input +user_data = { + k: v for k, v in validated_input.items() + if v is not None and k not in auth_params +} +user = AnsibleUser(**user_data) +``` + +**What happens**: +1. Filters out auth parameters (not part of user data) +2. Creates `AnsibleUser` dataclass instance + +**Input**: +```python +{ + 'username': 'demo777', + 'email': 'demo006@example.com', + 'state': 'present' +} +``` + +**Output**: `AnsibleUser(username='demo777', email='demo006@example.com', state='present')` + +**Code**: `ansible_models/user.py:12-61` + +--- + +#### Step 7: Detect Operation Type + +**Location**: `plugins/action/user.py:119-121` + +**Code Flow**: +```python +# Step 5: Detect operation +operation = self._detect_operation(validated_input) +``` + +**What `_detect_operation()` does** (`base_action.py:432-450`): +- `state='absent'` → `'delete'` +- `state='present'` + `id` provided → `'update'` +- `state='present'` + no `id` → `'create'` +- `state='find'` → `'find'` + +**Output**: `'create'` (since no `id` provided) + +--- + +#### Step 8: Idempotency Check (for create operations) + +**Location**: `plugins/action/user.py:123-139` + +**Code Flow**: +```python +# Step 5.5: For 'create' with state='present', check if user exists +if operation == 'create' and validated_input.get('state') == 'present': + find_result = manager.execute( + operation='find', + module_name='user', + ansible_data={'username': user.username} + ) + if find_result and find_result.get('id'): + operation = 'update' # Switch to update + user.id = find_result.get('id') +``` + +**What happens**: +1. Calls manager's `find` operation +2. If user exists, switches to `update` and sets `user.id` +3. If not found, proceeds with `create` + +**This ensures idempotency**: Running the playbook twice won't create duplicate users. + +--- + +### Phase 6: RPC Communication + +#### Step 9: Execute Operation via Manager (RPC Call) + +**Location**: `plugins/action/user.py:141-147` + +**Code Flow**: +```python +# Step 6: Execute via manager +manager_result = manager.execute( + operation='create', # or 'update' + module_name='user', + ansible_data=user.__dict__ +) +``` + +**What `manager.execute()` does** (`rpc_client.py:67-99`): +1. Converts dataclass to dict: `asdict(user)` +2. Calls service proxy via BaseManager RPC: + ```python + result_dict = self.service_proxy.execute( + 'create', + 'user', + {'username': 'demo777', 'email': 'demo006@example.com', ...} + ) + ``` + +**RPC Communication**: +- Client (`rpc_client.py`) → Unix Socket → Server (`platform_manager.py`) +- BaseManager handles serialization (pickle) +- Method call sent over socket +- Response received and unpickled + +--- + +### Phase 7: Manager-Side Processing + +#### Step 10: Manager Receives RPC Call + +**Location**: `plugins/plugin_utils/manager/platform_manager.py:196-273` + +**Code Flow**: +```python +def execute(self, operation, module_name, ansible_data_dict): + # Load version-appropriate classes + AnsibleClass, APIClass, MixinClass = self.loader.load_classes_for_module( + 'user', + self.api_version # e.g., '1' + ) + + # Reconstruct Ansible dataclass + ansible_instance = AnsibleClass(**ansible_data_dict) + # → AnsibleUser(username='demo777', ...) +``` + +**What happens**: +1. `DynamicClassLoader` loads: + - `AnsibleUser` from `ansible_models/user.py` + - `APIUser_v1` from `api/v1/user.py` + - `UserTransformMixin_v1` from `api/v1/user.py` +2. Reconstructs `AnsibleUser` from dict + +--- + +#### Step 11: Forward Transformation (Ansible → API) + +**Location**: `platform_manager.py:275-312` (for create) + +**Code Flow**: +```python +def _create_resource(self, ansible_data, mixin_class, context): + # FORWARD TRANSFORM: Ansible → API + api_data = ansible_data.to_api(context) +``` + +**What `to_api()` does** (`ansible_models/user.py:49-61`): +```python +def to_api(self, context): + from ..api.v1.user import UserTransformMixin_v1 + return UserTransformMixin_v1.from_ansible_data(self, context) +``` + +**What `from_ansible_data()` does** (`api/v1/user.py:48-80`): +1. Maps simple fields: `username`, `email`, etc. +2. Transforms `organizations` (names → IDs): + ```python + if ansible_instance.organizations: + api_data['organization_ids'] = cls._names_to_ids( + ansible_instance.organizations, + context + ) + ``` +3. Returns `APIUser_v1` instance + +**Input**: `AnsibleUser(username='demo777', email='demo006@example.com')` +**Output**: `APIUser_v1(username='demo777', email='demo006@example.com')` + +--- + +#### Step 12: Execute API Call + +**Location**: `platform_manager.py:298-301` + +**Code Flow**: +```python +# Get endpoint operations from mixin +operations = mixin_class.get_endpoint_operations() +# → {'create': EndpointOperation(path='/api/gateway/v1/users/', method='POST', ...)} + +# Execute operations +api_result = self._execute_operations( + operations, api_data, context, required_for='create' +) +``` + +**What `_execute_operations()` does** (`platform_manager.py:472-588`): +1. Gets `create` operation from `UserTransformMixin_v1.get_endpoint_operations()` +2. Extracts fields for this endpoint +3. Builds URL: `https://18.205.116.155/api/gateway/v1/users/` +4. Makes HTTP POST request: + ```python + response = self.session.post( + url, + json={'username': 'demo777', 'email': 'demo006@example.com'}, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + ``` +5. Parses JSON response + +**API Response**: +```json +{ + "id": 7, + "username": "demo777", + "email": "demo006@example.com", + "created": "2025-11-15T16:10:42.860956Z", + "modified": "2025-11-15T16:10:42.860948Z", + "url": "/api/gateway/v1/users/7/" +} +``` + +--- + +#### Step 13: Reverse Transformation (API → Ansible) + +**Location**: `platform_manager.py:303-310` + +**Code Flow**: +```python +# REVERSE TRANSFORM: API → Ansible +if api_result: + ansible_result = mixin_class.from_api(api_result, context) + ansible_result['changed'] = True # Mark as changed + return ansible_result +``` + +**What `from_api()` does** (`api/v1/user.py:243-278`): +1. Maps API fields back to Ansible fields +2. Transforms `organization_ids` → `organizations` (IDs → names): + ```python + if 'organization_ids' in api_data: + ansible_data['organizations'] = cls._ids_to_names( + api_data['organization_ids'], + context + ) + ``` +3. Returns dict in Ansible format + +**Input**: API response dict +**Output**: `{'username': 'demo777', 'email': 'demo006@example.com', 'id': 7, 'changed': True, ...}` + +--- + +### Phase 8: Response Handling + +#### Step 14: Receive Result from Manager + +**Location**: `plugins/action/user.py:148-149` + +**Code Flow**: +```python +# Step 6: Execute via manager +manager_result = manager.execute(...) +# → {'username': 'demo777', 'email': 'demo006@example.com', 'id': 7, 'changed': True, ...} +``` + +**Result received via RPC**: +- BaseManager unpickles the response +- Returns dict to action plugin + +--- + +#### Step 15: Validate Output + +**Location**: `plugins/action/user.py:151-175` + +**Code Flow**: +```python +# Step 7: Validate output +read_only_fields = {'id', 'created', 'modified', 'url'} +argspec_fields = set(argspec.get('argument_spec', {}).keys()) + +# Filter out read-only fields for validation +filtered_result = { + k: v for k, v in manager_result.items() + if k in argspec_fields or k in read_only_fields +} + +validated_output = self._validate_data( + {k: v for k, v in filtered_result.items() if k in argspec_fields}, + argspec, + 'output' +) + +# Add back read-only fields +for field in read_only_fields: + if field in filtered_result: + validated_output[field] = filtered_result[field] +``` + +**Why filter read-only fields?** +- API returns `id`, `created`, `modified`, `url` +- These aren't in the module's `DOCUMENTATION` (argspec) +- We filter them out for validation, then add them back + +--- + +#### Step 16: Format Return Dictionary + +**Location**: `plugins/action/user.py:177-183` + +**Code Flow**: +```python +# Step 8: Format return dict +result.update({ + 'changed': manager_result.get('changed', False), # True for create + 'failed': False, + 'user': validated_output, # Full user data + 'id': validated_output.get('id'), # Convenience field +}) +``` + +**Final Result**: +```python +{ + 'changed': True, + 'failed': False, + 'user': { + 'username': 'demo777', + 'email': 'demo006@example.com', + 'id': 7, + 'created': '2025-11-15T16:10:42.860956Z', + 'modified': '2025-11-15T16:10:42.860948Z', + 'url': '/api/gateway/v1/users/7/' + }, + 'id': 7 +} +``` + +--- + +### Phase 9: Ansible Output + +#### Step 17: Ansible Displays Result + +**Location**: Ansible core (not our code) + +**What Ansible does**: +1. Receives result dict from action plugin +2. Displays to user: + ``` + ok: [127.0.0.1] => { + "changed": true, + "id": 7, + "user": { + "username": "demo777", + "email": "demo006@example.com", + ... + } + } + ``` +3. Updates play recap: `changed=1` + +--- + +## Second Task: Reusing Manager + +When the second task runs: + +```yaml +- name: Ensure user demo888 exists + ansible.platform.user: + username: demo888 + # ... same auth params ... +``` + +**What's different**: + +1. **Step 5 (Manager Connection)**: + - Finds existing manager in `hostvars` + - Connects to existing manager (no spawn) + - Reuses persistent HTTP session + +2. **All other steps**: Same as first task + +**Benefits**: +- No authentication overhead (session reused) +- No API version detection (cached) +- Faster execution (50-75% improvement) + +--- + +## Key Data Transformations + +### Transformation Flow + +``` +Playbook Input (YAML) + ↓ +Validated Input (dict) + ↓ +AnsibleUser (dataclass) + ↓ [Forward Transform] +APIUser_v1 (dataclass) + ↓ [HTTP POST] +Platform API + ↓ [HTTP Response] +API Response (dict) + ↓ [Reverse Transform] +AnsibleUser (dict) + ↓ +Validated Output (dict) + ↓ +Playbook Output (YAML) +``` + +### Example Transformation + +**Input** (Playbook): +```yaml +username: demo777 +email: demo006@example.com +organizations: ['Engineering', 'DevOps'] +``` + +**After Forward Transform** (API Request): +```json +{ + "username": "demo777", + "email": "demo006@example.com", + "organization_ids": [1, 2] // Names converted to IDs +} +``` + +**After API Response** (Reverse Transform): +```python +{ + "username": "demo777", + "email": "demo006@example.com", + "organizations": ["Engineering", "DevOps"], // IDs converted back to names + "id": 7, + "created": "2025-11-15T16:10:42.860956Z" +} +``` + +--- + +## File Locations Summary + +| Component | File | Key Function/Method | +|-----------|------|---------------------| +| **Playbook** | `user.yml` | Defines tasks | +| **Action Plugin** | `plugins/action/user.py` | `ActionModule.run()` | +| **Base Action** | `plugins/action/base_action.py` | `_get_or_spawn_manager()`, `_validate_data()`, etc. | +| **RPC Client** | `plugins/plugin_utils/manager/rpc_client.py` | `ManagerRPCClient.execute()` | +| **Manager Service** | `plugins/plugin_utils/manager/platform_manager.py` | `PlatformService.execute()` | +| **Manager Process** | `plugins/plugin_utils/manager/manager_process.py` | `main()` - spawns manager | +| **Ansible Model** | `plugins/plugin_utils/ansible_models/user.py` | `AnsibleUser` dataclass | +| **API Model** | `plugins/plugin_utils/api/v1/user.py` | `APIUser_v1`, `UserTransformMixin_v1` | +| **Documentation** | `plugins/plugin_utils/docs/user.py` | `DOCUMENTATION` string | + +--- + +## Debugging Tips + +### Enable Verbose Output + +```bash +ansible-playbook user.yml -vvv # Shows all debug messages +``` + +### Check Manager Logs + +```bash +# Manager error log +cat /tmp/ansible_platform/manager_error_localhost.log + +# Manager stderr log +cat /tmp/ansible_platform/manager_stderr_localhost.log +``` + +### Verify Manager is Running + +```bash +# Check if socket exists +ls -la /tmp/ansible_platform/manager_*.sock + +# Check process +ps aux | grep manager_process.py +``` + +### Trace RPC Calls + +Add logging to `rpc_client.py` and `platform_manager.py` to see RPC communication. + +--- + +## Code Snippets with Line Numbers + +### Step 1: Build Argspec (`base_action.py:379-412`) + +```python +def _build_argspec_from_docs(self, documentation: str) -> dict: + doc_data = yaml.safe_load(documentation) # Parse YAML + options = doc_data.get('options', {}) + + argspec = { + 'argument_spec': options, # ← Key for ArgumentSpecValidator + 'mutually_exclusive': doc_data.get('mutually_exclusive', []), + 'required_together': doc_data.get('required_together', []), + 'required_one_of': doc_data.get('required_one_of', []), + 'required_if': doc_data.get('required_if', []), + } + return argspec +``` + +### Step 2: Validate Input (`base_action.py:414-467`) + +```python +def _validate_data(self, data: dict, argspec: dict, direction: str) -> dict: + validator = ArgumentSpecValidator( + argument_spec=argspec.get('argument_spec', {}), + mutually_exclusive=argspec.get('mutually_exclusive'), + # ... other params ... + ) + + result = validator.validate(data) + if result.error_messages: + raise AnsibleError(f"{direction.title()} validation failed: ...") + + return result.validated_parameters +``` + +### Step 3: Get/Spawn Manager (`base_action.py:169-318`) + +```python +def _get_or_spawn_manager(self, task_vars: dict): + # Extract gateway config using Platform SDK + from ...platform.config import extract_gateway_config + gateway_config = extract_gateway_config( + task_args=self._task.args, + host_vars=host_vars, + required=True + ) + + # Check for existing manager + socket_path = host_vars.get('platform_manager_socket') or task_vars.get('platform_manager_socket') + if socket_path and Path(socket_path).exists(): + return ManagerRPCClient(...), None # Returns tuple + + # Spawn new manager using Platform SDK + from ...manager.process_manager import ProcessManager + conn_info = ProcessManager.generate_connection_info(...) + process = ProcessManager.spawn_manager_process(...) + ProcessManager.wait_for_process_startup(...) + + # Return tuple: (client, facts_dict) + return ManagerRPCClient(...), { + 'platform_manager_socket': socket_path, + 'platform_manager_authkey': authkey_b64, + 'gateway_url': gateway_config.base_url + } +``` + +**Facts are set in result** (`user.py:101-106`): +```python +manager, facts_to_set = self._get_or_spawn_manager(task_vars) +if facts_to_set: + result['ansible_facts'] = facts_to_set + result['_ansible_facts_cacheable'] = True +``` + +### Step 4: Create Dataclass (`user.py:110-117`) + +```python +user_data = { + k: v for k, v in validated_input.items() + if v is not None and k not in auth_params +} +user = AnsibleUser(**user_data) +# → AnsibleUser(username='demo777', email='demo006@example.com', ...) +``` + +### Step 5: Execute via Manager (`user.py:143-147`) + +```python +manager_result = manager.execute( + operation='create', + module_name='user', + ansible_data=user.__dict__ +) +``` + +### Step 6: RPC Client (`rpc_client.py:67-99`) + +```python +def execute(self, operation: str, module_name: str, ansible_data: Any): + # Convert to dict + data_dict = asdict(ansible_data) if is_dataclass(ansible_data) else ansible_data + + # RPC call via BaseManager + result_dict = self.service_proxy.execute( + operation, + module_name, + data_dict + ) + return result_dict +``` + +### Step 7: Manager Execute (`platform_manager.py:196-273`) + +```python +def execute(self, operation: str, module_name: str, ansible_data_dict: dict): + # Load classes + AnsibleClass, APIClass, MixinClass = self.loader.load_classes_for_module( + module_name, self.api_version + ) + + # Reconstruct dataclass + ansible_instance = AnsibleClass(**ansible_data_dict) + + # Build TransformContext (type-safe, not dict) + from ...platform.types import TransformContext + context = TransformContext( + manager=self, + session=self.session, + cache=self.cache, + api_version=self.api_version + ) + + # Execute operation + if operation == 'create': + result = self._create_resource(ansible_instance, MixinClass, context) + # ... + + return result +``` + +### Step 8: Forward Transform (`platform_manager.py:292-293`) + +```python +# In _create_resource() +api_data = ansible_data.to_api(context) +# → Calls UserTransformMixin_v1.from_ansible_data() +# → context is TransformContext dataclass (type-safe) +# → Returns APIUser_v1 instance +``` + +### Step 9: API Call (`platform_manager.py:472-588`) + +```python +def _execute_operations(self, operations, api_data, context, required_for): + # Get create operation + endpoint_op = operations['create'] + # → EndpointOperation(path='/api/gateway/v1/users/', method='POST', ...) + + # Build URL + url = self._build_url(endpoint_op.path) + # → https://18.205.116.155/api/gateway/v1/users/ + + # Make request + response = self.session.post( + url, + json=asdict(api_data), # Convert APIUser_v1 to dict + timeout=self.request_timeout, + verify=self.verify_ssl + ) + + return response.json() +``` + +### Step 10: Reverse Transform (`platform_manager.py:307`) + +```python +# In _create_resource() +ansible_result = mixin_class.from_api(api_result, context) +# → Calls UserTransformMixin_v1.from_api() +# → Returns dict in Ansible format +ansible_result['changed'] = True +return ansible_result +``` + +### Step 11: Validate Output (`user.py:151-175`) + +```python +# Filter read-only fields +read_only_fields = {'id', 'created', 'modified', 'url'} +filtered_result = { + k: v for k, v in manager_result.items() + if k in argspec_fields or k in read_only_fields +} + +# Validate +validated_output = self._validate_data( + {k: v for k, v in filtered_result.items() if k in argspec_fields}, + argspec, + 'output' +) + +# Add back read-only fields +for field in read_only_fields: + if field in filtered_result: + validated_output[field] = filtered_result[field] +``` + +### Step 12: Format Result (`user.py:177-183`) + +```python +result.update({ + 'changed': manager_result.get('changed', False), # True + 'failed': False, + 'user': validated_output, + 'id': validated_output.get('id'), +}) +``` + +--- + +## Summary + +1. **Ansible** loads action plugin +2. **Action Plugin** validates input, gets/spawns manager +3. **RPC Client** sends request to manager via Unix socket +4. **Manager** transforms data, calls API, transforms response +5. **RPC Client** receives response +6. **Action Plugin** validates output, formats result +7. **Ansible** displays result to user + +The entire flow maintains **type safety** and **validation** at every step, ensuring data integrity throughout the process. + +--- + +## Key Design Decisions + +### Why Manager-Side Transformations? + +- **Performance**: Transformations happen once in persistent process +- **Consistency**: Single source of truth for API format +- **Version Management**: Manager handles API version detection + +### Why RPC Instead of Direct Calls? + +- **Persistence**: Manager maintains HTTP session across tasks +- **Isolation**: Manager process separate from Ansible process +- **Reusability**: Multiple tasks share same manager + +### Why subprocess.Popen Instead of multiprocessing.Process? + +- **Reliability**: Avoids import issues with Ansible's complex plugin system +- **macOS Compatibility**: No fork/SSL issues +- **Simplicity**: No pickling concerns + +### Why BaseManager for RPC? + +- **Built-in**: Python standard library, well-tested +- **Thread-safe**: ThreadingMixIn handles concurrent clients +- **Efficient**: Unix domain sockets for local IPC + diff --git a/plugins/action/__init__.py b/plugins/action/__init__.py new file mode 100644 index 0000000..a024461 --- /dev/null +++ b/plugins/action/__init__.py @@ -0,0 +1,3 @@ +"""Action plugins for ansible.platform collection.""" + + diff --git a/plugins/action/base_action.py b/plugins/action/base_action.py new file mode 100644 index 0000000..6a6e040 --- /dev/null +++ b/plugins/action/base_action.py @@ -0,0 +1,972 @@ +"""Base action plugin for platform resources. + +Provides common functionality inherited by all resource action plugins. +""" + +from ansible.plugins.action import ActionBase +from ansible.module_utils.common.arg_spec import ArgumentSpecValidator +from ansible.errors import AnsibleError +from ansible.module_utils.six import string_types +from pathlib import Path +import yaml +import importlib.util +import logging +import tempfile +import secrets +import base64 +import time +import subprocess +import json +import fcntl +import os + +logger = logging.getLogger(__name__) + + +def _manager_process_entry(socket_path, socket_dir, inventory_hostname, gateway_url, + gateway_username, gateway_password, gateway_token, + gateway_validate_certs, gateway_request_timeout, authkey_b64, sys_path): + """ + Entry point for the manager process. + + This is a module-level function so it can be pickled for multiprocessing.spawn. + Uses the same pattern as python-multiproc repository. + """ + import sys + import traceback + import base64 + from pathlib import Path + + # Redirect stderr to a file for debugging + error_log_path = Path(socket_dir) / f'manager_error_{inventory_hostname}.log' + stderr_log = Path(socket_dir) / f'manager_stderr_{inventory_hostname}.log' + + try: + sys.stderr = open(stderr_log, 'w', buffering=1) + sys.stdout = open(stderr_log, 'a', buffering=1) + except Exception as e: + pass # Continue without redirecting + + try: + # Restore parent's sys.path in child process (spawn starts fresh) + sys.path = sys_path + + # Decode authkey from base64 string + authkey = base64.b64decode(authkey_b64.encode('utf-8')) + + # Write to log immediately to capture any early failures + with open(error_log_path, 'w') as f: + f.write(f"Process started, socket_path={socket_path}\n") + f.write(f"sys.path has {len(sys_path)} entries\n") + f.write(f"Manager starting at {socket_path}\n") + f.write(f"About to create service with base_url={gateway_url}\n") + f.flush() + except Exception as e: + # Can't even write to log, print to stderr + print(f"ERROR in early startup: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + try: + + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import ( + PlatformManager, + PlatformService + ) + + with open(error_log_path, 'a') as f: + f.write("Imports successful\n") + f.flush() + + # Create service + try: + service = PlatformService( + base_url=gateway_url, + username=gateway_username, + password=gateway_password, + oauth_token=gateway_token, + verify_ssl=gateway_validate_certs, + request_timeout=gateway_request_timeout + ) + with open(error_log_path, 'a') as f: + f.write("Service created successfully\n") + f.flush() + except Exception as service_err: + with open(error_log_path, 'a') as f: + f.write(f"Service creation failed: {service_err}\n") + f.write(traceback.format_exc()) + f.flush() + raise + + with open(error_log_path, 'a') as f: + f.write("Service created\n") + f.flush() + + # Register with manager (must happen before creating manager instance) + # Store service in a closure to avoid pickling issues + _service_ref = [service] + + def _get_service(): + return _service_ref[0] + + PlatformManager.register( + 'get_platform_service', + callable=_get_service + ) + + with open(error_log_path, 'a') as f: + f.write("Service registered\n") + f.flush() + + # Create manager instance (like python-multiproc pattern) + manager = PlatformManager(address=socket_path, authkey=authkey) + + with open(error_log_path, 'a') as f: + f.write("Manager instance created\n") + f.flush() + + # Start manager server + # Note: We use get_server().serve_forever() instead of manager.start() + # because manager.start() internally uses multiprocessing which causes issues + # when we're already in a subprocess + server = manager.get_server() + + with open(error_log_path, 'a') as f: + f.write("Server obtained, starting serve_forever()\n") + f.flush() + + server.serve_forever() + + except Exception as e: + # Log to a temp file for debugging + with open(error_log_path, 'a') as f: + f.write(f"\n\nManager startup failed: {e}\n") + f.write(traceback.format_exc()) + sys.exit(1) + + +class BaseResourceActionPlugin(ActionBase): + """ + Base action plugin for all platform resources. + + Provides common functionality: + - Manager spawning/connection (_get_or_spawn_manager) + - Input/output validation (_validate_data) + - ArgumentSpec generation (_build_argspec_from_docs) + + Subclasses must define: + - MODULE_NAME: Name of the resource (e.g., 'user', 'organization') + - DOCUMENTATION: Module documentation string + - ANSIBLE_DATACLASS: The Ansible dataclass type + + Example subclass: + class ActionModule(BaseResourceActionPlugin): + MODULE_NAME = 'user' + + def run(self, tmp=None, task_vars=None): + # Use inherited methods + manager = self._get_or_spawn_manager(task_vars) + # ... implement resource-specific logic + """ + + MODULE_NAME = None # Subclass must override + + # Class-level tracking of spawned manager processes + # Key: socket_path, Value: (process, socket_path, authkey_b64) + _spawned_processes = {} # type: dict + + # Playbook task tracking: track total tasks and completed tasks per play + # NOTE: Using file-based tracking for process-safety (works across forks) + # Class-level dict would not work with Ansible's fork/worker processes + + # Track which manager each task uses + # Key: task_uuid, Value: socket_path + _task_to_manager = {} # type: dict + + def _get_or_spawn_manager(self, task_vars: dict): + """ + Get connection client based on connection mode. + + Also stores task_vars for use in cleanup() method. + + Connection modes: + - Standard mode (default): Returns DirectHTTPClient (direct HTTP, no persistent process) + - Experimental mode (opt-in): Returns ManagerRPCClient (persistent manager process) + + This method is Ansible-specific and handles Ansible constructs like + task_vars, AnsibleError. The actual gateway config extraction and + process management are delegated to platform SDK modules. + + Args: + task_vars: Task variables from Ansible + + Returns: + Tuple of (client, facts_dict): + - client: DirectHTTPClient (standard) or ManagerRPCClient (experimental) + - facts_dict: Dict with facts to set (only for experimental mode) + None for standard mode (no facts needed) + + Raises: + AnsibleError: If gateway URL is missing + RuntimeError: If manager fails to start (experimental mode only) + """ + import sys + + # Import platform SDK modules (generic, not Ansible-specific) + from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import ( + extract_gateway_config + ) + + # Extract gateway configuration (includes connection_mode) + gateway_config = extract_gateway_config( + task_args=self._task.args, + host_vars=task_vars, + required=True + ) + + # Route based on connection mode + if gateway_config.connection_mode == 'experimental': + # Experimental mode: Use persistent manager + return self._get_or_spawn_persistent_manager(task_vars, gateway_config) + else: + # Standard mode (default): Use direct HTTP client + return self._get_direct_client(task_vars, gateway_config) + + def _get_direct_client(self, task_vars: dict, gateway_config): + """ + Get or create DirectHTTPClient for standard mode. + + Args: + task_vars: Task variables from Ansible + gateway_config: Gateway configuration + + Returns: + Tuple of (DirectHTTPClient, None): + - DirectHTTPClient: Direct HTTP client instance + - None: No facts to set (standard mode doesn't need facts) + """ + from ansible_collections.ansible.platform.plugins.plugin_utils.platform.direct_client import DirectHTTPClient + + logger.info("Using standard connection mode (DirectHTTPClient)") + + # Create direct HTTP client (new instance per task) + client = DirectHTTPClient(gateway_config) + + logger.info(f"DirectHTTPClient created for {gateway_config.base_url}") + + return client, None + + def _get_or_spawn_persistent_manager(self, task_vars: dict, gateway_config): + """ + Get existing persistent manager or spawn new one (experimental mode). + + This is the original persistent manager logic, now only used when + connection_mode is 'experimental'. + + Args: + task_vars: Task variables from Ansible + gateway_config: Gateway configuration + + Returns: + Tuple of (ManagerRPCClient, facts_dict): + - ManagerRPCClient: The manager client instance + - facts_dict: Dict with facts to set (socket, authkey, gateway_url) + if new manager was spawned, or None if reusing existing manager. + """ + import sys + + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.process_manager import ( + ProcessManager + ) + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.rpc_client import ManagerRPCClient + + logger.info("Using experimental connection mode (Persistent Manager)") + + # Store task_vars for cleanup() method + self._task_vars = task_vars + + # Initialize playbook task tracking if this is the first task + self._initialize_playbook_tracking() + + # Check if manager info in hostvars (Ansible-specific) + hostvars = task_vars.get('hostvars', {}) + inventory_hostname = task_vars.get('inventory_hostname', 'localhost') + host_vars = hostvars.get(inventory_hostname, {}) + + logger.info(f"Getting or spawning manager for host: {inventory_hostname}") + + # Check both hostvars and top-level task_vars (facts might be in either location) + socket_path_from_hostvars = host_vars.get('platform_manager_socket') + socket_path_from_taskvars = task_vars.get('platform_manager_socket') + socket_path_raw = socket_path_from_hostvars or socket_path_from_taskvars + + # CRITICAL: Convert to plain string explicitly (Fedora/_AnsibleTaggedStr compatibility) + # BaseManager expects a plain str type, not _AnsibleTaggedStr (which is a str subclass) + if socket_path_raw is not None: + socket_path = f"{socket_path_raw}" # f-string forces plain str + if type(socket_path) is not str: + socket_path = str(socket_path) + else: + socket_path = None + + # Get authkey from facts + authkey_from_hostvars = host_vars.get('platform_manager_authkey') + authkey_from_taskvars = task_vars.get('platform_manager_authkey') + authkey_b64 = authkey_from_hostvars or authkey_from_taskvars + + # Validate socket file if found + if socket_path: + socket_file = Path(socket_path) + socket_exists = socket_file.exists() + if socket_exists and not socket_file.is_socket(): + logger.warning(f"Socket path exists but is not a valid socket: {socket_path}") + socket_exists = False + else: + socket_exists = False + + # Generate expected socket path based on current credentials + import tempfile + socket_dir = Path(tempfile.gettempdir()) / 'ansible_platform' + + # Generate expected connection info with current credentials + expected_conn_info = ProcessManager.generate_connection_info( + identifier=inventory_hostname, + socket_dir=socket_dir, + gateway_config=gateway_config + ) + expected_socket_path = expected_conn_info.socket_path + + # Check if manager with matching credentials already exists + manager_found = False + actual_socket_path = None + actual_authkey_b64 = None + + if socket_path and authkey_b64: + stored_path_exists = Path(socket_path).exists() + if stored_path_exists: + # Check if stored socket path matches expected (same credentials) + if socket_path == expected_socket_path: + manager_found = True + actual_socket_path = socket_path + actual_authkey_b64 = authkey_b64 + logger.info(f"Found existing manager: {socket_path}") + else: + logger.info(f"Credentials changed, will spawn new manager") + + # Also check if expected socket path exists (in case facts weren't updated) + if not manager_found and Path(expected_socket_path).exists() and authkey_b64: + manager_found = True + actual_socket_path = expected_socket_path + actual_authkey_b64 = authkey_b64 + logger.info(f"Found manager at expected path: {expected_socket_path}") + + # If manager already running with matching credentials, try to connect + if manager_found and actual_socket_path and actual_authkey_b64: + logger.info(f"Connecting to existing manager: {actual_socket_path}") + + try: + authkey = base64.b64decode(actual_authkey_b64) + + # CRITICAL: Ensure socket_path is a plain str (Fedora/_AnsibleTaggedStr compatibility) + actual_socket_path_str = f"{actual_socket_path}" # f-string forces plain str + if type(actual_socket_path_str) is not str: + actual_socket_path_str = str(actual_socket_path_str) + + client = ManagerRPCClient(gateway_config.base_url, actual_socket_path_str, authkey) + + # Track this task's manager + task_uuid = self._get_task_uuid(task_vars) + BaseResourceActionPlugin._task_to_manager[task_uuid] = actual_socket_path_str + + # Track this manager in playbook tracking (process-safe) + play_id = self._get_play_id() + tracking = self._read_tracking_file(play_id) + if tracking: + if 'socket_paths' in tracking: + if isinstance(tracking['socket_paths'], list): + tracking['socket_paths'] = set(tracking['socket_paths']) + tracking['socket_paths'].add(actual_socket_path_str) + self._write_tracking_file(play_id, tracking) + + logger.info(f"Connected to existing manager: {actual_socket_path_str}") + + return client, { + 'platform_manager_socket': actual_socket_path_str, + 'platform_manager_authkey': actual_authkey_b64 + } + except Exception as e: + logger.warning(f"Failed to connect to existing manager: {e}, spawning new one") + # Fall through to spawn new one + + # Spawn new manager + logger.info(f"Spawning new manager for host: {inventory_hostname}") + + # Generate connection info using platform SDK (with credentials) + conn_info = ProcessManager.generate_connection_info( + identifier=inventory_hostname, + socket_dir=socket_dir, + gateway_config=gateway_config + ) + socket_path = conn_info.socket_path + authkey = conn_info.authkey + authkey_b64 = conn_info.authkey_b64 + + # Clean up old socket if exists + ProcessManager.cleanup_old_socket(socket_path) + + # Capture sys.path from parent to ensure child has same imports + parent_sys_path = list(sys.path) + + # Get path to manager process script + script_path = Path(__file__).parent.parent / 'plugin_utils' / 'manager' / 'manager_process.py' + + # Spawn process + process = ProcessManager.spawn_manager_process( + script_path=script_path, + socket_path=socket_path, + socket_dir=str(socket_dir), + identifier=inventory_hostname, + gateway_config=gateway_config, + authkey_b64=authkey_b64, + sys_path=parent_sys_path + ) + + logger.info(f"Manager process spawned (PID: {process.pid})") + + # Wait for process startup + ProcessManager.wait_for_process_startup( + socket_path=socket_path, + socket_dir=socket_dir, + identifier=inventory_hostname, + process=process + ) + + # Verify socket file was created + socket_file = Path(socket_path) + if not socket_file.exists(): + raise RuntimeError(f"Manager process started but socket file not found: {socket_path}") + + # CRITICAL: Ensure socket_path is a string (Fedora/Path object compatibility) + socket_path_str = str(socket_path) + + # Connect to newly spawned manager + client = ManagerRPCClient(gateway_config.base_url, socket_path_str, authkey) + + # Track this task's manager + task_uuid = self._get_task_uuid(task_vars) + BaseResourceActionPlugin._task_to_manager[task_uuid] = socket_path_str + + # Track this manager in playbook tracking (process-safe) + play_id = self._get_play_id() + tracking = self._read_tracking_file(play_id) + if tracking: + if 'socket_paths' not in tracking: + tracking['socket_paths'] = set() + if isinstance(tracking['socket_paths'], list): + tracking['socket_paths'] = set(tracking['socket_paths']) + tracking['socket_paths'].add(socket_path_str) + self._write_tracking_file(play_id, tracking) + + logger.info(f"Connected to new manager: {socket_path_str} (PID: {process.pid})") + + return client, { + 'platform_manager_socket': socket_path_str, + 'platform_manager_authkey': authkey_b64, + 'gateway_url': gateway_config.base_url + } + + def _build_argspec_from_docs(self, documentation: str) -> dict: + """ + Build argument spec from DOCUMENTATION string. + + Parses the YAML documentation and merges documentation fragments + (e.g., ansible.platform.auth) before converting to ArgumentSpec format. + + Args: + documentation: DOCUMENTATION string from module + + Returns: + ArgumentSpec dict suitable for ArgumentSpecValidator + + Raises: + ValueError: If documentation cannot be parsed + """ + try: + doc_data = yaml.safe_load(documentation) + except yaml.YAMLError as e: + raise ValueError(f"Failed to parse DOCUMENTATION: {e}") from e + + # Start with module's own options + options = doc_data.get('options', {}).copy() + + # Merge documentation fragments if specified + extends_fragments = doc_data.get('extends_documentation_fragment', []) + if not isinstance(extends_fragments, list): + extends_fragments = [extends_fragments] + + for fragment_name in extends_fragments: + fragment_options = self._load_documentation_fragment(fragment_name) + if fragment_options: + # Merge fragment options into module options + options.update(fragment_options) + + # Build argspec in Ansible format + # ArgumentSpecValidator expects 'argument_spec' key, not 'options' + argspec = { + 'argument_spec': options, + 'mutually_exclusive': doc_data.get('mutually_exclusive', []), + 'required_together': doc_data.get('required_together', []), + 'required_one_of': doc_data.get('required_one_of', []), + 'required_if': doc_data.get('required_if', []), + } + + return argspec + + def _load_documentation_fragment(self, fragment_name: str) -> dict: + """ + Load documentation fragment options. + + Args: + fragment_name: Fragment name (e.g., 'ansible.platform.auth') + + Returns: + Dict of options from fragment, or empty dict if not found + """ + try: + # Fragment name format: 'ansible.platform.auth' or 'auth' + if '.' in fragment_name: + # Full collection path: 'ansible.platform.auth' + parts = fragment_name.split('.') + if len(parts) >= 3: + collection = '.'.join(parts[:-1]) # 'ansible.platform' + fragment = parts[-1] # 'auth' + else: + fragment = fragment_name + else: + # Just fragment name: 'auth' + fragment = fragment_name + + # Try to load fragment from doc_fragments + fragment_path = Path(__file__).parent.parent / 'doc_fragments' / f'{fragment}.py' + + if fragment_path.exists(): + import importlib.util + spec = importlib.util.spec_from_file_location(f"doc_fragment_{fragment}", fragment_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Get DOCUMENTATION from ModuleDocFragment class + if hasattr(module, 'ModuleDocFragment'): + fragment_class = module.ModuleDocFragment + fragment_doc = getattr(fragment_class, 'DOCUMENTATION', '') + + if fragment_doc: + fragment_data = yaml.safe_load(fragment_doc) + return fragment_data.get('options', {}) + + logger.debug(f"Documentation fragment '{fragment_name}' not found, skipping") + return {} + + except Exception as e: + logger.warning(f"Failed to load documentation fragment '{fragment_name}': {e}") + return {} + + def _validate_data( + self, + data: dict, + argspec: dict, + direction: str + ) -> dict: + """ + Validate data against argument spec. + + Uses Ansible's built-in ArgumentSpecValidator to validate + both input (from playbook) and output (from manager). + + Args: + data: Data dict to validate + argspec: Argument specification + direction: 'input' or 'output' (for error messages) + + Returns: + Validated and normalized data dict + + Raises: + AnsibleError: If validation fails + """ + logger.debug(f"Creating ArgumentSpecValidator with argspec keys: {list(argspec.keys())}") + + # Create validator - pass all parameters as kwargs + validator = ArgumentSpecValidator( + argument_spec=argspec.get('argument_spec', {}), + mutually_exclusive=argspec.get('mutually_exclusive'), + required_together=argspec.get('required_together'), + required_one_of=argspec.get('required_one_of'), + required_if=argspec.get('required_if'), + required_by=argspec.get('required_by') + ) + + logger.debug(f"Validating {direction} data with keys: {list(data.keys())}") + + # Validate + result = validator.validate(data) + + # Check for errors + if result.error_messages: + error_msg = ( + f"{direction.title()} validation failed: " + + ", ".join(result.error_messages) + ) + raise AnsibleError(error_msg) + + logger.debug(f"Validation successful for {direction}") + return result + + def _get_play_id(self): + """ + Get unique identifier for current play. + + Uses play name and hosts to create a unique ID. + """ + task = self._task + play = getattr(task, '_play', None) + if play: + play_name = getattr(play, 'name', None) or 'unknown' + hosts = getattr(play, 'hosts', []) + hosts_str = ','.join(str(h) for h in hosts[:3]) # First 3 hosts for uniqueness + play_id = f"{play_name}::{hosts_str}" + else: + play_id = 'unknown_play' + return play_id + + def _get_task_uuid(self, task_vars): + """ + Get unique identifier for current task. + + Uses play name, task name, and hostname to create a unique ID. + """ + task = self._task + play = getattr(task, '_play', None) + play_name = getattr(play, 'name', None) or 'unknown' + task_name = getattr(task, 'name', None) or getattr(task, '_uuid', None) or 'unnamed' + hostname = task_vars.get('inventory_hostname', 'localhost') + # Use task's internal UUID if available, otherwise construct one + task_uuid = getattr(task, '_uuid', None) or f"{play_name}::{task_name}::{hostname}" + return str(task_uuid) + + def _get_tracking_file_path(self, play_id): + """ + Get path to tracking file for this play (process-safe). + + Args: + play_id: Unique play identifier + + Returns: + Path to tracking file + """ + import tempfile + tracking_dir = Path(tempfile.gettempdir()) / 'ansible_platform_tracking' + tracking_dir.mkdir(exist_ok=True) + # Sanitize play_id for filename + safe_play_id = play_id.replace('/', '_').replace(':', '_').replace(' ', '_') + return tracking_dir / f'playbook_{safe_play_id}.json' + + def _read_tracking_file(self, play_id): + """ + Read tracking data from file (process-safe with file locking). + + Args: + play_id: Unique play identifier + + Returns: + dict with tracking data, or None if file doesn't exist + """ + file_path = self._get_tracking_file_path(play_id) + if file_path.exists(): + try: + with open(file_path, 'r') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_SH) # Shared lock for reading + try: + data = json.load(f) + # Convert socket_paths list back to set + if 'socket_paths' in data and isinstance(data['socket_paths'], list): + data['socket_paths'] = set(data['socket_paths']) + return data + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except (IOError, json.JSONDecodeError) as e: + logger.warning(f"Error reading tracking file {file_path}: {e}") + return None + return None + + def _write_tracking_file(self, play_id, data): + """ + Write tracking data to file (process-safe with file locking). + + Args: + play_id: Unique play identifier + data: dict with tracking data + """ + file_path = self._get_tracking_file_path(play_id) + try: + # Convert socket_paths set to list for JSON serialization + data_copy = data.copy() + if 'socket_paths' in data_copy and isinstance(data_copy['socket_paths'], set): + data_copy['socket_paths'] = list(data_copy['socket_paths']) + + with open(file_path, 'w') as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) # Exclusive lock for writing + try: + json.dump(data_copy, f, indent=2) + f.flush() + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except IOError as e: + logger.warning(f"Error writing tracking file {file_path}: {e}") + + def _delete_tracking_file(self, play_id): + """ + Delete tracking file for this play. + + Args: + play_id: Unique play identifier + """ + file_path = self._get_tracking_file_path(play_id) + try: + if file_path.exists(): + file_path.unlink() + logger.debug(f"Deleted tracking file: {file_path}") + except Exception as e: + logger.debug(f"Could not delete tracking file {file_path}: {e}") + + def _initialize_playbook_tracking(self): + """ + Initialize tracking for the current playbook. + + Counts total tasks in the play (pre_tasks + tasks + post_tasks). + Only initializes once per play. + """ + play_id = self._get_play_id() + + # Check if already initialized (process-safe file read) + existing_tracking = self._read_tracking_file(play_id) + if existing_tracking is not None: + logger.debug(f"Playbook tracking already initialized for play '{play_id}'") + return + + # Initialize tracking (process-safe) + task = self._task + play = getattr(task, '_play', None) + + total_tasks = 0 + if play: + # Count tasks in pre_tasks, tasks, and post_tasks + pre_tasks = getattr(play, 'pre_tasks', []) or [] + tasks = getattr(play, 'tasks', []) or [] + post_tasks = getattr(play, 'post_tasks', []) or [] + + # Count all tasks (including tasks in blocks) + def count_tasks_in_list(task_list): + count = 0 + for item in task_list: + # Check if it's a block + if hasattr(item, 'block') and item.block: + # Count tasks in block + count += count_tasks_in_list(item.block) + elif hasattr(item, 'tasks') and item.tasks: + # It's a block with tasks attribute + count += count_tasks_in_list(item.tasks) + else: + # It's a regular task + count += 1 + return count + + total_tasks = ( + count_tasks_in_list(pre_tasks) + + count_tasks_in_list(tasks) + + count_tasks_in_list(post_tasks) + ) + + # Initialize tracking (process-safe file write) + tracking_data = { + 'total_tasks': total_tasks, + 'completed_tasks': 0, + 'socket_paths': [] + } + self._write_tracking_file(play_id, tracking_data) + + logger.info( + f"Initialized playbook tracking for play '{play_id}': " + f"{total_tasks} total tasks (file-based, process-safe)" + ) + + def cleanup(self, force=False): + """ + Clean up manager processes when all tasks in playbook complete. + + This method is called by Ansible after EACH task completes. + We track total tasks and completed tasks, and only shutdown when all are done. + + Args: + force: If True, force cleanup even if async is in use + """ + # Call parent cleanup first + super().cleanup(force) + + # Import ProcessManager for cleanup + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.process_manager import ( + ProcessManager + ) + + # Get play ID + try: + play_id = self._get_play_id() + except Exception as e: + logger.debug(f"Could not determine play ID for cleanup: {e}") + return + + # Read tracking data (process-safe) + tracking = self._read_tracking_file(play_id) + if tracking is None: + logger.debug(f"Play '{play_id}' not in tracking (may not have platform tasks)") + return + + # Increment completed tasks counter (process-safe with file locking) + # Use atomic read-modify-write pattern + tracking['completed_tasks'] = tracking.get('completed_tasks', 0) + 1 + + total_tasks = tracking.get('total_tasks', 0) + completed_tasks = tracking['completed_tasks'] + + # Convert socket_paths list to set if needed + if 'socket_paths' in tracking: + if isinstance(tracking['socket_paths'], list): + tracking['socket_paths'] = set(tracking['socket_paths']) + + logger.debug( + f"Task completed for play '{play_id}': " + f"{completed_tasks}/{total_tasks} tasks completed (process-safe)" + ) + + # Write updated tracking (process-safe) + self._write_tracking_file(play_id, tracking) + + # Check if all tasks are done + if completed_tasks >= total_tasks: + logger.info( + f"All tasks completed for play '{play_id}' " + f"({completed_tasks}/{total_tasks}), shutting down manager processes..." + ) + + # Shutdown all managers used by this play + socket_paths = list(tracking.get('socket_paths', set())) + for socket_path in socket_paths: + self._shutdown_manager_process(socket_path, ProcessManager) + + # Clean up tracking file + self._delete_tracking_file(play_id) + logger.info(f"Cleanup complete for play '{play_id}'") + else: + logger.debug( + f"Play '{play_id}' still has {total_tasks - completed_tasks} " + f"task(s) remaining, keeping managers alive" + ) + + def _shutdown_manager_process(self, socket_path, ProcessManager): + """ + Shutdown a specific manager process. + + Args: + socket_path: Socket path of the manager to shutdown + ProcessManager: ProcessManager class for cleanup utilities + """ + process_info = BaseResourceActionPlugin._spawned_processes.get(socket_path) + if not process_info: + logger.debug(f"Manager {socket_path} not found in spawned processes") + return + + process = process_info['process'] + authkey_b64 = process_info.get('authkey_b64') + + # Check if process is still running + if process.poll() is None: + logger.debug(f"Manager process still running at {socket_path}, shutting down...") + + try: + # Try graceful shutdown via RPC + if authkey_b64 and Path(socket_path).exists(): + try: + authkey = base64.b64decode(authkey_b64) + from .plugin_utils.manager.rpc_client import ManagerRPCClient + # CRITICAL: Ensure socket_path is a string (Fedora/Path object compatibility) + socket_path_str = str(socket_path) + client = ManagerRPCClient(process_info.get('gateway_url', ''), socket_path_str, authkey) + # Call shutdown method + try: + shutdown_result = client.shutdown_manager() + logger.debug(f"Sent shutdown signal to manager at {socket_path}: {shutdown_result}") + except Exception as e: + logger.debug(f"Shutdown RPC failed (manager may have already shut down): {e}") + finally: + client.close() + except Exception as e: + logger.debug(f"Could not connect for graceful shutdown: {e}") + + # Wait for graceful shutdown (max 5 seconds) + try: + process.wait(timeout=5) + logger.debug(f"Manager process at {socket_path} shut down gracefully") + except subprocess.TimeoutExpired: + logger.warning(f"Manager process at {socket_path} did not shut down gracefully, forcing termination") + process.terminate() + time.sleep(1) + if process.poll() is None: + process.kill() + process.wait() + except Exception as e: + logger.warning(f"Error shutting down manager at {socket_path}: {e}") + # Force kill as fallback + try: + if process.poll() is None: + process.kill() + process.wait() + except Exception: + pass + + # Clean up socket file + try: + ProcessManager.cleanup_old_socket(socket_path) + logger.debug(f"Cleaned up socket file: {socket_path}") + except Exception as e: + logger.debug(f"Could not clean up socket file {socket_path}: {e}") + + # Remove from tracking + BaseResourceActionPlugin._spawned_processes.pop(socket_path, None) + + def _detect_operation(self, args: dict) -> str: + """ + Detect operation type from arguments. + + Args: + args: Module arguments + + Returns: + Operation name ('create', 'update', 'delete', 'find') + """ + state = args.get('state', 'present') + + if state == 'absent': + return 'delete' + elif state == 'present': + # Check if ID is provided (update) or not (create) + if args.get('id'): + return 'update' + else: + return 'create' + elif state == 'find': + return 'find' + else: + raise AnsibleError(f"Unknown state: {state}") + + diff --git a/plugins/action/user.py b/plugins/action/user.py new file mode 100644 index 0000000..a8e3097 --- /dev/null +++ b/plugins/action/user.py @@ -0,0 +1,197 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +""" +Action plugin for ansible.platform.user module. + +This action plugin uses the persistent connection manager architecture. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging + +from ansible.errors import AnsibleError +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.docs.user import DOCUMENTATION +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.user import AnsibleUser + +logger = logging.getLogger(__name__) + + +class ActionModule(BaseResourceActionPlugin): + """ + Action plugin for user module. + + Uses the persistent connection manager architecture for improved performance. + """ + + MODULE_NAME = 'user' + + def run(self, tmp=None, task_vars=None): + """ + Execute the user module using persistent manager. + + Args: + tmp: Temporary directory (deprecated) + task_vars: Task variables from Ansible + + Returns: + Result dictionary with user data + """ + import time + + if task_vars is None: + task_vars = dict() + + # Store task_vars for cleanup() method + self._task_vars = task_vars + + # Performance timing: Action plugin start + action_start = time.perf_counter() + + result = super(ActionModule, self).run(tmp, task_vars) + del tmp # not used + + try: + # Build argspec from DOCUMENTATION (includes fragments) + argspec = self._build_argspec_from_docs(DOCUMENTATION) + + # Extract auth parameters separately (not part of module validation) + # Auth params come from task_vars or task args, handled by extract_gateway_config + auth_params = [ + 'gateway_hostname', 'gateway_username', 'gateway_password', + 'gateway_token', 'gateway_validate_certs', 'gateway_request_timeout', + 'aap_hostname', 'aap_username', 'aap_password', 'aap_token', + 'aap_validate_certs', 'aap_request_timeout' + ] + + # Validate input (module-specific params only, auth params excluded) + module_args = self._task.args.copy() + validated_input = self._validate_data( + module_args, + argspec, + 'input' + ) + + # Get or spawn manager + manager, facts_to_set = self._get_or_spawn_manager(task_vars) + + # Set facts in result if a new manager was spawned + if facts_to_set: + result['ansible_facts'] = facts_to_set + result['_ansible_facts_cacheable'] = True + + # Create dataclass from validated input + validated_params = validated_input.validated_parameters + user_data = { + k: v for k, v in validated_params.items() + if v is not None and k not in auth_params + } + user = AnsibleUser(**user_data) + + # Detect operation + operation = self._detect_operation(validated_params) + + # For 'create' with state='present', check if user exists first (idempotency) + if operation == 'create' and validated_params.get('state') == 'present': + try: + find_result = manager.execute( + operation='find', + module_name=self.MODULE_NAME, + ansible_data={'username': user.username} + ) + if find_result and find_result.get('id'): + operation = 'update' + user.id = find_result.get('id') + except Exception: + # User doesn't exist, proceed with create + pass + + # Execute via manager + manager_result = manager.execute( + operation=operation, + module_name=self.MODULE_NAME, + ansible_data=user.__dict__ + ) + + + # Validate output + read_only_fields = {'id', 'created', 'modified', 'url'} + argspec_fields = set(argspec.get('argument_spec', {}).keys()) + filtered_result = { + k: v for k, v in manager_result.items() + if k in argspec_fields or k in read_only_fields + } + try: + validated_output = self._validate_data( + {k: v for k, v in filtered_result.items() if k in argspec_fields}, + argspec, + 'output' + ) + for field in read_only_fields: + if field in filtered_result: + validated_output[field] = filtered_result[field] + except Exception: + validated_output = manager_result + + # Format return dict + result.update({ + 'changed': manager_result.get('changed', False), + 'failed': False, + self.MODULE_NAME: validated_output, + 'id': validated_output.get('id'), + }) + + # Performance timing: Action plugin end + action_end = time.perf_counter() + action_elapsed = action_end - action_start + + # Extract timing info from manager result if available + timing = {} + if isinstance(manager_result, dict) and '_timing' in manager_result: + timing = manager_result['_timing'] + + # Calculate our code time (excluding AAP response time) + rpc_time = timing.get('rpc_time', 0) + manager_time = timing.get('manager_processing_time', 0) + api_time = timing.get('api_call_time', 0) + + # Our code time = RPC + Manager processing (excluding API call which is AAP's time) + our_code_time = rpc_time + manager_time + + # Add timing to result + result.setdefault('_timing', {})['action_plugin_time'] = action_elapsed + result['_timing']['action_plugin_start'] = action_start + result['_timing']['action_plugin_end'] = action_end + result['_timing']['total_time'] = action_elapsed + + # Add component times + result['_timing']['rpc_time'] = rpc_time + result['_timing']['manager_processing_time'] = manager_time + result['_timing']['api_call_time'] = api_time # AAP response time + + # Key metric: Our code execution time (excluding AAP) + result['_timing']['our_code_time'] = our_code_time + result['_timing']['aap_response_time'] = api_time + + # Add HTTP and TLS metrics from manager + result['_timing']['http_request_count'] = timing.get('http_request_count', 0) + result['_timing']['tls_handshake_count'] = timing.get('tls_handshake_count', 0) + + self._display.vvv("Action plugin completed successfully") + + except Exception as e: + self._display.vvv(f"❌ Error in action plugin: {e}") + result['failed'] = True + result['msg'] = str(e) + + # Include traceback in verbose mode + if self._display.verbosity >= 3: + import traceback + result['exception'] = traceback.format_exc() + + return result + diff --git a/plugins/plugin_utils/__init__.py b/plugins/plugin_utils/__init__.py new file mode 100644 index 0000000..6958681 --- /dev/null +++ b/plugins/plugin_utils/__init__.py @@ -0,0 +1,3 @@ +"""Plugin utilities for ansible.platform collection.""" + + diff --git a/plugins/plugin_utils/ansible_models/__init__.py b/plugins/plugin_utils/ansible_models/__init__.py new file mode 100644 index 0000000..5ce6828 --- /dev/null +++ b/plugins/plugin_utils/ansible_models/__init__.py @@ -0,0 +1,3 @@ +"""Ansible dataclasses representing user-facing data models.""" + + diff --git a/plugins/plugin_utils/ansible_models/user.py b/plugins/plugin_utils/ansible_models/user.py new file mode 100644 index 0000000..9b17d6e --- /dev/null +++ b/plugins/plugin_utils/ansible_models/user.py @@ -0,0 +1,67 @@ +""" +Ansible User dataclass - user-facing stable interface. + +This dataclass represents the user as seen by Ansible playbooks. +Field names and types remain stable across API versions. +""" + +from dataclasses import dataclass, field +from typing import Optional, List, Union, Dict, Any + +from ..platform.types import TransformContext + + +@dataclass +class AnsibleUser: + """ + Ansible representation of a user. + + This is the stable interface that playbooks interact with. + Field names match the DOCUMENTATION and remain consistent + across different platform API versions. + """ + + # Required fields + username: str + + # Optional fields + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + password: Optional[str] = None + is_superuser: Optional[bool] = None + is_platform_auditor: Optional[bool] = None + organizations: Optional[List[str]] = None + state: str = 'present' + + # Read-only fields (populated from API responses) + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + def __post_init__(self): + """Validate and normalize data after initialization.""" + # Ensure organizations is a list + if self.organizations is None: + self.organizations = [] + elif not isinstance(self.organizations, list): + self.organizations = [self.organizations] + + def to_api(self, context: Union[TransformContext, Dict[str, Any]]): + """ + Transform to API format using version-specific mixin. + + The actual transformation is done by the mixin class loaded + by the manager based on the detected API version. + + Args: + context: TransformContext or dict with manager and other runtime info + """ + # Import at runtime to avoid circular dependencies + from ..api.v1.user import UserTransformMixin_v1 + + # Create a temporary instance with transform mixin + # The mixin will handle the actual transformation + return UserTransformMixin_v1.from_ansible_data(self, context) + diff --git a/plugins/plugin_utils/api/__init__.py b/plugins/plugin_utils/api/__init__.py new file mode 100644 index 0000000..7273623 --- /dev/null +++ b/plugins/plugin_utils/api/__init__.py @@ -0,0 +1,3 @@ +"""API dataclasses and transform mixins (versioned).""" + + diff --git a/plugins/plugin_utils/api/v1/__init__.py b/plugins/plugin_utils/api/v1/__init__.py new file mode 100644 index 0000000..ae96d2f --- /dev/null +++ b/plugins/plugin_utils/api/v1/__init__.py @@ -0,0 +1,3 @@ +"""API v1 implementations.""" + + diff --git a/plugins/plugin_utils/api/v1/user.py b/plugins/plugin_utils/api/v1/user.py new file mode 100644 index 0000000..77d5b4a --- /dev/null +++ b/plugins/plugin_utils/api/v1/user.py @@ -0,0 +1,330 @@ +""" +API v1 User dataclass and transform mixin. + +Handles transformations between Ansible format and Gateway API v1 format. +""" + +import logging +from dataclasses import dataclass +from typing import Optional, List, Dict, Any, ClassVar, Union +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + + +@dataclass +class APIUser_v1(BaseTransformMixin): + """ + API v1 representation of a user. + + This dataclass knows how to transform to/from the Gateway API v1 format. + """ + + # API fields (snake_case as per API) + username: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + password: Optional[str] = None + is_superuser: Optional[bool] = None + is_platform_auditor: Optional[bool] = None + + # Read-only fields from API + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + # For organizations - handled separately via associations + organization_ids: Optional[List[int]] = None + + +class UserTransformMixin_v1(BaseTransformMixin): + """ + Transform mixin for User API v1. + + Defines how to transform between Ansible format and API v1 format. + """ + + @classmethod + def from_ansible_data(cls, ansible_instance, context: Union[TransformContext, Dict[str, Any]]) -> 'APIUser_v1': + """ + Create API instance from Ansible dataclass. + + Args: + ansible_instance: AnsibleUser instance + context: TransformContext or dict with manager + + Returns: + APIUser_v1 instance + """ + logger.info(f"Transforming AnsibleUser to APIUser_v1: username={getattr(ansible_instance, 'username', None)}") + api_data = {} + + # Simple field mappings + simple_fields = [ + 'username', 'email', 'first_name', 'last_name', + 'password', 'is_superuser', 'is_platform_auditor', + 'id', 'created', 'modified', 'url' + ] + + for field in simple_fields: + value = getattr(ansible_instance, field, None) + if value is not None: + api_data[field] = value + logger.debug(f"Mapped field {field}: {value}") + + # Complex transformation: organizations (names -> IDs) + if ansible_instance.organizations: + logger.debug(f"Transforming organizations from names to IDs: {ansible_instance.organizations}") + org_ids = cls._names_to_ids( + ansible_instance.organizations, + context + ) + api_data['organization_ids'] = org_ids + logger.info(f"Organizations transformed: {ansible_instance.organizations} -> {org_ids}") + + logger.debug(f"APIUser_v1 data prepared with {len(api_data)} fields") + return APIUser_v1(**api_data) + + @staticmethod + def _names_to_ids(names: List[str], context: Union[TransformContext, Dict[str, Any]]) -> List[int]: + """Convert organization names to IDs.""" + if not names: + return [] + + # Use manager to lookup IDs + if isinstance(context, TransformContext): + return context.manager.lookup_organization_ids(names) + else: + manager = context.get('manager') + if manager: + return manager.lookup_organization_ids(names) + + return [] + + @staticmethod + def _ids_to_names(ids: List[int], context: Union[TransformContext, Dict[str, Any]]) -> List[str]: + """Convert organization IDs to names.""" + if not ids: + logger.debug("No organization IDs to convert") + return [] + + logger.debug(f"Looking up organization names for IDs: {ids}") + + # Use manager to lookup names + if isinstance(context, TransformContext): + result = context.manager.lookup_organization_names(ids) + else: + manager = context.get('manager') + if manager: + result = manager.lookup_organization_names(ids) + else: + logger.warning("No manager in context for organization lookup") + return [] + + logger.info(f"Organization lookup completed: {ids} -> {result}") + return result + + # Field mapping: ansible_field -> api_field or complex mapping + _field_mapping: ClassVar[Dict[str, Any]] = { + 'username': 'username', + 'email': 'email', + 'first_name': 'first_name', + 'last_name': 'last_name', + 'password': 'password', + 'is_superuser': 'is_superuser', + 'is_platform_auditor': 'is_platform_auditor', + 'id': 'id', + 'created': 'created', + 'modified': 'modified', + 'url': 'url', + + # Complex mapping for organizations (names <-> IDs) + 'organizations': { + 'api_field': 'organization_ids', + 'forward_transform': 'names_to_ids', + 'reverse_transform': 'ids_to_names', + }, + } + + # Transform functions registry + # Note: context is normalized to TransformContext in base_transform._apply_transform + _transform_registry: ClassVar[Dict[str, Any]] = { + 'names_to_ids': lambda names, ctx: ctx.manager.lookup_organization_ids(names) if names else [], + 'ids_to_names': lambda ids, ctx: ctx.manager.lookup_organization_names(ids) if ids else [], + } + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + """ + Define API endpoints for different operations. + + Returns: + Dictionary mapping operation names to endpoint configurations + """ + return { + 'create': EndpointOperation( + path='/api/gateway/v1/users/', + method='POST', + fields=['username', 'email', 'first_name', 'last_name', 'password', 'is_superuser', 'is_platform_auditor'], + required_for='create', + order=1 + ), + 'update': EndpointOperation( + path='/api/gateway/v1/users/{id}/', + method='PATCH', + fields=['username', 'email', 'first_name', 'last_name', 'password', 'is_superuser', 'is_platform_auditor'], + path_params=['id'], + required_for='update', + order=1 + ), + 'delete': EndpointOperation( + path='/api/gateway/v1/users/{id}/', + method='DELETE', + fields=[], + path_params=['id'], + required_for='delete', + order=1 + ), + 'get': EndpointOperation( + path='/api/gateway/v1/users/{id}/', + method='GET', + fields=[], + path_params=['id'], + required_for='find', + order=1 + ), + 'list': EndpointOperation( + path='/api/gateway/v1/users/', + method='GET', + fields=[], + required_for='find', + order=1 + ), + # Secondary operation for organization associations + 'associate_organizations': EndpointOperation( + path='/api/gateway/v1/users/{id}/organizations/', + method='POST', + fields=['organizations'], + path_params=['id'], + depends_on='create', + required_for='create', + order=2 + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + """ + Return the field name used to look up existing resources. + + Returns: + Field name for lookups (e.g., 'username', 'name') + """ + return 'username' + + def to_api(self, context: Union[TransformContext, Dict[str, Any]]) -> 'APIUser_v1': + """ + Transform from Ansible format to API format. + + Args: + context: TransformContext or dict with manager and other runtime info + + Returns: + APIUser_v1 instance ready for API submission + """ + logger.info(f"Transforming to API format: username={getattr(self, 'username', None)}") + api_data = {} + + # Apply field mappings + for ansible_field, mapping in self._field_mapping.items(): + if not hasattr(self, ansible_field): + continue + + value = getattr(self, ansible_field) + if value is None: + continue + + # Simple 1:1 mapping + if isinstance(mapping, str): + api_data[mapping] = value + logger.debug(f"Mapped {ansible_field} -> {mapping}: {value}") + + # Complex mapping with transformation + elif isinstance(mapping, dict): + api_field = mapping['api_field'] + transform_name = mapping.get('forward_transform') + + if transform_name and transform_name in self._transform_registry: + logger.debug(f"Applying forward transform '{transform_name}' for {ansible_field} -> {api_field}") + transform_func = self._transform_registry[transform_name] + transformed_value = transform_func(value, context) + api_data[api_field] = transformed_value + logger.debug(f"Transform completed: {value} -> {transformed_value}") + else: + api_data[api_field] = value + logger.debug(f"Direct mapping {ansible_field} -> {api_field}: {value}") + + logger.info(f"APIUser_v1 transformation completed with {len(api_data)} fields") + return APIUser_v1(**api_data) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: Union[TransformContext, Dict[str, Any]]) -> Dict[str, Any]: + """ + Transform from API format to Ansible format. + + Args: + api_data: Data from API response + context: TransformContext or dict with manager and other runtime info + + Returns: + Dictionary in Ansible format (dict is used here because manager adds 'changed' field) + """ + username = api_data.get('username', 'unknown') + logger.info(f"Transforming APIUser_v1 to Ansible format: username={username}") + logger.debug(f"API data keys: {list(api_data.keys())}") + + ansible_data = {} + + # Reverse mapping + for ansible_field, mapping in cls._field_mapping.items(): + # Simple 1:1 mapping + if isinstance(mapping, str): + if mapping in api_data: + ansible_data[ansible_field] = api_data[mapping] + logger.debug(f"Mapped {mapping} -> {ansible_field}: {api_data[mapping]}") + + # Complex mapping with reverse transformation + elif isinstance(mapping, dict): + api_field = mapping['api_field'] + transform_name = mapping.get('reverse_transform') + + if api_field in api_data: + value = api_data[api_field] + + if transform_name and transform_name in cls._transform_registry: + logger.debug(f"Applying reverse transform '{transform_name}' for {api_field} -> {ansible_field}") + transform_func = cls._transform_registry[transform_name] + # Normalize context for transform function (base_transform normalizes, but we handle both for safety) + if isinstance(context, dict): + # Convert dict to TransformContext for type safety + normalized_ctx = TransformContext( + manager=context['manager'], + session=context['session'], + cache=context.get('cache', {}), + api_version=context.get('api_version', '1') + ) + else: + normalized_ctx = context + transformed_value = transform_func(value, normalized_ctx) + ansible_data[ansible_field] = transformed_value + logger.debug(f"Transform completed: {value} -> {transformed_value}") + else: + ansible_data[ansible_field] = value + logger.debug(f"Direct mapping {api_field} -> {ansible_field}: {value}") + + logger.info(f"Ansible format transformation completed with {len(ansible_data)} fields") + return ansible_data + diff --git a/plugins/plugin_utils/api/v2/__init__.py b/plugins/plugin_utils/api/v2/__init__.py new file mode 100644 index 0000000..9829f32 --- /dev/null +++ b/plugins/plugin_utils/api/v2/__init__.py @@ -0,0 +1,3 @@ +"""API v2 implementations (mocked for POC / version-selection testing).""" + + diff --git a/plugins/plugin_utils/api/v2/user.py b/plugins/plugin_utils/api/v2/user.py new file mode 100644 index 0000000..af6a0bd --- /dev/null +++ b/plugins/plugin_utils/api/v2/user.py @@ -0,0 +1,234 @@ +""" +API v2 User dataclass and transform mixin (mocked for POC testing). + +Why this exists +--------------- +AAP Gateway only exposes v1 today, but for ANSTRAT-1640 we want to validate that +our architecture can: + - Discover multiple API versions from the filesystem (api/v1, api/v2, ...) + - Select a version based on detected API version (from /ping) + - Load version-specific classes without conflicts + +This v2 implementation intentionally mirrors v1, but uses v2 endpoint paths so +we can exercise it against the local mock server. +""" + +import logging +from dataclasses import dataclass +from typing import Optional, List, Dict, Any, ClassVar, Union + +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + + +@dataclass +class APIUser_v2(BaseTransformMixin): + """API v2 representation of a user (mock).""" + + username: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + password: Optional[str] = None + is_superuser: Optional[bool] = None + is_platform_auditor: Optional[bool] = None + + # Read-only fields from API + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + # For organizations - handled separately via associations + organization_ids: Optional[List[int]] = None + + +class UserTransformMixin_v2(BaseTransformMixin): + """ + Transform mixin for User API v2 (mock). + + Mirrors v1 behavior but uses v2 endpoint paths. + """ + + # Field mapping: ansible_field -> api_field or complex mapping + _field_mapping: ClassVar[Dict[str, Any]] = { + "username": "username", + "email": "email", + "first_name": "first_name", + "last_name": "last_name", + "password": "password", + "is_superuser": "is_superuser", + "is_platform_auditor": "is_platform_auditor", + "id": "id", + "created": "created", + "modified": "modified", + "url": "url", + # Complex mapping for organizations (names <-> IDs) + "organizations": { + "api_field": "organization_ids", + "forward_transform": "names_to_ids", + "reverse_transform": "ids_to_names", + }, + } + + _transform_registry: ClassVar[Dict[str, Any]] = { + "names_to_ids": lambda names, ctx: ctx.manager.lookup_organization_ids(names) if names else [], + "ids_to_names": lambda ids, ctx: ctx.manager.lookup_organization_names(ids) if ids else [], + } + + @classmethod + def from_ansible_data( + cls, ansible_instance, context: Union[TransformContext, Dict[str, Any]] + ) -> "APIUser_v2": + logger.info( + f"[v2] Transforming AnsibleUser -> APIUser_v2: username={getattr(ansible_instance, 'username', None)}" + ) + api_data: Dict[str, Any] = {} + + simple_fields = [ + "username", + "email", + "first_name", + "last_name", + "password", + "is_superuser", + "is_platform_auditor", + "id", + "created", + "modified", + "url", + ] + for field in simple_fields: + value = getattr(ansible_instance, field, None) + if value is not None: + api_data[field] = value + + # organizations (names -> IDs) + if getattr(ansible_instance, "organizations", None): + org_names = ansible_instance.organizations + if isinstance(context, TransformContext): + api_data["organization_ids"] = context.manager.lookup_organization_ids(org_names) + else: + mgr = context.get("manager") + api_data["organization_ids"] = mgr.lookup_organization_ids(org_names) if mgr else [] + + return APIUser_v2(**api_data) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + # NOTE: v2 endpoints only exist on the local mock server today. + return { + "create": EndpointOperation( + path="/api/gateway/v2/users/", + method="POST", + fields=[ + "username", + "email", + "first_name", + "last_name", + "password", + "is_superuser", + "is_platform_auditor", + ], + required_for="create", + order=1, + ), + "update": EndpointOperation( + path="/api/gateway/v2/users/{id}/", + method="PATCH", + fields=[ + "username", + "email", + "first_name", + "last_name", + "password", + "is_superuser", + "is_platform_auditor", + ], + path_params=["id"], + required_for="update", + order=1, + ), + "delete": EndpointOperation( + path="/api/gateway/v2/users/{id}/", + method="DELETE", + fields=[], + path_params=["id"], + required_for="delete", + order=1, + ), + "get": EndpointOperation( + path="/api/gateway/v2/users/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + "list": EndpointOperation( + path="/api/gateway/v2/users/", + method="GET", + fields=[], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "username" + + def to_api(self, context: Union[TransformContext, Dict[str, Any]]) -> "APIUser_v2": + # Reuse BaseTransformMixin behavior via the v1-style mapping pattern. + api_data: Dict[str, Any] = {} + for ansible_field, mapping in self._field_mapping.items(): + if not hasattr(self, ansible_field): + continue + value = getattr(self, ansible_field) + if value is None: + continue + if isinstance(mapping, str): + api_data[mapping] = value + elif isinstance(mapping, dict): + api_field = mapping["api_field"] + transform_name = mapping.get("forward_transform") + if transform_name and transform_name in self._transform_registry: + api_data[api_field] = self._transform_registry[transform_name](value, context) + else: + api_data[api_field] = value + return APIUser_v2(**api_data) + + @classmethod + def from_api( + cls, api_data: Dict[str, Any], context: Union[TransformContext, Dict[str, Any]] + ) -> Dict[str, Any]: + # Keep identical to v1 behavior: return dict so manager can add 'changed' + ansible_data: Dict[str, Any] = {} + for ansible_field, mapping in cls._field_mapping.items(): + if isinstance(mapping, str): + if mapping in api_data: + ansible_data[ansible_field] = api_data[mapping] + elif isinstance(mapping, dict): + api_field = mapping["api_field"] + transform_name = mapping.get("reverse_transform") + if api_field in api_data: + value = api_data[api_field] + if transform_name and transform_name in cls._transform_registry: + # Normalize context + if isinstance(context, dict): + ctx = TransformContext( + manager=context["manager"], + session=context["session"], + cache=context.get("cache", {}), + api_version=context.get("api_version", "2"), + ) + else: + ctx = context + ansible_data[ansible_field] = cls._transform_registry[transform_name](value, ctx) + else: + ansible_data[ansible_field] = value + return ansible_data + + diff --git a/plugins/plugin_utils/docs/__init__.py b/plugins/plugin_utils/docs/__init__.py new file mode 100644 index 0000000..481a7c4 --- /dev/null +++ b/plugins/plugin_utils/docs/__init__.py @@ -0,0 +1,3 @@ +"""Module documentation strings (DOCUMENTATION).""" + + diff --git a/plugins/plugin_utils/docs/user.py b/plugins/plugin_utils/docs/user.py new file mode 100644 index 0000000..269c7b6 --- /dev/null +++ b/plugins/plugin_utils/docs/user.py @@ -0,0 +1,85 @@ +""" +DOCUMENTATION string for user module. + +This serves as the single source of truth for the module's interface. +""" + +DOCUMENTATION = """ +--- +module: user +author: Sean Sullivan (@sean-m-sullivan) +short_description: Manage gateway users +description: + - Create, update, or delete users in Ansible Automation Platform Gateway + - This module uses the persistent connection manager for improved performance +version_added: "1.0.0" + +options: + username: + description: + - Username for the user + - Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. + required: true + type: str + + email: + description: + - Email address of the user + type: str + + first_name: + description: + - First name of the user + type: str + + last_name: + description: + - Last name of the user + type: str + + password: + description: + - Password for the user + - Write-only field used to set or change the password + type: str + no_log: true + + is_superuser: + description: + - Whether this user has superuser privileges + - Grants all permissions without explicitly assigning them + type: bool + aliases: ['superuser'] + + is_platform_auditor: + description: + - Whether this user is a platform auditor + - Deprecated - use role_user_assignment module instead + type: bool + aliases: ['auditor'] + + organizations: + description: + - List of organization names to associate with the user + - Organizations must already exist + - Deprecated - use role_user_assignment module instead + type: list + elements: str + + state: + description: + - Desired state of the user + type: str + choices: ['present', 'absent'] + default: 'present' + +extends_documentation_fragment: + - ansible.platform.auth + - ansible.platform.state + +notes: + - This module uses a persistent connection manager for improved performance + - Multiple tasks in a playbook will reuse the same connection + - The organizations and is_platform_auditor fields are deprecated +""" + diff --git a/plugins/plugin_utils/manager/__init__.py b/plugins/plugin_utils/manager/__init__.py new file mode 100644 index 0000000..f2eaf31 --- /dev/null +++ b/plugins/plugin_utils/manager/__init__.py @@ -0,0 +1,3 @@ +"""Manager service components for persistent platform connections.""" + + diff --git a/plugins/plugin_utils/manager/_manager_process.py b/plugins/plugin_utils/manager/_manager_process.py new file mode 100644 index 0000000..bb3bbd5 --- /dev/null +++ b/plugins/plugin_utils/manager/_manager_process.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Standalone script for the persistent manager process. + +This is executed as a separate process and doesn't rely on multiprocessing.spawn. +""" + +import sys +import json +import base64 +import traceback +from pathlib import Path + + +def main(): + """Main entry point for the manager process.""" + # Read configuration from command line args + if len(sys.argv) < 2: + print("ERROR: No config provided", file=sys.stderr) + sys.exit(1) + + config_json = sys.argv[1] + config = json.loads(config_json) + + socket_path = config['socket_path'] + socket_dir = config['socket_dir'] + inventory_hostname = config['inventory_hostname'] + gateway_url = config['gateway_url'] + gateway_username = config['gateway_username'] + gateway_password = config['gateway_password'] + gateway_token = config['gateway_token'] + gateway_validate_certs = config['gateway_validate_certs'] + gateway_request_timeout = config['gateway_request_timeout'] + authkey_b64 = config['authkey_b64'] + sys_path = config['sys_path'] + + # Redirect stderr to a file for debugging + stderr_log = Path(socket_dir) / f'manager_stderr_{inventory_hostname}.log' + error_log = Path(socket_dir) / f'manager_error_{inventory_hostname}.log' + + try: + sys.stderr = open(stderr_log, 'w', buffering=1) + sys.stdout = open(stderr_log, 'a', buffering=1) + except Exception: + pass # Continue without redirecting + + try: + # Restore parent's sys.path in child process + sys.path = sys_path + + # Decode authkey from base64 + authkey = base64.b64decode(authkey_b64) + + # Write to log immediately + with open(error_log, 'w') as f: + f.write(f"Process started, socket_path={socket_path}\n") + f.write(f"sys.path has {len(sys_path)} entries\n") + f.write(f"Manager starting at {socket_path}\n") + f.write(f"About to create service with base_url={gateway_url}\n") + f.flush() + + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import ( + PlatformManager, + PlatformService + ) + + with open(error_log, 'a') as f: + f.write("Imports successful\n") + f.flush() + + # Create service + try: + service = PlatformService( + base_url=gateway_url, + username=gateway_username, + password=gateway_password, + oauth_token=gateway_token, + verify_ssl=gateway_validate_certs, + request_timeout=gateway_request_timeout + ) + with open(error_log, 'a') as f: + f.write("Service created successfully\n") + f.flush() + except Exception as service_err: + with open(error_log, 'a') as f: + f.write(f"Service creation failed: {service_err}\n") + f.write(traceback.format_exc()) + f.flush() + raise + + with open(error_log, 'a') as f: + f.write("Service created\n") + f.flush() + + # Register with manager + PlatformManager.register( + 'get_platform_service', + callable=lambda: service + ) + + with open(error_log, 'a') as f: + f.write("Service registered\n") + f.flush() + + # Start manager server + manager = PlatformManager(address=socket_path, authkey=authkey) + + with open(error_log, 'a') as f: + f.write("Manager instance created\n") + f.flush() + + server = manager.get_server() + + with open(error_log, 'a') as f: + f.write("Server obtained, starting serve_forever()\n") + f.flush() + + server.serve_forever() + + except Exception as e: + # Log to a temp file for debugging + with open(error_log, 'a') as f: + f.write(f"\n\nManager startup failed: {e}\n") + f.write(traceback.format_exc()) + sys.exit(1) + + +if __name__ == '__main__': + main() + diff --git a/plugins/plugin_utils/manager/manager_process.py b/plugins/plugin_utils/manager/manager_process.py new file mode 100644 index 0000000..ae5f7f1 --- /dev/null +++ b/plugins/plugin_utils/manager/manager_process.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Standalone script for the persistent manager process. + +This is executed as a separate process via subprocess to avoid multiprocessing issues. +""" + +import sys +import os +import json +import base64 +import traceback +from pathlib import Path + + +def main(): + """Main entry point for the manager process.""" + # Write startup marker immediately + try: + marker = Path('/tmp/ansible_platform_manager_started.txt') + with open(marker, 'a') as f: + f.write(f"Script started with {len(sys.argv)} args\n") + f.write(f"Args: {sys.argv}\n") + except: + pass + + # Read configuration from command line args + if len(sys.argv) < 10: + print(f"ERROR: Expected 9 args, got {len(sys.argv) - 1}", file=sys.stderr) + print(f"Args received: {sys.argv}", file=sys.stderr) + sys.exit(1) + + # Log progress + marker = Path('/tmp/ansible_platform_manager_started.txt') + def log_marker(msg): + try: + with open(marker, 'a') as f: + f.write(f"{msg}\n") + except: + pass + + log_marker("Parsing arguments...") + socket_path = sys.argv[1] + socket_dir = sys.argv[2] + inventory_hostname = sys.argv[3] + gateway_url = sys.argv[4] + gateway_username = sys.argv[5] or None + gateway_password = sys.argv[6] or None + gateway_token = sys.argv[7] or None + gateway_validate_certs = sys.argv[8].lower() == 'true' + gateway_request_timeout = float(sys.argv[9]) + log_marker("Arguments parsed successfully") + + # Read sys.path and authkey from environment + log_marker("Reading environment variables...") + sys_path_b64 = os.environ.get('ANSIBLE_PLATFORM_SYS_PATH', '') + authkey_b64 = os.environ.get('ANSIBLE_PLATFORM_AUTHKEY', '') + log_marker(f"Got sys_path_b64 length: {len(sys_path_b64)}") + log_marker(f"Got authkey_b64 length: {len(authkey_b64)}") + + # Decode sys.path + log_marker("Decoding sys.path...") + try: + sys_path_json = base64.b64decode(sys_path_b64).decode('utf-8') + sys_path_list = json.loads(sys_path_json) + log_marker(f"Decoded sys.path with {len(sys_path_list)} entries") + except Exception as e: + log_marker(f"FAILED to decode sys.path: {e}") + sys.exit(1) + + # Redirect stderr to a file for debugging + log_marker("Setting up logging...") + stderr_log = Path(socket_dir) / f'manager_stderr_{inventory_hostname}.log' + error_log = Path(socket_dir) / f'manager_error_{inventory_hostname}.log' + + try: + sys.stderr = open(stderr_log, 'w', buffering=1) + sys.stdout = open(stderr_log, 'a', buffering=1) + log_marker("Logging redirected") + except Exception as e: + log_marker(f"Failed to redirect logging: {e}") + pass # Continue without redirecting + + try: + log_marker("Restoring sys.path...") + # Restore parent's sys.path in child process + sys.path = sys_path_list + log_marker(f"sys.path restored with entries: {sys_path_list}") + + # Ensure collections directory is on sys.path + # The script is in: ansible_collections/ansible/platform/plugins/plugin_utils/manager/ + # To import ansible_collections.ansible.platform, we need the PARENT of ansible_collections/ + script_dir = Path(__file__).resolve().parent + collections_dir = script_dir.parent.parent.parent.parent.parent # ansible_collections/ + workspace_root = collections_dir.parent # parent of ansible_collections/ + workspace_root_str = str(workspace_root) + log_marker(f"Workspace root: {workspace_root_str}") + log_marker(f"Collections dir: {collections_dir}") + if workspace_root_str not in sys.path: + sys.path.insert(0, workspace_root_str) + log_marker(f"Added workspace root to sys.path") + else: + log_marker(f"Workspace root already in sys.path") + + # Decode authkey from base64 + log_marker("Decoding authkey...") + authkey = base64.b64decode(authkey_b64) + log_marker(f"Authkey decoded, length: {len(authkey)}") + + # Write to log immediately + log_marker(f"Writing to error log: {error_log}") + with open(error_log, 'w') as f: + f.write(f"Process started, socket_path={socket_path}\n") + f.write(f"sys.path has {len(sys_path_list)} entries\n") + f.write(f"Manager starting at {socket_path}\n") + f.write(f"About to create service with base_url={gateway_url}\n") + f.flush() + log_marker("Error log written successfully") + + log_marker("About to import platform_manager...") + try: + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import ( + PlatformManager, + PlatformService + ) + log_marker("Imports successful!") + except Exception as import_err: + log_marker(f"Import failed: {import_err}") + log_marker(f"Import traceback: {traceback.format_exc()}") + raise + + with open(error_log, 'a') as f: + f.write("Imports successful\n") + f.flush() + + # Create service + try: + service = PlatformService( + base_url=gateway_url, + username=gateway_username, + password=gateway_password, + oauth_token=gateway_token, + verify_ssl=gateway_validate_certs, + request_timeout=gateway_request_timeout + ) + with open(error_log, 'a') as f: + f.write("Service created successfully\n") + f.flush() + except Exception as service_err: + with open(error_log, 'a') as f: + f.write(f"Service creation failed: {service_err}\n") + f.write(traceback.format_exc()) + f.flush() + raise + + with open(error_log, 'a') as f: + f.write("Service created\n") + f.flush() + + # Register with manager + PlatformManager.register( + 'get_platform_service', + callable=lambda: service + ) + + # Register shutdown method + PlatformManager.register( + 'shutdown', + callable=lambda: service.shutdown() + ) + + with open(error_log, 'a') as f: + f.write("Service registered with shutdown method\n") + f.flush() + + # Set up signal handlers for graceful shutdown + import signal + + def signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + with open(error_log, 'a') as f: + f.write(f"Received signal {signum}, shutting down...\n") + f.flush() + try: + service.shutdown() + except Exception as e: + with open(error_log, 'a') as f: + f.write(f"Error during shutdown: {e}\n") + f.flush() + sys.exit(0) + + # Register signal handlers + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + with open(error_log, 'a') as f: + f.write("Signal handlers registered\n") + f.flush() + + # Start manager server + manager = PlatformManager(address=socket_path, authkey=authkey) + + with open(error_log, 'a') as f: + f.write("Manager instance created\n") + f.flush() + + server = manager.get_server() + + with open(error_log, 'a') as f: + f.write("Server obtained, starting serve_forever()\n") + f.flush() + + try: + server.serve_forever() + except KeyboardInterrupt: + with open(error_log, 'a') as f: + f.write("Keyboard interrupt received, shutting down...\n") + f.flush() + service.shutdown() + sys.exit(0) + + except Exception as e: + # Log to a temp file for debugging + with open(error_log, 'a') as f: + f.write(f"\n\nManager startup failed: {e}\n") + f.write(traceback.format_exc()) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py new file mode 100644 index 0000000..e7fceb7 --- /dev/null +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -0,0 +1,1238 @@ +"""Platform Manager - Persistent service for API communication. + +This module provides the server-side manager that maintains persistent +connections to the platform API and handles all data transformations. +""" + +import base64 +import logging +import threading +from multiprocessing.managers import BaseManager +from socketserver import ThreadingMixIn +from typing import Any, Dict, Optional, Tuple +from dataclasses import asdict, is_dataclass +from urllib.parse import urlparse, urlencode +import requests + +from ..platform.registry import APIVersionRegistry +from ..platform.loader import DynamicClassLoader +from ..platform.types import EndpointOperation, TransformContext +from ..platform.credential_manager import ( + get_credential_manager, + CredentialStore, + TokenInfo +) +from ..platform.exceptions import ( + PlatformError, + AuthenticationError, + NetworkError, + ValidationError, + APIError, + TimeoutError, + classify_exception +) +from ..platform.retry import retry_http_request, RetryConfig + +logger = logging.getLogger(__name__) + + +class PlatformService: + """ + Generic platform service - resource agnostic. + + This service maintains a persistent connection and handles all resource operations + generically. It performs all transformations and API calls. + + Attributes: + base_url: Platform base URL + session: Persistent HTTP session + api_version: Detected/cached API version + registry: Version registry + loader: Class loader + cache: Lookup cache (org names ↔ IDs, etc.) + username: Authentication username + password: Authentication password + oauth_token: OAuth token for authentication + verify_ssl: SSL verification flag + """ + + def __init__( + self, + base_url: str, + username: Optional[str] = None, + password: Optional[str] = None, + oauth_token: Optional[str] = None, + verify_ssl: bool = True, + request_timeout: float = 10.0 + ): + """ + Initialize platform service. + + Args: + base_url: Platform base URL (e.g., https://platform.example.com) + username: Username for basic auth + password: Password for basic auth + oauth_token: OAuth token for bearer auth + verify_ssl: Whether to verify SSL certificates + request_timeout: Request timeout in seconds + """ + self.base_url = base_url.rstrip('/') + self.verify_ssl = verify_ssl + self.request_timeout = request_timeout + + # Initialize credential manager and store credentials securely + self.credential_manager = get_credential_manager() + self.credential_store = self.credential_manager.get_or_create_store( + gateway_url=self.base_url, + username=username, + password=password, + oauth_token=oauth_token, + process_id=str(id(self)) # Use object ID as process identifier + ) + + # Store namespace ID for credential operations + self.namespace_id = self.credential_store.namespace.namespace_id + + # Get credentials from store (they're stored securely there) + self.username, self.password, self.oauth_token = self.credential_store.get_auth_credentials() + + # Initialize persistent session (thread-safe) + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': 'Ansible Platform Collection', + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }) + + # Track authentication state + self._auth_lock = threading.Lock() + self._last_auth_error = None + + # Authenticate (with error handling) + try: + self._authenticate() + logger.info("Authentication successful") + except Exception as e: + logger.error(f"Authentication failed: {e}") + self._last_auth_error = e + # Continue anyway - some operations might work without auth + + # Detect API version (cached for lifetime) + try: + self.api_version = self._detect_version() + logger.info(f"PlatformService initialized with API v{self.api_version}") + except Exception as e: + logger.warning(f"Version detection failed: {e}, defaulting to v1") + self.api_version = '1' + + # Initialize registry and loader + self.registry = APIVersionRegistry() + self.loader = DynamicClassLoader(self.registry) + + # Cache for lookups + self.cache: Dict[str, Any] = {} + + # Performance counters (thread-safe) + self._http_request_count = 0 + self._tls_handshake_count = 1 # 1 handshake when session is created (HTTPS) + self._lock = threading.Lock() + + # Shutdown flag + self._shutdown_requested = False + self._shutdown_lock = threading.Lock() + + # Retry configuration + self.retry_config = RetryConfig( + max_attempts=3, + initial_delay=1.0, + max_delay=60.0, + exponential_base=2.0, + jitter=True + ) + + def _make_request( + self, + method: str, + url: str, + operation: str = 'http_request', + resource: str = 'unknown', + **kwargs + ) -> requests.Response: + """ + Make HTTP request with retry logic (using decorator pattern). + + This method uses the retry decorator to handle retries automatically. + + Args: + method: HTTP method ('get', 'post', 'put', 'patch', 'delete') + url: Request URL + operation: Operation name for error context + resource: Resource type for error context + **kwargs: Additional arguments for requests method + + Returns: + Response object + + Raises: + PlatformError: Classified platform error + """ + # Create a retried version of the request function + @retry_http_request(config=self.retry_config) + def _execute_with_retry(): + # Set default timeout and verify_ssl if not provided + request_kwargs = kwargs.copy() + if 'timeout' not in request_kwargs: + request_kwargs['timeout'] = self.request_timeout + if 'verify' not in request_kwargs: + request_kwargs['verify'] = self.verify_ssl + + # Get the appropriate session method + session_method = getattr(self.session, method.lower()) + + # Track request count + with self._lock: + self._http_request_count += 1 + + # Make the actual HTTP request + response = session_method(url, **request_kwargs) + + # Check for HTTP error status codes + if response.status_code >= 400: + # Handle 401 separately (authentication recovery) + if response.status_code == 401: + # Try to recover authentication + if self._handle_auth_error(response): + # Retry the request after re-authentication + response = session_method(url, **request_kwargs) + if response.status_code == 401: + # Still 401 after recovery attempt + raise AuthenticationError( + message=f"Authentication failed: HTTP {response.status_code}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] + }, + status_code=response.status_code + ) + else: + # Authentication recovery failed + raise AuthenticationError( + message=f"Authentication failed: HTTP {response.status_code}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] + }, + status_code=response.status_code + ) + + # For other HTTP errors, raise APIError + # The decorator will determine if it's retryable + response.raise_for_status() # Will raise requests.HTTPError + + return response + + # Execute with retry logic + return _execute_with_retry() + """ + Make HTTP request with retry logic and error classification. + + Args: + method: HTTP method ('get', 'post', 'put', 'patch', 'delete') + url: Request URL + operation: Operation name for error context + resource: Resource type for error context + **kwargs: Additional arguments for requests method + + Returns: + Response object + + Raises: + PlatformError: Classified platform error + """ + # Set default timeout and verify_ssl if not provided + if 'timeout' not in kwargs: + kwargs['timeout'] = self.request_timeout + if 'verify' not in kwargs: + kwargs['verify'] = self.verify_ssl + + # Get the appropriate session method + session_method = getattr(self.session, method.lower()) + + # Track request count + with self._lock: + self._http_request_count += 1 + + # Make request with retry logic + last_exception = None + for attempt in range(self.retry_config.max_attempts): + try: + response = session_method(url, **kwargs) + + # Check for HTTP error status codes + if response.status_code >= 400: + # Handle 401 separately (authentication) + if response.status_code == 401: + # Try to recover authentication + if self._handle_auth_error(response): + # Retry the request after re-authentication + if attempt < self.retry_config.max_attempts - 1: + continue + + # Authentication failed + raise AuthenticationError( + message=f"Authentication failed: HTTP {response.status_code}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] # Limit response body + }, + status_code=response.status_code + ) + + # Create APIError for other HTTP errors + error = APIError( + message=f"HTTP {response.status_code} error: {response.reason}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] + }, + status_code=response.status_code, + response_body=response.json() if response.headers.get('content-type', '').startswith('application/json') else None + ) + + # Check if retryable and not last attempt + if error.retryable and attempt < self.retry_config.max_attempts - 1: + delay = self.retry_config.calculate_delay(attempt) + logger.warning( + f"Retrying {method.upper()} {url} (attempt {attempt + 1}/{self.retry_config.max_attempts}) " + f"after {delay:.2f}s: HTTP {response.status_code}" + ) + import time + time.sleep(delay) + continue + else: + response.raise_for_status() # Will raise requests.HTTPError + + return response + + except requests.exceptions.Timeout as e: + last_exception = e + if attempt < self.retry_config.max_attempts - 1: + delay = self.retry_config.calculate_delay(attempt) + logger.warning( + f"Retrying {method.upper()} {url} (attempt {attempt + 1}/{self.retry_config.max_attempts}) " + f"after {delay:.2f}s: Timeout" + ) + import time + time.sleep(delay) + continue + else: + raise TimeoutError( + message=f"Request timed out after {self.retry_config.max_attempts} attempts: {str(e)}", + operation=operation, + resource=resource, + details={'url': url, 'timeout': kwargs.get('timeout')}, + timeout_seconds=kwargs.get('timeout') + ) + + except (requests.exceptions.ConnectionError, requests.exceptions.SSLError) as e: + last_exception = e + if attempt < self.retry_config.max_attempts - 1: + delay = self.retry_config.calculate_delay(attempt) + logger.warning( + f"Retrying {method.upper()} {url} (attempt {attempt + 1}/{self.retry_config.max_attempts}) " + f"after {delay:.2f}s: Network error" + ) + import time + time.sleep(delay) + continue + else: + raise NetworkError( + message=f"Network error after {self.retry_config.max_attempts} attempts: {str(e)}", + operation=operation, + resource=resource, + details={'url': url, 'original_exception': str(e)}, + original_exception=e + ) + + except requests.exceptions.HTTPError as e: + # HTTPError from raise_for_status() + response = e.response + error = APIError( + message=f"HTTP {response.status_code} error: {response.reason}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] + }, + status_code=response.status_code, + response_body=response.json() if response.headers.get('content-type', '').startswith('application/json') else None + ) + + if error.retryable and attempt < self.retry_config.max_attempts - 1: + delay = self.retry_config.calculate_delay(attempt) + logger.warning( + f"Retrying {method.upper()} {url} (attempt {attempt + 1}/{self.retry_config.max_attempts}) " + f"after {delay:.2f}s: HTTP {response.status_code}" + ) + import time + time.sleep(delay) + continue + else: + raise error + + except Exception as e: + # Classify and handle other exceptions + platform_error = classify_exception(e, operation, resource) + platform_error.details['url'] = url + + if platform_error.retryable and attempt < self.retry_config.max_attempts - 1: + delay = self.retry_config.calculate_delay(attempt) + logger.warning( + f"Retrying {method.upper()} {url} (attempt {attempt + 1}/{self.retry_config.max_attempts}) " + f"after {delay:.2f}s: {type(e).__name__}" + ) + import time + time.sleep(delay) + continue + else: + raise platform_error + + # If we get here, all retries failed + if last_exception: + raise classify_exception(last_exception, operation, resource) + + raise RuntimeError(f"Request failed for {method.upper()} {url}") + + def _authenticate(self) -> None: + """Authenticate with the platform API.""" + with self._auth_lock: + # Get fresh credentials from store + username, password, oauth_token = self.credential_store.get_auth_credentials() + + # Use simple URL for auth - we don't know the API version yet + url = self.base_url + + if oauth_token: + # OAuth token authentication + header = {"Authorization": f"Bearer {oauth_token}"} + self.session.headers.update(header) + try: + response = self.session.get(url, timeout=self.request_timeout, verify=self.verify_ssl) + response.raise_for_status() + self._last_auth_error = None + except requests.RequestException as e: + self._last_auth_error = e + raise ValueError(f"Authentication error with token: {e}") from e + elif username and password: + # Basic authentication + basic_str = base64.b64encode( + f"{username}:{password}".encode("ascii") + ) + header = {"Authorization": f"Basic {basic_str.decode('ascii')}"} + self.session.headers.update(header) + try: + response = self.session.get(url, timeout=self.request_timeout, verify=self.verify_ssl) + response.raise_for_status() + self._last_auth_error = None + except requests.RequestException as e: + self._last_auth_error = e + raise ValueError(f"Authentication error: {e}") from e + else: + error_msg = "Either oauth_token or username/password must be provided" + self._last_auth_error = ValueError(error_msg) + raise ValueError(error_msg) + + def _check_token_expiration(self) -> Tuple[bool, Optional[float]]: + """ + Check if current token is expired. + + Returns: + Tuple of (is_expired, seconds_until_expiry) + """ + return self.credential_manager.check_token_expiration(self.namespace_id) + + def _refresh_token(self) -> bool: + """ + Attempt to refresh OAuth token. + + Returns: + True if token was refreshed, False otherwise + """ + with self._auth_lock: + if not self.credential_store.token_info: + logger.debug("No token info available for refresh") + return False + + token_info = self.credential_store.token_info + if not token_info.refresh_token: + logger.debug("No refresh token available") + return False + + # Attempt to refresh token + # Note: This is a placeholder - actual refresh endpoint depends on Gateway API + try: + # Gateway token refresh endpoint (if available) + refresh_url = f"{self.base_url}/api/gateway/v1/auth/token/refresh/" + response = self.session.post( + refresh_url, + json={"refresh_token": token_info.refresh_token}, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + + if response.status_code == 200: + data = response.json() + new_token = data.get('access_token') + new_refresh_token = data.get('refresh_token', token_info.refresh_token) + expires_in = data.get('expires_in') + + if new_token: + self.credential_store.update_token( + token=new_token, + refresh_token=new_refresh_token, + expires_in=expires_in + ) + # Update session header + self.session.headers.update({ + "Authorization": f"Bearer {new_token}" + }) + logger.info("Token refreshed successfully") + return True + except Exception as e: + logger.warning(f"Token refresh failed: {e}") + + return False + + def _re_authenticate(self) -> bool: + """ + Re-authenticate using stored credentials. + + Returns: + True if re-authentication succeeded, False otherwise + """ + try: + self._authenticate() + return True + except Exception as e: + logger.error(f"Re-authentication failed: {e}") + return False + + def _handle_auth_error(self, response: requests.Response) -> bool: + """ + Handle authentication error (401) and attempt recovery. + + Args: + response: HTTP response with 401 status + + Returns: + True if authentication was recovered, False otherwise + """ + if response.status_code != 401: + return False + + logger.warning("Received 401 Unauthorized, attempting to recover authentication") + + # Try token refresh first (if using OAuth) + _, _, oauth_token = self.credential_store.get_auth_credentials() + if oauth_token: + if self._refresh_token(): + logger.info("Authentication recovered via token refresh") + return True + + # Fall back to re-authentication + if self._re_authenticate(): + logger.info("Authentication recovered via re-authentication") + return True + + logger.error("Failed to recover authentication") + return False + + def _detect_version(self) -> str: + """ + Detect platform API version. + + Returns: + Version string (e.g., '1', '2.1') + """ + try: + # Try to get version from API + # Most AAP APIs have a version endpoint or include version in response + response = self.session.get( + f'{self.base_url}/api/gateway/v1/ping/', + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + + # Try to extract version from response or default to v1 + version_str = '1' # Default to v1 for AAP Gateway + + # If API provides version info, extract it + if response.headers.get('X-API-Version'): + version_str = response.headers.get('X-API-Version', '1') + elif response.json().get('version'): + version_str = str(response.json().get('version', '1')) + + # Normalize version string + if version_str.startswith('v'): + version_str = version_str[1:] + + return version_str + + except Exception as e: + logger.warning(f"Failed to detect API version: {e}, using default '1'") + return '1' + + def _build_url(self, endpoint: str, query_params: Optional[Dict] = None) -> str: + """ + Build full URL for an endpoint. + + Args: + endpoint: API endpoint path + query_params: Optional query parameters + + Returns: + Full URL string + """ + # Ensure endpoint starts with /api/gateway/v1 + if not endpoint.startswith("/"): + endpoint = f"/{endpoint}" + if not endpoint.startswith("/api/"): + endpoint = f"/api/gateway/v{self.api_version}{endpoint}" + if not endpoint.endswith("/") and "?" not in endpoint: + endpoint = f"{endpoint}/" + + url = f"{self.base_url}{endpoint}" + + if query_params: + url = f"{url}?{urlencode(query_params)}" + + return url + + def execute( + self, + operation: str, + module_name: str, + ansible_data_dict: dict + ) -> dict: + """ + Execute a generic operation on any resource. + + This is the main entry point called by action plugins via RPC. + + Args: + operation: Operation type ('create', 'update', 'delete', 'find') + module_name: Module name (e.g., 'user', 'organization') + ansible_data_dict: Ansible dataclass as dict + + Returns: + Result as dict (Ansible format) with timing information + + Raises: + ValueError: If operation is unknown or execution fails + """ + import time + + # Performance timing: Manager processing start + manager_start = time.perf_counter() + + logger.info(f"Executing {operation} on {module_name}") + + # Load version-appropriate classes + AnsibleClass, APIClass, MixinClass = self.loader.load_classes_for_module( + module_name, + self.api_version + ) + + # Reconstruct Ansible dataclass + ansible_instance = AnsibleClass(**ansible_data_dict) + + # Build transformation context (using dataclass for type safety) + context = TransformContext( + manager=self, + session=self.session, + cache=self.cache, + api_version=self.api_version + ) + + # Execute operation + try: + if operation == 'create': + result = self._create_resource( + ansible_instance, MixinClass, context + ) + elif operation == 'update': + result = self._update_resource( + ansible_instance, MixinClass, context + ) + elif operation == 'delete': + result = self._delete_resource( + ansible_instance, MixinClass, context + ) + elif operation == 'find': + result = self._find_resource( + ansible_instance, MixinClass, context + ) + else: + raise ValueError(f"Unknown operation: {operation}") + + # Performance timing: Manager processing end + manager_end = time.perf_counter() + manager_elapsed = manager_end - manager_start + + # Extract API call time from context if available + api_time = 0 + if isinstance(context, dict) and 'timing' in context: + api_time = context['timing'].get('api_call_time', 0) + elif hasattr(context, 'timing'): + api_time = getattr(context.timing, 'api_call_time', 0) + + # Calculate our code time in manager (excluding API call which is AAP's time) + # Manager time includes: transformations, class loading, etc. + # But API call time is AAP response time, so subtract it + our_manager_code_time = manager_elapsed - api_time + + # Add timing info to result + if isinstance(result, dict): + result.setdefault('_timing', {})['manager_processing_time'] = manager_elapsed + result['_timing']['manager_start'] = manager_start + result['_timing']['manager_end'] = manager_end + result['_timing']['api_call_time'] = api_time + result['_timing']['our_manager_code_time'] = our_manager_code_time + + # Add HTTP and TLS metrics (thread-safe read) + with self._lock: + result['_timing']['http_request_count'] = self._http_request_count + result['_timing']['tls_handshake_count'] = self._tls_handshake_count + + return result + + except Exception as e: + logger.error( + f"Operation {operation} on {module_name} failed: {e}", + exc_info=True + ) + raise + + def _create_resource( + self, + ansible_data: Any, + mixin_class: type, + context: dict + ) -> dict: + """ + Create resource with transformation. + + Args: + ansible_data: Ansible dataclass instance + mixin_class: Transform mixin class + context: Transformation context + + Returns: + Created resource as dict (Ansible format) with 'changed': True + """ + # FORWARD TRANSFORM: Ansible → API + api_data = ansible_data.to_api(context) + + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + + # Execute operations (potentially multi-endpoint) + api_result = self._execute_operations( + operations, api_data, context, required_for='create' + ) + + # REVERSE TRANSFORM: API → Ansible + if api_result: + # Use mixin's from_api method which handles field filtering + # from_api always returns a dict + ansible_result = mixin_class.from_api(api_result, context) + # Creating a resource always results in a change + ansible_result['changed'] = True + return ansible_result + + return {'changed': True} + + def _update_resource( + self, + ansible_data: Any, + mixin_class: type, + context: dict + ) -> dict: + """ + Update resource with transformation. + + Args: + ansible_data: Ansible dataclass instance + mixin_class: Transform mixin class + context: Transformation context + + Returns: + Updated resource as dict (Ansible format) with 'changed': True/False + """ + # Get the resource ID + resource_id = getattr(ansible_data, 'id', None) + if not resource_id: + raise ValueError("Resource ID required for update operation") + + # Fetch current state for comparison + try: + current_data = self._find_resource(ansible_data, mixin_class, context) + except Exception: + # If we can't fetch current state, assume change + current_data = {} + + # FORWARD TRANSFORM: Ansible → API + api_data = ansible_data.to_api(context) + + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + + # Execute update operation + api_result = self._execute_operations( + operations, api_data, context, required_for='update' + ) + + # REVERSE TRANSFORM: API → Ansible + if api_result: + # Use mixin's from_api method which handles field filtering + ansible_result = mixin_class.from_api(api_result, context) + + # Compare current vs new to determine if changed + # from_api always returns a dict, and current_data from _find_resource is also a dict + new_dict = ansible_result + current_dict = current_data if isinstance(current_data, dict) else {} + + # Compare relevant fields (exclude read-only fields like created, modified, url) + read_only_fields = {'id', 'created', 'modified', 'url'} + new_comparable = {k: v for k, v in new_dict.items() if k not in read_only_fields} + current_comparable = {k: v for k, v in current_dict.items() if k not in read_only_fields} + + changed = new_comparable != current_comparable + + # from_api always returns a dict, so we can directly add changed + ansible_result['changed'] = changed + return ansible_result + + return {'changed': False} + + def _delete_resource( + self, + ansible_data: Any, + mixin_class: type, + context: dict + ) -> dict: + """ + Delete resource. + + Args: + ansible_data: Ansible dataclass instance + mixin_class: Transform mixin class + context: Transformation context + + Returns: + Empty dict (resource deleted) + """ + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + + # Find delete operation + delete_op = None + for op_name, op in operations.items(): + if op_name == 'delete' or (op.required_for == 'delete'): + delete_op = op + break + + if not delete_op: + raise ValueError("No delete operation defined for this resource") + + # Need ID for delete + resource_id = ansible_data.id + if not resource_id: + raise ValueError("Resource ID required for delete operation") + + # Build URL with path parameters + path = delete_op.path + if delete_op.path_params: + for param in delete_op.path_params: + if param == 'id': + path = path.replace(f'{{{param}}}', str(resource_id)) + + url = self._build_url(path) + + # Make DELETE request + logger.debug(f"Calling DELETE {url}") + response = self.session.delete( + url, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + + # Deleting a resource always results in a change + return {'changed': True} + + def _find_resource( + self, + ansible_data: Any, + mixin_class: type, + context: dict + ) -> dict: + """ + Find resource by identifier. + + Args: + ansible_data: Ansible dataclass instance + mixin_class: Transform mixin class + context: Transformation context + + Returns: + Found resource as dict (Ansible format) + """ + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + + # Find list operation (for querying) or get operation (for ID lookup) + list_op = operations.get('list') + get_op = operations.get('get') + + # Get lookup field name (e.g., 'username', 'name') + lookup_field = mixin_class.get_lookup_field() + unique_value = getattr(ansible_data, lookup_field, None) or getattr(ansible_data, 'id', None) + + if not unique_value: + raise ValueError(f"Cannot find resource: no {lookup_field} or id provided") + + # If we have an ID, use get endpoint + if hasattr(ansible_data, 'id') and ansible_data.id: + if not get_op: + raise ValueError("No GET operation defined for this resource") + url = self._build_url(get_op.path.replace('{id}', str(ansible_data.id))) + response = self.session.get( + url, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + api_result = response.json() + else: + # Use list endpoint and filter by lookup field + if not list_op: + raise ValueError("No LIST operation defined for this resource") + url = self._build_url(list_op.path, query_params={lookup_field: unique_value}) + logger.debug(f"Calling GET {url} to find {lookup_field}={unique_value}") + response = self.session.get( + url, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + list_result = response.json() + + # Find matching item in results + results = list_result.get('results', []) + if not results: + raise ValueError(f"Resource with {lookup_field}={unique_value} not found") + + # Return first match + api_result = results[0] + + # REVERSE TRANSFORM: API → Ansible + # from_api expects a dict and handles field filtering/mapping + ansible_result = mixin_class.from_api(api_result, context) + return ansible_result + + def _execute_operations( + self, + operations: Dict, + api_data: Any, + context: dict, + required_for: str = None + ) -> dict: + """ + Execute potentially multiple API endpoint operations. + + Args: + operations: Dict of EndpointOperations + api_data: API dataclass instance + context: Context + required_for: Filter operations by required_for field + + Returns: + Combined API response dict + """ + # Filter operations + relevant_ops = { + name: op for name, op in operations.items() + if op.required_for is None or op.required_for == required_for + } + + # Sort by dependencies and order + sorted_ops = self._sort_operations(relevant_ops) + + # Execute in order + results = {} + api_data_dict = asdict(api_data) + + for op_name in sorted_ops: + endpoint_op = relevant_ops[op_name] + + # Extract fields for this endpoint + request_data = {} + for field in endpoint_op.fields: + if field in api_data_dict and api_data_dict[field] is not None: + request_data[field] = api_data_dict[field] + + if not request_data: + logger.debug(f"Skipping {op_name} - no data") + continue + + # Build URL with path parameters + path = endpoint_op.path + if endpoint_op.path_params: + for param in endpoint_op.path_params: + if param in results: + path = path.replace(f'{{{param}}}', str(results[param])) + elif param == 'id' and 'id' in api_data_dict: + path = path.replace(f'{{{param}}}', str(api_data_dict['id'])) + + url = self._build_url(path) + + # Make API call + logger.debug(f"Calling {endpoint_op.method} {url}") + # Performance timing: API call start + import time + api_start = time.perf_counter() + + try: + # Increment HTTP request counter (thread-safe) + with self._lock: + self._http_request_count += 1 + + response = self.session.request( + endpoint_op.method, + url, + json=request_data, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + + # Performance timing: API call end + api_end = time.perf_counter() + api_elapsed = api_end - api_start + + # Store timing in context for later retrieval + if hasattr(context, 'timing'): + context.timing['api_call_time'] = api_elapsed + context.timing['api_call_start'] = api_start + context.timing['api_call_end'] = api_end + elif isinstance(context, dict): + context.setdefault('timing', {})['api_call_time'] = api_elapsed + context['timing']['api_call_start'] = api_start + context['timing']['api_call_end'] = api_end + + except Exception as e: + logger.error(f"API call failed: {e}") + if hasattr(e, 'response') and e.response is not None: + logger.error(f"Response status: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + raise + + # Store result + result_data = response.json() if response.content else {} + results[op_name] = result_data + + # Store ID for dependent operations + if 'id' in result_data and 'id' not in results: + results['id'] = result_data['id'] + + # Return main result + return results.get('create') or results.get('update') or results.get('main') or {} + + def _sort_operations(self, operations: Dict) -> list: + """ + Sort operations by dependencies and order. + + Args: + operations: Dict of EndpointOperations + + Returns: + List of operation names in execution order + """ + sorted_ops = [] + remaining = dict(operations) + + # Topological sort based on depends_on + while remaining: + # Find operations with no unmet dependencies + ready = [ + name for name, op in remaining.items() + if op.depends_on is None or op.depends_on in sorted_ops + ] + + if not ready: + raise ValueError( + f"Circular dependency in operations: " + f"{list(remaining.keys())}" + ) + + # Sort ready operations by order field + ready.sort(key=lambda name: remaining[name].order) + + # Add first ready operation + sorted_ops.append(ready[0]) + remaining.pop(ready[0]) + + return sorted_ops + + # Helper methods for transformations (called via context) + + def lookup_org_ids(self, org_names: list) -> list: + """ + Convert organization names to IDs. + + Args: + org_names: List of organization names + + Returns: + List of organization IDs + """ + ids = [] + for name in org_names: + # Check cache + cache_key = f'org_name:{name}' + if cache_key in self.cache: + ids.append(self.cache[cache_key]) + continue + + # API lookup + url = self._build_url('organizations', query_params={'name': name}) + response = self.session.get( + url, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + results = response.json().get('results', []) + + if results: + org_id = results[0]['id'] + self.cache[cache_key] = org_id + ids.append(org_id) + else: + raise ValueError(f"Organization '{name}' not found") + + return ids + + def lookup_org_names(self, org_ids: list) -> list: + """ + Convert organization IDs to names. + + Args: + org_ids: List of organization IDs + + Returns: + List of organization names + """ + names = [] + for org_id in org_ids: + # Check reverse cache + cache_key = f'org_id:{org_id}' + if cache_key in self.cache: + names.append(self.cache[cache_key]) + continue + + # API lookup + url = self._build_url(f'organizations/{org_id}/') + response = self.session.get( + url, + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + org = response.json() + + name = org['name'] + self.cache[cache_key] = name + self.cache[f'org_name:{name}'] = org_id # Store both directions + names.append(name) + + return names + + # Aliases for consistency with transform mixins + def lookup_organization_ids(self, org_names: list) -> list: + """Alias for lookup_org_ids.""" + return self.lookup_org_ids(org_names) + + def lookup_organization_names(self, org_ids: list) -> list: + """Alias for lookup_org_names.""" + return self.lookup_org_names(org_ids) + + def shutdown(self) -> dict: + """ + Gracefully shutdown the manager service. + + This method: + - Closes the HTTP session + - Cleans up resources + - Signals the manager process to exit + + Returns: + dict with shutdown status + """ + with self._shutdown_lock: + if self._shutdown_requested: + logger.debug("Shutdown already requested") + return {"status": "already_shutdown"} + + self._shutdown_requested = True + logger.info("Shutdown requested for PlatformService") + + # Close HTTP session + try: + if hasattr(self, 'session') and self.session: + self.session.close() + logger.debug("HTTP session closed") + except Exception as e: + logger.warning(f"Error closing HTTP session: {e}") + + # Clear cache + try: + self.cache.clear() + logger.debug("Cache cleared") + except Exception as e: + logger.warning(f"Error clearing cache: {e}") + + logger.info("PlatformService shutdown complete") + return {"status": "shutdown", "message": "Manager service shut down gracefully"} + + +class PlatformManager(ThreadingMixIn, BaseManager): + """ + Custom Manager for sharing PlatformService across processes. + + Uses ThreadingMixIn to handle concurrent client connections. + """ + daemon_threads = True + + @staticmethod + def register_shutdown_method(service): + """Register shutdown method with manager.""" + PlatformManager.register('shutdown', callable=lambda: service.shutdown()) + + diff --git a/plugins/plugin_utils/manager/process_manager.py b/plugins/plugin_utils/manager/process_manager.py new file mode 100644 index 0000000..c21d410 --- /dev/null +++ b/plugins/plugin_utils/manager/process_manager.py @@ -0,0 +1,249 @@ +"""Generic Process Manager - Platform SDK. + +Generic process management utilities for spawning and connecting to manager processes. +This module is part of the platform SDK and is not Ansible-specific. +""" + +import sys +import os +import subprocess +import secrets +import base64 +import json +import time +import logging +from pathlib import Path +from typing import Optional, Tuple, TYPE_CHECKING +from dataclasses import dataclass + +if TYPE_CHECKING: + from ..platform.config import GatewayConfig + +logger = logging.getLogger(__name__) + + +@dataclass +class ProcessConnectionInfo: + """Information needed to connect to a manager process.""" + socket_path: str + authkey: bytes + authkey_b64: str + + +class ProcessManager: + """ + Generic process manager for spawning and managing manager processes. + + This class handles: + - Socket path generation + - Authkey generation + - Process spawning + - Process startup waiting + + It's generic and not Ansible-specific, making it reusable for CLI, MCP, etc. + """ + + @staticmethod + def generate_connection_info( + identifier: str, + socket_dir: Optional[Path] = None, + gateway_config: Optional['GatewayConfig'] = None + ) -> ProcessConnectionInfo: + """ + Generate connection information for a new manager process. + + Args: + identifier: Unique identifier (e.g., inventory_hostname) + socket_dir: Directory for socket files (default: tempdir) + gateway_config: Gateway configuration (optional, for credential-aware socket path) + + Returns: + ProcessConnectionInfo with socket_path and authkey + """ + logger.info(f"Generating connection info for identifier: {identifier}") + + if socket_dir is None: + import tempfile + socket_dir = Path(tempfile.gettempdir()) / 'ansible_platform' + + # Create socket directory with user-only permissions (0700) + # This prevents other users from enumerating running jobs or accessing error logs + import os + socket_dir.mkdir(exist_ok=True) + try: + # Set permissions to 0700 (user read/write/execute only) + os.chmod(socket_dir, 0o700) + logger.debug(f"Set socket directory permissions to 0700: {socket_dir}") + except OSError as e: + logger.warning(f"Failed to set socket directory permissions: {e}") + + # Include user ID and credentials in socket path to prevent collisions + # User ID ensures different users on same jump host don't collide + # Credential hash ensures different credentials get different managers + import hashlib + user_id = os.getuid() + + if gateway_config: + # Create a hash of credentials to include in socket path + # This ensures different credentials = different socket path = different manager + cred_string = f"{gateway_config.username or ''}:{gateway_config.password or ''}:{gateway_config.oauth_token or ''}" + cred_hash = hashlib.sha256(cred_string.encode('utf-8')).hexdigest()[:8] + socket_path = str(socket_dir / f'manager_{user_id}_{identifier}_{cred_hash}.sock') + logger.debug(f"Including user ID ({user_id}) and credentials in socket path (hash: {cred_hash[:4]}...)") + else: + # Backward compatibility: if no gateway_config, use old format but still include user ID + socket_path = str(socket_dir / f'manager_{user_id}_{identifier}.sock') + logger.debug(f"Including user ID ({user_id}) in socket path (no gateway_config provided)") + + authkey = secrets.token_bytes(32) + authkey_b64 = base64.b64encode(authkey).decode('utf-8') + + logger.debug(f"Connection info generated: socket_path={socket_path}, socket_dir={socket_dir}, authkey_length={len(authkey)}") + + return ProcessConnectionInfo( + socket_path=socket_path, + authkey=authkey, + authkey_b64=authkey_b64 + ) + + @staticmethod + def cleanup_old_socket(socket_path: str) -> None: + """ + Clean up old socket file if it exists. + + Args: + socket_path: Path to socket file + """ + socket_file = Path(socket_path) + if socket_file.exists(): + try: + socket_file.unlink() + logger.debug(f"Removed old socket: {socket_path}") + except Exception as e: + logger.warning(f"Failed to remove old socket: {e}") + + @staticmethod + def spawn_manager_process( + script_path: Path, + socket_path: str, + socket_dir: str, + identifier: str, + gateway_config: 'GatewayConfig', # type: ignore + authkey_b64: str, + sys_path: Optional[list] = None + ) -> subprocess.Popen: + """ + Spawn a manager process. + + Args: + script_path: Path to manager process script + socket_path: Path to Unix socket + socket_dir: Directory for socket files + identifier: Unique identifier (e.g., inventory_hostname) + gateway_config: Gateway configuration + authkey_b64: Base64-encoded authkey + sys_path: Python sys.path to pass to child process + + Returns: + Popen process object + + Raises: + RuntimeError: If process fails to start + """ + logger.info(f"Spawning manager process for identifier: {identifier}") + logger.debug(f"Script path: {script_path}, socket: {socket_path}, gateway: {gateway_config.base_url}") + + if sys_path is None: + sys_path = list(sys.path) + + logger.debug(f"Preparing to spawn with sys.path containing {len(sys_path)} entries") + + # Encode sys.path for passing via environment + sys_path_json = json.dumps(sys_path) + sys_path_b64 = base64.b64encode(sys_path_json.encode('utf-8')).decode('utf-8') + + # Prepare environment + env = os.environ.copy() + env['ANSIBLE_PLATFORM_SYS_PATH'] = sys_path_b64 + env['ANSIBLE_PLATFORM_AUTHKEY'] = authkey_b64 + + # Build command + cmd = [ + sys.executable, # Use same Python interpreter + str(script_path), + socket_path, + socket_dir, + identifier, + gateway_config.base_url, + gateway_config.username or '', + gateway_config.password or '', + gateway_config.oauth_token or '', + str(gateway_config.verify_ssl), + str(gateway_config.request_timeout) + ] + + logger.debug(f"Command: {sys.executable} {script_path} [args: socket_path, socket_dir, identifier, gateway_url, ...]") + + try: + process = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True # Detach from parent + ) + logger.info(f"Manager process started successfully with PID: {process.pid}") + return process + except Exception as e: + logger.error(f"Failed to start manager process: {e}") + import traceback + logger.error(traceback.format_exc()) + raise RuntimeError(f"Failed to start manager process: {e}") from e + + @staticmethod + def wait_for_process_startup( + socket_path: str, + socket_dir: Path, + identifier: str, + process: subprocess.Popen, + max_wait: int = 50 + ) -> None: + """ + Wait for manager process to start and create socket. + + Args: + socket_path: Path to Unix socket + socket_dir: Directory for socket files + identifier: Unique identifier (e.g., inventory_hostname) + process: Process object to monitor + max_wait: Maximum number of 0.1s intervals to wait + + Raises: + RuntimeError: If process fails to start within timeout + """ + logger.info(f"Waiting for manager process to create socket: {socket_path} (max wait: {max_wait * 0.1}s)") + + for attempt in range(max_wait): + if Path(socket_path).exists(): + logger.info(f"Socket created successfully after {attempt * 0.1:.1f}s") + return + time.sleep(0.1) + if attempt % 10 == 0 and attempt > 0: # Log every second + logger.debug(f"Still waiting for socket... ({attempt * 0.1:.1f}s elapsed)") + + # Check if there's an error log + error_log = socket_dir / f'manager_error_{identifier}.log' + error_msg = f"Manager failed to start within {max_wait * 0.1} seconds" + + if error_log.exists(): + error_content = error_log.read_text() + error_msg += f"\n\nManager error log:\n{error_content}" + error_log.unlink() # Clean up + + # Check if process is still alive + returncode = process.poll() + if returncode is not None: + error_msg += f"\n\nManager process died (exitcode: {returncode})" + + raise RuntimeError(error_msg) + diff --git a/plugins/plugin_utils/manager/rpc_client.py b/plugins/plugin_utils/manager/rpc_client.py new file mode 100644 index 0000000..14f3c08 --- /dev/null +++ b/plugins/plugin_utils/manager/rpc_client.py @@ -0,0 +1,154 @@ +"""RPC Client for communicating with Platform Manager. + +Provides the client-side interface for action plugins to communicate +with the persistent Platform Manager service. +""" + +from multiprocessing.managers import BaseManager +from pathlib import Path +from typing import Dict, Any, Optional +import logging +import base64 +import time + +logger = logging.getLogger(__name__) + + +class ManagerRPCClient: + """ + Client for communicating with Platform Manager. + + Handles connection to the manager service and provides a simple + interface for action plugins to execute operations. + + Attributes: + base_url: Platform base URL + socket_path: Path to Unix socket + authkey: Authentication key + manager: Manager instance + service_proxy: Proxy to PlatformService + """ + + def __init__( + self, + base_url: str, + socket_path: str, + authkey: bytes + ): + """ + Initialize RPC client. + + Args: + base_url: Platform base URL + socket_path: Path to Unix socket + authkey: Authentication key + """ + self.base_url = base_url + # CRITICAL: Ensure socket_path is always a plain str (Fedora/_AnsibleTaggedStr compatibility) + # BaseManager.address must be a plain str type, not _AnsibleTaggedStr (str subclass) or Path object + # On Fedora, BaseManager.address_type() is strict and rejects subclasses + if socket_path is not None: + # Force conversion to plain Python str using f-string (not a subclass) + self.socket_path = f"{socket_path}" # f-string forces plain str + # Double-check: ensure it's actually a plain str, not a subclass + if type(self.socket_path) is not str: + self.socket_path = str(self.socket_path) + else: + self.socket_path = socket_path + self.authkey = authkey + + # Import manager class + from .platform_manager import PlatformManager + + # Register remote service + PlatformManager.register('get_platform_service') + + # Connect to manager + # CRITICAL: BaseManager.address must be a plain str type (not subclass) + # Use f-string to ensure plain str type + socket_path_str = f"{self.socket_path}" if self.socket_path is not None else self.socket_path + # Double-check: ensure it's actually a plain str + if socket_path_str is not None and type(socket_path_str) is not str: + socket_path_str = str(socket_path_str) + logger.debug(f"Connecting to manager at {socket_path_str} (type: {type(socket_path_str)}, is plain str: {type(socket_path_str) is str})") + self.manager = PlatformManager( + address=socket_path_str, + authkey=authkey + ) + self.manager.connect() + + # Get service proxy + self.service_proxy = self.manager.get_platform_service() + logger.info("Connected to Platform Manager") + + def execute( + self, + operation: str, + module_name: str, + ansible_data: Any + ) -> Any: + """ + Execute operation via manager. + + Args: + operation: Operation type + module_name: Module name + ansible_data: Ansible dataclass instance + + Returns: + Result dict (Ansible format) with timing information + """ + from dataclasses import asdict, is_dataclass + + # Performance timing: RPC call start + rpc_start = time.perf_counter() + + # Convert to dict for RPC + if is_dataclass(ansible_data): + data_dict = asdict(ansible_data) + else: + data_dict = ansible_data + + # Execute via proxy + result_dict = self.service_proxy.execute( + operation, + module_name, + data_dict + ) + + # Performance timing: RPC call end + rpc_end = time.perf_counter() + rpc_elapsed = rpc_end - rpc_start + + # Add timing info to result if it's a dict + if isinstance(result_dict, dict): + result_dict.setdefault('_timing', {})['rpc_time'] = rpc_elapsed + result_dict['_timing']['rpc_start'] = rpc_start + result_dict['_timing']['rpc_end'] = rpc_end + + return result_dict + + def shutdown_manager(self) -> dict: + """ + Request manager to shutdown gracefully. + + Returns: + dict with shutdown status + """ + try: + if hasattr(self, 'service_proxy') and self.service_proxy: + result = self.service_proxy.shutdown() + logger.debug(f"Manager shutdown response: {result}") + return result + except Exception as e: + logger.debug(f"Error calling shutdown on manager: {e}") + return {"status": "error", "error": str(e)} + return {"status": "not_connected"} + + def close(self) -> None: + """Close connection to manager.""" + if hasattr(self, 'manager'): + self.manager.shutdown() + logger.debug("Disconnected from Platform Manager") + + diff --git a/plugins/plugin_utils/platform/__init__.py b/plugins/plugin_utils/platform/__init__.py new file mode 100644 index 0000000..ac0bb42 --- /dev/null +++ b/plugins/plugin_utils/platform/__init__.py @@ -0,0 +1,3 @@ +"""Core platform components for transformation and version management.""" + + diff --git a/plugins/plugin_utils/platform/base_client.py b/plugins/plugin_utils/platform/base_client.py new file mode 100644 index 0000000..fbcf422 --- /dev/null +++ b/plugins/plugin_utils/platform/base_client.py @@ -0,0 +1,139 @@ +"""Base API Client - Abstract interface for platform API communication. + +This module defines the base interface that both standard and experimental +connection modes must implement. All shared functionality (version detection, +error handling, credential management, CRUD operations) is used by both modes. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional +from ..platform.config import GatewayConfig +from ..platform.registry import APIVersionRegistry +from ..platform.loader import DynamicClassLoader +from ..platform.types import TransformContext + +logger = logging.getLogger(__name__) + + +class BaseAPIClient(ABC): + """ + Abstract base class for platform API clients. + + Both standard mode (DirectHTTPClient) and experimental mode (PlatformService) + inherit from this class and share the same interface and shared layers. + + Shared layers used by both: + - Version detection (APIVersionRegistry, DynamicClassLoader) + - Error taxonomy (exceptions.py, retry.py) + - Credential management (credential_manager.py) + - CRUD operations (transform mixins, endpoint operations) + - Optimizations (caching, lookup helpers) + """ + + def __init__(self, config: GatewayConfig): + """ + Initialize base API client. + + Args: + config: Gateway configuration + """ + self.config = config + self.base_url = config.base_url.rstrip('/') + self.verify_ssl = config.verify_ssl + self.request_timeout = config.request_timeout + + # Shared: Version detection infrastructure + self.registry = APIVersionRegistry() + self.loader = DynamicClassLoader(self.registry) + + # Shared: API version (detected during initialization) + self.api_version: Optional[str] = None + + # Shared: Cache for lookups (org names ↔ IDs, etc.) + self.cache: Dict[str, Any] = {} + + logger.info(f"BaseAPIClient initialized: base_url={self.base_url}, mode={config.connection_mode}") + + @abstractmethod + def _detect_api_version(self) -> str: + """ + Detect API version from platform. + + This is implemented differently by each mode: + - Standard mode: Direct HTTP request to /ping endpoint + - Experimental mode: Same, but cached in persistent process + + Returns: + API version string (e.g., '1', '2') + """ + pass + + @abstractmethod + def _authenticate(self) -> None: + """ + Authenticate with the platform. + + This is implemented differently by each mode: + - Standard mode: Create new session, authenticate + - Experimental mode: Reuse persistent session + + Raises: + AuthenticationError: If authentication fails + """ + pass + + @abstractmethod + def execute( + self, + operation: str, + module_name: str, + ansible_data_dict: dict + ) -> dict: + """ + Execute a generic operation on any resource. + + This is the main entry point called by action plugins. + Both modes implement this using shared layers. + + Args: + operation: Operation type ('create', 'update', 'delete', 'find') + module_name: Module name (e.g., 'user', 'organization') + ansible_data_dict: Ansible dataclass as dict + + Returns: + Result as dict (Ansible format) with timing information + + Raises: + ValueError: If operation is unknown or execution fails + """ + pass + + def lookup_organization_ids(self, names: list) -> list: + """ + Lookup organization IDs from names (shared helper). + + Args: + names: List of organization names + + Returns: + List of organization IDs + """ + # This is a shared helper that both modes can use + # Implementation will be in the shared CRUD layer + pass + + def lookup_organization_names(self, ids: list) -> list: + """ + Lookup organization names from IDs (shared helper). + + Args: + ids: List of organization IDs + + Returns: + List of organization names + """ + # This is a shared helper that both modes can use + # Implementation will be in the shared CRUD layer + pass + diff --git a/plugins/plugin_utils/platform/base_transform.py b/plugins/plugin_utils/platform/base_transform.py new file mode 100644 index 0000000..551e834 --- /dev/null +++ b/plugins/plugin_utils/platform/base_transform.py @@ -0,0 +1,385 @@ +"""Base transformation mixin for bidirectional data transformation. + +This module provides the core transformation logic used by all Ansible +and API dataclasses. +""" + +import logging +from abc import ABC +from dataclasses import asdict +from typing import TypeVar, Type, Optional, Dict, Any, Union + +from .types import TransformContext + +logger = logging.getLogger(__name__) +T = TypeVar('T') + + +class BaseTransformMixin(ABC): + """ + Base transformation mixin providing bidirectional data transformation. + + All Ansible dataclasses and API dataclasses inherit from this mixin. + It provides generic transformation logic that works with the specific + field mappings and transform functions defined in subclasses. + + Attributes: + _field_mapping: Dict defining field mappings (set by subclasses) + _transform_registry: Dict of transformation functions (set by subclasses) + """ + + # Subclasses must define these class variables + _field_mapping: Optional[Dict] = None + _transform_registry: Optional[Dict] = None + + def to_api(self, context: Optional[Union[TransformContext, Dict[str, Any]]] = None) -> Any: + """ + Transform from Ansible format to API format. + + Args: + context: Optional TransformContext or dict containing: + - manager: PlatformService instance for lookups + - session: HTTP session + - cache: Lookup cache + - api_version: Current API version + + Returns: + API dataclass instance + """ + logger.debug(f"Transforming {self.__class__.__name__} to API format") + ctx = self._normalize_context(context) + result = self._transform( + target_class=self._get_api_class(), + direction='forward', + context=ctx + ) + logger.debug(f"Transformation to API format completed: {result.__class__.__name__}") + return result + + def to_ansible(self, context: Optional[Union[TransformContext, Dict[str, Any]]] = None) -> Any: + """ + Transform from API format to Ansible format. + + Args: + context: Optional TransformContext or dict (same as to_api) + + Returns: + Ansible dataclass instance + """ + logger.debug(f"Transforming {self.__class__.__name__} to Ansible format") + ctx = self._normalize_context(context) + result = self._transform( + target_class=self._get_ansible_class(), + direction='reverse', + context=ctx + ) + logger.debug(f"Transformation to Ansible format completed: {result.__class__.__name__}") + return result + + @staticmethod + def _normalize_context(context: Optional[Union[TransformContext, Dict[str, Any]]]) -> TransformContext: + """ + Normalize context to TransformContext dataclass. + + Args: + context: TransformContext or dict + + Returns: + TransformContext instance + """ + if context is None: + raise ValueError("Context is required for transformation") + + if isinstance(context, TransformContext): + return context + + if isinstance(context, dict): + # Convert dict to TransformContext for backward compatibility + return TransformContext( + manager=context['manager'], + session=context['session'], + cache=context.get('cache', {}), + api_version=context.get('api_version', '1') + ) + + raise TypeError(f"Context must be TransformContext or dict, got {type(context)}") + + def _transform( + self, + target_class: Type[T], + direction: str, + context: TransformContext + ) -> T: + """ + Generic bidirectional transformation logic. + + Args: + target_class: Target dataclass type to instantiate + direction: 'forward' (Ansible→API) or 'reverse' (API→Ansible) + context: Context dict for transformation functions + + Returns: + Instance of target_class with transformed data + """ + logger.debug(f"Starting {direction} transformation: {self.__class__.__name__} -> {target_class.__name__}") + + # Convert self to dict + source_data = asdict(self) + logger.debug(f"Source data keys: {list(source_data.keys())}") + + transformed_data = {} + + # Get field mapping from subclass + mapping = self._field_mapping or {} + logger.debug(f"Field mapping contains {len(mapping)} fields") + + # Apply mapping based on direction + if direction == 'forward': + transformed_data = self._apply_forward_mapping( + source_data, mapping, context + ) + elif direction == 'reverse': + transformed_data = self._apply_reverse_mapping( + source_data, mapping, context + ) + else: + raise ValueError(f"Invalid direction: {direction}") + + logger.debug(f"Transformed data keys: {list(transformed_data.keys())}") + + # Allow subclass post-processing hook + transformed_data = self._post_transform_hook( + transformed_data, direction, context + ) + + # Create and return target class instance + result = target_class(**transformed_data) + logger.debug(f"Created {target_class.__name__} instance successfully") + return result + + def _apply_forward_mapping( + self, + source_data: dict, + mapping: dict, + context: TransformContext + ) -> dict: + """ + Apply forward mapping (Ansible → API). + + Args: + source_data: Source data as dict + mapping: Field mapping configuration + context: Transform context + + Returns: + Transformed data dict + """ + result = {} + + for ansible_field, spec in mapping.items(): + # Get value from source + value = self._get_nested(source_data, ansible_field) + + if value is None: + continue + + # Apply forward transformation if specified + if isinstance(spec, dict) and 'forward_transform' in spec: + transform_name = spec['forward_transform'] + value = self._apply_transform(value, transform_name, context) + + # Get target field name + if isinstance(spec, str): + target_field = spec + elif isinstance(spec, dict): + target_field = spec.get('api_field', ansible_field) + else: + target_field = ansible_field + + # Set in result + self._set_nested(result, target_field, value) + + return result + + def _apply_reverse_mapping( + self, + source_data: dict, + mapping: dict, + context: TransformContext + ) -> dict: + """ + Apply reverse mapping (API → Ansible). + + Args: + source_data: Source data as dict + mapping: Field mapping configuration + context: Transform context + + Returns: + Transformed data dict + """ + result = {} + + for ansible_field, spec in mapping.items(): + # Determine source field name + if isinstance(spec, str): + source_field = spec + elif isinstance(spec, dict): + source_field = spec.get('api_field', ansible_field) + else: + source_field = ansible_field + + # Get value from source + value = self._get_nested(source_data, source_field) + + if value is None: + continue + + # Apply reverse transformation if specified + if isinstance(spec, dict) and 'reverse_transform' in spec: + transform_name = spec['reverse_transform'] + value = self._apply_transform(value, transform_name, context) + + # Set in result + self._set_nested(result, ansible_field, value) + + return result + + def _apply_transform( + self, + value: Any, + transform_name: str, + context: TransformContext + ) -> Any: + """ + Apply a named transformation function. + + Args: + value: Value to transform + transform_name: Name of transform function in registry + context: Transform context + + Returns: + Transformed value + """ + if self._transform_registry and transform_name in self._transform_registry: + logger.debug(f"Applying transform '{transform_name}' to value: {type(value).__name__}") + transform_func = self._transform_registry[transform_name] + result = transform_func(value, context) + logger.debug(f"Transform '{transform_name}' completed: {type(result).__name__}") + return result + logger.warning(f"Transform '{transform_name}' not found in registry, returning value unchanged") + return value + + def _get_nested(self, data: dict, path: str) -> Any: + """ + Get value from nested dict using dot-delimited path. + + Args: + data: Source dict + path: Dot-delimited path (e.g., 'user.address.city') + + Returns: + Value at path, or None if not found + """ + keys = path.split('.') + current = data + + for key in keys: + if isinstance(current, dict): + current = current.get(key) + if current is None: + return None + else: + return None + + return current + + def _set_nested(self, data: dict, path: str, value: Any) -> None: + """ + Set value in nested dict using dot-delimited path. + + Args: + data: Target dict + path: Dot-delimited path + value: Value to set + """ + keys = path.split('.') + current = data + + # Navigate to parent + for key in keys[:-1]: + if key not in current: + current[key] = {} + current = current[key] + + # Set final value + current[keys[-1]] = value + + def _post_transform_hook( + self, + data: dict, + direction: str, + context: TransformContext + ) -> dict: + """ + Hook for module-specific post-processing after transformation. + + Subclasses can override this to add custom logic. + + Args: + data: Transformed data + direction: Transform direction + context: Transform context + + Returns: + Possibly modified data + """ + return data + + @classmethod + def _get_api_class(cls) -> Type: + """ + Get the API dataclass type for this resource. + + Must be overridden by module-specific mixins. + + Returns: + API dataclass type + + Raises: + NotImplementedError: If not overridden + """ + raise NotImplementedError( + f"{cls.__name__} must implement _get_api_class()" + ) + + @classmethod + def _get_ansible_class(cls) -> Type: + """ + Get the Ansible dataclass type for this resource. + + Must be overridden by module-specific mixins. + + Returns: + Ansible dataclass type + + Raises: + NotImplementedError: If not overridden + """ + raise NotImplementedError( + f"{cls.__name__} must implement _get_ansible_class()" + ) + + def validate(self) -> bool: + """ + Hook for module-specific validation. + + Subclasses can override to add custom validation logic. + + Returns: + True if valid, False otherwise + """ + return True + + diff --git a/plugins/plugin_utils/platform/config.py b/plugins/plugin_utils/platform/config.py new file mode 100644 index 0000000..a54b38a --- /dev/null +++ b/plugins/plugin_utils/platform/config.py @@ -0,0 +1,148 @@ +"""Platform SDK - Gateway Configuration. + +Generic configuration extraction for platform gateway connections. +This module is part of the platform SDK and is not Ansible-specific. +""" + +import logging +from typing import Optional, Dict, Any +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class GatewayConfig: + """Gateway connection configuration. + + This is a generic configuration object that can be used by any + entry point (Ansible, CLI, MCP, etc.). + """ + base_url: str + username: Optional[str] = None + password: Optional[str] = None + oauth_token: Optional[str] = None + verify_ssl: bool = True + request_timeout: float = 10.0 + connection_mode: str = "standard" # "standard" or "experimental" + + def __post_init__(self): + """Normalize URL after initialization.""" + original_url = self.base_url + self.base_url = self._normalize_url(self.base_url) + if original_url != self.base_url: + logger.debug(f"Normalized gateway URL: {original_url} -> {self.base_url}") + logger.info(f"GatewayConfig initialized: base_url={self.base_url}, verify_ssl={self.verify_ssl}, timeout={self.request_timeout}") + + @staticmethod + def _normalize_url(url: str) -> str: + """Normalize gateway URL. + + Args: + url: Gateway URL (may or may not have protocol) + + Returns: + Normalized URL with protocol + """ + if not url: + return url + + if not url.startswith(('https://', 'http://')): + return f"https://{url}" + + return url + + +def extract_gateway_config( + task_args: Optional[Dict[str, Any]] = None, + host_vars: Optional[Dict[str, Any]] = None, + required: bool = True +) -> GatewayConfig: + """ + Extract gateway configuration from task arguments and host variables. + + This is a generic function that extracts gateway configuration from + any dict-like structure. It's not Ansible-specific and can be used + by CLI tools, MCP tools, or other entry points. + + Args: + task_args: Task/command arguments (higher priority) + host_vars: Host/inventory variables (lower priority) + required: Whether gateway_url is required (default: True) + + Returns: + GatewayConfig object with normalized values + + Raises: + ValueError: If required gateway_url is missing + """ + task_args = task_args or {} + host_vars = host_vars or {} + + logger.debug(f"Extracting gateway config from task_args (keys: {list(task_args.keys())}) and host_vars (keys: {list(host_vars.keys())})") + + # Get gateway URL from task args first, then host_vars + gateway_url = ( + task_args.get('gateway_url') or + task_args.get('gateway_hostname') or + host_vars.get('gateway_url') or + host_vars.get('gateway_hostname') + ) + logger.debug(f"Gateway URL extracted: {gateway_url}") + + # Get auth parameters from task args first, then host_vars + gateway_username = ( + task_args.get('gateway_username') or + host_vars.get('gateway_username') or + host_vars.get('aap_username') + ) + gateway_password = ( + task_args.get('gateway_password') or + host_vars.get('gateway_password') or + host_vars.get('aap_password') + ) + gateway_token = ( + task_args.get('gateway_token') or + host_vars.get('gateway_token') or + host_vars.get('aap_token') + ) + gateway_validate_certs = ( + task_args.get('gateway_validate_certs') + if 'gateway_validate_certs' in task_args + else host_vars.get('gateway_validate_certs', True) + ) + gateway_request_timeout = ( + task_args.get('gateway_request_timeout') or + host_vars.get('gateway_request_timeout') or + 10.0 + ) + # Connection mode: "standard" (default) or "experimental" (persistent manager) + connection_mode = ( + task_args.get('platform_connection_mode') or + host_vars.get('platform_connection_mode') or + 'standard' + ) + + if required and not gateway_url: + logger.error("Gateway URL is required but not found in task_args or host_vars") + raise ValueError( + "gateway_url or gateway_hostname must be provided as task parameter or defined in inventory" + ) + + # Log auth method being used (without exposing secrets) + auth_method = "token" if gateway_token else ("username/password" if gateway_username else "none") + logger.info(f"Gateway config extracted: url={gateway_url}, auth_method={auth_method}, verify_ssl={gateway_validate_certs}, timeout={gateway_request_timeout}") + + config = GatewayConfig( + base_url=gateway_url or '', + username=gateway_username, + password=gateway_password, + oauth_token=gateway_token, + verify_ssl=gateway_validate_certs, + request_timeout=gateway_request_timeout, + connection_mode=connection_mode + ) + + logger.debug(f"GatewayConfig created successfully") + return config + diff --git a/plugins/plugin_utils/platform/credential_manager.py b/plugins/plugin_utils/platform/credential_manager.py new file mode 100644 index 0000000..98d0d1f --- /dev/null +++ b/plugins/plugin_utils/platform/credential_manager.py @@ -0,0 +1,318 @@ +""" +Credential Management for Platform Persistent Connection Manager. + +This module provides secure credential handling, including: +- In-memory credential storage with process/namespace isolation +- Token refresh and expiration detection +- Secure credential lifecycle management +""" + +import logging +import threading +import time +import hashlib +from typing import Optional, Dict, Any, Tuple +from dataclasses import dataclass, field +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + + +@dataclass +class CredentialNamespace: + """ + Represents a credential namespace for isolation. + + A namespace is identified by a combination of: + - Gateway URL + - Credential hash (username/password or token) + - Process identifier + + This ensures that different credentials for the same gateway + get separate manager processes and isolated storage. + """ + gateway_url: str + credential_hash: str + process_id: Optional[str] = None + + def __post_init__(self): + """Generate namespace identifier.""" + self.namespace_id = self._generate_namespace_id() + + def _generate_namespace_id(self) -> str: + """Generate unique namespace identifier.""" + components = [self.gateway_url, self.credential_hash] + if self.process_id: + components.append(self.process_id) + namespace_str = ':'.join(components) + return hashlib.sha256(namespace_str.encode('utf-8')).hexdigest()[:16] + + @classmethod + def from_credentials( + cls, + gateway_url: str, + username: Optional[str] = None, + password: Optional[str] = None, + oauth_token: Optional[str] = None, + process_id: Optional[str] = None + ) -> 'CredentialNamespace': + """ + Create namespace from credentials. + + Args: + gateway_url: Gateway base URL + username: Username (for basic auth) + password: Password (for basic auth) + oauth_token: OAuth token (for bearer auth) + process_id: Optional process identifier + + Returns: + CredentialNamespace instance + """ + # Create credential hash (without storing actual credentials) + if oauth_token: + cred_string = f"token:{oauth_token}" + elif username and password: + cred_string = f"basic:{username}:{password}" + else: + cred_string = "none" + + credential_hash = hashlib.sha256(cred_string.encode('utf-8')).hexdigest()[:16] + + return cls( + gateway_url=gateway_url, + credential_hash=credential_hash, + process_id=process_id + ) + + +@dataclass +class TokenInfo: + """Information about an OAuth token.""" + token: str + refresh_token: Optional[str] = None + expires_at: Optional[datetime] = None + issued_at: Optional[datetime] = None + + def is_expired(self, buffer_seconds: int = 60) -> bool: + """ + Check if token is expired (with buffer). + + Args: + buffer_seconds: Seconds before expiration to consider expired + + Returns: + True if expired or will expire within buffer + """ + if not self.expires_at: + return False # No expiration info, assume valid + + return datetime.now() >= (self.expires_at - timedelta(seconds=buffer_seconds)) + + def time_until_expiry(self) -> Optional[float]: + """ + Get seconds until token expires. + + Returns: + Seconds until expiry, or None if no expiration info + """ + if not self.expires_at: + return None + + delta = self.expires_at - datetime.now() + return delta.total_seconds() + + +@dataclass +class CredentialStore: + """ + Secure in-memory credential storage for a namespace. + + Credentials are stored only in memory and are never written to disk. + Each namespace has its own isolated credential store. + """ + namespace: CredentialNamespace + username: Optional[str] = None + password: Optional[str] = None + token_info: Optional[TokenInfo] = None + last_used: datetime = field(default_factory=datetime.now) + lock: threading.Lock = field(default_factory=threading.Lock) + + def get_auth_credentials(self) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Get current authentication credentials. + + Returns: + Tuple of (username, password, oauth_token) + """ + with self.lock: + self.last_used = datetime.now() + token = self.token_info.token if self.token_info else None + return (self.username, self.password, token) + + def update_token(self, token: str, refresh_token: Optional[str] = None, expires_in: Optional[int] = None) -> None: + """ + Update OAuth token. + + Args: + token: New OAuth token + refresh_token: Optional refresh token + expires_in: Optional expiration time in seconds from now + """ + with self.lock: + expires_at = None + if expires_in: + expires_at = datetime.now() + timedelta(seconds=expires_in) + + self.token_info = TokenInfo( + token=token, + refresh_token=refresh_token, + expires_at=expires_at, + issued_at=datetime.now() + ) + self.last_used = datetime.now() + logger.info(f"Token updated for namespace {self.namespace.namespace_id}, expires_at={expires_at}") + + def clear_credentials(self) -> None: + """Clear all stored credentials.""" + with self.lock: + self.username = None + self.password = None + self.token_info = None + logger.info(f"Credentials cleared for namespace {self.namespace.namespace_id}") + + +class CredentialManager: + """ + Central credential manager with namespace isolation. + + This manager provides: + - Per-namespace credential isolation + - Thread-safe credential access + - Token expiration detection + - Secure credential lifecycle management + """ + + def __init__(self): + """Initialize credential manager.""" + self._stores: Dict[str, CredentialStore] = {} + self._lock = threading.Lock() + logger.info("CredentialManager initialized") + + def get_or_create_store( + self, + gateway_url: str, + username: Optional[str] = None, + password: Optional[str] = None, + oauth_token: Optional[str] = None, + process_id: Optional[str] = None + ) -> CredentialStore: + """ + Get or create credential store for namespace. + + Args: + gateway_url: Gateway base URL + username: Username (for basic auth) + password: Password (for basic auth) + oauth_token: OAuth token (for bearer auth) + process_id: Optional process identifier + + Returns: + CredentialStore for the namespace + """ + namespace = CredentialNamespace.from_credentials( + gateway_url=gateway_url, + username=username, + password=password, + oauth_token=oauth_token, + process_id=process_id + ) + + with self._lock: + if namespace.namespace_id not in self._stores: + store = CredentialStore( + namespace=namespace, + username=username, + password=password, + token_info=TokenInfo(token=oauth_token) if oauth_token else None + ) + self._stores[namespace.namespace_id] = store + logger.info(f"Created credential store for namespace {namespace.namespace_id}") + else: + store = self._stores[namespace.namespace_id] + logger.debug(f"Reusing credential store for namespace {namespace.namespace_id}") + + return store + + def get_store_by_namespace_id(self, namespace_id: str) -> Optional[CredentialStore]: + """ + Get credential store by namespace ID. + + Args: + namespace_id: Namespace identifier + + Returns: + CredentialStore or None if not found + """ + with self._lock: + return self._stores.get(namespace_id) + + def check_token_expiration(self, namespace_id: str) -> Tuple[bool, Optional[float]]: + """ + Check if token is expired for a namespace. + + Args: + namespace_id: Namespace identifier + + Returns: + Tuple of (is_expired, seconds_until_expiry) + """ + store = self.get_store_by_namespace_id(namespace_id) + if not store or not store.token_info: + return (False, None) + + with store.lock: + is_expired = store.token_info.is_expired() + time_until = store.token_info.time_until_expiry() + return (is_expired, time_until) + + def clear_namespace(self, namespace_id: str) -> None: + """ + Clear credentials for a namespace. + + Args: + namespace_id: Namespace identifier + """ + with self._lock: + if namespace_id in self._stores: + self._stores[namespace_id].clear_credentials() + del self._stores[namespace_id] + logger.info(f"Cleared credential store for namespace {namespace_id}") + + def clear_all(self) -> None: + """Clear all credential stores.""" + with self._lock: + for store in self._stores.values(): + store.clear_credentials() + self._stores.clear() + logger.info("Cleared all credential stores") + + +# Global credential manager instance (per-process) +_global_credential_manager: Optional[CredentialManager] = None +_global_credential_manager_lock = threading.Lock() + + +def get_credential_manager() -> CredentialManager: + """ + Get global credential manager instance (singleton per process). + + Returns: + CredentialManager instance + """ + global _global_credential_manager + with _global_credential_manager_lock: + if _global_credential_manager is None: + _global_credential_manager = CredentialManager() + return _global_credential_manager + diff --git a/plugins/plugin_utils/platform/direct_client.py b/plugins/plugin_utils/platform/direct_client.py new file mode 100644 index 0000000..d1c8f06 --- /dev/null +++ b/plugins/plugin_utils/platform/direct_client.py @@ -0,0 +1,735 @@ +"""Direct HTTP Client - Standard connection mode. + +This module provides a direct HTTP client for standard mode (default). +It uses direct requests.Session without a persistent manager process, +but shares all the same layers (version detection, error handling, +credential management, CRUD operations). +""" + +import base64 +import logging +import threading +import time +from typing import Any, Dict, Optional +import requests + +from .base_client import BaseAPIClient +from .config import GatewayConfig +from .credential_manager import get_credential_manager, CredentialStore +from .exceptions import ( + PlatformError, + AuthenticationError, + NetworkError, + APIError, + TimeoutError, + classify_exception +) +from .retry import retry_http_request, RetryConfig +from .types import TransformContext + +logger = logging.getLogger(__name__) + + +class DirectHTTPClient(BaseAPIClient): + """ + Direct HTTP client for standard connection mode. + + This is the default connection mode. It uses direct HTTP requests + without a persistent manager process. Each task creates its own + session, authenticates, and makes requests directly. + + All shared layers are used: + - Version detection (APIVersionRegistry, DynamicClassLoader) + - Error taxonomy (exceptions.py, retry.py) + - Credential management (credential_manager.py) + - CRUD operations (transform mixins, endpoint operations) + - Optimizations (caching, lookup helpers) + """ + + def __init__(self, config: GatewayConfig): + """ + Initialize direct HTTP client. + + Args: + config: Gateway configuration + """ + super().__init__(config) + + # Initialize credential manager and store credentials securely + self.credential_manager = get_credential_manager() + self.credential_store = self.credential_manager.get_or_create_store( + gateway_url=self.base_url, + username=config.username, + password=config.password, + oauth_token=config.oauth_token, + process_id=str(id(self)) # Use object ID as process identifier + ) + + # Store namespace ID for credential operations + self.namespace_id = self.credential_store.namespace.namespace_id + + # Get credentials from store (they're stored securely there) + self.username, self.password, self.oauth_token = self.credential_store.get_auth_credentials() + + # Initialize session (new session for each client instance) + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': 'Ansible Platform Collection', + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }) + + # Track authentication state + self._auth_lock = threading.Lock() + self._last_auth_error = None + + # Performance counters + self._http_request_count = 0 + self._tls_handshake_count = 1 # 1 handshake when session is created (HTTPS) + self._lock = threading.Lock() + + # Retry configuration + self.retry_config = RetryConfig( + max_attempts=3, + initial_delay=1.0, + max_delay=60.0, + exponential_base=2.0, + jitter=True + ) + + # Authenticate (with error handling) + try: + self._authenticate() + logger.info("DirectHTTPClient: Authentication successful") + except Exception as e: + logger.error(f"DirectHTTPClient: Authentication failed: {e}") + self._last_auth_error = e + raise + + # Detect API version + try: + self.api_version = self._detect_api_version() + logger.info(f"DirectHTTPClient: Initialized with API v{self.api_version}") + except Exception as e: + logger.warning(f"DirectHTTPClient: Version detection failed: {e}, defaulting to v1") + self.api_version = '1' + + def _detect_api_version(self) -> str: + """ + Detect API version from platform. + + Returns: + API version string (e.g., '1', '2') + """ + try: + # Try to get version from API + response = self.session.get( + f'{self.base_url}/api/gateway/v1/ping/', + timeout=self.request_timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + + # Try to extract version from response or default to v1 + version_str = '1' # Default to v1 for AAP Gateway + + # If API provides version info, extract it + if response.headers.get('X-API-Version'): + version_str = response.headers.get('X-API-Version', '1') + elif response.json().get('version'): + version_str = str(response.json().get('version', '1')) + + # Normalize version string + if version_str.startswith('v'): + version_str = version_str[1:] + + return version_str + + except Exception as e: + logger.warning(f"Version detection failed: {e}, defaulting to v1") + return '1' + + def _authenticate(self) -> None: + """ + Authenticate with the platform API. + + Raises: + AuthenticationError: If authentication fails + """ + with self._auth_lock: + # Get fresh credentials from store + username, password, oauth_token = self.credential_store.get_auth_credentials() + + # Use simple URL for auth - we don't know the API version yet + url = self.base_url + + if oauth_token: + # OAuth token authentication + header = {"Authorization": f"Bearer {oauth_token}"} + self.session.headers.update(header) + try: + response = self.session.get(url, timeout=self.request_timeout, verify=self.verify_ssl) + response.raise_for_status() + self._last_auth_error = None + except requests.RequestException as e: + self._last_auth_error = e + raise AuthenticationError( + message=f"Authentication error with token: {str(e)}", + operation='authenticate', + resource='auth', + details={'url': url, 'original_exception': str(e)}, + original_exception=e + ) from e + elif username and password: + # Basic authentication + basic_str = base64.b64encode( + f"{username}:{password}".encode("ascii") + ) + header = {"Authorization": f"Basic {basic_str.decode('ascii')}"} + self.session.headers.update(header) + try: + response = self.session.get(url, timeout=self.request_timeout, verify=self.verify_ssl) + response.raise_for_status() + self._last_auth_error = None + except requests.RequestException as e: + self._last_auth_error = e + raise AuthenticationError( + message=f"Authentication error with username/password: {str(e)}", + operation='authenticate', + resource='auth', + details={'url': url, 'original_exception': str(e)}, + original_exception=e + ) from e + else: + raise AuthenticationError( + message="No authentication credentials provided", + operation='authenticate', + resource='auth', + details={'url': url} + ) + + def _make_request( + self, + method: str, + url: str, + operation: str = 'http_request', + resource: str = 'unknown', + **kwargs + ) -> requests.Response: + """ + Make HTTP request with retry logic (using decorator pattern). + + This method uses the retry decorator to handle retries automatically. + + Args: + method: HTTP method ('get', 'post', 'put', 'patch', 'delete') + url: Request URL + operation: Operation name for error context + resource: Resource type for error context + **kwargs: Additional arguments for requests method + + Returns: + Response object + + Raises: + PlatformError: Classified platform error + """ + # Create a retried version of the request function + @retry_http_request(config=self.retry_config) + def _execute_with_retry(): + # Set default timeout and verify_ssl if not provided + request_kwargs = kwargs.copy() + if 'timeout' not in request_kwargs: + request_kwargs['timeout'] = self.request_timeout + if 'verify' not in request_kwargs: + request_kwargs['verify'] = self.verify_ssl + + # Get the appropriate session method + session_method = getattr(self.session, method.lower()) + + # Track request count + with self._lock: + self._http_request_count += 1 + + # Make the actual HTTP request + response = session_method(url, **request_kwargs) + + # Check for HTTP error status codes + if response.status_code >= 400: + # Handle 401 separately (authentication recovery) + if response.status_code == 401: + # Try to recover authentication + if self._handle_auth_error(response): + # Retry the request after re-authentication + response = session_method(url, **request_kwargs) + if response.status_code == 401: + # Still 401 after recovery attempt + raise AuthenticationError( + message=f"Authentication failed: HTTP {response.status_code}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] + }, + status_code=response.status_code + ) + else: + # Authentication recovery failed + raise AuthenticationError( + message=f"Authentication failed: HTTP {response.status_code}", + operation=operation, + resource=resource, + details={ + 'status_code': response.status_code, + 'url': url, + 'response_body': response.text[:500] + }, + status_code=response.status_code + ) + + # For other HTTP errors, raise APIError + response.raise_for_status() # Will raise requests.HTTPError + + return response + + # Execute with retry logic + return _execute_with_retry() + + def _handle_auth_error(self, response: requests.Response) -> bool: + """ + Handle authentication error (401) and attempt recovery. + + Args: + response: HTTP response with 401 status + + Returns: + True if authentication was recovered, False otherwise + """ + if response.status_code != 401: + return False + + logger.warning("Received 401 Unauthorized, attempting to recover authentication") + + # Try token refresh first (if using OAuth) + _, _, oauth_token = self.credential_store.get_auth_credentials() + if oauth_token: + if self._refresh_token(): + return True + + # Fall back to re-authentication + if self._re_authenticate(): + return True + + logger.error("Failed to recover authentication") + return False + + def _refresh_token(self) -> bool: + """ + Refresh OAuth token if expired. + + Returns: + True if token was refreshed, False otherwise + """ + # TODO: Implement token refresh logic + # This would check if token is expired and refresh it + return False + + def _re_authenticate(self) -> bool: + """ + Re-authenticate with stored credentials. + + Returns: + True if re-authentication succeeded, False otherwise + """ + try: + self._authenticate() + return True + except Exception as e: + logger.error(f"Re-authentication failed: {e}") + return False + + def _build_url(self, endpoint: str, query_params: Optional[Dict] = None) -> str: + """ + Build full URL from endpoint. + + Args: + endpoint: API endpoint (e.g., '/api/gateway/v1/users/') + query_params: Optional query parameters + + Returns: + Full URL + """ + # Ensure endpoint starts with / + if not endpoint.startswith('/'): + endpoint = f'/{endpoint}' + + # Build base URL + url = f"{self.base_url}{endpoint}" + + # Add query parameters if provided + if query_params: + from urllib.parse import urlencode + url = f"{url}?{urlencode(query_params)}" + + return url + + def execute( + self, + operation: str, + module_name: str, + ansible_data: Any + ) -> dict: + """ + Execute a generic operation on any resource. + + This is the main entry point called by action plugins. + Uses the same shared CRUD logic as PlatformService. + + Args: + operation: Operation type ('create', 'update', 'delete', 'find') + module_name: Module name (e.g., 'user', 'organization') + ansible_data: Ansible dataclass instance or dict + + Returns: + Result as dict (Ansible format) with timing information + + Raises: + ValueError: If operation is unknown or execution fails + """ + from dataclasses import asdict, is_dataclass + + # Convert to dict if dataclass (for consistency with ManagerRPCClient) + if is_dataclass(ansible_data): + ansible_data_dict = asdict(ansible_data) + else: + ansible_data_dict = ansible_data + # Performance timing: Processing start + processing_start = time.perf_counter() + + logger.info(f"Executing {operation} on {module_name}") + + # Load version-appropriate classes (shared layer) + AnsibleClass, APIClass, MixinClass = self.loader.load_classes_for_module( + module_name, + self.api_version + ) + + # Reconstruct Ansible dataclass + ansible_instance = AnsibleClass(**ansible_data_dict) + + # Build transformation context (using dataclass for type safety) + context = TransformContext( + manager=self, + session=self.session, + cache=self.cache, + api_version=self.api_version + ) + + # Execute operation (shared CRUD logic) + try: + if operation == 'create': + result = self._create_resource( + ansible_instance, MixinClass, context + ) + elif operation == 'update': + result = self._update_resource( + ansible_instance, MixinClass, context + ) + elif operation == 'delete': + result = self._delete_resource( + ansible_instance, MixinClass, context + ) + elif operation == 'find': + result = self._find_resource( + ansible_instance, MixinClass, context + ) + else: + raise ValueError(f"Unknown operation: {operation}") + + # Performance timing: Processing end + processing_end = time.perf_counter() + processing_elapsed = processing_end - processing_start + + # Extract API call time from context if available + api_time = 0 + if isinstance(context, dict) and 'timing' in context: + api_time = context['timing'].get('api_call_time', 0) + elif hasattr(context, 'timing'): + api_time = getattr(context.timing, 'api_call_time', 0) + + # Calculate our code time (excluding API call which is AAP's time) + our_code_time = processing_elapsed - api_time + + # Add timing info to result + if isinstance(result, dict): + result.setdefault('_timing', {})['processing_time'] = processing_elapsed + result['_timing']['processing_start'] = processing_start + result['_timing']['processing_end'] = processing_end + result['_timing']['api_call_time'] = api_time + result['_timing']['our_code_time'] = our_code_time + + # Add HTTP and TLS metrics (thread-safe read) + with self._lock: + result['_timing']['http_request_count'] = self._http_request_count + result['_timing']['tls_handshake_count'] = self._tls_handshake_count + + return result + + except Exception as e: + logger.error(f"Operation {operation} on {module_name} failed: {e}") + raise + + # CRUD operation methods (shared logic - same as PlatformService) + # These will be extracted to a shared module later, but for now + # we'll duplicate them here to get standard mode working + + def _create_resource( + self, + ansible_data: Any, + mixin_class: type, + context: TransformContext + ) -> dict: + """Create resource with transformation.""" + # FORWARD TRANSFORM: Ansible → API + api_data = ansible_data.to_api(context) + + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + + # Execute operations (potentially multi-endpoint) + api_result = self._execute_operations( + operations, api_data, context, required_for='create' + ) + + # REVERSE TRANSFORM: API → Ansible + if api_result: + ansible_result = mixin_class.from_api(api_result, context) + ansible_result['changed'] = True + return ansible_result + + return {'changed': True} + + def _update_resource( + self, + ansible_data: Any, + mixin_class: type, + context: TransformContext + ) -> dict: + """Update resource with transformation.""" + # Get the resource ID + resource_id = getattr(ansible_data, 'id', None) + if not resource_id: + raise ValueError("Resource ID required for update operation") + + # Fetch current state for comparison + try: + current_data = self._find_resource(ansible_data, mixin_class, context) + except Exception: + current_data = {} + + # FORWARD TRANSFORM: Ansible → API + api_data = ansible_data.to_api(context) + + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + + # Execute update operation + api_result = self._execute_operations( + operations, api_data, context, required_for='update' + ) + + # REVERSE TRANSFORM: API → Ansible + if api_result: + ansible_result = mixin_class.from_api(api_result, context) + # Compare with current state to determine if changed + changed = ansible_result != current_data + ansible_result['changed'] = changed + return ansible_result + + return {'changed': False} + + def _delete_resource( + self, + ansible_data: Any, + mixin_class: type, + context: TransformContext + ) -> dict: + """Delete resource.""" + # Get the resource ID + resource_id = getattr(ansible_data, 'id', None) + if not resource_id: + raise ValueError("Resource ID required for delete operation") + + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + delete_op = operations.get('delete') + + if not delete_op: + raise ValueError(f"Delete operation not defined for {mixin_class.__name__}") + + # Build URL + url = self._build_url(delete_op.path.format(id=resource_id)) + + # Execute delete + response = self._make_request( + delete_op.method, + url, + operation='delete', + resource=mixin_class.__name__ + ) + + return {'changed': True, 'deleted': True} + + def _find_resource( + self, + ansible_data: Any, + mixin_class: type, + context: TransformContext + ) -> dict: + """Find resource by lookup field.""" + # Get lookup field from mixin + lookup_field = mixin_class.get_lookup_field() + lookup_value = getattr(ansible_data, lookup_field, None) + + if not lookup_value: + raise ValueError(f"Lookup field '{lookup_field}' not found in data") + + # Get endpoint operations from mixin + operations = mixin_class.get_endpoint_operations() + list_op = operations.get('list') + + if not list_op: + raise ValueError(f"List operation not defined for {mixin_class.__name__}") + + # Build URL with query parameter + url = self._build_url(list_op.path, {lookup_field: lookup_value}) + + # Execute list request + response = self._make_request( + list_op.method, + url, + operation='find', + resource=mixin_class.__name__ + ) + + # Parse response + results = response.json().get('results', []) + if results: + # Return first match + api_data = results[0] + ansible_result = mixin_class.from_api(api_data, context) + return ansible_result + + # Not found + raise ValueError(f"Resource not found: {lookup_field}={lookup_value}") + + def _execute_operations( + self, + operations: Dict, + api_data: Any, + context: TransformContext, + required_for: str = None + ) -> dict: + """ + Execute endpoint operations (potentially multi-endpoint). + + This handles operations that may require multiple API calls + (e.g., create user, then associate organizations). + """ + results = {} + + # Filter operations by required_for + relevant_ops = { + name: op for name, op in operations.items() + if op.required_for == required_for or required_for is None + } + + # Sort by order + sorted_ops = sorted(relevant_ops.items(), key=lambda x: x[1].order) + + for op_name, endpoint_op in sorted_ops: + # Check dependencies + if endpoint_op.depends_on and endpoint_op.depends_on not in results: + continue + + # Build URL + url = endpoint_op.path + if endpoint_op.path_params: + # Replace path parameters + for param in endpoint_op.path_params: + param_value = results.get('id') or getattr(api_data, 'id', None) + if param_value: + url = url.replace(f'{{{param}}}', str(param_value)) + + url = self._build_url(url) + + # Prepare request data + request_data = {} + if endpoint_op.fields: + for field in endpoint_op.fields: + value = getattr(api_data, field, None) + if value is not None: + request_data[field] = value + + # Performance timing: API call start + api_start = time.perf_counter() + + try: + # Increment HTTP request counter (thread-safe) + with self._lock: + self._http_request_count += 1 + + response = self._make_request( + endpoint_op.method, + url, + json=request_data, + operation=op_name, + resource=endpoint_op.path.split('/')[-2] if '/' in endpoint_op.path else 'unknown' + ) + + # Performance timing: API call end + api_end = time.perf_counter() + api_elapsed = api_end - api_start + + # Store timing in context + if hasattr(context, 'timing'): + context.timing['api_call_time'] = api_elapsed + context.timing['api_call_start'] = api_start + context.timing['api_call_end'] = api_end + elif isinstance(context, dict): + context.setdefault('timing', {})['api_call_time'] = api_elapsed + context['timing']['api_call_start'] = api_start + context['timing']['api_call_end'] = api_end + + except Exception as e: + logger.error(f"DirectHTTPClient: API call failed: {e}") + if hasattr(e, 'response') and e.response is not None: + logger.error(f"Response status: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + raise + + # Store result + result_data = response.json() if response.content else {} + results[op_name] = result_data + + # Store ID for dependent operations + if 'id' in result_data and 'id' not in results: + results['id'] = result_data['id'] + + # Return main result + return results.get('create') or results.get('update') or results.get('get') or results + + def lookup_organization_ids(self, names: list) -> list: + """Lookup organization IDs from names (shared helper).""" + # TODO: Implement lookup using cache + # This should use the cache to avoid repeated lookups + pass + + def lookup_organization_names(self, ids: list) -> list: + """Lookup organization names from IDs (shared helper).""" + # TODO: Implement lookup using cache + # This should use the cache to avoid repeated lookups + pass + diff --git a/plugins/plugin_utils/platform/exceptions.py b/plugins/plugin_utils/platform/exceptions.py new file mode 100644 index 0000000..e498651 --- /dev/null +++ b/plugins/plugin_utils/platform/exceptions.py @@ -0,0 +1,326 @@ +""" +Error Taxonomy for Platform Collection. + +This module defines a hierarchy of exceptions for platform operations, +enabling proper error classification and retry logic. +""" + +import logging +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +class PlatformError(Exception): + """ + Base exception for all platform-related errors. + + All platform exceptions inherit from this class, allowing + catch-all error handling when needed. + """ + + def __init__( + self, + message: str, + operation: Optional[str] = None, + resource: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + """ + Initialize platform error. + + Args: + message: Human-readable error message + operation: Operation that failed (e.g., 'create', 'update', 'find') + resource: Resource type (e.g., 'user', 'organization') + details: Additional error details (e.g., HTTP status, response body) + """ + super().__init__(message) + self.message = message + self.operation = operation + self.resource = resource + self.details = details or {} + + def __str__(self) -> str: + """Return formatted error message.""" + parts = [self.message] + if self.operation: + parts.append(f"Operation: {self.operation}") + if self.resource: + parts.append(f"Resource: {self.resource}") + return " | ".join(parts) + + def to_dict(self) -> Dict[str, Any]: + """ + Convert error to dictionary for serialization. + + Returns: + Dictionary representation of error + """ + return { + 'error_type': self.__class__.__name__, + 'message': self.message, + 'operation': self.operation, + 'resource': self.resource, + 'details': self.details + } + + +class AuthenticationError(PlatformError): + """ + Authentication failures. + + Raised when: + - Invalid credentials provided + - Token expired and refresh failed + - Authentication endpoint returns 401/403 + """ + + def __init__( + self, + message: str, + operation: Optional[str] = None, + resource: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + super().__init__(message, operation, resource, details) + self.retryable = False # Authentication errors are not retryable + + def get_suggestion(self) -> str: + """Get suggestion for fixing authentication error.""" + if 'token' in self.message.lower() or 'expired' in self.message.lower(): + return "Check if token has expired. Provide a valid token or refresh token." + elif 'password' in self.message.lower() or 'username' in self.message.lower(): + return "Verify username and password are correct." + else: + return "Check gateway credentials (username/password or token) are valid and have proper permissions." + + +class NetworkError(PlatformError): + """ + Network/connection failures (retryable). + + Raised when: + - Connection timeout + - DNS resolution failure + - Connection refused + - Network unreachable + - SSL/TLS errors (connection-level) + """ + + def __init__( + self, + message: str, + operation: Optional[str] = None, + resource: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + original_exception: Optional[Exception] = None + ): + super().__init__(message, operation, resource, details) + self.retryable = True # Network errors are retryable + self.original_exception = original_exception + + def get_suggestion(self) -> str: + """Get suggestion for fixing network error.""" + if 'timeout' in self.message.lower(): + return "Check network connectivity and gateway availability. Consider increasing timeout." + elif 'connection' in self.message.lower() or 'refused' in self.message.lower(): + return "Verify gateway URL is correct and gateway service is running." + elif 'dns' in self.message.lower() or 'resolve' in self.message.lower(): + return "Check DNS resolution for gateway hostname." + elif 'ssl' in self.message.lower() or 'tls' in self.message.lower(): + return "Verify SSL certificate is valid. Use gateway_validate_certs=false for testing only." + else: + return "Check network connectivity and gateway availability." + + +class ValidationError(PlatformError): + """ + Input validation errors (not retryable). + + Raised when: + - Invalid input parameters + - Missing required fields + - Invalid data format + - Constraint violations + """ + + def __init__( + self, + message: str, + operation: Optional[str] = None, + resource: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + invalid_fields: Optional[list] = None + ): + super().__init__(message, operation, resource, details) + self.retryable = False # Validation errors are not retryable + self.invalid_fields = invalid_fields or [] + + def get_suggestion(self) -> str: + """Get suggestion for fixing validation error.""" + if self.invalid_fields: + fields_str = ", ".join(self.invalid_fields) + return f"Check the following fields are valid: {fields_str}" + else: + return "Review input parameters and ensure all required fields are provided with valid values." + + +class APIError(PlatformError): + """ + API-level errors (may be retryable). + + Raised when: + - HTTP 4xx errors (client errors, may be retryable for some) + - HTTP 5xx errors (server errors, usually retryable) + - API returns error response + - Rate limiting (429) + """ + + def __init__( + self, + message: str, + operation: Optional[str] = None, + resource: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + status_code: Optional[int] = None, + response_body: Optional[Dict[str, Any]] = None + ): + super().__init__(message, operation, resource, details) + self.status_code = status_code + self.response_body = response_body or {} + + # Determine if retryable based on status code + if status_code: + # 5xx errors are retryable (server errors) + # 429 (rate limit) is retryable + # 408 (timeout) is retryable + # 4xx errors (except above) are generally not retryable + self.retryable = status_code >= 500 or status_code in [408, 429] + else: + self.retryable = False + + def get_suggestion(self) -> str: + """Get suggestion for fixing API error.""" + if self.status_code == 401: + return "Authentication failed. Check credentials are valid and have proper permissions." + elif self.status_code == 403: + return "Access forbidden. Check user has required permissions for this operation." + elif self.status_code == 404: + return "Resource not found. Verify the resource exists or check the resource identifier." + elif self.status_code == 409: + return "Conflict. Resource may already exist or be in use. Check for duplicate resources." + elif self.status_code == 422: + return "Validation error. Check input parameters and required fields." + elif self.status_code == 429: + return "Rate limit exceeded. Wait before retrying or reduce request frequency." + elif self.status_code >= 500: + return "Server error. This may be temporary. Retry the operation." + else: + return "Check API response for details and verify input parameters." + + +class TimeoutError(PlatformError): + """ + Operation timeout errors (retryable). + + Raised when: + - Request timeout exceeded + - Operation takes too long + """ + + def __init__( + self, + message: str, + operation: Optional[str] = None, + resource: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + timeout_seconds: Optional[float] = None + ): + super().__init__(message, operation, resource, details) + self.retryable = True # Timeout errors are retryable + self.timeout_seconds = timeout_seconds + + def get_suggestion(self) -> str: + """Get suggestion for fixing timeout error.""" + if self.timeout_seconds: + return f"Operation timed out after {self.timeout_seconds}s. Consider increasing gateway_request_timeout or check network/gateway performance." + else: + return "Operation timed out. Consider increasing gateway_request_timeout or check network/gateway performance." + + +def classify_exception( + exception: Exception, + operation: Optional[str] = None, + resource: Optional[str] = None +) -> PlatformError: + """ + Classify a generic exception into platform error taxonomy. + + Args: + exception: Exception to classify + operation: Operation that failed + resource: Resource type + + Returns: + Classified PlatformError + """ + import requests + + # If already a PlatformError, return as-is + if isinstance(exception, PlatformError): + return exception + + # Classify based on exception type + if isinstance(exception, requests.exceptions.Timeout): + return TimeoutError( + message=f"Request timed out: {str(exception)}", + operation=operation, + resource=resource, + details={'original_exception': str(exception)}, + timeout_seconds=getattr(exception, 'timeout', None) + ) + + elif isinstance(exception, requests.exceptions.ConnectionError): + return NetworkError( + message=f"Connection error: {str(exception)}", + operation=operation, + resource=resource, + details={'original_exception': str(exception)}, + original_exception=exception + ) + + elif isinstance(exception, requests.exceptions.SSLError): + return NetworkError( + message=f"SSL error: {str(exception)}", + operation=operation, + resource=resource, + details={'original_exception': str(exception), 'error_type': 'ssl'}, + original_exception=exception + ) + + elif isinstance(exception, ValueError) and ('auth' in str(exception).lower() or 'credential' in str(exception).lower()): + return AuthenticationError( + message=f"Authentication error: {str(exception)}", + operation=operation, + resource=resource, + details={'original_exception': str(exception)} + ) + + elif isinstance(exception, ValueError): + return ValidationError( + message=f"Validation error: {str(exception)}", + operation=operation, + resource=resource, + details={'original_exception': str(exception)} + ) + + else: + # Generic platform error for unclassified exceptions + return PlatformError( + message=f"Unexpected error: {str(exception)}", + operation=operation, + resource=resource, + details={'original_exception': str(exception), 'exception_type': type(exception).__name__} + ) + diff --git a/plugins/plugin_utils/platform/loader.py b/plugins/plugin_utils/platform/loader.py new file mode 100644 index 0000000..9f7acb2 --- /dev/null +++ b/plugins/plugin_utils/platform/loader.py @@ -0,0 +1,232 @@ +"""Dynamic class loader for version-specific implementations. + +This module loads Ansible and API dataclasses based on the detected +API version without hardcoded imports. +""" + +import importlib +import inspect +from typing import Type, Tuple, Optional, Dict +from pathlib import Path +import logging + +from .base_transform import BaseTransformMixin +from .registry import APIVersionRegistry + +logger = logging.getLogger(__name__) + + +class DynamicClassLoader: + """ + Dynamically load version-specific classes at runtime. + + Loads the appropriate Ansible dataclass and API dataclass/mixin + based on the module name and API version. + + Attributes: + registry: APIVersionRegistry for version discovery + class_cache: Cache of loaded classes to avoid repeated imports + """ + + def __init__(self, registry: APIVersionRegistry): + """ + Initialize loader with a version registry. + + Args: + registry: Version registry for discovering available versions + """ + self.registry = registry + self._class_cache: Dict[str, Tuple[Type, Type, Type]] = {} + + def load_classes_for_module( + self, + module_name: str, + api_version: str + ) -> Tuple[Type, Type, Type]: + """ + Load classes for a module and API version. + + Args: + module_name: Module name (e.g., 'user', 'organization') + api_version: API version (e.g., '1', '2.1') + + Returns: + Tuple of (AnsibleClass, APIClass, MixinClass) + + Raises: + ValueError: If classes cannot be loaded + """ + # Find best matching version + best_version = self.registry.find_best_version(api_version, module_name) + + if not best_version: + raise ValueError( + f"No compatible API version found for module '{module_name}' " + f"with requested version '{api_version}'" + ) + + # Check cache + cache_key = f"{module_name}_{best_version.replace('.', '_')}" + if cache_key in self._class_cache: + logger.debug(f"Using cached classes for {cache_key}") + return self._class_cache[cache_key] + + # Load classes + logger.info( + f"Loading classes for {module_name} (API version {best_version})" + ) + + ansible_class = self._load_ansible_class(module_name) + api_class, mixin_class = self._load_api_classes(module_name, best_version) + + # Cache and return + result = (ansible_class, api_class, mixin_class) + self._class_cache[cache_key] = result + + return result + + def _load_ansible_class(self, module_name: str) -> Type: + """ + Load stable Ansible dataclass. + + Args: + module_name: Module name + + Returns: + Ansible dataclass type + + Raises: + ImportError: If module cannot be imported + ValueError: If class cannot be found + """ + # Import from ansible_models/.py + module_path = f'ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.{module_name}' + + try: + module = importlib.import_module(module_path) + except ImportError as e: + raise ImportError( + f"Failed to import Ansible module {module_path}: {e}" + ) from e + + # Find Ansible dataclass (e.g., AnsibleUser) + class_name = f'Ansible{module_name.title()}' + + if hasattr(module, class_name): + return getattr(module, class_name) + + # Fallback: find any class starting with 'Ansible' + for name, obj in inspect.getmembers(module, inspect.isclass): + if name.startswith('Ansible'): + return obj + + raise ValueError( + f"No Ansible dataclass found in {module_path} " + f"(expected {class_name})" + ) + + def _load_api_classes( + self, + module_name: str, + api_version: str + ) -> Tuple[Type, Type]: + """ + Load API dataclass and transform mixin for a version. + + Args: + module_name: Module name + api_version: API version + + Returns: + Tuple of (APIClass, MixinClass) + + Raises: + ImportError: If module cannot be imported + ValueError: If classes cannot be found + """ + # Import from api/v/.py + version_normalized = api_version.replace('.', '_') + module_path = ( + f'ansible_collections.ansible.platform.plugins.plugin_utils.api.' + f'v{version_normalized}.{module_name}' + ) + + try: + module = importlib.import_module(module_path) + except ImportError as e: + raise ImportError( + f"Failed to import API module {module_path}: {e}" + ) from e + + # Find API dataclass (e.g., APIUser_v1) + api_class_name = f'API{module_name.title()}_v{version_normalized}' + api_class = self._find_class_in_module( + module, + [api_class_name, f'API{module_name.title()}', 'API*'], + f"API dataclass for {module_name}" + ) + + # Find transform mixin (e.g., UserTransformMixin_v1) + mixin_class_name = f'{module_name.title()}TransformMixin_v{version_normalized}' + mixin_class = self._find_class_in_module( + module, + [mixin_class_name, f'{module_name.title()}TransformMixin', '*TransformMixin'], + f"Transform mixin for {module_name}", + base_class=BaseTransformMixin + ) + + return api_class, mixin_class + + def _find_class_in_module( + self, + module, + patterns: list, + description: str, + base_class: Optional[Type] = None + ) -> Type: + """ + Find a class in a module matching patterns. + + Args: + module: Imported module + patterns: List of patterns to try (wildcards supported) + description: Description for error messages + base_class: Optional base class to filter by + + Returns: + Matched class type + + Raises: + ValueError: If no matching class found + """ + # Get all classes from module + classes = inspect.getmembers(module, inspect.isclass) + + # Filter by base class if specified + if base_class: + classes = [ + (name, cls) for name, cls in classes + if issubclass(cls, base_class) and cls != base_class + ] + + # Try each pattern + for pattern in patterns: + if '*' in pattern: + # Wildcard pattern + prefix = pattern.replace('*', '') + for name, cls in classes: + if name.startswith(prefix): + return cls + else: + # Exact match + for name, cls in classes: + if name == pattern: + return cls + + # Not found + raise ValueError( + f"No {description} found in {module.__name__}. " + f"Tried patterns: {patterns}" + ) + + diff --git a/plugins/plugin_utils/platform/registry.py b/plugins/plugin_utils/platform/registry.py new file mode 100644 index 0000000..82b4899 --- /dev/null +++ b/plugins/plugin_utils/platform/registry.py @@ -0,0 +1,269 @@ +"""API version registry for dynamic version discovery. + +This module provides filesystem-based discovery of available API versions +and module implementations without hardcoded version lists. +""" + +from pathlib import Path +from typing import Dict, List, Optional +import logging +import q + +logger = logging.getLogger(__name__) + +try: + from packaging import version +except ImportError: + # Fallback for environments without packaging + import re + + class SimpleVersion: + """Simple version parser for basic version comparison.""" + def __init__(self, version_str: str): + self.version_str = version_str + # Extract numeric parts + parts = re.findall(r'\d+', version_str) + self.parts = [int(p) for p in parts] if parts else [0] + + def __le__(self, other): + return self.parts <= other.parts + + def __lt__(self, other): + return self.parts < other.parts + + def __gt__(self, other): + return self.parts > other.parts + + def version_parse(v: str): + return SimpleVersion(v) + + version = type('version', (), {'parse': version_parse})() + + +class APIVersionRegistry: + """ + Registry that discovers and manages API version information. + + Scans the api/ directory to find available versions and tracks + which modules are implemented for each version. + + Attributes: + api_base_path: Path to api/ directory containing versioned modules + ansible_models_path: Path to ansible_models/ with stable interfaces + versions: Dict mapping version string to available modules + module_versions: Dict mapping module name to available versions + """ + + def __init__( + self, + api_base_path: Optional[str] = None, + ansible_models_path: Optional[str] = None + ): + """ + Initialize registry and discover versions. + + Args: + api_base_path: Path to api/ directory (auto-detected if None) + ansible_models_path: Path to ansible_models/ (auto-detected if None) + """ + # Auto-detect paths if not provided + q("Inside APIVersionRegistry init") + q("api_base_path: {api_base_path}") + q("ansible_models_path: {ansible_models_path}") + + if api_base_path is None: + # Assume we're in plugin_utils/platform/ + current_file = Path(__file__) + plugin_utils = current_file.parent.parent + api_base_path = str(plugin_utils / 'api') + + if ansible_models_path is None: + current_file = Path(__file__) + plugin_utils = current_file.parent.parent + ansible_models_path = str(plugin_utils / 'ansible_models') + + self.api_base_path = Path(api_base_path) + self.ansible_models_path = Path(ansible_models_path) + q("self.api_base_path: {self.api_base_path}") + q("self.ansible_models_path: {self.ansible_models_path}") + + # Storage for discovered information + self.versions: Dict[str, List[str]] = {} # version -> [modules] + self.module_versions: Dict[str, List[str]] = {} # module -> [versions] + + q("self.versions: {self.versions}") + q("self.module_versions: {self.module_versions}") + # Discover on init + self._discover_versions() + q("self.versions: {self.versions}") + q("self.module_versions: {self.module_versions}") + + def _discover_versions(self) -> None: + """Scan filesystem to discover API versions and modules.""" + if not self.api_base_path.exists(): + logger.warning(f"API base path not found: {self.api_base_path}") + return + + # Scan api/ directory for version directories (v1/, v2/, etc.) + for version_dir in self.api_base_path.iterdir(): + if not version_dir.is_dir(): + continue + + # Must start with 'v' and contain digits + if not version_dir.name.startswith('v'): + continue + + # Extract version string: v1 -> 1, v2_1 -> 2.1 + version_str = version_dir.name[1:].replace('_', '.') + + # Find module implementations in this version + module_files = [ + f for f in version_dir.glob('*.py') + if not f.name.startswith('_') and f.name != 'generated' + ] + + module_names = [f.stem for f in module_files] + + # Store version info + self.versions[version_str] = module_names + + # Update module -> versions mapping + for module_name in module_names: + if module_name not in self.module_versions: + self.module_versions[module_name] = [] + self.module_versions[module_name].append(version_str) + + # Sort version lists + for module_name in self.module_versions: + self.module_versions[module_name].sort(key=version.parse) + + logger.info( + f"Discovered {len(self.versions)} API versions: " + f"{sorted(self.versions.keys(), key=version.parse)}" + ) + + def get_supported_versions(self) -> List[str]: + """ + Get all discovered API versions, sorted. + + Returns: + List of version strings (e.g., ['1', '2', '2.1']) + """ + return sorted(self.versions.keys(), key=version.parse) + + def get_latest_version(self) -> Optional[str]: + """ + Get the latest available API version. + + Returns: + Latest version string, or None if no versions found + """ + versions = self.get_supported_versions() + return versions[-1] if versions else None + + def get_modules_for_version(self, api_version: str) -> List[str]: + """ + Get list of modules available for a specific API version. + + Args: + api_version: Version string (e.g., '1', '2.1') + + Returns: + List of module names + """ + return self.versions.get(api_version, []) + + def get_versions_for_module(self, module_name: str) -> List[str]: + """ + Get list of API versions that implement a module. + + Args: + module_name: Module name (e.g., 'user', 'organization') + + Returns: + List of version strings + """ + return self.module_versions.get(module_name, []) + + def find_best_version( + self, + requested_version: str, + module_name: str + ) -> Optional[str]: + """ + Find the best available version for a module. + + Strategy: + 1. Try exact match + 2. Try closest lower version (backward compatible) + 3. Try closest higher version (forward compatible, with warning) + + Args: + requested_version: Desired API version + module_name: Module name + + Returns: + Best matching version string, or None if not found + """ + available = self.get_versions_for_module(module_name) + + if not available: + logger.error( + f"Module '{module_name}' not found in any API version" + ) + return None + + requested = version.parse(requested_version) + available_parsed = [(v, version.parse(v)) for v in available] + + # Exact match + if requested_version in available: + return requested_version + + # Find closest lower version (prefer backward compatibility) + lower_versions = [ + (v, vp) for v, vp in available_parsed if vp <= requested + ] + + if lower_versions: + best = max(lower_versions, key=lambda x: x[1])[0] + logger.warning( + f"Using version {best} for {module_name} " + f"(requested {requested_version}, closest lower version)" + ) + return best + + # Fallback: closest higher version + higher_versions = [ + (v, vp) for v, vp in available_parsed if vp > requested + ] + + if higher_versions: + best = min(higher_versions, key=lambda x: x[1])[0] + logger.warning( + f"Using version {best} for {module_name} " + f"(requested {requested_version}, closest higher version - " + f"may have compatibility issues)" + ) + return best + + return None + + def module_supports_version( + self, + module_name: str, + api_version: str + ) -> bool: + """ + Check if a module has an implementation for an API version. + + Args: + module_name: Module name + api_version: Version string + + Returns: + True if module exists for version + """ + return api_version in self.get_versions_for_module(module_name) + + diff --git a/plugins/plugin_utils/platform/retry.py b/plugins/plugin_utils/platform/retry.py new file mode 100644 index 0000000..9fe8d28 --- /dev/null +++ b/plugins/plugin_utils/platform/retry.py @@ -0,0 +1,286 @@ +""" +Retry Logic for Platform Operations. + +This module provides retry decorators and utilities for handling +transient failures with exponential backoff. +""" + +import logging +import time +import functools +from typing import Callable, TypeVar, Optional, Dict, Any +from .exceptions import PlatformError + +logger = logging.getLogger(__name__) + +T = TypeVar('T') + + +class RetryConfig: + """ + Configuration for retry behavior. + """ + + def __init__( + self, + max_attempts: int = 3, + initial_delay: float = 1.0, + max_delay: float = 60.0, + exponential_base: float = 2.0, + jitter: bool = True + ): + """ + Initialize retry configuration. + + Args: + max_attempts: Maximum number of retry attempts (default: 3) + initial_delay: Initial delay in seconds (default: 1.0) + max_delay: Maximum delay in seconds (default: 60.0) + exponential_base: Base for exponential backoff (default: 2.0) + jitter: Whether to add random jitter to delays (default: True) + """ + self.max_attempts = max_attempts + self.initial_delay = initial_delay + self.max_delay = max_delay + self.exponential_base = exponential_base + self.jitter = jitter + + def calculate_delay(self, attempt: int) -> float: + """ + Calculate delay for retry attempt. + + Args: + attempt: Attempt number (0-indexed) + + Returns: + Delay in seconds + """ + # Exponential backoff: delay = initial_delay * (base ^ attempt) + delay = self.initial_delay * (self.exponential_base ** attempt) + + # Cap at max_delay + delay = min(delay, self.max_delay) + + # Add jitter to prevent thundering herd + if self.jitter: + import random + jitter_amount = delay * 0.1 # 10% jitter + delay = delay + random.uniform(-jitter_amount, jitter_amount) + delay = max(0, delay) # Ensure non-negative + + return delay + + +# Default retry configuration +DEFAULT_RETRY_CONFIG = RetryConfig( + max_attempts=3, + initial_delay=1.0, + max_delay=60.0, + exponential_base=2.0, + jitter=True +) + + +def retry_on_failure( + config: Optional[RetryConfig] = None, + retryable_exceptions: Optional[tuple] = None +) -> Callable: + """ + Decorator for retrying operations on transient failures. + + Args: + config: Retry configuration (uses default if not provided) + retryable_exceptions: Tuple of exception types to retry (default: PlatformError) + + Returns: + Decorated function with retry logic + """ + if config is None: + config = DEFAULT_RETRY_CONFIG + + if retryable_exceptions is None: + retryable_exceptions = (PlatformError,) + + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @functools.wraps(func) + def wrapper(*args, **kwargs) -> T: + last_exception = None + operation = kwargs.get('operation') or getattr(args[0] if args else None, 'operation', 'unknown') + resource = kwargs.get('resource') or getattr(args[0] if args else None, 'resource', 'unknown') + + for attempt in range(config.max_attempts): + try: + return func(*args, **kwargs) + + except Exception as e: + last_exception = e + + # Check if exception is retryable + is_retryable = False + if isinstance(e, PlatformError): + is_retryable = getattr(e, 'retryable', False) + elif isinstance(e, retryable_exceptions): + is_retryable = True + + # Don't retry if not retryable or last attempt + if not is_retryable or attempt == config.max_attempts - 1: + logger.debug( + f"Not retrying {func.__name__} (attempt {attempt + 1}/{config.max_attempts}): " + f"retryable={is_retryable}, exception={type(e).__name__}" + ) + raise + + # Calculate delay for next retry + delay = config.calculate_delay(attempt) + + logger.warning( + f"Retrying {func.__name__} (attempt {attempt + 1}/{config.max_attempts}) " + f"after {delay:.2f}s: {type(e).__name__}: {str(e)}" + ) + + # Wait before retry + time.sleep(delay) + + # If we get here, all retries failed + if last_exception: + raise last_exception + + # Should never reach here, but just in case + raise RuntimeError(f"Retry logic failed for {func.__name__}") + + return wrapper + return decorator + + +def retry_http_request( + config: Optional[RetryConfig] = None +) -> Callable: + """ + Decorator specifically for HTTP requests with retry logic. + + This decorator handles: + - Network errors (retryable) + - Timeout errors (retryable) + - 5xx server errors (retryable) + - 429 rate limit errors (retryable) + - 4xx client errors (not retryable, except 408, 429) + + Args: + config: Retry configuration (uses default if not provided) + + Returns: + Decorated function with HTTP retry logic + """ + if config is None: + config = DEFAULT_RETRY_CONFIG + + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @functools.wraps(func) + def wrapper(*args, **kwargs) -> T: + import requests + from .exceptions import ( + NetworkError, TimeoutError, APIError, classify_exception + ) + + last_exception = None + operation = kwargs.get('operation', 'http_request') + resource = kwargs.get('resource', 'unknown') + + for attempt in range(config.max_attempts): + try: + response = func(*args, **kwargs) + + # Check for HTTP error status codes + if hasattr(response, 'status_code'): + status_code = response.status_code + + # Retry on 5xx errors or specific 4xx errors + if status_code >= 500 or status_code in [408, 429]: + # Create APIError for retryable status codes + error = APIError( + message=f"HTTP {status_code} error", + operation=operation, + resource=resource, + details={'status_code': status_code}, + status_code=status_code + ) + + # Check if we should retry + if error.retryable and attempt < config.max_attempts - 1: + delay = config.calculate_delay(attempt) + logger.warning( + f"Retrying HTTP request (attempt {attempt + 1}/{config.max_attempts}) " + f"after {delay:.2f}s: HTTP {status_code}" + ) + time.sleep(delay) + continue + else: + raise error + + return response + + except (requests.exceptions.Timeout, TimeoutError) as e: + last_exception = e + if attempt < config.max_attempts - 1: + delay = config.calculate_delay(attempt) + logger.warning( + f"Retrying HTTP request (attempt {attempt + 1}/{config.max_attempts}) " + f"after {delay:.2f}s: Timeout error" + ) + time.sleep(delay) + continue + else: + raise TimeoutError( + message=f"Request timed out after {config.max_attempts} attempts: {str(e)}", + operation=operation, + resource=resource, + details={'original_exception': str(e)}, + timeout_seconds=getattr(e, 'timeout', None) + ) + + except (requests.exceptions.ConnectionError, requests.exceptions.SSLError, NetworkError) as e: + last_exception = e + if attempt < config.max_attempts - 1: + delay = config.calculate_delay(attempt) + logger.warning( + f"Retrying HTTP request (attempt {attempt + 1}/{config.max_attempts}) " + f"after {delay:.2f}s: Network error" + ) + time.sleep(delay) + continue + else: + if isinstance(e, NetworkError): + raise + else: + raise NetworkError( + message=f"Network error after {config.max_attempts} attempts: {str(e)}", + operation=operation, + resource=resource, + details={'original_exception': str(e)}, + original_exception=e + ) + + except Exception as e: + # Classify exception and check if retryable + platform_error = classify_exception(e, operation, resource) + + if platform_error.retryable and attempt < config.max_attempts - 1: + delay = config.calculate_delay(attempt) + logger.warning( + f"Retrying HTTP request (attempt {attempt + 1}/{config.max_attempts}) " + f"after {delay:.2f}s: {type(e).__name__}" + ) + time.sleep(delay) + continue + else: + raise platform_error + + # If we get here, all retries failed + if last_exception: + raise last_exception + + raise RuntimeError(f"Retry logic failed for {func.__name__}") + + return wrapper + return decorator + diff --git a/plugins/plugin_utils/platform/types.py b/plugins/plugin_utils/platform/types.py new file mode 100644 index 0000000..459f355 --- /dev/null +++ b/plugins/plugin_utils/platform/types.py @@ -0,0 +1,81 @@ +"""Shared type definitions for the platform collection. + +This module contains dataclasses and type definitions used throughout +the framework. +""" + +from dataclasses import dataclass +from typing import List, Optional, Dict, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from requests import Session + from ..manager.platform_manager import PlatformService + + +@dataclass +class EndpointOperation: + """ + Configuration for a single API endpoint operation. + + Defines how to call a specific API endpoint, what data to send, + and how it relates to other operations. + + Attributes: + path: API endpoint path (e.g., '/api/gateway/v1/users/') + method: HTTP method ('GET', 'POST', 'PATCH', 'DELETE') + fields: List of dataclass field names to include in request + path_params: Optional list of path parameter names (e.g., ['id']) + required_for: Optional operation type this is required for + ('create', 'update', 'delete', or None for always) + depends_on: Optional name of operation this depends on + order: Execution order (lower runs first) + + Examples: + >>> # Main create operation + >>> EndpointOperation( + ... path='/api/gateway/v1/users/', + ... method='POST', + ... fields=['username', 'email'], + ... order=1 + ... ) + + >>> # Dependent operation (runs after create) + >>> EndpointOperation( + ... path='/api/gateway/v1/users/{id}/organizations/', + ... method='POST', + ... fields=['organizations'], + ... path_params=['id'], + ... depends_on='create', + ... order=2 + ... ) + """ + + path: str + method: str + fields: List[str] + path_params: Optional[List[str]] = None + required_for: Optional[str] = None + depends_on: Optional[str] = None + order: int = 0 + + +@dataclass +class TransformContext: + """ + Context for data transformations between Ansible and API formats. + + This dataclass provides type-safe access to transformation context + instead of using Dict[str, Any], which improves mypy type checking. + + Attributes: + manager: PlatformService instance for lookups and API operations + session: HTTP session for making requests + cache: Lookup cache (e.g., org names ↔ IDs) + api_version: Current API version string + """ + manager: 'PlatformService' + session: 'Session' + cache: Dict[str, Any] + api_version: str + + diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py new file mode 100644 index 0000000..d101dd1 --- /dev/null +++ b/tools/mock_gateway_server.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +""" +Local mock server for AAP Gateway API. + +Purpose +------- +Gateway API v2 does not exist (yet), but we still want to validate our-side +multi-version routing/selection and isolation behavior for ANSTRAT-1640. + +This server implements a minimal subset of endpoints used by the POC: + - GET /api/gateway/v1/ping/ + - GET /api/gateway/v2/ping/ + - GET/POST /api/gateway/v{1,2}/users/ + - GET/PATCH/DELETE /api/gateway/v{1,2}/users/{id}/ + - GET /api/gateway/v{1,2}/organizations/?name= + - GET /api/gateway/v{1,2}/organizations/{id}/ + +Notes +----- +- Auth is intentionally permissive: if an Authorization header is present, we accept it. + This keeps the mock focused on client behavior, not auth correctness. +- Data is stored in-memory and resets on restart. +""" + +from __future__ import annotations + +import argparse +import json +import threading +import time +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict, Optional +from urllib.parse import parse_qs, urlparse + + +def _now_iso() -> str: + # Good enough for test output + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +@dataclass +class Store: + lock: threading.Lock = field(default_factory=threading.Lock) + next_user_id: int = 1000 + users: Dict[int, Dict[str, Any]] = field(default_factory=dict) + # Pre-seed orgs used by lookup logic (name -> id) + orgs_by_id: Dict[int, Dict[str, Any]] = field(default_factory=dict) + orgs_by_name: Dict[str, int] = field(default_factory=dict) + + def seed_defaults(self) -> None: + with self.lock: + if self.orgs_by_id: + return + # Minimal org objects for name/id lookup. + default_orgs = [ + {"id": 1, "name": "Default"}, + {"id": 2, "name": "Engineering"}, + {"id": 3, "name": "DevOps"}, + ] + for org in default_orgs: + self.orgs_by_id[org["id"]] = org + self.orgs_by_name[org["name"]] = org["id"] + + def create_user(self, version: str, payload: Dict[str, Any]) -> Dict[str, Any]: + with self.lock: + user_id = self.next_user_id + self.next_user_id += 1 + + username = payload.get("username") + if not username: + raise ValueError("username is required") + + user = { + "id": user_id, + "username": username, + "email": payload.get("email"), + "first_name": payload.get("first_name", ""), + "last_name": payload.get("last_name", ""), + "is_superuser": payload.get("is_superuser", False), + "is_platform_auditor": payload.get("is_platform_auditor", False), + "created": _now_iso(), + "modified": _now_iso(), + "url": f"/api/gateway/v{version}/users/{user_id}/", + # mimic redaction in real outputs + "password": "$encrypted$" if payload.get("password") else None, + } + self.users[user_id] = user + return user + + def list_users(self, username: Optional[str] = None) -> Dict[str, Any]: + with self.lock: + items = list(self.users.values()) + if username: + items = [u for u in items if u.get("username") == username] + return {"count": len(items), "results": items} + + def get_user(self, user_id: int) -> Dict[str, Any]: + with self.lock: + if user_id not in self.users: + raise KeyError("not found") + return self.users[user_id] + + def patch_user(self, user_id: int, payload: Dict[str, Any]) -> Dict[str, Any]: + with self.lock: + if user_id not in self.users: + raise KeyError("not found") + user = dict(self.users[user_id]) + for k, v in payload.items(): + # allow patch of known fields only (keep it simple) + if k in { + "username", + "email", + "first_name", + "last_name", + "password", + "is_superuser", + "is_platform_auditor", + }: + user[k] = "$encrypted$" if k == "password" and v else v + user["modified"] = _now_iso() + self.users[user_id] = user + return user + + def delete_user(self, user_id: int) -> None: + with self.lock: + if user_id not in self.users: + raise KeyError("not found") + del self.users[user_id] + + def find_orgs_by_name(self, name: str) -> Dict[str, Any]: + self.seed_defaults() + with self.lock: + org_id = self.orgs_by_name.get(name) + if not org_id: + return {"count": 0, "results": []} + return {"count": 1, "results": [self.orgs_by_id[org_id]]} + + def get_org(self, org_id: int) -> Dict[str, Any]: + self.seed_defaults() + with self.lock: + if org_id not in self.orgs_by_id: + raise KeyError("not found") + return self.orgs_by_id[org_id] + + +class MockGatewayHandler(BaseHTTPRequestHandler): + server_version = "MockGateway/0.1" + + # Populated from server instance + store: Store + reported_api_version: str + + def log_message(self, fmt: str, *args) -> None: + # Reduce noise; comment out if you want request logs. + return + + def _send_json(self, code: int, payload: Any, headers: Optional[Dict[str, str]] = None) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + if headers: + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _send_empty(self, code: int) -> None: + self.send_response(code) + self.end_headers() + + def _require_auth(self) -> bool: + # Very permissive: accept any Authorization header + return bool(self.headers.get("Authorization")) + + def _parse_json_body(self) -> Dict[str, Any]: + length = int(self.headers.get("Content-Length", "0") or "0") + if length <= 0: + return {} + raw = self.rfile.read(length) + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + def _route(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + qs = parse_qs(parsed.query or "") + + # Auth: return 401 if missing header (matches our client expectations enough) + if not self._require_auth(): + self._send_json(401, {"detail": "Missing Authorization header"}) + return + + # Match /api/gateway/v{n}/... + parts = [p for p in path.split("/") if p] + if len(parts) < 3 or parts[0] != "api" or parts[1] != "gateway": + self._send_json(404, {"detail": "Not Found"}) + return + + version_part = parts[2] # e.g. v1, v2 + if not version_part.startswith("v"): + self._send_json(404, {"detail": "Not Found"}) + return + version = version_part[1:] + + # /api/gateway/vX/ping/ + if len(parts) == 4 and parts[3] == "ping" and self.command == "GET": + headers = {"X-API-Version": self.reported_api_version} + self._send_json(200, {"version": self.reported_api_version}, headers=headers) + return + + # /api/gateway/vX/users/ + if len(parts) == 4 and parts[3] == "users": + if self.command == "GET": + username = (qs.get("username") or [None])[0] + self._send_json(200, self.store.list_users(username=username)) + return + if self.command == "POST": + try: + payload = self._parse_json_body() + created = self.store.create_user(version=version, payload=payload) + self._send_json(201, created) + except ValueError as e: + self._send_json(400, {"detail": str(e)}) + return + + # /api/gateway/vX/users/{id}/ + if len(parts) == 5 and parts[3] == "users": + try: + user_id = int(parts[4]) + except ValueError: + self._send_json(404, {"detail": "Not Found"}) + return + + if self.command == "GET": + try: + self._send_json(200, self.store.get_user(user_id)) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + if self.command == "PATCH": + try: + payload = self._parse_json_body() + self._send_json(200, self.store.patch_user(user_id, payload)) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + if self.command == "DELETE": + try: + self.store.delete_user(user_id) + self._send_empty(204) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + + # /api/gateway/vX/organizations/ + if len(parts) == 4 and parts[3] == "organizations" and self.command == "GET": + name = (qs.get("name") or [None])[0] + if not name: + self._send_json(200, {"count": 0, "results": []}) + return + self._send_json(200, self.store.find_orgs_by_name(name)) + return + + # /api/gateway/vX/organizations/{id}/ + if len(parts) == 5 and parts[3] == "organizations" and self.command == "GET": + try: + org_id = int(parts[4]) + except ValueError: + self._send_json(404, {"detail": "Not Found"}) + return + try: + self._send_json(200, self.store.get_org(org_id)) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + + self._send_json(404, {"detail": "Not Found"}) + + def do_GET(self) -> None: # noqa: N802 + self._route() + + def do_POST(self) -> None: # noqa: N802 + self._route() + + def do_PATCH(self) -> None: # noqa: N802 + self._route() + + def do_DELETE(self) -> None: # noqa: N802 + self._route() + + +class MockGatewayServer(ThreadingHTTPServer): + def __init__(self, server_address, RequestHandlerClass, *, store: Store, reported_api_version: str): + super().__init__(server_address, RequestHandlerClass) + self.store = store + self.reported_api_version = reported_api_version + + +def main() -> int: + parser = argparse.ArgumentParser(description="Mock AAP Gateway API server (v1 + mocked v2).") + parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)") + parser.add_argument( + "--reported-api-version", + default="1", + help="Version reported by /api/gateway/v1/ping/ via X-API-Version and JSON (default: 1)", + ) + args = parser.parse_args() + + store = Store() + store.seed_defaults() + + # Inject store + version into handler via class attributes. + MockGatewayHandler.store = store + MockGatewayHandler.reported_api_version = str(args.reported_api_version) + + httpd = MockGatewayServer((args.host, args.port), MockGatewayHandler, store=store, reported_api_version=str(args.reported_api_version)) + print(f"Mock Gateway listening on http://{args.host}:{args.port} (reported_api_version={args.reported_api_version})") + print("Endpoints: /api/gateway/v{1,2}/ping/, /api/gateway/v{1,2}/users/, /api/gateway/v{1,2}/organizations/") + httpd.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + diff --git a/tools/scripts/get_aap_gateway_and_dab.py b/tools/scripts/get_aap_gateway_and_dab.py deleted file mode 100755 index 1fb0d26..0000000 --- a/tools/scripts/get_aap_gateway_and_dab.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import base64 - -import requests - -GH_WORKSPACE = os.environ.get('GH_WORKSPACE', '') -TOKEN = os.environ.get('GH_TOKEN') - -GH_API_HEADERS = { - "Authorization": f"token {TOKEN}", - "Accept": "application/vnd.github.v3+json" -} - - -def _git_auth_header(): - """Create the authorization header. - - helpful: https://github.com/actions/checkout/blob/main/src/git-auth-helper.ts#L56 - - :param token: The token - :return: The base64 encoded token and the authorization header cli parameter - """ - basic = f"x-access-token:{TOKEN}" - basic_encoded = base64.b64encode(basic.encode("utf-8")).decode("utf-8") - return basic_encoded - - -def _git_clone(repo_url, branch, local_destination): - """Clone the a repo with a specified branch in local directory. - :param repo_url: The repository to clone. - :param branch: The branch in the repository to clone. - :param local_destination: The local directory where the repo will be cloned. - """ - print(f'Checking out {branch} branch of {repo_url} into {GH_WORKSPACE}/{local_destination}') - os.system(f"git clone {repo_url} -b {branch} --depth=1 -c http.extraheader='AUTHORIZATION: basic {_git_auth_header()}' {GH_WORKSPACE}/{local_destination}") - - -def _get_requires(pr_body, target): - """Return the Pull Request number specified as required. - - :param pr_body: The Pull Request body to parse. - :param target: The repository name containing the Pull Request. - """ - requires_re = re.compile(f'requires.*ansible-automation-platform/{target}(?:#|/pull/)([0-9]+)', re.IGNORECASE) - matches = requires_re.search(pr_body) - if matches: - return matches.group(1) - - -def _checkout_aap_gateway(pr_body): - """Checkout aap-gateway, either from devel OR from a specified Pull Request. - Return the body of the specified Pull Request, if any. - :param pr_body: The ansible.platform PR body. - """ - repo_url = 'https://github.com/ansible-automation-platform/aap-gateway' - branch = 'devel' - aap_gateway_pr_body = "" - - required_pr = _get_requires(pr_body, target="aap-gateway") - if required_pr: - print(f"This ansible.platform PR requires aap-gateway PR {required_pr}") - url = f'https://api.github.com/repos/ansible-automation-platform/aap-gateway/pulls/{required_pr}' - response = requests.get(url, headers=GH_API_HEADERS) - pr_data = response.json() - merged = pr_data['merged'] - - if not merged: - # if PR is not merged, checkout the repo and branch specified by "Requires" - repo_url = pr_data['head']['repo']['html_url'] - branch = pr_data['head']['ref'] - aap_gateway_pr_body = pr_data.get('body', '') - else: - print(f"The referenced PR {required_pr} of aap-gateway has been merged already, no need to check out the branch!") - - _git_clone(repo_url=repo_url, branch=branch, local_destination='aap-gateway') - - return aap_gateway_pr_body - - -def _checkout_django_ansible_base(pr_body): - """Checkout a django-ansible-base branch if specified in an aap-gateway Pull Request. - :param pr_body: The aap-gateway PR body. - """ - required_pr = _get_requires(pr_body, target="django-ansible-base") - - if required_pr: - print(f"This aap-gateway PR requires django-ansible-base PR {required_pr}") - url = f'https://api.github.com/repos/ansible/django-ansible-base/pulls/{required_pr}' - response = requests.get(url) - pr_data = response.json() - merged = pr_data['merged'] - - if not merged: - # if PR is not merged, checkout the repo and branch specified by "Requires" - repo_url = pr_data['head']['repo']['html_url'] - branch = pr_data['head']['ref'] - _git_clone(repo_url=repo_url, branch=branch, local_destination='aap-gateway/django-ansible-base') - else: - print(f"The referenced PR {required_pr} of django-ansible-base has been merged already, no need to check out the branch!") - else: - print("No django-ansible-base PR was specified!") - - -def main(): - # get ansible.platform Pull Request body - platform_pr_body = os.environ.get('PR_BODY', '') - - # checkout aap-gateway - aap_gateway_pr_body = _checkout_aap_gateway(pr_body=platform_pr_body) - - # checkout a specific DAB branch (only if specified in aap-gateway PR) - _checkout_django_ansible_base(pr_body=aap_gateway_pr_body) - - -if __name__ == "__main__": - main()