diff --git a/Dockerfile b/Dockerfile index 9957f46..771d52a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,32 +6,17 @@ ENV HYPERGLASS_PORT=8001 ENV HYPERGLASS_DEBUG=false ENV HYPERGLASS_DEV_MODE=false ENV HYPERGLASS_REDIS_HOST=redis -ENV HYPEGLASS_DISABLE_UI=false +ENV HYPEGLASS_DISABLE_UI=true ENV HYPERGLASS_CONTAINER=true COPY . . -FROM base AS ui +FROM base as ui WORKDIR /opt/hyperglass/hyperglass/ui -RUN apk add --no-cache build-base pkgconfig cairo-dev nodejs npm \ - gcc \ - g++ \ - musl-dev \ - python3-dev \ - libffi-dev \ - openssl-dev \ - jpeg-dev \ - zlib-dev \ - freetype-dev \ - lcms2-dev \ - openjpeg-dev \ - tiff-dev \ - tk-dev \ - tcl-dev \ - harfbuzz-dev \ - fribidi-dev \ - curl && sleep 2 && npm install -g pnpm && pnpm install -P +RUN apk add build-base pkgconfig cairo-dev nodejs npm +RUN npm install -g pnpm +RUN pnpm install -P -FROM ui AS hyperglass +FROM ui as hyperglass WORKDIR /opt/hyperglass RUN pip3 install -e . diff --git a/TRACEROUTE_RESTRUCTURE_SUMMARY.md b/TRACEROUTE_RESTRUCTURE_SUMMARY.md deleted file mode 100644 index f8f3942..0000000 --- a/TRACEROUTE_RESTRUCTURE_SUMMARY.md +++ /dev/null @@ -1,134 +0,0 @@ -# MikroTik Traceroute Enhancement - Restructured Implementation - -## Overview -Restructured the MikroTik traceroute implementation to follow consistent naming conventions and architectural patterns used throughout the hyperglass codebase, specifically matching the BGP route plugin structure. - -## Key Changes Made - -### 1. Consistent Naming Convention ✅ -- **OLD**: `mikrotik_traceroute_structured.py` -- **NEW**: `trace_route_mikrotik.py` (matches `bgp_route_mikrotik.py` pattern) - -This follows the established pattern: -- `bgp_route_{platform}.py` for BGP parsing -- `trace_route_{platform}.py` for traceroute parsing - -### 2. Platform-Specific Parsing in models/parsing/ ✅ -- **Added**: `MikrotikTracerouteTable` and `MikrotikTracerouteHop` classes in `models/parsing/mikrotik.py` -- **Removed**: `MikroTikTracerouteParser` from generic `models/parsing/traceroute.py` -- Follows the same pattern as BGP routes where platform-specific parsing is in `models/parsing/{platform}.py` - -### 3. Structured Data Model Enhancements ✅ -Enhanced `TracerouteHop` model in `models/data/traceroute.py` with MikroTik-specific statistics: -```python -# MikroTik-specific statistics -loss_pct: Optional[int] = None # Packet loss percentage -sent_count: Optional[int] = None # Number of probes sent -last_rtt: Optional[float] = None # Last RTT measurement -avg_rtt: Optional[float] = None # Average RTT -best_rtt: Optional[float] = None # Best (minimum) RTT -worst_rtt: Optional[float] = None # Worst (maximum) RTT -``` - -### 4. BGP.tools Enrichment - Structured Only ✅ -- **BEFORE**: Applied enrichment to text-based traceroute output -- **NOW**: Only applies to structured `TracerouteResult` objects -- Added reverse DNS lookup using Python's socket library -- Cleaner separation of concerns - -### 5. UI Table Component Structure ✅ -Created complete table structure for displaying traceroute data: -- `TracerouteTable` component following BGP table patterns -- `TracerouteCell` component for cell rendering -- `traceroute-fields.tsx` for field-specific formatting -- TypeScript types in `globals.d.ts` - -## File Structure - -``` -hyperglass/ -├── models/ -│ ├── data/ -│ │ └── traceroute.py # Enhanced TracerouteResult/TracerouteHop -│ └── parsing/ -│ ├── traceroute.py # Generic traceroute parsers (removed MikroTik) -│ └── mikrotik.py # MikroTik-specific parsing + MikrotikTracerouteTable -├── plugins/_builtin/ -│ ├── trace_route_mikrotik.py # NEW: MikroTik traceroute plugin (consistent naming) -│ └── bgptools_traceroute_enrichment.py # Updated: structured data only -└── ui/ - ├── components/output/ - │ ├── traceroute-table.tsx # Table component - │ ├── traceroute-cell.tsx # Cell rendering - │ └── traceroute-fields.tsx # Field formatters - └── types/ - └── globals.d.ts # TracerouteResult/TracerouteHop types -``` - -## Benefits of Restructuring - -### 1. Consistency ✅ -- Matches established BGP route plugin patterns -- Predictable file locations and naming -- Easier for developers to understand and maintain - -### 2. Separation of Concerns ✅ -- Platform-specific parsing isolated to `models/parsing/{platform}.py` -- Text-based vs structured output clearly separated -- Enrichment only applies where it makes sense (structured data) - -### 3. Enhanced Data Model ✅ -- Full MikroTik statistics preserved (Loss, Sent, Last, AVG, Best, Worst) -- Ready for BGP.tools ASN/organization enrichment -- Reverse DNS lookup integration -- JSON serializable for API responses - -### 4. UI Table Ready ✅ -- Complete table component structure -- Proper cell formatting for latency, loss, ASN -- Color coding for performance indicators -- Responsive design following existing patterns - -## Table Display Format -``` -Hop | IP Address | Hostname | ASN | Loss | Sent | Last | AVG | Best | Worst - 1 | 192.168.1.1 | gateway.local | AS65001 (MyISP) | 0% | 3 | 1.2ms | 1.1ms | 0.9ms | 1.3ms - 2 | 10.0.0.1 | core1.isp.com | AS1234 (BigISP) | 0% | 3 | 15.2ms | 14.8ms | 14.2ms | 15.5ms - 3 | — | — | — | 100% | 3 | * | * | * | * - 4 | 203.0.113.1 | transit.net | AS5678 (Transit) | 0% | 3 | 25.4ms | 26.1ms | 25.1ms | 27.8ms -``` - -## Testing Results ✅ - -Standalone parser test confirms: -- ✅ Correct parsing of MikroTik traceroute format -- ✅ Proper handling of timeouts and timeout aggregation -- ✅ MikroTik-specific statistics extraction -- ✅ Ready for structured data enrichment - -## Next Steps - -1. **DNS Tools Integration**: Could integrate dedicated DNS tools library for more robust reverse DNS lookups -2. **Additional Platforms**: Apply same pattern to other platforms (Cisco, Juniper, etc.) -3. **Performance Optimization**: Bulk BGP.tools queries for multiple IPs -4. **Caching**: Cache BGP.tools and DNS results to avoid repeated lookups - -## Migration Notes - -### Plugin Registration -Updated `plugins/_builtin/__init__.py`: -```python -from .trace_route_mikrotik import TraceroutePluginMikrotik # New - -__all__ = ( - # ... existing plugins ... - "TraceroutePluginMikrotik", # Added -) -``` - -### Execution Order -1. `trace_route_mikrotik.py` - Parse raw output to structured format -2. `bgptools_traceroute_enrichment.py` - Enrich structured data (common phase) -3. UI renders structured data in table format - -This restructuring makes the traceroute functionality consistent, maintainable, and feature-rich while following established hyperglass patterns. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index a4d1d95..74a6ff6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,21 +1,9 @@ -networks: - Rede-LG-Hyperglass: - name: Rede-LG-Hyperglass - driver: bridge - ipam: - config: - - subnet: "172.29.1.24/29" - gateway: "172.29.1.25" services: redis: image: "redis:alpine" - networks: - Rede-LG-Hyperglass: hyperglass: depends_on: - redis - networks: - Rede-LG-Hyperglass: environment: - HYPERGLASS_APP_PATH=/etc/hyperglass - HYPERGLASS_HOST=${HYPERGLASS_HOST-0.0.0.0} @@ -28,6 +16,6 @@ services: - HYPERGLASS_ORIGINAL_APP_PATH=${HYPERGLASS_APP_PATH} build: . ports: - - "${IP_HOST_VM_VPS_DOCKER-0.0.0.0}:${HYPERGLASS_PORT-8001}:${HYPERGLASS_PORT-8001}" + - "${HYPERGLASS_PORT-8001}:${HYPERGLASS_PORT-8001}" volumes: - ${HYPERGLASS_APP_PATH-/etc/hyperglass}:/etc/hyperglass diff --git a/debug_mikrotik_minimal.py b/debug_mikrotik_minimal.py deleted file mode 100644 index 85953b3..0000000 --- a/debug_mikrotik_minimal.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 -"""Minimal debug script for MikroTik traceroute parsing without full hyperglass deps.""" - -import re -import typing as t -from dataclasses import dataclass - -# Simulate just the parsing logic without all the hyperglass imports - - -@dataclass -class MikrotikTracerouteHop: - """Individual MikroTik traceroute hop.""" - - hop_number: int - ip_address: t.Optional[str] = None - hostname: t.Optional[str] = None - loss_pct: t.Optional[int] = None - sent_count: t.Optional[int] = None - last_rtt: t.Optional[float] = None - avg_rtt: t.Optional[float] = None - best_rtt: t.Optional[float] = None - worst_rtt: t.Optional[float] = None - - @property - def is_timeout(self) -> bool: - """Check if this hop is a timeout.""" - return self.ip_address is None or self.loss_pct == 100 - - -@dataclass -class MikrotikTracerouteTable: - """MikroTik Traceroute Table.""" - - target: str - source: str - hops: t.List[MikrotikTracerouteHop] - max_hops: int = 30 - packet_size: int = 60 - - @classmethod - def parse_text(cls, text: str, target: str, source: str) -> "MikrotikTracerouteTable": - """Parse MikroTik traceroute output with detailed debugging.""" - - # DEBUG: Log the raw input - print(f"=== RAW MIKROTIK TRACEROUTE INPUT ===") - print(f"Target: {target}, Source: {source}") - print(f"Raw text length: {len(text)} characters") - print(f"Raw text:\n{repr(text)}") - print(f"=== END RAW INPUT ===") - - lines = text.strip().split("\n") - print(f"Split into {len(lines)} lines") - - # DEBUG: Log each line with line numbers - for i, line in enumerate(lines): - print(f"Line {i:2d}: {repr(line)}") - - # Find all table starts - table_starts = [] - for i, line in enumerate(lines): - if ("Columns:" in line and "ADDRESS" in line) or ( - "ADDRESS" in line - and "LOSS" in line - and "SENT" in line - and not line.strip().startswith(("1", "2", "3", "4", "5", "6", "7", "8", "9")) - ): - table_starts.append(i) - print(f"Found table start at line {i}: {repr(line)}") - - if not table_starts: - print("WARNING: No traceroute table headers found in output") - return MikrotikTracerouteTable(target=target, source=source, hops=[]) - - # Take the LAST table (newest/final results) - last_table_start = table_starts[-1] - print( - f"Found {len(table_starts)} tables, using the last one starting at line {last_table_start}" - ) - - # Determine format by checking the header line - header_line = lines[last_table_start].strip() - is_columnar_format = "Columns:" in header_line - print(f"Header line: {repr(header_line)}") - print(f"Is columnar format: {is_columnar_format}") - - # Parse only the last table - hops = [] - in_data_section = False - hop_counter = 1 # For old format without hop numbers - - # Start from the last table header - for i in range(last_table_start, len(lines)): - line = lines[i].strip() - - # Skip empty lines - if not line: - print(f"Line {i}: EMPTY - skipping") - continue - - # Skip the column header lines - if ( - ("Columns:" in line) - or ("ADDRESS" in line and "LOSS" in line and "SENT" in line) - or line.startswith("#") - ): - in_data_section = True - print(f"Line {i}: HEADER - entering data section: {repr(line)}") - continue - - # Skip paging prompts - if "-- [Q quit|C-z pause]" in line: - print(f"Line {i}: PAGING PROMPT - breaking: {repr(line)}") - break # End of this table - - if in_data_section and line: - print(f"Line {i}: PROCESSING DATA LINE: {repr(line)}") - try: - if is_columnar_format: - # New format: "1 10.0.0.41 0% 1 0.5ms 0.5 0.5 0.5 0" - parts = line.split() - print(f"Line {i}: Columnar format, parts: {parts}") - if len(parts) < 3: - print(f"Line {i}: Too few parts ({len(parts)}), skipping") - continue - - hop_number = int(parts[0]) - - # Check if there's an IP address or if it's empty (timeout hop) - if len(parts) >= 8 and not parts[1].endswith("%"): - # Normal hop with IP address - ip_address = parts[1] if parts[1] else None - loss_pct = int(parts[2].rstrip("%")) - sent_count = int(parts[3]) - last_rtt_str = parts[4] - avg_rtt_str = parts[5] - best_rtt_str = parts[6] - worst_rtt_str = parts[7] - elif len(parts) >= 4 and parts[1].endswith("%"): - # Timeout hop without IP address - ip_address = None - loss_pct = int(parts[1].rstrip("%")) - sent_count = int(parts[2]) - last_rtt_str = parts[3] if len(parts) > 3 else "timeout" - avg_rtt_str = "timeout" - best_rtt_str = "timeout" - worst_rtt_str = "timeout" - else: - print(f"Line {i}: Doesn't match columnar patterns, skipping") - continue - else: - # Old format: "196.60.8.198 0% 1 17.1ms 17.1 17.1 17.1 0" - parts = line.split() - print(f"Line {i}: Old format, parts: {parts}") - if len(parts) < 6: - print(f"Line {i}: Too few parts ({len(parts)}), skipping") - continue - - ip_address = parts[0] if not parts[0].endswith("%") else None - - # Handle truncated IPv6 addresses that end with "..." - if ip_address and ip_address.endswith("..."): - print( - f"Line {i}: Truncated IPv6 address detected: {ip_address}, setting to None" - ) - ip_address = None - - if ip_address: - loss_pct = int(parts[1].rstrip("%")) - sent_count = int(parts[2]) - last_rtt_str = parts[3] - avg_rtt_str = parts[4] - best_rtt_str = parts[5] - worst_rtt_str = parts[6] if len(parts) > 6 else parts[5] - else: - # Timeout line - loss_pct = int(parts[0].rstrip("%")) - sent_count = int(parts[1]) - last_rtt_str = "timeout" - avg_rtt_str = "timeout" - best_rtt_str = "timeout" - worst_rtt_str = "timeout" - - # Convert timing values - def parse_rtt(rtt_str: str) -> t.Optional[float]: - if rtt_str in ("timeout", "-", "0ms"): - return None - # Remove 'ms' suffix and convert to float - rtt_clean = re.sub(r"ms$", "", rtt_str) - try: - return float(rtt_clean) - except ValueError: - return None - - if is_columnar_format: - # Use hop number from the data - final_hop_number = hop_number - else: - # Use sequential numbering for old format - final_hop_number = hop_counter - hop_counter += 1 - - hop_obj = MikrotikTracerouteHop( - hop_number=final_hop_number, - ip_address=ip_address, - hostname=None, # MikroTik doesn't do reverse DNS by default - loss_pct=loss_pct, - sent_count=sent_count, - last_rtt=parse_rtt(last_rtt_str), - avg_rtt=parse_rtt(avg_rtt_str), - best_rtt=parse_rtt(best_rtt_str), - worst_rtt=parse_rtt(worst_rtt_str), - ) - - hops.append(hop_obj) - print( - f"Line {i}: Created hop {final_hop_number}: {ip_address} - {loss_pct}% - {sent_count} sent" - ) - - except (ValueError, IndexError) as e: - print(f"Failed to parse line '{line}': {e}") - continue - - print(f"Before deduplication: {len(hops)} hops") - - # For old format, we need to deduplicate by IP and take only final stats - if not is_columnar_format and hops: - # For old format, we need to deduplicate by IP and take only final stats - print(f"Old format detected - deduplicating {len(hops)} total entries") - - # Group by IP address and take the HIGHEST SENT count (final stats) - ip_to_final_hop = {} - ip_to_max_sent = {} - hop_order = [] - - for hop in hops: - # Use IP address if available, otherwise use hop position for truncated addresses - if hop.ip_address: - ip_key = hop.ip_address - elif hop.ip_address is None: - ip_key = f"truncated_hop_{hop.hop_number}" - else: - ip_key = f"timeout_{hop.hop_number}" - - # Track first appearance order - if ip_key not in hop_order: - hop_order.append(ip_key) - ip_to_max_sent[ip_key] = 0 - print(f"New IP discovered: {ip_key}") - - # Keep hop with highest SENT count (most recent/final stats) - if hop.sent_count and hop.sent_count >= ip_to_max_sent[ip_key]: - ip_to_max_sent[ip_key] = hop.sent_count - ip_to_final_hop[ip_key] = hop - print(f"Updated {ip_key}: SENT={hop.sent_count} (final stats)") - - print(f"IP order: {hop_order}") - print(f"Final IP stats: {[(ip, ip_to_max_sent[ip]) for ip in hop_order]}") - - # Rebuild hops list with final stats and correct hop numbers - final_hops = [] - for i, ip_key in enumerate(hop_order, 1): - final_hop = ip_to_final_hop[ip_key] - final_hop.hop_number = i # Correct hop numbering - final_hops.append(final_hop) - print( - f"Final hop {i}: {ip_key} - Loss: {final_hop.loss_pct}% - Sent: {final_hop.sent_count}" - ) - - hops = final_hops - print(f"Deduplication complete: {len(hops)} unique hops with final stats") - - print(f"After processing: {len(hops)} final hops") - for hop in hops: - print( - f"Final hop {hop.hop_number}: {hop.ip_address} - {hop.loss_pct}% loss - {hop.sent_count} sent" - ) - - return MikrotikTracerouteTable(target=target, source=source, hops=hops) - - -if __name__ == "__main__": - # Test with the actual IPv6 traceroute output that has truncated addresses - mikrotik_output = """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS -2001:43f8:6d1::71:114 0% 1 20ms 20 20 20 0 -2620:0:1cff:dead:beef::5e0 0% 1 0.1ms 0.1 0.1 0.1 0 -2620:0:1cff:dead:beef::30e3 0% 1 0.1ms 0.1 0.1 0.1 0 -2a03:2880:f066:ffff::7 0% 1 0.2ms 0.2 0.2 0.2 0 -2a03:2880:f163:81:face:b00c:0... 0% 1 0.1ms 0.1 0.1 0.1 0 -2001:43f8:6d1::71:114 0% 2 0.9ms 10.5 0.9 20 9.6 -2620:0:1cff:dead:beef::5e0 0% 2 0.1ms 0.1 0.1 0.1 0 -2620:0:1cff:dead:beef::30e3 0% 2 0.2ms 0.2 0.1 0.2 0.1 -2a03:2880:f066:ffff::7 0% 2 0.1ms 0.2 0.1 0.2 0.1 -2a03:2880:f163:81:face:b00c:0... 0% 2 0ms 0.1 0 0.1 0.1 -2001:43f8:6d1::71:114 0% 3 0.8ms 7.2 0.8 20 9 -2620:0:1cff:dead:beef::5e0 0% 3 0.1ms 0.1 0.1 0.1 0 -2620:0:1cff:dead:beef::30e3 0% 3 0.2ms 0.2 0.1 0.2 0 -2a03:2880:f066:ffff::7 0% 3 0.1ms 0.1 0.1 0.2 0 -2a03:2880:f163:81:face:b00c:0... 0% 3 0.1ms 0.1 0 0.1 0""" - - print("Testing MikroTik IPv6 traceroute parser with truncated address...") - result = MikrotikTracerouteTable.parse_text( - mikrotik_output, "2a03:2880:f163:81:face:b00c:0:25de", "CAPETOWN_ZA" - ) - - print(f"\n=== FINAL RESULTS ===") - print(f"Target: {result.target}") - print(f"Source: {result.source}") - print(f"Number of hops: {len(result.hops)}") - for hop in result.hops: - print( - f" Hop {hop.hop_number}: {hop.ip_address or ''} - {hop.loss_pct}% loss - {hop.sent_count} sent - {hop.avg_rtt}ms avg" - ) diff --git a/example_community_names_config.yaml b/example_community_names_config.yaml deleted file mode 100644 index a9eacc4..0000000 --- a/example_community_names_config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Example configuration using the new 'name' mode for BGP communities -# This would typically go in your main config.yaml file - -structured: - communities: - mode: name - names: - "65000:1000:0": "Upstream Any" - "65000:1001:0": "Upstream A (all locations)" - "65000:1101:0": "Upstream A Location 1" - "65000:1201:0": "Upstream A Location 2" - "65000:1002:0": "Upstream B (all locations)" - "65000:1102:0": "Upstream B Location 1" - "65000:2000:0": "IXP Any" - -# With this configuration: -# - BGP communities that appear in the output and match the keys in 'names' -# will be displayed with friendly names appended -# - For example: "65000:1000:0" becomes "65000:1000:0,Upstream Any" -# - Communities without mappings remain unchanged -# - The frontend will display them as "65000:1000:0 - Upstream Any" \ No newline at end of file diff --git a/test_community_names.py b/test_community_names.py deleted file mode 100644 index 7286a7a..0000000 --- a/test_community_names.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Test the new name mode for community validation.""" - -# Standard Library -import typing as t -from unittest.mock import Mock - -# Third Party -from pydantic import ValidationError - -# Project -from hyperglass.models.data.bgp_route import BGPRoute -from hyperglass.models.config.structured import StructuredCommunities - - -def test_community_validation_name_mode(): - """Test that name mode correctly appends friendly names to communities.""" - - # Mock the state to return our test configuration - from hyperglass import state - - # Create a mock structured config with name mode - mock_structured = Mock() - mock_structured.communities = StructuredCommunities( - mode="name", - names={ - "65000:1000:0": "Upstream Any", - "65000:1001:0": "Upstream A (all locations)", - "65000:1": "Test Community", - }, - ) - - # Mock the params with our structured config - mock_params = Mock() - mock_params.structured = mock_structured - - # Mock the use_state function to return our mock params - original_use_state = getattr(state, "use_state", None) - state.use_state = Mock(return_value=mock_params) - - try: - # Test data for BGP route - test_data = { - "prefix": "192.0.2.0/24", - "active": True, - "age": 3600, - "weight": 100, - "med": 0, - "local_preference": 100, - "as_path": [65000, 65001], - "communities": [ - "65000:1000:0", # Should get friendly name - "65000:1001:0", # Should get friendly name - "65000:9999:0", # Should remain unchanged (no mapping) - "65000:1", # Should get friendly name - ], - "next_hop": "192.0.2.1", - "source_as": 65001, - "source_rid": "192.0.2.1", - "peer_rid": "192.0.2.2", - "rpki_state": 1, - } - - # Create BGPRoute instance - route = BGPRoute(**test_data) - - # Check that communities have been transformed correctly - expected_communities = [ - "65000:1000:0,Upstream Any", - "65000:1001:0,Upstream A (all locations)", - "65000:9999:0", # No friendly name, stays unchanged - "65000:1,Test Community", - ] - - assert route.communities == expected_communities - - finally: - # Restore original use_state function - if original_use_state: - state.use_state = original_use_state - - -def test_community_validation_permit_mode_unchanged(): - """Test that permit mode still works as before.""" - - from hyperglass import state - - # Create a mock structured config with permit mode - mock_structured = Mock() - mock_structured.communities = StructuredCommunities( - mode="permit", items=["^65000:.*$", "1234:1"] - ) - - mock_params = Mock() - mock_params.structured = mock_structured - - original_use_state = getattr(state, "use_state", None) - state.use_state = Mock(return_value=mock_params) - - try: - test_data = { - "prefix": "192.0.2.0/24", - "active": True, - "age": 3600, - "weight": 100, - "med": 0, - "local_preference": 100, - "as_path": [65000, 65001], - "communities": [ - "65000:100", # Should be permitted (matches ^65000:.*$) - "65001:200", # Should be denied (doesn't match patterns) - "1234:1", # Should be permitted (exact match) - ], - "next_hop": "192.0.2.1", - "source_as": 65001, - "source_rid": "192.0.2.1", - "peer_rid": "192.0.2.2", - "rpki_state": 1, - } - - route = BGPRoute(**test_data) - - # Should only include permitted communities - expected_communities = ["65000:100", "1234:1"] - assert route.communities == expected_communities - - finally: - if original_use_state: - state.use_state = original_use_state - - -if __name__ == "__main__": - test_community_validation_name_mode() - test_community_validation_permit_mode_unchanged() - print("All tests passed!")