Repository: aristanetworks/anta (python)

Issue:
Add the test case to verify dynamic vlans
**Command**:  show vlan dynamic
```
# show vlan dynamic 
Dynamic VLAN source       VLANS
dmf                       NONE
dot1x                     NONE
dynvtep                   NONE
evpn                      1199
mlag                      NONE
mlagsync                  NONE
mvpn                      NONE
vccbfd                    NONE
vxlan                     NONE
```

**Verification**: 1. Dynamic Vlans source should be either evpn or mlagsync 

Pull request description:
feat(anta): Added test case to verify dynamic vlans source
# Description

  This test performs the following checks for specified dynamic VLAN(s):

      1. Ensures that dynamic VLAN(s) are properly configured in the system.
      2. Confirms that dynamic VLAN(s) are enabled on all the designated sources.
      3. When strict mode is enabled (`strict: true`):
        - The dynamic VLAN(s) are enabled on all the designated sources and disabled for non designated sources.

    Expected Results
    ----------------
    * Success: The test will pass if all of the following conditions are met:
        - The dynamic VLAN(s) are properly configured in the system.
        - The dynamic VLAN(s) are enabled for all of the designated sources.
        - In strict mode, The dynamic VLAN(s) are enabled on all the designated sources and disabled for non designated sources.
    * Failure: The test will fail if any of the following conditions is met:
        - The dynamic VLAN(s) are disabled on any of designated sources.
        - In strict mode, dynamic VLAN(s) are disabled on any of the designated sources. or enabled for non designated sources.
    * Skipped: The test will skip if the following conditions is met:
        - Dynamic VLAN(s) are not configured on the device.       
    
Fixes #864 

# Checklist:

<!-- Delete not relevant items !-->

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have run pre-commit for code linting and typing (`pre-commit run`)
- [ ] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes (`tox -e testenv`)


Patch:
diff --git a/anta/custom_types.py b/anta/custom_types.py
index 4763be49..f3877459 100644
--- a/anta/custom_types.py
+++ b/anta/custom_types.py
@@ -240,3 +240,4 @@ IPv4RouteType = Literal[
 SnmpVersion = Literal["v1", "v2c", "v3"]
 SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"]
 SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"]
+DynamicVlanSource = Literal["dmf", "dot1x", "dynvtep", "evpn", "mlag", "mlagsync", "mvpn", "swfwd", "vccbfd"]


Test patch:
diff --git a/anta/tests/vlan.py b/anta/tests/vlan.py
index f2a1934c..09b450ae 100644
--- a/anta/tests/vlan.py
+++ b/anta/tests/vlan.py
@@ -9,7 +9,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, ClassVar, Literal
 
-from anta.custom_types import Vlan
+from anta.custom_types import DynamicVlanSource, Vlan
 from anta.models import AntaCommand, AntaTest
 from anta.tools import get_failed_logs, get_value
 
@@ -68,3 +68,77 @@ class VerifyVlanInternalPolicy(AntaTest):
             self.result.is_failure(failed_log)
         else:
             self.result.is_success()
+
+
+class VerifyDynamicVlanSource(AntaTest):
+    """Verifies dynamic VLAN allocation for specified VLAN sources.
+
+    This test performs the following checks for each specified VLAN source:
+
+      1. Validates source exists in dynamic VLAN table.
+      2. Verifies at least one VLAN is allocated to the source.
+      3. When strict mode is enabled (`strict: true`), ensures no other sources have VLANs allocated.
+
+    Expected Results
+    ----------------
+    * Success: The test will pass if all of the following conditions are met:
+        - Each specified source exists in dynamic VLAN table.
+        - Each specified source has at least one VLAN allocated.
+        - In strict mode: No other sources have VLANs allocated.
+    * Failure: The test will fail if any of the following conditions is met:
+        - Specified source not found in configuration.
+        - Source exists but has no VLANs allocated.
+        - In strict mode: Non-specified sources have VLANs allocated.
+
+    Examples
+    --------
+    ```yaml
+    anta.tests.vlan:
+      - VerifyDynamicVlanSource:
+          sources:
+            - evpn
+            - mlagsync
+          strict: False
+    ```
+    """
+
+    categories: ClassVar[list[str]] = ["vlan"]
+    commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show vlan dynamic", revision=1)]
+
+    class Input(AntaTest.Input):
+        """Input model for the VerifyDynamicVlanSource test."""
+
+        sources: list[DynamicVlanSource]
+        """The dynamic VLAN source list."""
+        strict: bool = False
+        """If True, only specified sources are allowed to have VLANs allocated. Default is False."""
+
+    @AntaTest.anta_test
+    def test(self) -> None:
+        """Main test function for VerifyDynamicVlanSource."""
+        self.result.is_success()
+        command_output = self.instance_commands[0].json_output
+        dynamic_vlans = command_output.get("dynamicVlans", {})
+
+        # Get all configured sources and sources with VLANs allocated
+        configured_sources = set(dynamic_vlans.keys())
+        sources_with_vlans = {source for source, data in dynamic_vlans.items() if data.get("vlanIds")}
+        expected_sources = set(self.inputs.sources)
+
+        # Check if all specified sources exist in configuration
+        missing_sources = expected_sources - configured_sources
+        if missing_sources:
+            self.result.is_failure(f"Dynamic VLAN source(s) not found in configuration: {', '.join(sorted(missing_sources))}")
+            return
+
+        # Check if configured sources have VLANs allocated
+        sources_without_vlans = expected_sources - sources_with_vlans
+        if sources_without_vlans:
+            self.result.is_failure(f"Dynamic VLAN source(s) exist but have no VLANs allocated: {', '.join(sorted(sources_without_vlans))}")
+            return
+
+        # In strict mode, verify no other sources have VLANs allocated
+        if self.inputs.strict:
+            unexpected_sources = sources_with_vlans - expected_sources
+            if unexpected_sources:
+                self.result.is_failure(f"Strict mode enabled: Unexpected sources have VLANs allocated: {', '.join(sorted(unexpected_sources))}")
diff --git a/examples/tests.yaml b/examples/tests.yaml
index c2e00f00..db1d179d 100644
--- a/examples/tests.yaml
+++ b/examples/tests.yaml
@@ -847,6 +847,12 @@ anta.tests.system:
       # Verifies the device uptime.
       minimum: 86400
 anta.tests.vlan:
+  - VerifyDynamicVlanSource:
+      # Verifies dynamic VLAN allocation for specified VLAN sources.
+      sources:
+        - evpn
+        - mlagsync
+      strict: False
   - VerifyVlanInternalPolicy:
       # Verifies the VLAN internal allocation policy and the range of VLANs.
       policy: ascending
diff --git a/tests/units/anta_tests/test_vlan.py b/tests/units/anta_tests/test_vlan.py
index e68bd06d..7fc8b868 100644
--- a/tests/units/anta_tests/test_vlan.py
+++ b/tests/units/anta_tests/test_vlan.py
@@ -7,7 +7,7 @@ from __future__ import annotations
 
 from typing import Any
 
-from anta.tests.vlan import VerifyVlanInternalPolicy
+from anta.tests.vlan import VerifyDynamicVlanSource, VerifyVlanInternalPolicy
 from tests.units.anta_tests import test
 
 DATA: list[dict[str, Any]] = [
@@ -33,4 +33,52 @@ DATA: list[dict[str, Any]] = [
             ],
         },
     },
