diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5662a2e3b..2eff9614f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -102,6 +102,15 @@ jobs: - name: Install ansible-base (v${{ matrix.ansible }}) run: pip install https://github.com/ansible/ansible/archive/v${{ matrix.ansible }}.tar.gz --disable-pip-version-check + + - name: Install Pydantic (v2) + run: pip install pydantic==2.11.10 + + - name: Install Requests + run: pip install requests==2.32.5 + + - name: Install DeepDiff (v8.5.0) + run: pip install deepdiff==8.5.0 - name: Install coverage (v7.3.4) run: pip install coverage==7.3.4 @@ -109,9 +118,6 @@ jobs: - name: Install pytest (v7.4.4) run: pip install pytest==7.4.4 - - name: Install requests - run: pip install requests - - name: Download migrated collection artifacts uses: actions/download-artifact@v4.1.7 with: diff --git a/plugins/module_utils/common/api/MIGRATION_EXAMPLE.md b/plugins/module_utils/common/api/MIGRATION_EXAMPLE.md new file mode 100644 index 000000000..b97bc90a1 --- /dev/null +++ b/plugins/module_utils/common/api/MIGRATION_EXAMPLE.md @@ -0,0 +1,411 @@ +# API Endpoint Migration: Inheritance to Composition + +This document demonstrates the migration from inheritance-based API endpoints to Pydantic models with composition. + +## Benefits of the New Approach + +1. **Centralized Path Management**: All base paths in one place (`base_paths.py`) +2. **Type Safety**: Pydantic validation and IDE autocomplete +3. **Testability**: Each endpoint is self-contained and easy to test +4. **Maintainability**: Clear, explicit endpoint definitions +5. **Flexibility**: No deep inheritance chains to navigate + +## Side-by-Side Comparison + +### Old Approach (Inheritance) + +```python +# Deep inheritance chain: +# EpFabricConfigDeploy → Fabrics → Control → Rest → LanFabric → V1 → Api + +from ..control import Control + +class Fabrics(Control): + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.fabric_types = FabricTypes() + self.fabrics = f"{self.control}/fabrics" # Builds on parent's path + self._build_properties() + +class EpFabricConfigDeploy(Fabrics): + def __init__(self): + super().__init__() + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.required_properties.add("fabric_name") + self._build_properties() + + def _build_properties(self): + super()._build_properties() + self.properties["force_show_run"] = False + self.properties["include_all_msd_switches"] = False + self.properties["switch_id"] = None + self.properties["verb"] = "POST" + + @property + def path(self): + _path = self.path_fabric_name + _path += "/config-deploy" + if self.switch_id: + _path += f"/{self.switch_id}" + _path += f"?forceShowRun={self.force_show_run}" + if not self.switch_id: + _path += f"&inclAllMSDSwitches={self.include_all_msd_switches}" + return _path + + @property + def fabric_name(self): + return self.properties["fabric_name"] + + @fabric_name.setter + def fabric_name(self, value): + # Validation logic... + self.properties["fabric_name"] = value + + # More boilerplate... +``` + +**Usage:** + +```python +endpoint = EpFabricConfigDeploy() +endpoint.fabric_name = "MyFabric" +endpoint.switch_id = ["CHM1234567", "CHM7654321"] +endpoint.force_show_run = True +path = endpoint.path +verb = endpoint.verb +``` + +**Problems:** + +- Must navigate 6 levels of inheritance to understand path building +- Path construction spread across multiple classes +- Heavy boilerplate (logging, properties dict, getters/setters) +- No type hints or validation +- Changing base paths requires editing multiple files + +--- + +### New Approach (Pydantic + Composition with Query Parameters) + +```python +from typing import Literal +from pydantic import BaseModel, Field +from ...base_paths import BasePath +from ...query_params import EndpointQueryParams, CompositeQueryParams + +class ConfigDeployQueryParams(EndpointQueryParams): + """Query parameters for config-deploy endpoint.""" + force_show_run: bool = False + include_all_msd_switches: bool = False + +class EpFabricConfigDeploy(BaseModel): + """Fabric Config Deploy Endpoint""" + + # Path parameters (optional for property-style interface) + fabric_name: str | None = Field(None, min_length=1) + switch_id: str | list[str] | None = None + + # Query parameter objects (composition) + query_params: ConfigDeployQueryParams = Field(default_factory=ConfigDeployQueryParams) + + @property + def path(self) -> str: + # Validate required parameters when path is accessed + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + segments = [self.fabric_name, "config-deploy"] + if self.switch_id: + segments.append(self.switch_id) + + base_path = BasePath.control_fabrics(*segments) + + # Build composite query string + composite = CompositeQueryParams() + composite.add(self.query_params) + + query_string = composite.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["POST"]: + return "POST" +``` + +**Usage (Full Property-Style Interface):** + +```python +# Create empty endpoint +request = EpFabricConfigDeploy() + +# Set path parameters via properties +request.fabric_name = "MyFabric" +request.switch_id = ["CHM1234567", "CHM7654321"] + +# Set query parameters via properties +request.query_params.force_show_run = True +request.query_params.include_all_msd_switches = True + +path = request.path +verb = request.verb +``` + +**Benefits:** + +- **Consistent Interface**: Both path and query parameters use property-style interface +- **Separation of Concerns**: Path parameters vs query parameters clearly distinguished +- **Type-safe**: Pydantic validation on all parameters +- **Composable**: Query params can be mixed (endpoint-specific + Lucene filtering) +- **Flexible**: Set parameters in any order, validation happens when accessing path +- **Maintainable**: Base paths in `BasePath` class +- **Less code**: ~40 lines vs ~100+ lines (including query param class) + +--- + +## Centralized Path Management + +### Old Approach + +Base paths scattered across inheritance hierarchy: + +```python +# api.py +class Api: + def __init__(self): + self.api = "/appcenter/cisco/ndfc/api" + +# v1.py +class V1(Api): + def __init__(self): + super().__init__() + self.v1 = f"{self.api}/v1" + +# lan_fabric.py +class LanFabric(V1): + def __init__(self): + super().__init__() + self.lan_fabric = f"{self.v1}/lan-fabric" + +# rest.py +class Rest(LanFabric): + def __init__(self): + super().__init__() + self.rest = f"{self.lan_fabric}/rest" + +# control.py +class Control(Rest): + def __init__(self): + super().__init__() + self.control = f"{self.rest}/control" + +# fabrics.py +class Fabrics(Control): + def __init__(self): + super().__init__() + self.fabrics = f"{self.control}/fabrics" +``` + +To change `/appcenter/cisco/ndfc/api` → you must edit `api.py` (but affects all endpoints). + +--- + +### New Approach + +All base paths in ONE location: + +```python +# base_paths.py +class BasePath: + NDFC_API: Final = "/appcenter/cisco/ndfc/api" + ONEMANAGE: Final = "/onemanage" + + @classmethod + def control_fabrics(cls, *segments: str) -> str: + return f"{cls.NDFC_API}/v1/lan-fabric/rest/control/fabrics/{'/'.join(segments)}" +``` + +To change base path → edit ONE constant in `base_paths.py`. + +--- + +## Migration Strategy + +### Phase 1: Create Infrastructure + +1. ✅ Create `base_paths.py` with centralized path builders +2. ✅ Create example Pydantic endpoint models in `endpoints.py` + +### Phase 2: Gradual Migration + +1. Keep old inheritance-based endpoints for backward compatibility +2. Create new Pydantic endpoints alongside old ones +3. Update calling code to use new endpoints incrementally +4. Remove old endpoints when all callers migrated + +### Phase 3: Cleanup + +1. Remove old inheritance hierarchy (`fabrics.py`, `control.py`, etc.) +2. Update documentation and examples + +--- + +## Testing Examples + +### Old Approach Testing + +```python +def test_fabric_config_deploy(): + endpoint = EpFabricConfigDeploy() + endpoint.fabric_name = "MyFabric" + endpoint.switch_id = "CHM1234567" + endpoint.force_show_run = True + + expected_path = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/MyFabric/config-deploy/CHM1234567?forceShowRun=True" + assert endpoint.path == expected_path + assert endpoint.verb == "POST" +``` + +### New Approach Testing + +```python +def test_fabric_config_deploy(): + # Create empty endpoint, then set all parameters via properties + request = EpFabricConfigDeploy() + + # Set path parameters + request.fabric_name = "MyFabric" + request.switch_id = "CHM1234567" + + # Set query parameters + request.query_params.force_show_run = True + + expected_path = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/MyFabric/config-deploy/CHM1234567?forceShowRun=true&inclAllMSDSwitches=false" + assert request.path == expected_path + assert request.verb == "POST" + +# Bonus: Pydantic validation testing +def test_validation(): + request = EpFabricConfigDeploy() + + # Validation occurs when accessing path + with pytest.raises(ValueError, match="fabric_name must be set"): + _ = request.path # Fails because fabric_name not set + + # Query params are type-safe via Pydantic + request.fabric_name = "MyFabric" + with pytest.raises(ValidationError): + request.query_params.force_show_run = "yes" # String not bool +``` + +--- + +## Real-World Usage Example + +### In a Module or Utility Class + +**Old:** + +```python +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.lan_fabric.rest.control.fabrics.fabrics import EpFabricConfigDeploy + +def deploy_fabric_config(rest_send, fabric_name, switch_ids): + endpoint = EpFabricConfigDeploy() + endpoint.fabric_name = fabric_name + endpoint.switch_id = switch_ids + endpoint.force_show_run = True + + rest_send.path = endpoint.path + rest_send.verb = endpoint.verb + rest_send.commit() +``` + +**New:** + +```python +from ansible_collections.cisco.dcnm.plugins.module_utils.common.api.v1.lan_fabric.endpoints import EpFabricConfigDeploy + +def deploy_fabric_config(rest_send, fabric_name, switch_ids): + # Create empty endpoint + request = EpFabricConfigDeploy() + + # Set path parameters + request.fabric_name = fabric_name + request.switch_id = switch_ids + + # Set query parameters + request.query_params.force_show_run = True + request.query_params.include_all_msd_switches = False + + rest_send.path = request.path + rest_send.verb = request.verb + rest_send.commit() +``` + +--- + +## Query Parameter Composition + +The new approach separates path parameters from query parameters and supports composition: + +### Path Parameters vs Query Parameters + +```python +# Create empty endpoint +request = EpFabricConfigDeploy() + +# Path parameters: set using properties +request.fabric_name = "MyFabric" # Path parameter +request.switch_id = "CHM1234567" # Path parameter + +# Query parameters: set using properties +request.query_params.force_show_run = True # Query parameter +``` + +### Composing Multiple Query Parameter Types + +For endpoints that support Lucene-style filtering: + +```python +# Create endpoint +request = EpFabricsList() + +# Set Lucene filtering parameters +lucene = request.lucene_params +lucene.filter = "name:Prod*" +lucene.max = 100 +lucene.sort = "name:asc" + +path = request.path +# Result: /api/v1/.../fabrics?filter=name:Prod*&max=100&sort=name:asc +``` + +### Benefits of Separation + +1. **Clear distinction** between path construction and query parameters +2. **Reusable** query parameter classes across endpoints +3. **Extensible** - easy to add new query parameter types (e.g., pagination) +4. **Type-safe** - Pydantic validates both path and query parameters + +--- + +## Summary + +| Aspect | Old (Inheritance) | New (Pydantic + Composition) | +|--------|------------------|------------------------------| +| Lines of code | 100+ per endpoint | ~40 per endpoint (with query params) | +| Inheritance depth | 6 levels | 1 level (BaseModel) | +| Type safety | Manual validation | Automatic Pydantic validation | +| Query params | Mixed with path params | Separated via composition | +| Lucene filtering | Not supported | Easy to add via composition | +| IDE support | Limited | Full autocomplete | +| Path changes | Edit multiple files | Edit one file (`base_paths.py`) | +| Testability | Complex setup | Simple instantiation | +| Readability | Must trace inheritance | Self-contained | +| Boilerplate | Heavy (logging, props dict) | Minimal | + +**Recommendation**: Use Pydantic + Composition approach for all new endpoints. The property-style interface for query parameters provides the flexibility you requested while maintaining type safety. diff --git a/plugins/module_utils/common/api/PROPERTY_STYLE_COMPARISON.md b/plugins/module_utils/common/api/PROPERTY_STYLE_COMPARISON.md new file mode 100644 index 000000000..219ed04f5 --- /dev/null +++ b/plugins/module_utils/common/api/PROPERTY_STYLE_COMPARISON.md @@ -0,0 +1,299 @@ +# Property-Style Interface: Constructor vs Property-Based + +This document compares two approaches for setting endpoint parameters and recommends the fully property-based approach for consistency. + +## Current Approach: Mixed (Constructor + Properties) + +### How It Works (constructor + properties) + +**Path parameters** are passed in the constructor, **query parameters** use properties: + +```python +# Path parameters in constructor +request = EpFabricConfigDeploy( + fabric_name="MyFabric", + switch_id="CHM1234567" +) + +# Query parameters via properties +request.query_params.force_show_run = True +request.lucene_params.filter = "name:Foo*" +``` + +### Pros (path params in constructor) + +- ✅ Forces required path parameters to be set upfront +- ✅ Constructor provides immediate validation of path parameters +- ✅ IDE autocomplete shows required parameters in constructor + +### Cons (path params in constructor) + +- ❌ **Inconsistent interface**: Path params (constructor) vs Query params (properties) +- ❌ **Less flexible**: Can't conditionally set path parameters +- ❌ **Harder to compose**: Can't easily build requests programmatically +- ❌ **Mixed patterns**: Two different ways to set parameters + +--- + +## Proposed Approach: Fully Property-Based + +### How It Works (fully property-based) + +**ALL parameters** (path and query) use the same property-style interface: + +```python +# Create empty endpoint +request = EpFabricConfigDeploy() + +# Set path parameters via properties +request.fabric_name = "MyFabric" +request.switch_id = "CHM1234567" + +# Set query parameters via properties (same style!) +request.query_params.force_show_run = True +request.lucene_params.filter = "name:Foo*" + +# Access path when ready +path = request.path +``` + +### Pros (path params as properties) + +- ✅ **Fully consistent interface**: All parameters use properties +- ✅ **More flexible**: Set parameters in any order +- ✅ **Better composition**: Easy to build requests programmatically +- ✅ **Clearer intent**: Explicit about what's being configured +- ✅ **Same pattern everywhere**: Path, endpoint query, and Lucene query params all work the same way + +### Cons (path params as properties) + +- ⚠️ **Validation delayed**: Path parameter validation happens when `.path` is accessed +- ⚠️ **More verbose**: Must check for `None` values in path property +- ⚠️ **Less obvious**: Required parameters not enforced by constructor + +--- + +## Side-by-Side Comparison + +### Example: Fabric Config Deploy + +#### Current Approach (Mixed) + +```python +# Create with path params in constructor +request = EpFabricConfigDeploy( + fabric_name="MyFabric", + switch_id=["CHM1234567", "CHM7654321"] +) + +# Set query params via properties +request.query_params.force_show_run = True +request.query_params.include_all_msd_switches = False + +# Add Lucene filtering via properties +request.lucene_params.filter = "serial:FDO*" +request.lucene_params.max = 50 + +path = request.path +``` + +#### Property-Based Approach + +```python +# Create empty request +request = EpFabricConfigDeploy() + +# Set path params via properties +request.fabric_name = "MyFabric" +request.switch_id = ["CHM1234567", "CHM7654321"] + +# Set query params via properties (same style!) +request.query_params.force_show_run = True +request.query_params.include_all_msd_switches = False + +# Add Lucene filtering via properties (same style!) +request.lucene_params.filter = "serial:FDO*" +request.lucene_params.max = 50 + +path = request.path +``` + +--- + +## Use Cases That Benefit from Full Property-Style + +### 1. Conditional Path Parameters + +```python +request = EpFabricConfigDeploy() +request.fabric_name = "MyFabric" + +# Conditionally add switch_id +if deploy_specific_switch: + request.switch_id = switch_serial + +path = request.path +``` + +### 2. Building Requests from Configuration + +```python +def build_request_from_config(config: dict): + request = EpFabricConfigDeploy() + + # Set path params from config + request.fabric_name = config["fabric_name"] + if "switch_id" in config: + request.switch_id = config["switch_id"] + + # Set query params from config + request.query_params.force_show_run = config.get("force_show_run", False) + + # Set filtering from config + if "filter" in config: + request.lucene_params.filter = config["filter"] + + return request +``` + +### 3. Programmatic Request Building + +```python +# Build request step by step +request = EpFabricConfigDeploy() + +# Step 1: Basic path params +request.fabric_name = get_fabric_name() + +# Step 2: Conditional parameters +if switches := get_switch_list(): + request.switch_id = switches + +# Step 3: Configuration-specific params +if should_force_show_run(): + request.query_params.force_show_run = True + +# Step 4: Apply filtering rules +apply_filters(request.lucene_params) + +# Step 5: Get final path +path = request.path +``` + +--- + +## Implementation Details + +### Validation Strategy + +**Current (Constructor):** + +```python +class EpFabricConfigDeploy(BaseModel): + # Required parameters enforced by Pydantic + fabric_name: str = Field(..., min_length=1) + switch_id: str | None = None +``` + +**Property-Based:** + +```python +class EpFabricConfigDeploy(BaseModel): + # All parameters optional, validated when path is accessed + fabric_name: str | None = Field(None, min_length=1) + switch_id: str | None = None + + @property + def path(self) -> str: + # Validate required parameters + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + # Build path... +``` + +### Error Handling + +**Constructor Approach:** + +```python +# Error at instantiation +try: + request = EpFabricConfigDeploy(fabric_name="") # ❌ Fails immediately +except ValidationError as e: + print(f"Invalid parameters: {e}") +``` + +**Property Approach:** + +```python +# Error when accessing path +request = EpFabricConfigDeploy() +request.fabric_name = "" # ❌ Fails during Pydantic validation + +try: + path = request.path # ❌ Or fails here if not set +except ValueError as e: + print(f"Missing required parameter: {e}") +``` + +--- + +## Comparison Table + +| Aspect | Constructor + Properties | Full Property-Style | +|--------|-------------------------|---------------------| +| **Consistency** | ❌ Mixed interface | ✅ Uniform interface | +| **Flexibility** | ⚠️ Limited (must set upfront) | ✅ High (set anytime) | +| **Validation timing** | ✅ Immediate | ⚠️ Delayed (on .path access) | +| **Composition** | ⚠️ Harder | ✅ Easier | +| **IDE support** | ✅ Good (constructor hints) | ✅ Good (property hints) | +| **Required params** | ✅ Enforced by type system | ⚠️ Enforced at runtime | +| **Code clarity** | ⚠️ Two patterns | ✅ One pattern | +| **Learning curve** | ⚠️ Must learn both styles | ✅ One style to learn | + +--- + +## Recommendation + +**Use the fully property-based approach** for maximum consistency and flexibility. + +### Why? + +1. **Consistency is King**: Having one way to set all parameters (path, query, Lucene) makes the API easier to learn and use +2. **Flexibility**: Allows building requests programmatically and conditionally +3. **Composition**: Fits better with the query parameter composition pattern you've already adopted +4. **Future-proof**: Easier to extend with new parameter types + +### Migration Path + +1. ✅ Create new `endpoints_property_style.py` with property-based approach +2. ✅ Migrate all 10 endpoint classes to property-style interface +3. ✅ All linters passing (black, isort, pylint 10/10, mypy) +4. ✅ Update documentation (MIGRATION_EXAMPLE.md, PROPERTY_STYLE_COMPARISON.md) +5. ✅ Replace old `endpoints.py` with property-style version +6. ⬜ Test thoroughly with real use cases +7. ⬜ Gradually migrate calling code + +### Trade-offs to Accept + +- **Delayed validation**: Accept that some errors appear when accessing `.path` instead of at instantiation +- **More explicit checks**: Add `if self.fabric_name is None` checks in path properties +- **Documentation**: Clearly document which parameters are required + +--- + +## Example Implementation + +See `endpoints.py` for complete working examples with: + +- Full property-style interface for all parameters (path and query) +- Proper validation in path properties (raises ValueError if required params not set) +- Comprehensive docstrings with usage examples +- All linters passing (black, isort, pylint 10/10, mypy) +- 10 endpoint classes fully migrated: + - EpFabricConfigDeploy, EpFabricConfigSave, EpFabricCreate, EpFabricsList + - EpMaintenanceModeDeploy, EpFabricDelete, EpFabricDetails, EpFabricUpdate + - EpMaintenanceModeEnable, EpMaintenanceModeDisable + +The implementation demonstrates that the property-based approach works well and provides the consistency you're looking for! diff --git a/plugins/module_utils/common/api/QUERY_PARAMS_DESIGN.md b/plugins/module_utils/common/api/QUERY_PARAMS_DESIGN.md new file mode 100644 index 000000000..52702faa1 --- /dev/null +++ b/plugins/module_utils/common/api/QUERY_PARAMS_DESIGN.md @@ -0,0 +1,395 @@ +# Query Parameters Design: Composition-Based Approach + +This document explains the design pattern for separating path parameters, endpoint-specific query parameters, and Lucene-style filtering query parameters using composition. + +## Problem Statement + +### Requirements + +1. **Distinguish** between path parameters (e.g., `fabric_name`, `serial_number`) and query parameters (e.g., `forceShowRun`, `filter`) +2. **Support endpoint-specific query parameters** (e.g., `ticketId`, `waitForModeChange`) +3. **Support generic Lucene-style filtering** (e.g., `filter=name:Foo*&max=100&sort=name:asc`) +4. **Fully property-style interface** for ALL parameters - path and query (consistent interface) +5. **Composable** - allow combining different parameter types without coupling + +### The Challenge + +In the previous simpler design, we passed everything to the constructor: + +```python +# Old approach - everything mixed together +request = EpFabricConfigDeploy( + fabric_name="MyFabric", # Path parameter + switch_id="CHM123", # Path parameter + force_show_run=True, # Query parameter + filter="name:Foo*" # Different type of query parameter +) +``` + +This doesn't distinguish between: + +- **Path parameters** (part of the URL path) +- **Endpoint-specific query parameters** (like `forceShowRun`) +- **Generic Lucene filtering** (like `filter`, `max`, `sort`) + +--- + +## Solution: Composition with Separate Parameter Objects + +### Design Overview + +```text +┌─────────────────────────────────────────────────────────┐ +│ EpFabricConfigDeploy │ +│ (Main endpoint request class) │ +├─────────────────────────────────────────────────────────┤ +│ Path Parameters: │ +│ - fabric_name: str │ +│ - switch_id: str | None │ +├─────────────────────────────────────────────────────────┤ +│ Query Parameter Objects (composition): │ +│ │ +│ ┌────────────────────────────────────────┐ │ +│ │ query_params: ConfigDeployQueryParams │ │ +│ │ - force_show_run: bool │ │ +│ │ - include_all_msd_switches: bool │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────┐ │ +│ │ lucene_params: LuceneQueryParams │ │ +│ │ - filter: str | None │ │ +│ │ - max: int | None │ │ +│ │ - sort: str | None │ │ +│ └────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Key Classes + +#### 1. `EndpointQueryParams` (Base Class) + +Abstract base for endpoint-specific query parameters. + +```python +class ConfigDeployQueryParams(EndpointQueryParams): + force_show_run: bool = False + include_all_msd_switches: bool = False +``` + +#### 2. `LuceneQueryParams` + +Generic Lucene-style filtering parameters. + +```python +class LuceneQueryParams(BaseModel): + filter: str | None = None + max: int | None = None + offset: int | None = None + sort: str | None = None + fields: str | None = None +``` + +#### 3. `CompositeQueryParams` + +Composes multiple parameter objects into a single query string. + +```python +composite = CompositeQueryParams() +composite.add(endpoint_params) +composite.add(lucene_params) +query_string = composite.to_query_string() +# Result: "forceShowRun=true&filter=name:Foo*&max=100" +``` + +--- + +## Usage Examples + +### Example 1: Full Property-Style Interface (Recommended) + +```python +# Create empty endpoint request +request = EpFabricConfigDeploy() + +# Set path parameters using properties +request.fabric_name = "MyFabric" +request.switch_id = "CHM1234567" + +# Set endpoint-specific query params using properties +query = request.query_params +query.force_show_run = True +query.include_all_msd_switches = False + +# Set Lucene filtering params using properties +lucene = request.lucene_params +lucene.filter = "name:Switch*" +lucene.max = 100 +lucene.sort = "name:asc" + +# Build the complete path with all query parameters +path = request.path +# Result: /api/v1/.../config-deploy/CHM1234567?forceShowRun=true&inclAllMSDSwitches=false&filter=name:Switch*&max=100&sort=name:asc + +verb = request.verb # "POST" +``` + +### Example 2: Minimal Property-Style (No Optional Params) + +```python +# Create empty endpoint request +request = EpFabricConfigDeploy() + +# Set only required path parameter +request.fabric_name = "MyFabric" + +# Set query parameters +request.query_params.force_show_run = True +request.lucene_params.filter = "state:deployed" +request.lucene_params.max = 50 + +path = request.path +``` + +### Example 3: Endpoint Without Lucene Filtering + +```python +# Some endpoints don't need Lucene filtering +request = EpFabricConfigSave() +request.fabric_name = "MyFabric" +request.query_params.ticket_id = "CHG0012345" + +path = request.path +# Result: /api/v1/.../config-save?ticketId=CHG0012345 +``` + +### Example 4: Query-Only Endpoint (List with Filtering) + +```python +# List all fabrics with Lucene filtering +request = EpFabricsList() + +lucene = request.lucene_params +lucene.filter = "name:Prod* AND state:deployed" +lucene.max = 50 +lucene.sort = "created:desc" + +path = request.path +# Result: /api/v1/.../fabrics?filter=name:Prod*%20AND%20state:deployed&max=50&sort=created:desc +``` + +--- + +## Benefits of This Design + +### 1. **Clear Separation of Concerns** + +- Path parameters: In the main request class +- Endpoint-specific query params: In `query_params` object +- Generic filtering: In `lucene_params` object + +### 2. **Type Safety** + +```python +request.query_params.force_show_run = "yes" # ❌ Pydantic validation error +request.query_params.force_show_run = True # ✅ Type-safe + +request.lucene_params.max = -1 # ❌ Validation error (min=1) +request.lucene_params.max = 100 # ✅ Valid +``` + +### 3. **Composability** + +You can mix and match different parameter types: + +```python +# Only endpoint params +request.query_params.force_show_run = True + +# Only Lucene params +request.lucene_params.filter = "name:Foo*" + +# Both together +request.query_params.force_show_run = True +request.lucene_params.filter = "name:Foo*" +``` + +### 4. **Extensibility** + +Adding new parameter types is easy: + +```python +# Future: Add pagination parameters +class PaginationQueryParams(EndpointQueryParams): + page: int = 1 + page_size: int = 50 + +class MyRequest(BaseModel): + fabric_name: str | None = Field(None, min_length=1) + query_params: ConfigDeployQueryParams = Field(default_factory=ConfigDeployQueryParams) + lucene_params: LuceneQueryParams = Field(default_factory=LuceneQueryParams) + pagination_params: PaginationQueryParams = Field(default_factory=PaginationQueryParams) + +# Usage with property-style interface +request = MyRequest() +request.fabric_name = "MyFabric" +request.pagination_params.page = 2 +request.pagination_params.page_size = 100 +``` + +### 5. **Testability** + +Each parameter type can be tested independently: + +```python +def test_config_deploy_query_params(): + params = ConfigDeployQueryParams(force_show_run=True) + assert params.to_query_string() == "forceShowRun=true&inclAllMSDSwitches=false" + +def test_lucene_query_params(): + params = LuceneQueryParams(filter="name:Foo*", max=100) + assert params.to_query_string() == "filter=name:Foo*&max=100" + +def test_composite(): + endpoint = ConfigDeployQueryParams(force_show_run=True) + lucene = LuceneQueryParams(filter="name:Foo*") + + composite = CompositeQueryParams() + composite.add(endpoint).add(lucene) + + assert composite.to_query_string() == "forceShowRun=true&inclAllMSDSwitches=false&filter=name:Foo*" +``` + +--- + +## Integrating Your Lucene Filter Class + +If you already have a Lucene filter implementation, you can integrate it easily: + +```python +# Your existing filter class +class YourLuceneFilter: + def __init__(self): + self.max = None + self.filter = None + self.sort = None + + def build_query_string(self) -> str: + # Your existing implementation + pass + +# Adapter to make it work with the composition pattern +class LuceneQueryParamsAdapter(BaseModel): + _filter_instance: YourLuceneFilter = None + + def __init__(self, filter_instance: YourLuceneFilter): + super().__init__() + self._filter_instance = filter_instance + + def to_query_string(self) -> str: + return self._filter_instance.build_query_string() + + def is_empty(self) -> bool: + return len(self.to_query_string()) == 0 + +# Use it in endpoint requests +request = EpFabricsList() +request.lucene_params = LuceneQueryParamsAdapter(your_existing_filter) +``` + +--- + +## Comparison: Old vs New Approach + +### Old Approach (All Parameters Mixed) + +```python +# Problem: Can't tell path params from query params +request = EpFabricConfigDeploy( + fabric_name="MyFabric", # Path + switch_id="CHM123", # Path + force_show_run=True, # Query + include_all_msd_switches=True, # Query + filter="name:Foo*", # Lucene query + max=100, # Lucene query + sort="name:asc" # Lucene query +) +``` + +**Problems:** + +- ❌ No distinction between path and query parameters +- ❌ Can't reuse Lucene filtering across endpoints +- ❌ Hard to add new parameter types +- ❌ Everything coupled in one constructor + +### New Approach (Composition with Separate Objects + Property-Style) + +```python +# Create empty endpoint +request = EpFabricConfigDeploy() + +# Path parameters set via properties +request.fabric_name = "MyFabric" +request.switch_id = "CHM123" + +# Endpoint-specific query parameters +request.query_params.force_show_run = True +request.query_params.include_all_msd_switches = True + +# Lucene filtering parameters +request.lucene_params.filter = "name:Foo*" +request.lucene_params.max = 100 +request.lucene_params.sort = "name:asc" + +path = request.path +``` + +**Benefits:** + +- ✅ Clear distinction: path vs endpoint query vs Lucene query +- ✅ Fully consistent property-style interface for ALL parameters +- ✅ Lucene filtering is reusable across all endpoints +- ✅ Easy to extend with new parameter types +- ✅ Type-safe with Pydantic validation +- ✅ Flexible: set parameters in any order + +--- + +## Migration Path + +### Phase 1: Create Query Parameter Infrastructure + +1. ✅ Create `query_params.py` with base classes +2. ✅ Create endpoint-specific param classes (ConfigDeployQueryParams, TicketIdQueryParams, MaintenanceModeQueryParams) +3. ✅ Create `LuceneQueryParams` +4. ✅ Create `CompositeQueryParams` + +### Phase 2: Update Endpoint Classes + +1. ✅ Add `query_params` field to endpoint requests +2. ✅ Add `lucene_params` field where applicable +3. ✅ Update `path` property to use `CompositeQueryParams` +4. ✅ Convert all path parameters to optional (property-style interface) +5. ✅ Add validation in `path` property for required parameters +6. ✅ All 10 endpoint classes migrated to full property-style interface + +### Phase 3: Integrate Your Existing Lucene Filter + +1. ⬜ Create adapter for your existing filter class (if needed) +2. ✅ New `LuceneQueryParams` class available for use +3. ⬜ Update calling code to use new interface + +--- + +## Summary + +The composition-based query parameter design provides: + +1. **Clear separation** between path parameters, endpoint-specific query parameters, and Lucene filtering +2. **Property-style interface** for setting parameters (`request.query_params.force_show_run = True`) +3. **Type safety** through Pydantic validation +4. **Composability** - mix and match parameter types +5. **Reusability** - Lucene filtering works across all endpoints +6. **Extensibility** - easy to add new parameter types + +This design allows you to integrate your existing Lucene filter implementation while keeping the codebase clean and maintainable. diff --git a/plugins/module_utils/common/api/api.py b/plugins/module_utils/common/api/api.py index 4c2d9ba36..deeec0d5a 100644 --- a/plugins/module_utils/common/api/api.py +++ b/plugins/module_utils/common/api/api.py @@ -14,7 +14,7 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type +__metaclass__ = type # pylint: disable=invalid-name __author__ = "Allen Robel" import logging @@ -45,7 +45,7 @@ def __init__(self): self._init_properties() def _init_properties(self): - self.properties = {} + self.properties: dict = {} self.properties["path"] = None self.properties["verb"] = None diff --git a/plugins/module_utils/common/api/base_paths.py b/plugins/module_utils/common/api/base_paths.py new file mode 100644 index 000000000..ac62a617e --- /dev/null +++ b/plugins/module_utils/common/api/base_paths.py @@ -0,0 +1,323 @@ +# Copyright (c) 2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Centralized base paths for ND API endpoints. + +This module provides a single location to manage all API base paths, +allowing easy modification when API paths change. All endpoint classes +should use these path builders for consistency. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__author__ = "Allen Robel" + +from typing import Final + + +class BasePath: + """ + ## Centralized API Base Paths + + ### Description + Provides centralized base path definitions for all ND API endpoints. + This allows API path changes to be managed in a single location. + + ### Usage + + ```python + # Get a complete base path + path = BasePath.control_fabrics("MyFabric", "config-deploy") + # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/MyFabric/config-deploy + + # Build custom paths + path = BasePath.v1("custom", "endpoint") + # Returns: /appcenter/cisco/ndfc/api/v1/custom/endpoint + ``` + + ### Design Notes + - All base paths are defined as class constants for easy modification + - Helper methods compose paths from base constants + - Use these methods in Pydantic endpoint models to ensure consistency + - If NDFC changes base API paths, only this class needs updating + """ + + # Root API paths + NDFC_API: Final = "/appcenter/cisco/ndfc/api" + ONEMANAGE: Final = "/onemanage" + LOGIN: Final = "/login" + + @classmethod + def api(cls, *segments: str) -> str: + """ + Build path from NDFC API root. + + ### Parameters + - segments: Path segments to append + + ### Returns + - Complete path string + + ### Example + ```python + path = BasePath.api("custom", "endpoint") + # Returns: /appcenter/cisco/ndfc/api/custom/endpoint + ``` + """ + if not segments: + return cls.NDFC_API + return f"{cls.NDFC_API}/{'/'.join(segments)}" + + @classmethod + def v1(cls, *segments: str) -> str: + """ + Build v1 API path. + + ### Parameters + - segments: Path segments to append after v1 + + ### Returns + - Complete v1 API path + + ### Example + ```python + path = BasePath.v1("lan-fabric", "rest") + # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest + ``` + """ + return cls.api("v1", *segments) + + @classmethod + def lan_fabric(cls, *segments: str) -> str: + """ + Build lan-fabric API path. + + ### Parameters + - segments: Path segments to append after lan-fabric + + ### Returns + - Complete lan-fabric path + + ### Example + ```python + path = BasePath.lan_fabric("rest", "control") + # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control + ``` + """ + return cls.v1("lan-fabric", *segments) + + @classmethod + def lan_fabric_rest(cls, *segments: str) -> str: + """ + Build lan-fabric/rest API path. + + ### Parameters + - segments: Path segments to append after rest + + ### Returns + - Complete lan-fabric/rest path + """ + return cls.lan_fabric("rest", *segments) + + @classmethod + def control(cls, *segments: str) -> str: + """ + Build lan-fabric/rest/control API path. + + ### Parameters + - segments: Path segments to append after control + + ### Returns + - Complete control path + """ + return cls.lan_fabric_rest("control", *segments) + + @classmethod + def control_fabrics(cls, *segments: str) -> str: + """ + Build control/fabrics API path. + + ### Parameters + - segments: Path segments to append after fabrics (e.g., fabric_name, operations) + + ### Returns + - Complete control/fabrics path + + ### Example + ```python + path = BasePath.control_fabrics("MyFabric", "config-deploy") + # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/MyFabric/config-deploy + ``` + """ + return cls.control("fabrics", *segments) + + @classmethod + def control_switches(cls, *segments: str) -> str: + """ + Build control/switches API path. + + ### Parameters + - segments: Path segments to append after switches + + ### Returns + - Complete control/switches path + """ + return cls.control("switches", *segments) + + @classmethod + def inventory(cls, *segments: str) -> str: + """ + Build lan-fabric/rest/inventory API path. + + ### Parameters + - segments: Path segments to append after inventory + + ### Returns + - Complete inventory path + """ + return cls.lan_fabric_rest("inventory", *segments) + + @classmethod + def configtemplate(cls, *segments: str) -> str: + """ + Build configtemplate API path. + + ### Parameters + - segments: Path segments to append after configtemplate + + ### Returns + - Complete configtemplate path + + ### Example + ```python + path = BasePath.configtemplate("rest", "config", "templates") + # Returns: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates + ``` + """ + return cls.v1("configtemplate", *segments) + + @classmethod + def onemanage(cls, *segments: str) -> str: + """ + Build onemanage API path. + + ### Parameters + - segments: Path segments to append after onemanage + + ### Returns + - Complete onemanage path + + ### Example + ```python + path = BasePath.onemanage("fabrics", "MyFabric") + # Returns: /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/MyFabric + ``` + """ + return cls.v1("onemanage", *segments) + + @classmethod + def onemanage_fabrics(cls, *segments: str) -> str: + """ + Build onemanage/fabrics API path. + + ### Parameters + - segments: Path segments to append after fabrics (e.g., fabric_name) + + ### Returns + - Complete onemanage/fabrics path + + ### Example + ```python + path = BasePath.onemanage_fabrics("MyFabric") + # Returns: /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/MyFabric + ``` + """ + return cls.onemanage("fabrics", *segments) + + @classmethod + def onemanage_links(cls, *segments: str) -> str: + """ + Build onemanage/links API path. + + ### Parameters + - segments: Path segments to append after links (e.g., link_uuid) + + ### Returns + - Complete onemanage/links path + + ### Example + ```python + path = BasePath.onemanage_links("63505f61-ce7b-40a6-a38c-ae9a355b2116") + # Returns: /appcenter/cisco/ndfc/api/v1/onemanage/links/63505f61-ce7b-40a6-a38c-ae9a355b2116 + ``` + """ + return cls.onemanage("links", *segments) + + @classmethod + def onemanage_links_fabrics(cls, *segments: str) -> str: + """ + Build onemanage/links/fabrics API path. + + ### Parameters + - segments: Path segments to append after links/fabrics (e.g., fabric_name) + + ### Returns + - Complete onemanage/links/fabrics path + + ### Example + ```python + path = BasePath.onemanage_links_fabrics("MyFabric") + # Returns: /appcenter/cisco/ndfc/api/v1/onemanage/links/fabrics/MyFabric + ``` + """ + return cls.onemanage("links", "fabrics", *segments) + + @classmethod + def onemanage_top_down(cls, *segments: str) -> str: + """ + Build onemanage/top-down API path. + + ### Parameters + - segments: Path segments to append after top-down (e.g., fabric_name) + + ### Returns + - Complete onemanage/top-down path + + ### Example + ```python + path = BasePath.onemanage_top_down("fabrics", "MyFabric") + # Returns: /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/MyFabric + ``` + """ + return cls.onemanage("top-down", *segments) + + @classmethod + def onemanage_top_down_fabrics(cls, *segments: str) -> str: + """ + Build onemanage/top-down/fabrics API path. + + ### Parameters + - segments: Path segments to append after top-down/fabrics (e.g., fabric_name) + + ### Returns + - Complete onemanage/top-down/fabrics path + + ### Example + ```python + path = BasePath.onemanage_top_down_fabrics("MyFabric") + # Returns: /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/MyFabric + ``` + """ + return cls.onemanage_top_down("fabrics", *segments) diff --git a/plugins/module_utils/common/api/onemanage/__init__.py b/plugins/module_utils/common/api/onemanage/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/common/api/onemanage/endpoints.py b/plugins/module_utils/common/api/onemanage/endpoints.py new file mode 100644 index 000000000..615ed85ad --- /dev/null +++ b/plugins/module_utils/common/api/onemanage/endpoints.py @@ -0,0 +1,1763 @@ +# Copyright (c) 2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=too-many-lines +""" +Pydantic-based endpoint models with property-style interface for ALL parameters. + +This module demonstrates a fully property-based approach where path parameters, +endpoint-specific query parameters, and Lucene-style filtering query parameters +are all set using the same consistent interface. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__author__ = "Allen Robel" + +import traceback +from typing import Literal, Optional, Union + +try: + from pydantic import BaseModel, Field +except ImportError: + HAS_PYDANTIC = False + PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name + + # Fallback: object base class + BaseModel = object # type: ignore[assignment,misc] + + # Fallback: Field that does nothing + def Field(**kwargs): # type: ignore[no-redef] # pylint: disable=unused-argument,invalid-name + """Pydantic Field fallback when pydantic is not available.""" + return None + + # Fallback: field_validator decorator that does nothing + def field_validator(*args, **kwargs): # pylint: disable=unused-argument,invalid-name + """Pydantic field_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + +else: + HAS_PYDANTIC = True + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + +from ..base_paths import BasePath +from ..query_params import EndpointQueryParams + +# ============================================================================ +# Endpoint-Specific Query Parameter Classes +# ============================================================================ + + +class FabricConfigDeployQueryParams(EndpointQueryParams): + """ + Query parameters for fabric config deploy endpoints. + + ### Parameters + - force_show_run: If true, fetch latest running config from device; if false, use cached version (default: "false") + - incl_all_msd_switches: If true and MSD fabric, deploy all child fabric changes; if false, skip child fabrics (default: "false") + """ + + force_show_run: Literal["false", "true"] = Field(default="false", description="Fetch latest running config from device") + incl_all_msd_switches: Literal["false", "true"] = Field(default="false", description="Deploy all MSD child fabric changes") + + def to_query_string(self) -> str: + """Build query string with forceShowRun and inclAllMSDSwitches parameters.""" + params = [] + if self.force_show_run: + params.append(f"forceShowRun={self.force_show_run}") + if self.incl_all_msd_switches: + params.append(f"inclAllMSDSwitches={self.incl_all_msd_switches}") + return "&".join(params) + + +class FabricConfigPreviewQueryParams(EndpointQueryParams): + """ + Query parameters for fabric config preview endpoints. + + ### Parameters + - force_show_run: Force show running config (default: "false") + - show_brief: Show brief output (default: "false") + """ + + force_show_run: Literal["false", "true"] = Field(default="false", description="Force show running config") + show_brief: Literal["false", "true"] = Field(default="false", description="Show brief output") + + def to_query_string(self) -> str: + """Build query string with forceShowRun and showBrief parameters.""" + params = [] + if self.force_show_run: + params.append(f"forceShowRun={self.force_show_run}") + if self.show_brief: + params.append(f"showBrief={self.show_brief}") + return "&".join(params) + + +class LinkByUuidQueryParams(EndpointQueryParams): + """ + # Summary + + Query parameters for link by UUID endpoints. + + ## Parameters + + - source_cluster_name: Source cluster name (e.g., "nd-cluster-1") + - destination_cluster_name: Destination cluster name (e.g., "nd-cluster-2") + """ + + source_cluster_name: Optional[str] = Field(default=None, min_length=1, description="Source cluster name") + destination_cluster_name: Optional[str] = Field(default=None, min_length=1, description="Destination cluster name") + + def to_query_string(self) -> str: + """Build query string with sourceClusterName and destinationClusterName parameters.""" + params = [] + if self.source_cluster_name: + params.append(f"sourceClusterName={self.source_cluster_name}") + if self.destination_cluster_name: + params.append(f"destinationClusterName={self.destination_cluster_name}") + return "&".join(params) + + +class NetworkNamesQueryParams(EndpointQueryParams): + """ + # Summary + + Query parameters for network deletion endpoints. + + ## Parameters + + - network_names: Comma-separated list of network names to delete e.g. "Net1,Net2,Net3" + """ + + network_names: Optional[str] = Field(default=None, min_length=1, description="Comma-separated network names") + + def to_query_string(self) -> str: + """Build query string with network-names parameter.""" + if self.network_names: + return f"network-names={self.network_names}" + return "" + + +class VrfNamesQueryParams(EndpointQueryParams): + """ + # Summary + + Query parameters for VRF deletion endpoints. + + ## Parameters + + - vrf_names: Comma-separated list of VRF names to delete e.g. "VRF1,VRF2,VRF3" + """ + + vrf_names: Optional[str] = Field(default=None, min_length=1, description="Comma-separated VRF names") + + def to_query_string(self) -> str: + """Build query string with vrf-names parameter.""" + if self.vrf_names: + return f"vrf-names={self.vrf_names}" + return "" + + +class EpOneManageFabricConfigDeploy(BaseModel): + """ + # Summary + + Fabric Config-Deploy Endpoint (OneManage) + + ## Description + + Endpoint to deploy the configuration for a specific multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/config-deploy + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageFabricConfigDeploy() + request.fabric_name = "MyFabric" + request.query_params.force_show_run = "true" + request.query_params.incl_all_msd_switches = "false" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricConfigDeploy", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + query_params: FabricConfigDeployQueryParams = Field(default_factory=FabricConfigDeployQueryParams) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with query parameters. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string with query parameters + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + base_path = BasePath.onemanage_fabrics(self.fabric_name, "config-deploy") + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageFabricConfigDeploySwitch(BaseModel): + """ + # Summary + + Fabric Config-Deploy Switch Endpoint (OneManage) + + ## Description + + Endpoint to deploy the configuration for a specific switch in a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/config-deploy/{switchSN} + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageFabricConfigDeploySwitch() + request.fabric_name = "MyFabric" + request.switch_sn = "92RZ2OMQCNC" + request.query_params.force_show_run = "true" + request.query_params.incl_all_msd_switches = "false" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricConfigDeploySwitch", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + query_params: FabricConfigDeployQueryParams = Field(default_factory=FabricConfigDeployQueryParams) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with query parameters. + + ## Raises + + - ValueError: If fabric_name or switch_sn is not set + + ## Returns + + - Complete endpoint path string with query parameters + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + + base_path = BasePath.onemanage_fabrics(self.fabric_name, "config-deploy", self.switch_sn) + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageFabricConfigPreview(BaseModel): + """ + # Summary + + Fabric Config-Preview Endpoint (OneManage) + + ## Description + + Endpoint to preview the configuration for a specific multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/config-preview + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageFabricConfigPreview() + request.fabric_name = "MyFabric" + request.query_params.force_show_run = "true" + request.query_params.show_brief = "false" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricConfigPreview", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + query_params: FabricConfigPreviewQueryParams = Field(default_factory=FabricConfigPreviewQueryParams) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with query parameters. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string with query parameters + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + base_path = BasePath.onemanage_fabrics(self.fabric_name, "config-preview") + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageFabricConfigPreviewSwitch(BaseModel): + """ + # Summary + + Fabric Config-Preview Switch Endpoint (OneManage) + + ## Description + + Endpoint to preview the configuration for a specific switch in a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/config-preview/{switchSN} + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageFabricConfigPreviewSwitch() + request.fabric_name = "MyFabric" + request.switch_sn = "92RZ2OMQCNC" + request.query_params.force_show_run = "true" + request.query_params.show_brief = "false" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricConfigPreviewSwitch", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + query_params: FabricConfigPreviewQueryParams = Field(default_factory=FabricConfigPreviewQueryParams) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with query parameters. + + ## Raises + + - ValueError: If fabric_name or switch_sn is not set + + ## Returns + + - Complete endpoint path string with query parameters + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + + base_path = BasePath.onemanage_fabrics(self.fabric_name, "config-preview", self.switch_sn) + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageFabricConfigSave(BaseModel): + """ + # Summary + + Fabric Config-Save Endpoint (OneManage) + + ## Description + + Endpoint to save the configuration for a specific multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/config-save + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageFabricConfigSave() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricConfigSave", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_fabrics(self.fabric_name, "config-save") + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageFabricCreate(BaseModel): + """ + # Summary + + Fabric Create Endpoint (OneManage) + + ## Description + + Endpoint to create a new multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageFabricCreate() + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricCreate", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + return "/appcenter/cisco/ndfc/api/v1/onemanage/fabrics" + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageFabricDelete(BaseModel): + """ + # Summary + + Fabric Delete Endpoint (OneManage) + + ## Description + + Endpoint to delete a specific multi-cluster fabric. + + ## Path (nd322m apidocs) + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName} + + ## Verb + + - DELETE + + ## Usage + + ```python + request = EpOneManageFabricDelete() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + + ### Note + The delete endpoint uses the regular LAN fabric control API with /onemanage prefix, + not the onemanage-specific API endpoint. This is required for multi-cluster fabrics. + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricDelete", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string with /onemanage prefix + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return f"/appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{self.fabric_name}" + + @property + def verb(self) -> Literal["DELETE"]: + """Return the HTTP verb for this endpoint.""" + return "DELETE" + + +class EpOneManageFabricDetails(BaseModel): + """ + # Summary + + Fabric Details Endpoint as documented in nd322m apidocs (OneManage) + + ### Description + + Endpoint to query details for a specific multi-cluster fabric. + + ### Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/MyFabric + + ### Verb + + - GET + + ### Usage + ```python + request = EpOneManageFabricDetails() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricDetails", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return f"/appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{self.fabric_name}" + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageFabricGroupMembersGet(BaseModel): + """ + # Summary + + Fabric Group Members Get Endpoint (OneManage) + + ## Description + + Endpoint to retrieve members of a specific multi-cluster fabric group. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/members + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageFabricGroupMembersGet() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricGroupMembersGet", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric group name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_fabrics(self.fabric_name, "members") + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageFabricGroupMembersUpdate(BaseModel): + """ + # Summary + + Fabric Group Members Update Endpoint (OneManage) + + ## Description + + Endpoint to add or remove members to/from a multi-cluster fabric group. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName}/members + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpOneManageFabricGroupMembersUpdate() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + + ### Request Body + + The request body should contain fabric group update parameters: + - clusterName: str - Name of the cluster + - fabricName: str - Name of the fabric + - operation: str - Operation type ("add" or "remove") + - "add": Add fabricName to clusterName + - "remove": Remove fabricName from clusterName + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricGroupMembersUpdate", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric group name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_fabrics(self.fabric_name, "members") + + @property + def verb(self) -> Literal["PUT"]: + """Return the HTTP verb for this endpoint.""" + return "PUT" + + +class EpOneManageFabricGroupUpdate(BaseModel): + """ + # Summary + + Fabric Group Update Endpoint (OneManage) + + ## Description + + Endpoint to update a specific multi-cluster fabric group. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics/{fabricName} + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpOneManageFabricGroupUpdate() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + + ## Request Body + + The request body should contain fabric update parameters: + - fabricName: str - Name of the Fabric + - fabricType: str - Type of the fabric + - fabricTechnology: str - Fabric technology + - nvPairs: dict - Key value pairs describing the fabric configuration + + nvPairs dictionary keys (all string values unless noted): + - ANYCAST_GW_MAC + - BGP_RP_ASN + - BGW_ROUTING_TAG + - BGW_ROUTING_TAG_PREV + - BORDER_GWY_CONNECTIONS + - DCI_SUBNET_RANGE + - DCI_SUBNET_TARGET_MASK + - DELAY_RESTORE + - ENABLE_BGP_BFD + - ENABLE_BGP_LOG_NEIGHBOR_CHANGE (boolean) + - ENABLE_BGP_SEND_COMM (boolean) + - ENABLE_PVLAN + - ENABLE_PVLAN_PREV + - ENABLE_RS_REDIST_DIRECT (boolean) + - ENABLE_TRM_TRMv6 + - ENABLE_TRM_TRMv6_PREV + - EXT_FABRIC_TYPE + - FABRIC_NAME + - FABRIC_TYPE + - FF + - L2_SEGMENT_ID_RANGE + - L3_PARTITION_ID_RANGE + - LOOPBACK100_IPV6_RANGE + - LOOPBACK100_IP_RANGE + - MSO_CONTROLER_ID + - MSO_SITE_GROUP_NAME + - MS_IFC_BGP_AUTH_KEY_TYPE + - MS_IFC_BGP_AUTH_KEY_TYPE_PREV + - MS_IFC_BGP_PASSWORD + - MS_IFC_BGP_PASSWORD_ENABLE + - MS_IFC_BGP_PASSWORD_ENABLE_PREV + - MS_IFC_BGP_PASSWORD_PREV + - MS_LOOPBACK_ID + - MS_UNDERLAY_AUTOCONFIG (boolean) + - PARENT_ONEMANAGE_FABRIC + - PREMSO_PARENT_FABRIC + - RP_SERVER_IP + - RS_ROUTING_TAG + - TOR_AUTO_DEPLOY + - V6_DCI_SUBNET_RANGE + - V6_DCI_SUBNET_TARGET_MASK + - VXLAN_UNDERLAY_IS_V6 + - default_network + - default_pvlan_sec_network + - default_vrf + - network_extension_template + - vrf_extension_template + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricGroupUpdate", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_fabrics(self.fabric_name) + + @property + def verb(self) -> Literal["PUT"]: + """Return the HTTP verb for this endpoint.""" + return "PUT" + + +class EpOneManageFabricsGet(BaseModel): + """ + # Summary + + Fabrics Get Endpoint (OneManage) + + ## Description + + Endpoint to retrieve all multi-cluster fabrics. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/fabrics + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageFabricsGet() + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageFabricsGet", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + return BasePath.onemanage_fabrics() + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageLinkCreate(BaseModel): + """ + # Summary + + Link Create Endpoint (OneManage) + + ## Description + + Endpoint to create a link between fabrics in multi-cluster setup. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/links + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageLinkCreate() + + path = request.path + verb = request.verb + ``` + + ## Request Body + + The request body should contain link creation parameters: + - sourceClusterName: str - Source cluster name + - destinationClusterName: str - Destination cluster name + - sourceFabric: str - Source fabric name + - destinationFabric: str - Destination fabric name + - sourceDevice: str - Source switch serial number + - destinationDevice: str - Destination switch serial number + - sourceSwitchName: str - Source switch name + - destinationSwitchName: str - Destination switch name + - sourceInterface: str - Source switch interface + - destinationInterface: str - Destination switch interface + - templateName: str - Link template name + - nvPairs: dict - Key/value pairs of configuration items + + nvPairs dictionary keys (all string values unless noted): + - IP_MASK + - NEIGHBOR_IP + - IPV6_MASK + - IPV6_NEIGHBOR + - MAX_PATHS + - ROUTING_TAG + - MTU + - SPEED + - DEPLOY_DCI_TRACKING (boolean) + - BGP_PASSWORD_ENABLE + - BGP_PASSWORD + - ENABLE_BGP_LOG_NEIGHBOR_CHANGE + - ENABLE_BGP_SEND_COMM + - BGP_PASSWORD_INHERIT_FROM_MSD + - BGP_AUTH_KEY_TYPE + - asn + - NEIGHBOR_ASNL + - ENABLE_BGP_BFD + """ + + class_name: Optional[str] = Field(default="EpOneManageLinkCreate", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + return BasePath.onemanage_links() + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageLinkGetByUuid(BaseModel): + """ + # Summary + + Link Get By UUID Endpoint (OneManage) + + ## Description + + Endpoint to retrieve a specific link by its UUID. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/links/{linkUUID} + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageLinkGetByUuid() + request.link_uuid = "63505f61-ce7b-40a6-a38c-ae9a355b2116" + request.query_params.source_cluster_name = "nd-cluster-1" + request.query_params.destination_cluster_name = "nd-cluster-2" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageLinkGetByUuid", description="Class name for backward compatibility") + link_uuid: Optional[str] = Field(default=None, min_length=1, description="Link UUID") + query_params: LinkByUuidQueryParams = Field(default_factory=LinkByUuidQueryParams) + + @property + def path(self) -> str: + """ + Build the endpoint path with query parameters. + + ### Raises + - ValueError: If link_uuid is not set + + ### Returns + - Complete endpoint path string with query parameters + """ + if self.link_uuid is None: + raise ValueError("link_uuid must be set before accessing path") + + base_path = BasePath.onemanage_links(self.link_uuid) + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageLinkUpdate(BaseModel): + """ + # Summary + + Link Update Endpoint (OneManage) + + ## Description + + Endpoint to update a specific link by its UUID. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/links/{linkUUID} + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpOneManageLinkUpdate() + request.link_uuid = "63505f61-ce7b-40a6-a38c-ae9a355b2116" + request.query_params.source_cluster_name = "nd-cluster-2" + request.query_params.destination_cluster_name = "nd-cluster-1" + + path = request.path + verb = request.verb + ``` + + ## Request Body + + The request body should contain link update parameters: + - sourceClusterName: str - Source cluster name + - destinationClusterName: str - Destination cluster name + - sourceFabric: str - Source fabric name + - destinationFabric: str - Destination fabric name + - sourceDevice: str - Source switch serial number + - destinationDevice: str - Destination switch serial number + - sourceSwitchName: str - Source switch name + - destinationSwitchName: str - Destination switch name + - sourceInterface: str - Source switch interface + - destinationInterface: str - Destination switch interface + - templateName: str - Link template name + - nvPairs: dict - Key/value pairs of configuration items + + nvPairs dictionary keys (all string values unless noted): + - IP_MASK + - NEIGHBOR_IP + - IPV6_MASK + - IPV6_NEIGHBOR + - MAX_PATHS + - ROUTING_TAG + - MTU + - SPEED + - DEPLOY_DCI_TRACKING (boolean) + - BGP_PASSWORD_ENABLE + - BGP_PASSWORD + - ENABLE_BGP_LOG_NEIGHBOR_CHANGE + - ENABLE_BGP_SEND_COMM + - BGP_PASSWORD_INHERIT_FROM_MSD + - BGP_AUTH_KEY_TYPE + - asn + - NEIGHBOR_ASNL + - ENABLE_BGP_BFD + """ + + class_name: Optional[str] = Field(default="EpOneManageLinkUpdate", description="Class name for backward compatibility") + link_uuid: Optional[str] = Field(default=None, min_length=1, description="Link UUID") + query_params: LinkByUuidQueryParams = Field(default_factory=LinkByUuidQueryParams) + + @property + def path(self) -> str: + """ + Build the endpoint path with query parameters. + + ### Raises + - ValueError: If link_uuid is not set + + ### Returns + - Complete endpoint path string with query parameters + """ + if self.link_uuid is None: + raise ValueError("link_uuid must be set before accessing path") + + base_path = BasePath.onemanage_links(self.link_uuid) + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["PUT"]: + """Return the HTTP verb for this endpoint.""" + return "PUT" + + +class EpOneManageLinksDelete(BaseModel): + """ + # Summary + + Links Delete Endpoint (OneManage) + + ## Description + + Endpoint to delete links in multi-cluster setup. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/links + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpOneManageLinksDelete() + + path = request.path + verb = request.verb + ``` + + ## Request Body + + The request body should contain link deletion parameters. + + - linkUUID: str - Link UUID (e.g., "63505f61-ce7b-40a6-a38c-ae9a355b2116") + - destinationClusterName: str - Destination cluster name (e.g., "nd-cluster-1") + - sourceClusterName: str - Source cluster name (e.g., "nd-cluster-2") + """ + + class_name: Optional[str] = Field(default="EpOneManageLinksDelete", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + return BasePath.onemanage_links() + + @property + def verb(self) -> Literal["PUT"]: + """Return the HTTP verb for this endpoint.""" + return "PUT" + + +class EpOneManageLinksGetByFabric(BaseModel): + """ + # Summary + + Links Get By Fabric Endpoint (OneManage) + + ## Description + + Endpoint to retrieve links for a specific multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/links/fabrics/{fabricName} + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageLinksGetByFabric() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageLinksGetByFabric", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + Build the endpoint path. + + ### Raises + - ValueError: If fabric_name is not set + + ### Returns + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_links_fabrics(self.fabric_name) + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageNetworkCreate(BaseModel): + """ + # Summary + + Network Create Endpoint (OneManage) + + ## Description + + Endpoint to create a network in a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabricName}/networks + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageNetworkCreate() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + + ### Request Body + + The request body should contain network creation parameters: + - id: int - Link ID + - vrfId: int - VRF ID + - networkId: int - Network ID + - vrf: str - Name of the VRF + - fabric: str - Name of the Fabric + - networkTemplate: str - Network template name + - networkTemplateConfig: str - Network extension template config + """ + + class_name: Optional[str] = Field(default="EpOneManageNetworkCreate", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + Build the endpoint path. + + ### Raises + - ValueError: If fabric_name is not set + + ### Returns + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_top_down_fabrics(self.fabric_name, "networks") + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageNetworkUpdate(BaseModel): + """ + # Summary + + Network Update Endpoint (OneManage) + + ## Description + + Endpoint to update single Network in a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabric_name}/networks/{network_name} + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpOneManageNetworkUpdate() + request.fabric_name = "MyFabric" + request.network_name = "MyNetwork1" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageNetworkUpdate", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + network_name: Optional[str] = Field(default=None, min_length=1, description="Network name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name or vrf_name is not set + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + if self.network_name is None: + raise ValueError("network_name must be set before accessing path") + + return BasePath.onemanage_top_down_fabrics(self.fabric_name, "networks", self.network_name) + + @property + def verb(self) -> Literal["PUT"]: + """Return the HTTP verb for this endpoint.""" + return "PUT" + + +class EpOneManageNetworksDelete(BaseModel): + """ + # Summary + + Networks Delete Endpoint (OneManage) + + ## Description + + Endpoint to bulk-delete networks from a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabric_name}/bulk-delete/networks + + ## Verb + + - DELETE + + ## Usage + + ```python + request = EpOneManageNetworksDelete() + request.fabric_name = "MyFabric" + request.query_params.network_names = "MyNetwork1,MyNetwork2,MyNetwork3" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageNetworksDelete", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + query_params: NetworkNamesQueryParams = Field(default_factory=NetworkNamesQueryParams) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with query parameters. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string with query parameters + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + base_path = BasePath.onemanage_top_down_fabrics(self.fabric_name, "bulk-delete", "networks") + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["DELETE"]: + """Return the HTTP verb for this endpoint.""" + return "DELETE" + + +class EpOneManageNetworksGet(BaseModel): + """ + # Summary + + Networks Get Endpoint (OneManage) + + ## Description + + Endpoint to retrieve all networks from a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabricName}/networks + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageNetworksGet() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageNetworksGet", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + Build the endpoint path. + + ### Raises + - ValueError: If fabric_name is not set + + ### Returns + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_top_down_fabrics(self.fabric_name, "networks") + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" + + +class EpOneManageVrfCreate(BaseModel): + """ + # Summary + + VRF Create Endpoint (OneManage) + + ## Description + + Endpoint to create a VRF in a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabricName}/vrfs + + ## Verb + + - POST + + ## Usage + + ```python + request = EpOneManageVrfCreate() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + + ### Request Body + + The request body should contain VRF creation parameters: + - id: int - Link ID + - vrfId: int - VRF ID + - vrfName: str - Name of the VRF + - fabric: str - Name of the fabric + - vrfTemplate: str - VRF template name + - vrfExtensionTemplate: str - VRF extension template name + - vrfTemplateConfig: str - JSON string representing the VRF configuration + """ + + class_name: Optional[str] = Field(default="EpOneManageVrfCreate", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + Build the endpoint path. + + ### Raises + - ValueError: If fabric_name is not set + + ### Returns + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_top_down_fabrics(self.fabric_name, "vrfs") + + @property + def verb(self) -> Literal["POST"]: + """Return the HTTP verb for this endpoint.""" + return "POST" + + +class EpOneManageVrfUpdate(BaseModel): + """ + # Summary + + VRF Update Endpoint (OneManage) + + ## Description + + Endpoint to update single VRF in a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabric_name}/vrfs/{vrf_name} + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpOneManageVrfUpdate() + request.fabric_name = "MyFabric" + request.vrf_name = "MyVRF1" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageVrfUpdate", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + vrf_name: Optional[str] = Field(default=None, min_length=1, description="VRF name") + + @property + def path(self) -> str: + """ + Build the endpoint path. + + ### Raises + - ValueError: If fabric_name or vrf_name is not set + + ### Returns + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + if self.vrf_name is None: + raise ValueError("vrf_name must be set before accessing path") + + return BasePath.onemanage_top_down_fabrics(self.fabric_name, "vrfs", self.vrf_name) + + @property + def verb(self) -> Literal["PUT"]: + """Return the HTTP verb for this endpoint.""" + return "PUT" + + +class EpOneManageVrfsDelete(BaseModel): + """ + # Summary + + VRFs Delete Endpoint (OneManage) + + ## Description + + Endpoint to bulk-delete VRFs from a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabric_name}/bulk-delete/vrfs + + ## Verb + + - DELETE + + ## Usage + + ```python + request = EpOneManageVrfsDelete() + request.fabric_name = "MyFabric" + request.query_params.vrf_names = "MyVRF1,MyVRF2,MyVRF3" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageVrfsDelete", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + query_params: VrfNamesQueryParams = Field(default_factory=VrfNamesQueryParams) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with query parameters. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string with query parameters + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + base_path = BasePath.onemanage_top_down_fabrics(self.fabric_name, "bulk-delete", "vrfs") + + query_string = self.query_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> Literal["DELETE"]: + """Return the HTTP verb for this endpoint.""" + return "DELETE" + + +class EpOneManageVrfsGet(BaseModel): + """ + # Summary + + VRFs Get Endpoint (OneManage) + + ## Description + + Endpoint to retrieve all VRFs from a multi-cluster fabric. + + ## Path + + - /appcenter/cisco/ndfc/api/v1/onemanage/top-down/fabrics/{fabricName}/vrfs + + ## Verb + + - GET + + ## Usage + + ```python + request = EpOneManageVrfsGet() + request.fabric_name = "MyFabric" + + path = request.path + verb = request.verb + ``` + """ + + class_name: Optional[str] = Field(default="EpOneManageVrfsGet", description="Class name for backward compatibility") + fabric_name: Optional[str] = Field(default=None, min_length=1, description="Fabric name") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Raises + + - ValueError: If fabric_name is not set + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + + return BasePath.onemanage_top_down_fabrics(self.fabric_name, "vrfs") + + @property + def verb(self) -> Literal["GET"]: + """Return the HTTP verb for this endpoint.""" + return "GET" diff --git a/plugins/module_utils/common/api/query_params.py b/plugins/module_utils/common/api/query_params.py new file mode 100644 index 000000000..33942cc2e --- /dev/null +++ b/plugins/module_utils/common/api/query_params.py @@ -0,0 +1,290 @@ +# Copyright (c) 2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Query parameter classes for API endpoints. + +This module provides composable query parameter classes for building +URL query strings. Supports endpoint-specific parameters and Lucene-style +filtering with type safety via Pydantic. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__author__ = "Allen Robel" + +import traceback +from abc import ABC, abstractmethod +from typing import Optional, Union + +try: + from pydantic import BaseModel, Field, field_validator +except ImportError: + HAS_PYDANTIC = False + PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name + + # Fallback: object base class + BaseModel = object # type: ignore[assignment,misc] + + # Fallback: Field that does nothing + def Field(**kwargs): # type: ignore[no-redef] # pylint: disable=unused-argument,invalid-name + """Pydantic Field fallback when pydantic is not available.""" + return None + + # Fallback: field_validator decorator that does nothing + def field_validator(*args, **kwargs): # type: ignore[no-redef] # pylint: disable=unused-argument,invalid-name + """Pydantic field_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + +else: + HAS_PYDANTIC = True + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + + +class QueryParams(ABC): + """ + ## Abstract Base Class for Query Parameters + + ### Description + Base class for all query parameter types. Subclasses implement + `to_query_string()` to convert their parameters to URL query string format. + + ### Design + This allows composition of different query parameter types: + - Endpoint-specific parameters (e.g., forceShowRun, ticketId) + - Generic Lucene-style filtering (e.g., filter, max, sort) + - Future parameter types can be added without changing existing code + """ + + @abstractmethod + def to_query_string(self) -> str: + """ + Convert parameters to URL query string format. + + ### Returns + - Query string (without leading '?') + - Empty string if no parameters are set + + ### Example + ```python + "forceShowRun=true&ticketId=12345" + ``` + """ + + def is_empty(self) -> bool: + """ + Check if any parameters are set. + + ### Returns + - True if no parameters are set + - False if at least one parameter is set + """ + return len(self.to_query_string()) == 0 + + +class EndpointQueryParams(BaseModel): + """ + ## Endpoint-Specific Query Parameters + + ### Description + Query parameters specific to a particular endpoint. + These are typed and validated by Pydantic. + + ### Usage + Subclass this for each endpoint that needs custom query parameters: + + ```python + class ConfigDeployQueryParams(EndpointQueryParams): + force_show_run: bool = False + include_all_msd_switches: bool = False + + def to_query_string(self) -> str: + params = [f"forceShowRun={str(self.force_show_run).lower()}"] + params.append(f"inclAllMSDSwitches={str(self.include_all_msd_switches).lower()}") + return "&".join(params) + ``` + """ + + def to_query_string(self) -> str: + """ + Default implementation: convert all fields to key=value pairs. + Override this method for custom formatting. + """ + params = [] + for field_name, field_value in self.model_dump(exclude_none=True).items(): + # Convert snake_case to camelCase for API compatibility + api_key = self._to_camel_case(field_name) + api_value = str(field_value).lower() if isinstance(field_value, bool) else str(field_value) + params.append(f"{api_key}={api_value}") + return "&".join(params) + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Convert snake_case to camelCase.""" + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + def is_empty(self) -> bool: + """Check if any parameters are set.""" + return len(self.model_dump(exclude_none=True, exclude_defaults=True)) == 0 + + +class LuceneQueryParams(BaseModel): + """ + ## Lucene-Style Query Parameters + + ### Description + Generic Lucene-style filtering query parameters for ND API. + Supports filtering, pagination, and sorting. + + ### Parameters + - filter: Lucene filter expression (e.g., "name:MyFabric AND state:deployed") + - max: Maximum number of results to return + - offset: Offset for pagination + - sort: Sort field and direction (e.g., "name:asc", "created:desc") + - fields: Comma-separated list of fields to return + + ### Usage + ```python + lucene = LuceneQueryParams( + filter="name:Fabric*", + max=100, + sort="name:asc" + ) + query_string = lucene.to_query_string() + # Returns: "filter=name:Fabric*&max=100&sort=name:asc" + ``` + + ### Lucene Filter Examples + - Single field: `name:MyFabric` + - Wildcard: `name:Fabric*` + - Multiple conditions: `name:MyFabric AND state:deployed` + - Range: `created:[2024-01-01 TO 2024-12-31]` + - OR conditions: `state:deployed OR state:pending` + - NOT conditions: `NOT state:deleted` + """ + + filter: Optional[str] = Field(default=None, description="Lucene filter expression") + max: Optional[int] = Field(default=None, ge=1, le=10000, description="Maximum results") + offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") + sort: Optional[str] = Field(default=None, description="Sort field and direction (e.g., 'name:asc')") + fields: Optional[str] = Field(default=None, description="Comma-separated list of fields to return") + + @field_validator("sort") + @classmethod + def validate_sort(cls, value): + """Validate sort format: field:direction.""" + if value is not None and ":" in value: + parts = value.split(":") + if len(parts) == 2 and parts[1].lower() not in ["asc", "desc"]: + raise ValueError("Sort direction must be 'asc' or 'desc'") + return value + + def to_query_string(self) -> str: + """Convert to URL query string format.""" + params = [] + for field_name, field_value in self.model_dump(exclude_none=True).items(): + if field_value is not None: + params.append(f"{field_name}={field_value}") + return "&".join(params) + + def is_empty(self) -> bool: + """Check if any filter parameters are set.""" + return all(v is None for v in self.model_dump().values()) + + +class CompositeQueryParams: + """ + ## Composite Query Parameters + + ### Description + Composes multiple query parameter types into a single query string. + This allows combining endpoint-specific parameters with Lucene filtering. + + ### Design Pattern + Uses composition to combine different query parameter types without + inheritance. Each parameter type can be independently configured and tested. + + ### Usage + ```python + # Endpoint-specific params + endpoint_params = ConfigDeployQueryParams( + force_show_run=True, + include_all_msd_switches=False + ) + + # Lucene filtering params + lucene_params = LuceneQueryParams( + filter="name:MySwitch*", + max=50, + sort="name:asc" + ) + + # Compose them together + composite = CompositeQueryParams() + composite.add(endpoint_params) + composite.add(lucene_params) + + query_string = composite.to_query_string() + # Returns: "forceShowRun=true&inclAllMSDSwitches=false&filter=name:MySwitch*&max=50&sort=name:asc" + ``` + """ + + def __init__(self) -> None: + self._param_groups: list[Union[EndpointQueryParams, LuceneQueryParams]] = [] + + def add(self, params: Union[EndpointQueryParams, LuceneQueryParams]) -> "CompositeQueryParams": + """ + Add a query parameter group to the composite. + + ### Parameters + - params: EndpointQueryParams or LuceneQueryParams instance + + ### Returns + - Self (for method chaining) + + ### Example + ```python + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + ``` + """ + self._param_groups.append(params) + return self + + def to_query_string(self) -> str: + """ + Build complete query string from all parameter groups. + + ### Returns + - Complete query string (without leading '?') + - Empty string if no parameters are set + """ + parts = [] + for param_group in self._param_groups: + if not param_group.is_empty(): + parts.append(param_group.to_query_string()) + return "&".join(parts) + + def is_empty(self) -> bool: + """Check if any parameters are set across all groups.""" + return all(param_group.is_empty() for param_group in self._param_groups) + + def clear(self) -> None: + """Remove all parameter groups.""" + self._param_groups.clear() diff --git a/plugins/module_utils/common/controller_features_v2.py b/plugins/module_utils/common/controller_features_v2.py new file mode 100644 index 000000000..9e5b7345f --- /dev/null +++ b/plugins/module_utils/common/controller_features_v2.py @@ -0,0 +1,322 @@ +""" +Class to retrieve and return information about an NDFC controller +""" + +# +# Copyright (c) 2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__copyright__ = "Copyright (c) 2025 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect +import logging + +from .api.v1.fm.fm import EpFeatures +from .conversion import ConversionUtils +from .exceptions import ControllerResponseError +from .rest_send_v2 import RestSend + + +class ControllerFeatures: + """ + ### Summary + Return feature information from the Controller + + ### Usage + + ```python + sender = Sender() + sender.ansible_module = ansible_module + rest_send = RestSend(ansible_module.params) + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + instance = ControllerFeatures() + instance.rest_send = rest_send + # retrieves all feature information + try: + instance.refresh() + except ControllerResponseError as error: + # handle error + # filters the feature information + instance.filter = "pmn" + # retrieves the admin_state for feature pmn + pmn_admin_state = instance.admin_state + # retrieves the operational state for feature pmn + pmn_oper_state = instance.oper_state + # etc... + ``` + + ### Retrievable properties for the filtered feature + + - admin_state - str + - "enabled" + - "disabled" + - apidoc, list of dict + - ```json + [ + { + "url": "https://path/to/api-docs", + "subpath": "pmn", + "schema": null + } + ] + ``` + - description + - "Media Controller for IP Fabrics" + - str + - healthz + - "https://path/to/healthz" + - str + - hidden + - True + - False + - bool + - featureset + - ```json + { + "lan": { + "default": false + } + } + ``` + - name + - "IP Fabric for Media" + - str + - oper_state + - "started" + - "stopped" + - "" + - str + - predisablecheck + - "https://path/to/predisablecheck" + - str + - installed + - "2024-05-08 18:02:45.626691263 +0000 UTC" + - str + - kind + - "feature" + - str + - requires + - ```json + ["pmn-telemetry-mgmt", "pmn-telemetry-data"] + ``` + - spec + - "" + - str + - ui + - True + - False + - bool + + ### Response + ```json + { + "status": "success", + "data": { + "name": "", + "version": 179, + "features": { + "change-mgmt": { + "name": "Change Control", + "description": "Tracking, Approval, and Rollback...", + "ui": false, + "predisablecheck": "https://path/preDisableCheck", + "spec": "", + "admin_state": "disabled", + "oper_state": "", + "kind": "featurette", + "featureset": { + "lan": { + "default": false + } + } + } + etc... + } + } + } + ``` + + ### Endpoint + /appcenter/cisco/ndfc/api/v1/fm/features + + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ControllerFeatures()") + + self.conversion = ConversionUtils() + self.ep_features = EpFeatures() + + self._filter = None + self._response_data = None + self._rest_send: RestSend = RestSend({}) + + def refresh(self): + """ + - Refresh self.response_data with current features info + from the controller + - Raise ``ValueError`` if the endpoint assignment fails. + """ + method_name = inspect.stack()[0][3] + + # pylint: disable=no-member + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.rest_send must be set " + msg += "before calling refresh()." + raise ValueError(msg) + + self.rest_send.path = self.ep_features.path + self.rest_send.verb = self.ep_features.verb + + # Store the current value of check_mode, then disable + # check_mode since ControllerFeatures() only reads data + # from the controller. + # Restore the value of check_mode after the commit. + self.rest_send.save_settings() + self.rest_send.check_mode = False + self.rest_send.commit() + self.rest_send.restore_settings() + + if self.rest_send.result_current["success"] is False: + msg = f"{self.class_name}.{method_name}: " + msg += f"Bad controller response: {self.rest_send.response_current}" + raise ControllerResponseError(msg) + + self._response_data = ( + self.rest_send.response_current.get("DATA", {}) + .get("data", {}) + .get("features", {}) + ) + if self.response_data == {}: + msg = f"{self.class_name}.{method_name}: " + msg += "Controller response does not match expected structure: " + msg += f"{self.rest_send.response_current}" + raise ControllerResponseError(msg) + + def _get(self, item): + """ + - Return the value of the item from the filtered response_data. + - Return None if the item does not exist. + """ + if self.response_data is None: + msg = f"{self.class_name}._get: " + msg += "response_data is None. Call refresh() before calling _get()." + raise ValueError(msg) + data = self.response_data.get(self.filter, {}).get(item, None) + return self.conversion.make_boolean(self.conversion.make_none(data)) + + @property + def admin_state(self): + """ + - Return the controller admin_state for filter, if it exists. + - Return None otherwise + - Possible values: + - enabled + - disabled + - None + """ + return self._get("admin_state") + + @property + def enabled(self): + """ + - Return True if the filtered feature admin_state is "enabled". + - Return False otherwise. + - Possible values: + - True + - False + """ + if self.admin_state == "enabled": + return True + return False + + @property + def filter(self): + """ + - getter: Return the filter value + - setter: Set the filter value + - The filter value should be the name of the feature + - For example: + - lan + - Full LAN functionality in addition to Fabric + Discovery + - pmn + - Media Controller for IP Fabrics + - vxlan + - Automation, Compliance, and Management for + NX-OS and Other devices + + """ + return self._filter + + @filter.setter + def filter(self, value): + self._filter = value + + @property + def oper_state(self): + """ + - Return the oper_state for the filtered feature, if it exists. + - Return None otherwise + - Possible values: + - started + - stopped + - "" + """ + return self._get("oper_state") + + @property + def response_data(self): + """ + Return the data retrieved from the request + """ + return self._response_data + + @property + def rest_send(self) -> RestSend: + """ + An instance of the RestSend class. + """ + return self._rest_send + + @rest_send.setter + def rest_send(self, value: RestSend) -> None: + if not value.params: + msg = f"{self.class_name}.rest_send must be set to an " + msg += "instance of RestSend with params set." + raise ValueError(msg) + self._rest_send = value + + @property + def started(self): + """ + - Return True if the filtered feature oper_state is "started". + - Return False otherwise. + - Possible values: + - True + - False + """ + if self.oper_state == "started": + return True + return False diff --git a/plugins/module_utils/common/controller_version_v2.py b/plugins/module_utils/common/controller_version_v2.py new file mode 100644 index 000000000..282fbd57f --- /dev/null +++ b/plugins/module_utils/common/controller_version_v2.py @@ -0,0 +1,437 @@ +# +# Copyright (c) 2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Return image version information from the Controller +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect +import logging + +from .api.v1.fm.fm import EpVersion +from .conversion import ConversionUtils +from .exceptions import ControllerResponseError +from .rest_send_v2 import RestSend + + +class ControllerVersion: + """ + Return image version information from the Controller + + ### Endpoint + ``/appcenter/cisco/ndfc/api/v1/fm/about/version`` + + ### Usage (where module is an instance of AnsibleModule): + ```python + sender = Sender() + sender.ansible_module = ansible_module + rest_send = RestSend(ansible_module.params) + rest_send.response_handler = ResponseHandler() + rest_send.sender = sender + + instance = ControllerVersion() + instance.rest_send = rest_send + instance.refresh() + if instance.version == "12.1.2e": + # do 12.1.2e stuff + else: + # do other stuff + ``` + + ### Response + + #### ND 3.x + + ```json + { + "version": "12.1.2e", + "mode": "LAN", + "isMediaController": false, + "dev": false, + "isHaEnabled": false, + "install": "EASYFABRIC", + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "is_upgrade_inprogress": false + } + ``` + + #### ND 4.1 EFT 138c + + ```json + { + "version": "12.4.1.225", + "mode": "", + "isMediaController": false, + "dev": false, + "isHaEnabled": false, + "install": "", + "uuid": "", + "is_upgrade_inprogress": false + } + ``` + + #### ND 4.1 EFT 156b + + ```json + { + "version": "12.4.1.245", + "mode": "", + "isMediaController": false, + "dev": false, + "isHaEnabled": false, + "install": "", + "uuid": "", + "is_upgrade_inprogress": false + } + + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + method_name = inspect.stack()[0][3] + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.conversion = ConversionUtils() + self.ep_version = EpVersion() + self._response_data: dict = {} + self._rest_send: RestSend = RestSend({}) + + msg = f"ENTERED {self.class_name}().{method_name}" + self.log.debug(msg) + + def refresh(self): + """ + Refresh self.response_data with current version info from the Controller + """ + # pylint: disable=no-member + method_name = inspect.stack()[0][3] + self.rest_send.path = self.ep_version.path + self.rest_send.verb = self.ep_version.verb + self.rest_send.commit() + + if self.rest_send.result_current["success"] is False: + msg = f"{self.class_name}.{method_name}: " + msg += f"failed: {self.rest_send.result_current}" + raise ControllerResponseError(msg) + + if self.rest_send.result_current["found"] is False: + msg = f"{self.class_name}.{method_name}: " + msg += f"failed: {self.rest_send.result_current}" + raise ControllerResponseError(msg) + + if "DATA" not in self.rest_send.response_current: + msg = f"{self.class_name}.{method_name}: " + msg += "response does not contain DATA key. Controller response: " + msg += f"{self.rest_send.response_current}" + raise ValueError(msg) + self._response_data = self.rest_send.response_current["DATA"] + if self.response_data is None: + msg = f"{self.class_name}.refresh() failed: response " + msg += "does not contain DATA key. Controller response: " + msg += f"{self.rest_send.response_current}" + raise ValueError(msg) + + # Ensure response_data is a dictionary + if not isinstance(self._response_data, dict): + msg = f"{self.class_name}.refresh() failed: " + msg += f"Expected response data to be a dictionary, got {type(self._response_data).__name__}. " + msg += f"Data: {self._response_data}" + raise ValueError(msg) + + def _get(self, item): + """ + # Summary + + Return the parameter (item) from the response + + ## Notes + + - With ND 4, parameters like "mode", "uuid" are empty strings. + Return empty string in this case, rather than None. + - None indicates that the parameter is missing in the response (i.e. an error) + """ + value = self.response_data.get(item) + if value == "": + return "" + return self.conversion.make_none(self.conversion.make_boolean(value)) + + def _validate_and_split_version(self): + """ + Validate version format and return split version parts. + + Expected formats: + w.x.y (3 parts) or w.x.y.z (4 parts) + + Returns: + list: Version parts split by '.' + + Raises: + ValueError: If version format is unexpected + """ + version_parts = self.version.split(".") + if len(version_parts) not in [3, 4]: + msg = f"{self.class_name}._validate_and_split_version: " + msg += f"Unexpected version format '{self.version}'. " + msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts" + raise ValueError(msg) + return version_parts + + @property + def dev(self): + """ + Return True if the Controller is running a development release. + Return False if the Controller is not running a development release. + Return None otherwise + + Possible values: + True + False + None + """ + return self._get("dev") + + @property + def install(self): + """ + Return the value of install, if it exists. + Return None otherwise + + Possible values: + EASYFABRIC + (probably other values) + None + """ + return self._get("install") + + @property + def is_ha_enabled(self): + """ + Return True if Controller is high-availability enabled. + Return False if Controller is not high-availability enabled. + Return None otherwise + + Possible values: + True + False + None + """ + return self._get("isHaEnabled") + + @property + def is_media_controller(self): + """ + Return True if Controller is a media controller. + Return False if Controller is not a media controller. + Return None otherwise + + Possible values: + True + False + None + """ + return self._get("isMediaController") + + @property + def is_upgrade_inprogress(self): + """ + Return True if a Controller upgrade is in progress. + Return False if a Controller upgrade is not in progress. + Return None otherwise + + Possible values: + True + False + None + """ + return self._get("is_upgrade_inprogress") + + @property + def response_data(self): + """ + Return the data retrieved from the request + """ + return self._response_data + + @property + def mode(self): + """ + # Summary + + Return the controller mode, if it exists. + Return None otherwise + + Possible values: + LAN + "" + + ## Notes + + - mode will be "" for ND 4 + """ + value = self._get("mode") + if value is None: + msg = "Controller response is missing 'mode' parameter." + raise ValueError(msg) + return value + + @property + def uuid(self): + """ + Return the value of uuid, if it exists. + Return None otherwise + + Possible values: + uuid e.g. "f49e6088-ad4f-4406-bef6-2419de914df1" + None + """ + return self._get("uuid") + + @property + def version(self): + """ + # Summary + + - Return the controller version, if it exists. + - Raise ValueError if version is not available. + + Possible values: + version, e.g. "12.1.2e" or "12.4.1.245" + """ + version = self._get("version") + if version is None: + msg = f"{self.class_name}.version: " + msg += "Version information not available in controller response" + raise ValueError(msg) + return version + + @property + def version_major(self): + """ + Return the controller major version as a string. + Raise ValueError if version format is unexpected. + + We are assuming semantic versioning based on: + https://semver.org + + Expected formats: + w.x.y (3 parts) or w.x.y.z (4 parts) + + Possible values: + if version is 12.1.2e, return "12" + if version is 12.4.1.245, return "12" + """ + version_parts = self._validate_and_split_version() + return version_parts[0] + + @property + def version_minor(self): + """ + Return the controller minor version as a string. + Raise ValueError if version format is unexpected. + + We are assuming semantic versioning based on: + https://semver.org + + Expected formats: + w.x.y (3 parts) or w.x.y.z (4 parts) + + Possible values: + if version is 12.1.2e, return "1" + if version is 12.4.1.245, return "4" + """ + version_parts = self._validate_and_split_version() + return version_parts[1] + + @property + def version_patch(self): + """ + Return the controller patch version as a string. + Raise ValueError if version format is unexpected. + + We are assuming semantic versioning based on: + https://semver.org + + Expected formats: + w.x.y (3 parts) or w.x.y.z (4 parts) + + Possible values: + if version is 12.1.2e, return "2e" + if version is 12.4.1.245, return "1" + """ + version_parts = self._validate_and_split_version() + return version_parts[2] + + @property + def is_controller_version_4x(self) -> bool: + """ + # Summary + + - Return True if the controller version implies ND 4.0 or higher. + - Return False otherwise. + + ## Raises + + - ValueError if unable to determine version + """ + method_name = inspect.stack()[0][3] + + result = None + try: + major = self.version_major + minor = self.version_minor + + if major is None or minor is None: + # This should never happen due to early validation, but if it does, raise an error + msg = f"{self.class_name}.{method_name}: " + msg += f"Unexpected None values: major={major}, minor={minor}" + raise ValueError(msg) + + # version_minor is always numeric, so we can convert directly to int + if int(major) == 12 and int(minor) < 3: + result = False + else: + result = True + except (ValueError, TypeError) as e: + # If version parsing fails, re-raise as ValueError - do not assume version + msg = f"{self.class_name}.{method_name}: " + msg += f"Error parsing version {self.version}: {e}" + raise ValueError(msg) from e + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.version: {self.version}, " + msg += f"Controller is version 4.x: {result}" + self.log.debug(msg) + + return result + + @property + def rest_send(self) -> RestSend: + """ + An instance of the RestSend class. + """ + return self._rest_send + + @rest_send.setter + def rest_send(self, value: RestSend) -> None: + if not value.params: + msg = f"{self.class_name}.rest_send must be set to an " + msg += "instance of RestSend with params set." + raise ValueError(msg) + self._rest_send = value diff --git a/plugins/module_utils/common/operation_type.py b/plugins/module_utils/common/operation_type.py new file mode 100644 index 000000000..170877029 --- /dev/null +++ b/plugins/module_utils/common/operation_type.py @@ -0,0 +1,101 @@ +# Copyright (c) 2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Enumeration for operation types used in Nexus Dashboard modules. +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__author__ = "Allen Robel" + +from enum import Enum + + +class OperationType(Enum): + """ + # Summary + + Enumeration for operation types. + + Used by ResultsV2 to determine if changes have occurred based on the operation type. + + - QUERY: Represents a query operation which does not change state. + - CREATE: Represents a create operation which adds new resources. + - UPDATE: Represents an update operation which modifies existing resources. + - DELETE: Represents a delete operation which removes resources. + + # Usage + + ```python + from plugins.module_utils.common.operation_types import OperationType + class MyModule: + def __init__(self): + self.operation_type = OperationType.QUERY + ``` + + The above informs the ResultsV2 class that the current operation is a query, and thus + no changes should be expected. + + Specifically, Results.has_anything_changed() will return False for QUERY operations, + while it will evaluate CREATE, UPDATE, and DELETE operations in more detail to + determine if any changes have occurred. + """ + + QUERY = "query" + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + def changes_state(self) -> bool: + """ + # Summary + + Return True if this operation type can change controller state. + + ## Returns + + - `bool`: True if operation can change state, False otherwise + + ## Examples + + ```python + OperationType.QUERY.changes_state() # Returns False + OperationType.CREATE.changes_state() # Returns True + OperationType.DELETE.changes_state() # Returns True + ``` + """ + return self in ( + OperationType.CREATE, + OperationType.UPDATE, + OperationType.DELETE, + ) + + def is_read_only(self) -> bool: + """ + # Summary + + Return True if this operation type is read-only. + + ## Returns + + - `bool`: True if operation is read-only, False otherwise + + ## Examples + + ```python + OperationType.QUERY.is_read_only() # Returns True + OperationType.CREATE.is_read_only() # Returns False + ``` + """ + return self == OperationType.QUERY diff --git a/plugins/module_utils/common/rest_send_v2.py b/plugins/module_utils/common/rest_send_v2.py index 3721e2b24..fee113eb3 100644 --- a/plugins/module_utils/common/rest_send_v2.py +++ b/plugins/module_utils/common/rest_send_v2.py @@ -12,10 +12,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +# pylint: disable=too-many-instance-attributes +""" +Send REST requests to the controller with retries. +""" from __future__ import absolute_import, division, print_function -__metaclass__ = type +__metaclass__ = type # pylint: disable=invalid-name __author__ = "Allen Robel" import copy @@ -148,35 +151,6 @@ def __init__(self, params): msg += f"check_mode: {self.check_mode}" self.log.debug(msg) - def _verify_commit_parameters(self): - """ - ### Summary - Verify that required parameters are set prior to calling ``commit()`` - - ### Raises - - ``ValueError`` if: - - ``path`` is not set - - ``response_handler`` is not set - - ``sender`` is not set - - ``verb`` is not set - """ - if self.path is None: - msg = f"{self.class_name}._verify_commit_parameters: " - msg += "path must be set before calling commit()." - raise ValueError(msg) - if self.response_handler is None: - msg = f"{self.class_name}._verify_commit_parameters: " - msg += "response_handler must be set before calling commit()." - raise ValueError(msg) - if self.sender is None: - msg = f"{self.class_name}._verify_commit_parameters: " - msg += "sender must be set before calling commit()." - raise ValueError(msg) - if self.verb is None: - msg = f"{self.class_name}._verify_commit_parameters: " - msg += "verb must be set before calling commit()." - raise ValueError(msg) - def restore_settings(self): """ ### Summary @@ -230,7 +204,7 @@ def commit(self): ### Raises - ``ValueError`` if: - - RestSend()._verify_commit_parameters() raises + - RestSend()._commit_normal_mode() raises ``ValueError`` - ResponseHandler() raises ``TypeError`` or ``ValueError`` - Sender().commit() raises ``ValueError`` @@ -260,16 +234,16 @@ def commit(self): try: if self.check_mode is True: - self.commit_check_mode() + self._commit_check_mode() else: - self.commit_normal_mode() + self._commit_normal_mode() except (TypeError, ValueError) as error: msg = f"{self.class_name}.{method_name}: " msg += "Error during commit. " msg += f"Error details: {error}" raise ValueError(msg) from error - def commit_check_mode(self): + def _commit_check_mode(self): """ ### Summary Simulate a controller request for check_mode. @@ -300,9 +274,24 @@ def commit_check_mode(self): msg += f"verb {self.verb}, path {self.path}." self.log.debug(msg) - self._verify_commit_parameters() + if self.path is None: + msg = f"{self.class_name}.{method_name}: " + msg += "path must be set before calling commit()." + raise ValueError(msg) + if self.response_handler is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response_handler must be set before calling commit()." + raise ValueError(msg) + if self.sender is None: + msg = f"{self.class_name}.{method_name}: " + msg += "sender must be set before calling commit()." + raise ValueError(msg) + if self.verb is None: + msg = f"{self.class_name}.{method_name}: " + msg += "verb must be set before calling commit()." + raise ValueError(msg) - response_current = {} + response_current: dict = {} response_current["RETURN_CODE"] = 200 response_current["METHOD"] = self.verb response_current["REQUEST_PATH"] = self.path @@ -324,7 +313,7 @@ def commit_check_mode(self): msg += f"Error detail: {error}" raise ValueError(msg) from error - def commit_normal_mode(self): + def _commit_normal_mode(self): """ Call dcnm_send() with retries until successful response or timeout is exceeded. @@ -346,10 +335,22 @@ def commit_normal_mode(self): method_name = inspect.stack()[0][3] caller = inspect.stack()[1][3] - try: - self._verify_commit_parameters() - except ValueError as error: - raise ValueError(error) from error + if self.path is None: + msg = f"{self.class_name}.{method_name}: " + msg += "path must be set before calling commit()." + raise ValueError(msg) + if self.response_handler is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response_handler must be set before calling commit()." + raise ValueError(msg) + if self.sender is None: + msg = f"{self.class_name}.{method_name}: " + msg += "sender must be set before calling commit()." + raise ValueError(msg) + if self.verb is None: + msg = f"{self.class_name}.{method_name}: " + msg += "verb must be set before calling commit()." + raise ValueError(msg) timeout = copy.copy(self.timeout) diff --git a/plugins/module_utils/common/results.py b/plugins/module_utils/common/results.py index 067a8001f..d3fd8b70b 100644 --- a/plugins/module_utils/common/results.py +++ b/plugins/module_utils/common/results.py @@ -1,4 +1,4 @@ -# Copyright (c) 2024 Cisco and/or its affiliates. +# Copyright (c) 2024-2025 Cisco and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -378,7 +378,7 @@ def failed_result(self) -> dict: """ return a result for a failed task with no changes """ - result = {} + result: dict = {} result["changed"] = False result["failed"] = True result["diff"] = [{}] @@ -391,7 +391,7 @@ def ok_result(self) -> dict: """ return a result for a successful task with no changes """ - result = {} + result: dict = {} result["changed"] = False result["failed"] = False result["diff"] = [{}] @@ -505,7 +505,7 @@ def diff_current(self): - setter: ``TypeError`` if value is not a dict. """ value = self.properties.get("diff_current") - value["sequence_number"] = self.task_sequence_number + value["sequence_number"] = self.task_sequence_number # type: ignore[index] return value @diff_current.setter @@ -532,7 +532,7 @@ def failed(self) -> set: return self.properties["failed"] @failed.setter - def failed(self, value): + def failed(self, value: bool) -> None: method_name = inspect.stack()[0][3] if not isinstance(value, bool): # Setting failed, itself failed(!) @@ -596,7 +596,7 @@ def response_current(self): - setter: ``TypeError`` if value is not a dict. """ value = self.properties.get("response_current") - value["sequence_number"] = self.task_sequence_number + value["sequence_number"] = self.task_sequence_number # type: ignore[index] return value @response_current.setter @@ -689,7 +689,7 @@ def result_current(self): - setter: ``TypeError`` if value is not a dict """ value = self.properties.get("result_current") - value["sequence_number"] = self.task_sequence_number + value["sequence_number"] = self.task_sequence_number # type: ignore[index] return value @result_current.setter diff --git a/plugins/module_utils/common/results_v2.py b/plugins/module_utils/common/results_v2.py new file mode 100644 index 000000000..80aa41133 --- /dev/null +++ b/plugins/module_utils/common/results_v2.py @@ -0,0 +1,999 @@ +# Copyright (c) 2024-2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=too-many-instance-attributes,too-many-public-methods,line-too-long +""" +Exposes public class Results to collect results across tasks. +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__copyright__ = "Copyright (c) 2024-2025 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect +import json +import logging +from typing import Any + +from .operation_type import OperationType + + +class Results: + """ + # Summary + + Collect results across tasks. + + ## Raises + + - `TypeError`: if properties are not of the correct type. + + ## Description + + Provides a mechanism to collect results across tasks. The task classes + must support this Results class. Specifically, they must implement the + following: + + 1. Accept an instantiation of `Results()` + - Typically a class property is used for this + 2. Populate the `Results` instance with the results of the task + - Typically done by transferring `RestSend()`'s responses to the + `Results` instance + 3. Register the results of the task with `Results`, using: + - `Results.register_task_result()` + - Typically done after the task is complete + + `Results` should be instantiated in the main Ansible Task class and + passed to all other task classes for which results are to be collected. + The task classes should populate the `Results` instance with the results + of the task and then register the results with `Results.register_task_result()`. + + This may be done within a separate class (as in the example below, where + the `FabricDelete()` class is called from the `TaskDelete()` class. + The `Results` instance can then be used to build the final result, by + calling `Results.build_final_result()`. + + ## Example Usage + + We assume an Ansible module structure as follows: + + - `TaskCommon()`: Common methods used by the various ansible + state classes. + - `TaskDelete(TaskCommon)`: Implements the delete state + - `TaskMerge(TaskCommon)`: Implements the merge state + - `TaskQuery(TaskCommon)`: Implements the query state + - etc... + + In TaskCommon, `Results` is instantiated and, hence, is inherited by all + state classes.: + + ```python + class TaskCommon: + def __init__(self): + self._results = Results() + + @property + def results(self) -> Results: + ''' + An instance of the Results class. + ''' + return self._results + + @results.setter + def results(self, value: Results) -> None: + self._results = value + ``` + + In each of the state classes (TaskDelete, TaskMerge, TaskQuery, etc...) + a class is instantiated (in the example below, FabricDelete) that + supports collecting results for the Results instance: + + ```python + class TaskDelete(TaskCommon): + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.fabric_delete = FabricDelete(self.ansible_module) + + def commit(self): + ''' + delete the fabric + ''' + ... + self.fabric_delete.fabric_names = ["FABRIC_1", "FABRIC_2"] + self.fabric_delete.results = self.results + # results.register_task_result() is called within the + # commit() method of the FabricDelete class. + self.fabric_delete.commit() + ``` + + Finally, within the main() method of the Ansible module, the final result + is built by calling Results.build_final_result(): + + ```python + if ansible_module.params["state"] == "deleted": + task = TaskDelete(ansible_module) + task.commit() + elif ansible_module.params["state"] == "merged": + task = TaskDelete(ansible_module) + task.commit() + # etc, for other states... + + # Build the final result + task.results.build_final_result() + + # Call fail_json() or exit_json() based on the final result + if True in task.results.failed: + ansible_module.fail_json(**task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + ``` + + results.final_result will be a dict with the following structure + + ```json + { + "changed": True, # or False + "failed": True, # or False + "diff": { + [{"diff1": "diff"}, {"diff2": "diff"}, {"etc...": "diff"}], + } + "response": { + [{"response1": "response"}, {"response2": "response"}, {"etc...": "response"}], + } + "result": { + [{"result1": "result"}, {"result2": "result"}, {"etc...": "result"}], + } + "metadata": { + [{"metadata1": "metadata"}, {"metadata2": "metadata"}, {"etc...": "metadata"}], + } + } + ``` + + diff, response, and result dicts are per the Ansible DCNM Collection standard output. + + An example of a result dict would be (sequence_number is added by Results): + + ```json + { + "found": true, + "sequence_number": 1, + "success": true + } + ``` + + An example of a metadata dict would be (sequence_number is added by Results): + + + ```json + { + "action": "merge", + "check_mode": false, + "state": "merged", + "sequence_number": 1 + } + ``` + + `sequence_number` indicates the order in which the task was registered + with `Results`. It provides a way to correlate the diff, response, + result, and metadata across all tasks. + + ## Typical usage within a task class such as FabricDelete + + ```python + from ansible_collections.cisco.dcnm.plugins.module_utils.common.operation_type import OperationType + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results_v2 import Results + from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_v2 import RestSend + ... + class FabricDelete: + def __init__(self, ansible_module): + ... + self.action: str = "fabric_delete" + self.operation_type: OperationType = OperationType.DELETE # Determines if changes might occur + self._rest_send: RestSend = RestSend(params) + self._results: Results = Results() + ... + + def commit(self): + ... + self._results.add_changed(True) # or False, depending on whether changes were made + self._results.add_response(self._rest_send.response_current) + self._results.add_result(self._rest_send.result_current) + self._results.register_task_result() + ... + + @property + def results(self) -> Results: + ''' + An instance of the Results class. + ''' + return self._results + @results.setter + def results(self, value: Results) -> None: + self._results = value + self._results.action = self.action + self._results.operation_type = self.operation_type + """ + + def __init__(self) -> None: + self.class_name: str = self.__class__.__name__ + + self.log: logging.Logger = logging.getLogger(f"dcnm.{self.class_name}") + + self.diff_keys: list = ["deleted", "merged", "query"] + self.response_keys: list = ["deleted", "merged", "query"] + + # Assign a unique sequence number to each registered task + self.task_sequence_number: int = 0 + + self.final_result: dict = {} + self._action: str = "" + self._operation_type: OperationType = OperationType.QUERY + self._changed: set = set() + self._check_mode: bool = False + self._diff: list[dict] = [] + self._diff_current: dict = {} + self._failed: set = set() + self._metadata: list[dict] = [] + self._response: list[dict] = [] + self._response_current: dict = {} + self._response_data: list[dict] = [] + self._result: list[dict] = [] + self._result_current: dict = {} + self._state: str = "" + + msg = f"ENTERED {self.class_name}():" + self.log.debug(msg) + + def add_changed(self, value: bool) -> None: + """ + # Summary + + Add a boolean value to the changed set. + + ## Raises + + - `ValueError`: if value is not a bool + + ## See also + + - `@changed` property + """ + if not isinstance(value, bool): + msg = f"{self.class_name}.add_changed: " + msg += f"instance.add_changed must be a bool. Got {value}" + raise ValueError(msg) + self._changed.add(value) + + def add_diff(self, value: dict) -> None: + """ + # Summary + + Add a dict to the diff list. + + ## Raises + + - `TypeError`: if value is not a dict + """ + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.diff must be a dict. Got {value}" + raise TypeError(msg) + value["sequence_number"] = self.task_sequence_number + self._diff.append(copy.deepcopy(value)) + + def add_failed(self, value: bool) -> None: + """ + # Summary + + Add a boolean value to the failed set. + + ## Raises + + - `ValueError`: if value is not a bool + + ## See also + + - `@failed` property + """ + if not isinstance(value, bool): + msg = f"{self.class_name}.add_failed: " + msg += f"instance.add_failed must be a bool. Got {value}" + raise ValueError(msg) + self._failed.add(value) + + def add_metadata(self, value: dict) -> None: + """ + # Summary + + Add a dict to the metadata list. + + ## Raises + + - `TypeError`: if value is not a dict + + ## See also + + `@metadata` property + """ + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + value["sequence_number"] = self.task_sequence_number + self._metadata.append(copy.deepcopy(value)) + + def add_response(self, value: dict) -> None: + """ + # Summary + + Add a dict to the response list. + + ## Raises + + - `TypeError`: if value is not a dict + + ## See also + + `@response` property + """ + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.add_response must be a dict. Got {value}" + raise TypeError(msg) + value["sequence_number"] = self.task_sequence_number + self._response.append(copy.deepcopy(value)) + + def add_response_data(self, value: dict) -> None: + """ + # Summary + + Add a dict to the response_data list. + + ## Raises + + - `TypeError`: if value is not a dict + + ## See also + + `@response_data` property + """ + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.add_response_data must be a dict. Got {value}" + raise TypeError(msg) + self._response_data.append(copy.deepcopy(value)) + + def add_result(self, value: dict) -> None: + """ + # Summary + + Add a dict to the result list. + + ## Raises + + - `TypeError`: if value is not a dict + + ## See also + + `@result` property + """ + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.add_result must be a dict. Got {value}" + raise TypeError(msg) + value["sequence_number"] = self.task_sequence_number + self._result.append(copy.deepcopy(value)) + + def _increment_task_sequence_number(self) -> None: + """ + # Summary + + Increment a unique task sequence number. + + ## Raises + + None + """ + self.task_sequence_number += 1 + msg = f"self.task_sequence_number: {self.task_sequence_number}" + self.log.debug(msg) + + def did_anything_change(self) -> bool: # pylint: disable=too-many-return-statements + """ + # Summary + + Determine if anything changed in the current task. + + - Return True if there were any changes + - Return False otherwise + + ## Raises + + None + """ + method_name: str = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: ENTERED: " + msg += f"self.action: {self.action}, " + msg += f"self.operation_type: {self.operation_type}, " + msg += f"self.state: {self.state}, " + msg += f"self.failed: {self.failed}, " + msg += f"self.result_current: {self.result_current}, " + msg += f"self.diff: {self.diff}" + self.log.debug(msg) + + something_changed: bool = False + if self.check_mode is True: + return False + + # Check operation_type first (preferred method) + if self.operation_type.is_read_only(): + return False + + # Fallback: Check action string for backward compatibility + if "query" in self.action or self.state == "query": + return False + if self.result_current.get("changed", False) is True: + return True + if self.result_current.get("changed", True) is False: + return False + for diff in self.diff: + something_changed = False + test_diff = copy.deepcopy(diff) + test_diff.pop("sequence_number", None) + if len(test_diff) != 0: + something_changed = True + msg = f"{self.class_name}.{method_name}: " + msg += f"something_changed: {something_changed}" + self.log.debug(msg) + return something_changed + + def register_task_result(self) -> None: + """ + # Summary + + Register a task's result. + + ## Raises + + None + + ## Description + + 1. Append result_current, response_current, diff_current and + metadata_current their respective lists (result, response, diff, + and metadata) + 2. Set self.changed based on current_diff. + If current_diff is empty, it is assumed that no changes were made + and self.changed is set to False. Else, self.changed is set to True. + 3. Set self.failed based on current_result. If current_result["success"] + is True, self.failed is set to False. Else, self.failed is set to True. + 4. Set self.metadata based on current_metadata. + + - self.response : list of controller responses + - self.result : list of results returned by the handler + - self.diff : list of diffs + - self.metadata : list of metadata + """ + method_name: str = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"ENTERED: self.action: {self.action}, " + msg += f"self.result_current: {self.result_current}" + self.log.debug(msg) + + self._increment_task_sequence_number() + self.add_metadata(self.metadata_current) + self.add_response(self.response_current) + self.add_result(self.result_current) + self.add_diff(self.diff_current) + + if self.did_anything_change() is False: + self.changed.add(False) + else: + self.changed.add(True) + if self.result_current.get("success") is True: + self._failed.add(False) + elif self.result_current.get("success") is False: + self._failed.add(True) + else: + msg = f"{self.class_name}.{method_name}: " + msg += "self.result_current['success'] is not a boolean. " + msg += f"self.result_current: {self.result_current}. " + msg += "Setting self.failed to False." + self.log.debug(msg) + self._failed.add(False) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.diff: {json.dumps(self.diff, indent=4, sort_keys=True)}, " + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.metadata: {json.dumps(self.metadata, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.response: {json.dumps(self.response, indent=4, sort_keys=True)}, " + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.result: {json.dumps(self.result, indent=4, sort_keys=True)}, " + self.log.debug(msg) + + def build_final_result(self) -> None: + """ + # Summary + + Build the final result. + + ## Raises + + None + + ## Description + + The final result consists of the following: + + ```json + { + "changed": True, # or False + "failed": True, + "diff": { + [], + }, + "response": { + [], + }, + "result": { + [], + }, + "metadata": { + [], + } + ``` + """ + msg = f"self.changed: {self.changed}, " + msg = f"self.failed: {self.failed}, " + self.log.debug(msg) + + if True in self.failed: # pylint: disable=unsupported-membership-test + self.final_result["failed"] = True + else: + self.final_result["failed"] = False + + if True in self.changed: # pylint: disable=unsupported-membership-test + self.final_result["changed"] = True + else: + self.final_result["changed"] = False + self.final_result["diff"] = self.diff + self.final_result["response"] = self.response + self.final_result["result"] = self.result + self.final_result["metadata"] = self.metadata + + def add_to_failed(self, value: bool) -> None: + """ + # Summary + + Add a boolean value to the failed set. + + ## Raises + + - `ValueError`: if value is not a bool + """ + if not isinstance(value, bool): + msg = f"{self.class_name}.add_to_failed: " + msg += f"instance.add_to_failed must be a bool. Got {value}" + raise ValueError(msg) + self._failed.add(value) + + @property + def failed_result(self) -> dict: + """ + # Summary + + Return a result for a failed task with no changes + + ## Raises + + None + """ + result: dict = {} + result["changed"] = False + result["failed"] = True + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result + + @property + def ok_result(self) -> dict: + """ + # Summary + + Return a result for a successful task with no changes + + ## Raises + + None + """ + result: dict = {} + result["changed"] = False + result["failed"] = False + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result + + @property + def action(self) -> str: + """ + # Summary + + Added to results to indicate the action that was taken + + ## Raises + + - `TypeError`: if value is not a string + """ + return self._action + + @action.setter + def action(self, value: str) -> None: + method_name: str = inspect.stack()[0][3] + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.{method_name} must be a string. " + msg += f"Got {value}." + raise TypeError(msg) + msg = f"{self.class_name}.{method_name}: " + msg += f"value: {value}" + self.log.debug(msg) + self._action = value + + @property + def operation_type(self) -> OperationType: + """ + # Summary + + The operation type for the current operation. + + Used to determine if the operation might change controller state. + + ## Raises + + - `ValueError`: if operation type is not an OperationType enum value + + ## Returns + + The current operation type (OperationType enum value) + """ + return self._operation_type + + @operation_type.setter + def operation_type(self, value: OperationType) -> None: + """ + # Summary + + Set the operation type. + + ## Raises + + - `TypeError`: if value is not an OperationType instance + + ## Parameters + + - value: The operation type to set (must be an OperationType enum value) + """ + method_name: str = inspect.stack()[0][3] + if not isinstance(value, OperationType): + msg = f"{self.class_name}.{method_name}: " + msg += "value must be an OperationType instance. " + msg += f"Got type {type(value)}, value {value}." + raise TypeError(msg) + msg = f"{self.class_name}.{method_name}: " + msg += f"value: {value}" + self.log.debug(msg) + self._operation_type = value + + @property + def changed(self) -> set: + """ + # Summary + + Returns a set() containing boolean values indicating whether anything changed. + + ## Raises + + None + + ## Returns + + - A set() of boolean values indicating whether any tasks changed + + ## See also + + - `add_changed()` method to add to the changed set. + """ + return self._changed + + @property + def check_mode(self) -> bool: + """ + # Summary + + - A boolean indicating whether Ansible check_mode is enabled. + - `True` if check_mode is enabled, `False` otherwise. + + ## Raises + + - `TypeError`: if value is not a bool + """ + return self._check_mode + + @check_mode.setter + def check_mode(self, value: bool) -> None: + method_name: str = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.{method_name} must be a bool. " + msg += f"Got {value}." + raise TypeError(msg) + self._check_mode = value + + @property + def diff(self) -> list[dict]: + """ + # Summary + + A list of dicts representing the changes made. + + - The setter appends a dict to the list. + - The getter returns the list. + + ## Raises + + - setter: `TypeError`: if value is not a dict + """ + return self._diff + + @property + def diff_current(self) -> dict: + """ + # Summary + + A dict representing the current diff. + + - getter: Return the current diff + - setter: Set the current diff + + ## Raises + + - setter: `TypeError` if value is not a dict. + """ + value = self._diff_current + value["sequence_number"] = self.task_sequence_number + return value + + @diff_current.setter + def diff_current(self, value: dict) -> None: + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.diff_current must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._diff_current = value + + @property + def failed(self) -> set[bool]: + """ + # Summary + + A set() of boolean values indicating whether any tasks failed + + - If the set contains True, at least one task failed. + - If the set contains only False all tasks succeeded. + + ## Raises + + - `TypeError` if value is not a bool. + + ## See also + + - `add_failed()` method to add to the failed set. + """ + return self._failed + + @property + def metadata(self) -> list[dict]: + """ + # Summary + + A list of dicts representing the metadata (if any) for each diff. + + - getter: Return the metadata. + - setter: Append value to the metadata list. + + ## Raises + + - setter: `TypeError` if value is not a dict. + """ + return self._metadata + + @property + def metadata_current(self) -> dict: + """ + # Summary + + Return the current metadata which is comprised of the following properties: + + - action + - check_mode + - sequence_number + - state + + ## Raises + + None + """ + value: dict[str, Any] = {} + value["action"] = self.action + value["check_mode"] = self.check_mode + value["sequence_number"] = self.task_sequence_number + value["state"] = self.state + return value + + @property + def response_current(self) -> dict: + """ + # Summary + + Return a `dict` containing the current response from the controller. + `instance.commit()` must be called first. + + - getter: Return the current response. + - setter: Set the current response. + + ## Raises + + - setter: `TypeError` if value is not a dict. + """ + value = self._response_current + value["sequence_number"] = self.task_sequence_number + return value + + @response_current.setter + def response_current(self, value: dict) -> None: + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.response_current must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._response_current = value + + @property + def response(self) -> list[dict]: + """ + # Summary + + Return the response list; `list` of `dict`, where each `dict` contains a + response from the controller. + + ## Raises + + None + + ## See also + + `add_response()` method to add to the response list. + """ + return self._response + + @property + def response_data(self) -> list[dict]: + """ + # Summary + + Return a `list` of `dict`, where each `dict` contains the contents of the DATA key + within the responses that have been added. + + ## Raises + + None + + ## See also + + `add_response_data()` method to add to the response_data list. + """ + return self._response_data + + @property + def result(self) -> list[dict]: + """ + # Summary + + A `list` of `dict`, where each `dict` contains a result. + + - getter: Return the result list. + - setter: Append `dict` to the result list. + + ## Raises + + - setter: `TypeError` if value is not a dict + + ## See also + + `add_result()` method to add to the result list. + """ + return self._result + + @property + def result_current(self) -> dict: + """ + # Summary + + A `dict` representing the current result. + + - getter: Return the current result. + - setter: Set the current result. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + value = self._result_current + value["sequence_number"] = self.task_sequence_number + return value + + @result_current.setter + def result_current(self, value) -> None: + method_name: str = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.result_current must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result_current = value + + @property + def state(self) -> str: + """ + # Summary + + The Ansible state + + - getter: Return the state. + - setter: Set the state. + + ## Raises + + - setter: `TypeError` if value is not a string + """ + return self._state + + @state.setter + def state(self, value) -> None: + method_name: str = inspect.stack()[0][3] + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.{method_name} must be a string. " + msg += f"Got {value}." + raise TypeError(msg) + self._state = value diff --git a/plugins/module_utils/common/ruleset_v2.py b/plugins/module_utils/common/ruleset_v2.py new file mode 100644 index 000000000..4a0afabb2 --- /dev/null +++ b/plugins/module_utils/common/ruleset_v2.py @@ -0,0 +1,508 @@ +# Copyright (c) 2024-2025 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=too-many-instance-attributes +""" +Generate a ruleset from a controller template +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name +__author__ = "Allen Robel" + + +import inspect +import json +import logging +import re +from typing import Optional, Union + +from .conversion import ConversionUtils + + +class RuleSet: + """ + # Summary + + Generate a ruleset from a controller template + + ## Usage + + ```python + ruleset = RuleSet() + ruleset.template =