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()