+    {
+        "name": "success",
+        "test": VerifyDynamicVlanSource,
+        "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": [1401]}, "vccbfd": {"vlanIds": [1501]}}}],
+        "inputs": {"sources": ["evpn", "mlagsync"], "strict": False},
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "failure-no-dynamic-vlan-sources",
+        "test": VerifyDynamicVlanSource,
+        "eos_data": [{"dynamicVlans": {}}],
+        "inputs": {"sources": ["evpn", "mlagsync"], "strict": False},
+        "expected": {"result": "failure", "messages": ["Dynamic VLAN source(s) not found in configuration: evpn, mlagsync"]},
+    },
+    {
+        "name": "failure-dynamic-vlan-sources-mismatch",
+        "test": VerifyDynamicVlanSource,
+        "eos_data": [{"dynamicVlans": {"vccbfd": {"vlanIds": [1500]}, "mlagsync": {"vlanIds": [1501]}}}],
+        "inputs": {"sources": ["evpn", "mlagsync"], "strict": False},
+        "expected": {
+            "result": "failure",
+            "messages": ["Dynamic VLAN source(s) not found in configuration: evpn"],
+        },
+    },
+    {
+        "name": "success-strict-mode",
+        "test": VerifyDynamicVlanSource,
+        "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": [1502], "vccbfd": {"vlanIds": []}}}}],
+        "inputs": {"sources": ["evpn", "mlagsync"], "strict": True},
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "failure-all-sources-exact-match-additional-source-found",
+        "test": VerifyDynamicVlanSource,
+        "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": [1500]}, "vccbfd": {"vlanIds": [1500]}}}],
+        "inputs": {"sources": ["evpn", "mlagsync"], "strict": True},
+        "expected": {
+            "result": "failure",
+            "messages": ["Strict mode enabled: Unexpected sources have VLANs allocated: vccbfd"],
+        },
+    },
+    {
+        "name": "failure-all-sources-exact-match-expected-source-not-found",
+        "test": VerifyDynamicVlanSource,
+        "eos_data": [{"dynamicVlans": {"evpn": {"vlanIds": [1199]}, "mlagsync": {"vlanIds": []}}}],
+        "inputs": {"sources": ["evpn", "mlagsync"], "strict": True},
+        "expected": {"result": "failure", "messages": ["Dynamic VLAN source(s) exist but have no VLANs allocated: mlagsync"]},
+    },
 ]