feat: initial implementation of NSX Security Group IP Sync tool

- Added main script (main.py) for syncing IPs between NSX managers.
- Implemented logging setup and YAML configuration loading.
- Created NSXClient for interacting with NSX Policy API.
- Developed SyncEngine for comparing and syncing IPs.
- Added interactive group selection feature.
- Implemented data export functionality for JSON and CSV formats.
- Created requirements.txt for dependencies.
- Added __init__.py for package versioning.
- Included error handling and validation for configuration.
This commit is contained in:
brandon committed 2026-05-01 09:46:04 -05:00
commit 5b76457ad8
9 files changed
+1579

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"""NSX Security Group IP Sync package."""
__version__ = "1.0.0"
+272
View File
@@ -0,0 +1,272 @@
"""
Export source/target group IP data to JSON or CSV for auditing and testing.
"""
import csv
import json
import logging
import os
from datetime import datetime
from typing import Dict, List, Optional
from .nsx_client import NSXClient
from .sync_engine import GroupDiff
logger = logging.getLogger(__name__)
def _timestamp() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _ensure_dir(path: str) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
# ------------------------------------------------------------------
# Data collection helpers
# ------------------------------------------------------------------
def collect_group_data(
client: NSXClient,
group_names: List[str],
domain: str = "default",
include_effective_ips: bool = True,
) -> List[Dict]:
"""
Build a list of dicts, one per group, with metadata and IP lists.
Effective IP fetching is optional (slow for large environments).
"""
records = []
for name in group_names:
record: Dict = {
"group_name": name,
"domain": domain,
"nsx_manager": client.label,
"timestamp": datetime.utcnow().isoformat() + "Z",
"static_ips": [],
"effective_ips": [],
"group_found": True,
"error": None,
}
try:
group = client.get_group(name, domain)
record["group_id"] = group.get("id", "")
record["display_name"] = group.get("display_name", "")
record["description"] = group.get("description", "")
for expr in group.get("expression", []):
if expr.get("resource_type") == "IPAddressExpression":
record["static_ips"].extend(expr.get("ip_addresses", []))
record["static_ips"] = sorted(set(record["static_ips"]))
if include_effective_ips:
record["effective_ips"] = client.get_group_effective_ips(name, domain)
except Exception as exc:
msg = str(exc)
if "not found" in msg.lower():
record["group_found"] = False
record["error"] = msg
records.append(record)
return records
def collect_diff_data(diffs: List[GroupDiff]) -> List[Dict]:
"""Serialize a list of GroupDiff objects to plain dicts for export."""
return [
{
"group_name": d.group_name,
"source_ip_count": len(d.source_ips),
"target_static_ip_count": len(d.target_static_ips),
"ips_to_add_count": len(d.ips_to_add),
"ips_to_remove_count": len(d.ips_to_remove),
"source_found": d.source_found,
"target_found": d.target_found,
"source_ips": d.source_ips,
"target_static_ips": d.target_static_ips,
"ips_to_add": d.ips_to_add,
"ips_to_remove": d.ips_to_remove,
"error": d.error,
}
for d in diffs
]
# ------------------------------------------------------------------
# JSON export
# ------------------------------------------------------------------
def export_json(
data: List[Dict],
output_path: Optional[str] = None,
label: str = "export",
) -> str:
if not output_path:
output_path = os.path.join("exports", f"{label}_{_timestamp()}.json")
_ensure_dir(output_path)
with open(output_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, default=str)
logger.info("JSON export written to: %s", output_path)
return output_path
# ------------------------------------------------------------------
# CSV export
# ------------------------------------------------------------------
def _flatten_for_csv(record: Dict) -> List[Dict]:
"""
Expand a group record so each IP occupies its own row.
If there are no IPs we still emit one row.
"""
base = {
"group_name": record.get("group_name", ""),
"group_id": record.get("group_id", ""),
"display_name": record.get("display_name", ""),
"nsx_manager": record.get("nsx_manager", ""),
"domain": record.get("domain", ""),
"timestamp": record.get("timestamp", ""),
"group_found": record.get("group_found", ""),
"error": record.get("error", ""),
}
all_ips = sorted(
set(record.get("effective_ips", []) + record.get("static_ips", []))
)
if not all_ips:
return [{**base, "ip_address": "", "ip_type": ""}]
rows = []
static_set = set(record.get("static_ips", []))
effective_set = set(record.get("effective_ips", []))
for ip in all_ips:
types = []
if ip in static_set:
types.append("static")
if ip in effective_set:
types.append("effective")
rows.append({**base, "ip_address": ip, "ip_type": "/".join(types)})
return rows
def _flatten_diff_for_csv(record: Dict) -> List[Dict]:
"""Expand a diff record so each IP occupies its own row."""
base = {
"group_name": record.get("group_name", ""),
"source_found": record.get("source_found", ""),
"target_found": record.get("target_found", ""),
"error": record.get("error", ""),
}
all_ips = sorted(
set(
record.get("source_ips", [])
+ record.get("target_static_ips", [])
+ record.get("ips_to_add", [])
+ record.get("ips_to_remove", [])
)
)
if not all_ips:
return [{**base, "ip_address": "", "action": "no_change"}]
src = set(record.get("source_ips", []))
tgt = set(record.get("target_static_ips", []))
add = set(record.get("ips_to_add", []))
rem = set(record.get("ips_to_remove", []))
rows = []
for ip in all_ips:
if ip in add:
action = "add"
elif ip in rem:
action = "remove"
else:
action = "in_sync"
rows.append(
{
**base,
"ip_address": ip,
"in_source": ip in src,
"in_target": ip in tgt,
"action": action,
}
)
return rows
def export_csv(
data: List[Dict],
output_path: Optional[str] = None,
label: str = "export",
is_diff: bool = False,
) -> str:
if not output_path:
output_path = os.path.join("exports", f"{label}_{_timestamp()}.csv")
_ensure_dir(output_path)
rows: List[Dict] = []
for record in data:
if is_diff:
rows.extend(_flatten_diff_for_csv(record))
else:
rows.extend(_flatten_for_csv(record))
if not rows:
logger.warning("No data to write to CSV.")
return output_path
with open(output_path, "w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
logger.info("CSV export written to: %s", output_path)
return output_path
# ------------------------------------------------------------------
# Convenience: export both source and target snapshots + diff
# ------------------------------------------------------------------
def export_full_report(
source_client: NSXClient,
target_client: NSXClient,
group_names: List[str],
diffs: List[GroupDiff],
output_dir: str = "exports",
fmt: str = "both",
source_domain: str = "default",
target_domain: str = "default",
) -> Dict[str, str]:
"""
Write source snapshot, target snapshot, and diff report.
fmt: 'json' | 'csv' | 'both'
Returns a dict of {report_name: file_path}.
"""
ts = _timestamp()
written: Dict[str, str] = {}
src_data = collect_group_data(source_client, group_names, source_domain)
tgt_data = collect_group_data(target_client, group_names, target_domain)
diff_data = collect_diff_data(diffs)
def _path(name: str, ext: str) -> str:
return os.path.join(output_dir, f"{name}_{ts}.{ext}")
if fmt in ("json", "both"):
written["source_json"] = export_json(src_data, _path("source", "json"), "source")
written["target_json"] = export_json(tgt_data, _path("target", "json"), "target")
written["diff_json"] = export_json(diff_data, _path("diff", "json"), "diff")
if fmt in ("csv", "both"):
written["source_csv"] = export_csv(src_data, _path("source", "csv"), "source")
written["target_csv"] = export_csv(tgt_data, _path("target", "csv"), "target")
written["diff_csv"] = export_csv(
diff_data, _path("diff", "csv"), "diff", is_diff=True
)
return written
+172
View File
@@ -0,0 +1,172 @@
"""
Interactive mode: list all Security Groups from the source manager and
allow the user to select which ones to sync via a terminal UI.
This is a nice-to-have feature and only runs when --interactive is passed.
Requires no additional dependencies beyond the standard library.
"""
import logging
from typing import List, Optional
from .nsx_client import NSXClient
logger = logging.getLogger(__name__)
_PAGE = 20 # groups per screen page
def _print_page(groups: List[dict], page: int, selected: set) -> None:
start = page * _PAGE
end = min(start + _PAGE, len(groups))
total_pages = (len(groups) + _PAGE - 1) // _PAGE
print(f"\n Security Groups (page {page + 1}/{total_pages}) "
f"– {len(selected)} selected")
print(f" {'#':<5} {'*':<3} {'Display Name':<50} {'ID'}")
print(f" {'-'*5} {'-'*3} {'-'*50} {'-'*36}")
for i, g in enumerate(groups[start:end], start=start):
marker = "*" if g["id"] in selected else " "
name = g.get("display_name", g["id"])[:50]
print(f" {i:<5} {marker:<3} {name:<50} {g['id']}")
def _prompt(msg: str, default: str = "") -> str:
try:
val = input(msg).strip()
return val if val else default
except (KeyboardInterrupt, EOFError):
return "q"
def select_groups(
source: NSXClient,
domain: str = "default",
preselected: Optional[List[str]] = None,
) -> List[str]:
"""
Interactive terminal selector. Returns a list of group names (display_name
if unique, otherwise id) the user wants to sync.
Navigation:
<number(s)> – toggle selection by row index (comma-separated)
a – select all on current page
n – next page
p – previous page
s – show currently selected list
d – done / confirm
q – quit without selecting
"""
print(f"\nFetching Security Groups from [{source.label}]...")
try:
all_groups = source.list_groups(domain)
except Exception as exc:
logger.error("Failed to list groups: %s", exc)
print(f"\nError: {exc}")
return []
if not all_groups:
print("No Security Groups found.")
return []
all_groups.sort(key=lambda g: g.get("display_name", g["id"]).lower())
selected: set = set()
if preselected:
for g in all_groups:
if g.get("display_name") in preselected or g["id"] in preselected:
selected.add(g["id"])
page = 0
total_pages = (len(all_groups) + _PAGE - 1) // _PAGE
print(
"\nControls: enter row number(s) to toggle | 'a' all on page | "
"'n' next | 'p' prev | 's' show selected | 'd' done | 'q' quit"
)
while True:
_print_page(all_groups, page, selected)
cmd = _prompt("\n > ").lower()
if cmd == "q":
print("Cancelled.")
return []
if cmd == "d":
break
if cmd == "n":
if page < total_pages - 1:
page += 1
else:
print(" Already on last page.")
continue
if cmd == "p":
if page > 0:
page -= 1
else:
print(" Already on first page.")
continue
if cmd == "a":
start = page * _PAGE
end = min(start + _PAGE, len(all_groups))
for g in all_groups[start:end]:
selected.add(g["id"])
continue
if cmd == "s":
if not selected:
print(" Nothing selected yet.")
else:
print(f"\n Selected ({len(selected)}):")
for gid in sorted(selected):
for g in all_groups:
if g["id"] == gid:
print(f" - {g.get('display_name', gid)}")
break
continue
# Try to parse as comma-separated indices
tokens = [t.strip() for t in cmd.replace(" ", ",").split(",") if t.strip()]
valid = True
indices = []
for tok in tokens:
if tok.isdigit():
idx = int(tok)
if 0 <= idx < len(all_groups):
indices.append(idx)
else:
print(f" Index {idx} is out of range.")
valid = False
else:
print(f" Unknown command or index: '{tok}'")
valid = False
if valid and indices:
for idx in indices:
gid = all_groups[idx]["id"]
if gid in selected:
selected.discard(gid)
print(
f" Deselected: {all_groups[idx].get('display_name', gid)}"
)
else:
selected.add(gid)
print(
f" Selected: {all_groups[idx].get('display_name', gid)}"
)
if not selected:
print("No groups selected – nothing to sync.")
return []
# Return display_names if they're unique, otherwise IDs
display_names = [g.get("display_name", g["id"]) for g in all_groups if g["id"] in selected]
print(f"\nConfirmed {len(display_names)} group(s) for sync:")
for name in display_names:
print(f" - {name}")
return display_names
+300
View File
@@ -0,0 +1,300 @@
"""
NSX Policy API client supporting NSX 3.x, 4.x, and 9.x managers.
Uses the Policy API (preferred over the deprecated Manager API).
"""
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
import urllib3
logger = logging.getLogger(__name__)
_POLICY_BASE = "/policy/api/v1"
_PAGE_SIZE = 1000
_MAX_RETRIES = 3
_RETRY_BACKOFF = 2 # seconds
class NSXAuthError(Exception):
pass
class NSXAPIError(Exception):
def __init__(self, message: str, status_code: int = 0, body: str = ""):
super().__init__(message)
self.status_code = status_code
self.body = body
class NSXClient:
"""
Thin wrapper around the NSX Policy REST API.
Supports basic auth (username + password) and handles:
- Cursor-based pagination
- Automatic retries on transient errors
- Optional SSL verification bypass (for self-signed certs)
"""
def __init__(
self,
host: str,
username: str,
password: str,
verify_ssl: bool = True,
timeout: int = 30,
label: str = "",
):
self.base_url = f"https://{host.rstrip('/')}{_POLICY_BASE}"
self.timeout = timeout
self.label = label or host
if not verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._session = requests.Session()
self._session.auth = (username, password)
self._session.verify = verify_ssl
self._session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"}
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _url(self, path: str) -> str:
return f"{self.base_url}/{path.lstrip('/')}"
def _request(
self,
method: str,
path: str,
**kwargs,
) -> requests.Response:
url = self._url(path)
last_exc: Optional[Exception] = None
for attempt in range(1, _MAX_RETRIES + 1):
try:
resp = self._session.request(
method, url, timeout=self.timeout, **kwargs
)
if resp.status_code == 401:
raise NSXAuthError(
f"[{self.label}] Authentication failed – check credentials."
)
if resp.status_code == 403:
raise NSXAuthError(
f"[{self.label}] Access denied to {path} – check permissions."
)
if resp.status_code >= 500 and attempt < _MAX_RETRIES:
logger.warning(
"[%s] HTTP %s on %s, retry %d/%d",
self.label, resp.status_code, path, attempt, _MAX_RETRIES,
)
time.sleep(_RETRY_BACKOFF * attempt)
continue
if not resp.ok:
raise NSXAPIError(
f"[{self.label}] HTTP {resp.status_code} on {method} {path}",
status_code=resp.status_code,
body=resp.text,
)
return resp
except (requests.ConnectionError, requests.Timeout) as exc:
last_exc = exc
if attempt < _MAX_RETRIES:
logger.warning(
"[%s] Connection error on %s, retry %d/%d: %s",
self.label, path, attempt, _MAX_RETRIES, exc,
)
time.sleep(_RETRY_BACKOFF * attempt)
raise NSXAPIError(
f"[{self.label}] Failed to reach {path} after {_MAX_RETRIES} attempts: {last_exc}"
)
def _get_paged(self, path: str, params: Optional[Dict] = None) -> List[Dict]:
"""Collect all pages for a list endpoint using cursor-based pagination."""
params = dict(params or {})
params["page_size"] = _PAGE_SIZE
results: List[Dict] = []
while True:
resp = self._request("GET", path, params=params)
data = resp.json()
results.extend(data.get("results", []))
cursor = data.get("cursor")
if not cursor:
break
params["cursor"] = cursor
return results
# ------------------------------------------------------------------
# Version / connectivity check
# ------------------------------------------------------------------
def get_version(self) -> str:
"""Return the NSX manager version string."""
resp = self._request("GET", "/infra/sites")
# Version is available via a different endpoint
try:
resp2 = self._session.get(
f"https://{self.base_url.split('//')[1].split('/')[0]}/api/v1/node/version",
timeout=self.timeout,
)
if resp2.ok:
return resp2.json().get("product_version", "unknown")
except Exception:
pass
return "unknown"
# ------------------------------------------------------------------
# Groups
# ------------------------------------------------------------------
def list_groups(self, domain: str = "default") -> List[Dict]:
"""Return all security groups in the given policy domain."""
logger.debug("[%s] Listing groups in domain '%s'", self.label, domain)
return self._get_paged(f"/infra/domains/{domain}/groups")
def get_group(self, group_id: str, domain: str = "default") -> Dict:
"""Return a single group definition by ID or display_name."""
groups = self.list_groups(domain)
# Try exact ID match first, then display_name
for g in groups:
if g.get("id") == group_id or g.get("display_name") == group_id:
return g
raise NSXAPIError(
f"[{self.label}] Group '{group_id}' not found in domain '{domain}'"
)
def get_group_effective_ips(
self, group_id: str, domain: str = "default"
) -> List[str]:
"""
Return the list of effective (computed) IP addresses that are members
of the group. This resolves VM-based, tag-based, etc. criteria.
"""
logger.debug(
"[%s] Fetching effective IPs for group '%s'", self.label, group_id
)
path = f"/infra/domains/{domain}/groups/{group_id}/members/ip-addresses"
raw = self._get_paged(path)
# Each item is a plain string (the IP / CIDR)
ips = [item if isinstance(item, str) else item.get("ip_address", "") for item in raw]
return sorted(ip for ip in ips if ip)
# ------------------------------------------------------------------
# IP Address Expression helpers
# ------------------------------------------------------------------
@staticmethod
def _extract_ip_expressions(
expressions: List[Dict],
) -> Tuple[List[Dict], List[str]]:
"""
Split expressions into (non-ip-entries, existing-static-ips).
Removes IPAddressExpression items and their preceding ConjunctionOperators.
Returns a clean expression list ready for re-injection and the existing IPs.
"""
existing_ips: List[str] = []
clean: List[Dict] = []
skip_next_conjunction = False
for expr in expressions:
rtype = expr.get("resource_type", "")
if rtype == "IPAddressExpression":
existing_ips.extend(expr.get("ip_addresses", []))
# Remove the conjunction that precedes this entry (if we added it)
if clean and clean[-1].get("resource_type") == "ConjunctionOperator":
clean.pop()
continue
clean.append(expr)
_ = skip_next_conjunction # unused, kept for clarity
return clean, existing_ips
def patch_group_ips(
self,
group_id: str,
ips_to_add: List[str],
ips_to_remove: List[str],
domain: str = "default",
whatif: bool = False,
) -> Dict:
"""
Modify the static IP address entries in a target group:
- Adds ips_to_add as static entries
- Removes ips_to_remove from static entries
- Leaves all non-IP expressions (Conditions, Tags, etc.) untouched
Returns a result dict describing what was (or would have been) done.
"""
group = self.get_group(group_id, domain)
gid = group["id"]
display = group.get("display_name", gid)
expressions = list(group.get("expression", []))
base_exprs, current_static_ips = self._extract_ip_expressions(expressions)
current_set = set(current_static_ips)
desired_set = (current_set | set(ips_to_add)) - set(ips_to_remove)
actually_added = sorted(set(ips_to_add) - current_set)
actually_removed = sorted(current_set & set(ips_to_remove))
result = {
"group_id": gid,
"display_name": display,
"ips_added": actually_added,
"ips_removed": actually_removed,
"whatif": whatif,
}
if not actually_added and not actually_removed:
logger.info("[%s] Group '%s': no changes needed.", self.label, display)
result["status"] = "no_change"
return result
if whatif:
logger.info(
"[%s] [WHATIF] Group '%s': would add %d, remove %d IPs.",
self.label, display, len(actually_added), len(actually_removed),
)
result["status"] = "whatif"
return result
# Build the new expression list
new_exprs = list(base_exprs)
if desired_set:
if new_exprs:
new_exprs.append(
{"resource_type": "ConjunctionOperator", "conjunction_operator": "OR"}
)
new_exprs.append(
{
"resource_type": "IPAddressExpression",
"ip_addresses": sorted(desired_set),
}
)
patch_body = dict(group)
patch_body["expression"] = new_exprs
self._request(
"PATCH",
f"/infra/domains/{domain}/groups/{gid}",
json=patch_body,
)
logger.info(
"[%s] Group '%s': added %d, removed %d IPs.",
self.label, display, len(actually_added), len(actually_removed),
)
result["status"] = "updated"
return result
+261
View File
@@ -0,0 +1,261 @@
"""
Core sync logic: compare source effective IPs to target static IPs and
produce a change plan, then optionally apply it.
"""
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from .nsx_client import NSXClient
logger = logging.getLogger(__name__)
@dataclass
class GroupDiff:
group_name: str
source_ips: List[str] = field(default_factory=list)
target_static_ips: List[str] = field(default_factory=list)
ips_to_add: List[str] = field(default_factory=list)
ips_to_remove: List[str] = field(default_factory=list)
source_found: bool = True
target_found: bool = True
error: Optional[str] = None
@dataclass
class SyncResult:
group_name: str
status: str # updated | no_change | whatif | error | skipped
ips_added: List[str] = field(default_factory=list)
ips_removed: List[str] = field(default_factory=list)
error: Optional[str] = None
class SyncEngine:
"""
Orchestrates a full source→target sync for a list of Security Group names.
Strategy
--------
1. Fetch effective IPs from each group on the source manager.
2. Fetch the current static IP entries from matching groups on the target.
3. Diff: source effective IPs are the desired state for static entries on target.
4. Apply: PATCH target groups to add/remove IPs (skipped in whatif mode).
"""
def __init__(
self,
source: NSXClient,
target: NSXClient,
source_domain: str = "default",
target_domain: str = "default",
max_workers: int = 4,
):
self.source = source
self.target = target
self.source_domain = source_domain
self.target_domain = target_domain
self.max_workers = max_workers
# ------------------------------------------------------------------
# Phase 1: gather data concurrently
# ------------------------------------------------------------------
def _fetch_source_ips(self, group_name: str) -> tuple:
"""Return (group_name, ips_list, error_or_None)."""
try:
ips = self.source.get_group_effective_ips(group_name, self.source_domain)
return group_name, ips, None
except Exception as exc:
return group_name, [], str(exc)
def _fetch_target_static_ips(self, group_name: str) -> tuple:
"""Return (group_name, static_ips_list, found_bool, error_or_None)."""
try:
group = self.target.get_group(group_name, self.target_domain)
expressions = group.get("expression", [])
ips: List[str] = []
for expr in expressions:
if expr.get("resource_type") == "IPAddressExpression":
ips.extend(expr.get("ip_addresses", []))
return group_name, sorted(ips), True, None
except Exception as exc:
msg = str(exc)
if "not found" in msg.lower():
return group_name, [], False, None
return group_name, [], True, msg
def build_diff(self, group_names: List[str]) -> List[GroupDiff]:
"""
Concurrently fetch source and target data, then compute per-group diffs.
"""
source_map: Dict[str, tuple] = {}
target_map: Dict[str, tuple] = {}
logger.info(
"Fetching data from source [%s] and target [%s] for %d group(s)...",
self.source.label, self.target.label, len(group_names),
)
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
src_futures = {
pool.submit(self._fetch_source_ips, g): g for g in group_names
}
tgt_futures = {
pool.submit(self._fetch_target_static_ips, g): g for g in group_names
}
for fut in as_completed(src_futures):
name, ips, err = fut.result()
source_map[name] = (ips, err)
for fut in as_completed(tgt_futures):
name, ips, found, err = fut.result()
target_map[name] = (ips, found, err)
diffs: List[GroupDiff] = []
for name in group_names:
src_ips, src_err = source_map.get(name, ([], "not fetched"))
tgt_ips, tgt_found, tgt_err = target_map.get(name, ([], True, "not fetched"))
diff = GroupDiff(group_name=name)
if src_err:
diff.error = f"Source error: {src_err}"
diff.source_found = False
diffs.append(diff)
continue
if tgt_err:
diff.error = f"Target error: {tgt_err}"
diffs.append(diff)
continue
if not tgt_found:
diff.target_found = False
diff.error = f"Group '{name}' not found on target – skipping."
diffs.append(diff)
continue
diff.source_ips = src_ips
diff.target_static_ips = tgt_ips
src_set = set(src_ips)
tgt_set = set(tgt_ips)
diff.ips_to_add = sorted(src_set - tgt_set)
diff.ips_to_remove = sorted(tgt_set - src_set)
diffs.append(diff)
return diffs
# ------------------------------------------------------------------
# Phase 2: apply changes
# ------------------------------------------------------------------
def _apply_diff(self, diff: GroupDiff, whatif: bool) -> SyncResult:
if diff.error:
return SyncResult(
group_name=diff.group_name,
status="skipped",
error=diff.error,
)
if not diff.ips_to_add and not diff.ips_to_remove:
logger.info("Group '%s': already in sync.", diff.group_name)
return SyncResult(group_name=diff.group_name, status="no_change")
try:
patch_result = self.target.patch_group_ips(
group_id=diff.group_name,
ips_to_add=diff.ips_to_add,
ips_to_remove=diff.ips_to_remove,
domain=self.target_domain,
whatif=whatif,
)
return SyncResult(
group_name=diff.group_name,
status=patch_result["status"],
ips_added=patch_result.get("ips_added", []),
ips_removed=patch_result.get("ips_removed", []),
)
except Exception as exc:
logger.error("Group '%s': error applying changes: %s", diff.group_name, exc)
return SyncResult(
group_name=diff.group_name,
status="error",
error=str(exc),
)
def sync(
self, group_names: List[str], whatif: bool = False
) -> tuple[List[GroupDiff], List[SyncResult]]:
"""
Full pipeline: build diffs, then apply (or simulate) changes.
Returns (diffs, results) so callers can inspect both.
"""
diffs = self.build_diff(group_names)
if whatif:
logger.info("--- WHATIF MODE: no changes will be written ---")
results: List[SyncResult] = []
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
futures = {
pool.submit(self._apply_diff, diff, whatif): diff.group_name
for diff in diffs
}
for fut in as_completed(futures):
results.append(fut.result())
# Sort results to match original group order
order = {name: i for i, name in enumerate(group_names)}
results.sort(key=lambda r: order.get(r.group_name, 9999))
return diffs, results
# ------------------------------------------------------------------
# Summary printer
# ------------------------------------------------------------------
@staticmethod
def print_summary(
diffs: List[GroupDiff],
results: List[SyncResult],
whatif: bool = False,
) -> None:
label = "[WHATIF] " if whatif else ""
print(f"\n{'='*60}")
print(f" {label}Sync Summary")
print(f"{'='*60}")
result_map = {r.group_name: r for r in results}
for diff in diffs:
r = result_map.get(diff.group_name)
print(f"\n Group: {diff.group_name}")
if diff.error:
print(f" STATUS : SKIPPED – {diff.error}")
continue
print(f" Source IPs : {len(diff.source_ips)}")
print(f" Target IPs : {len(diff.target_static_ips)}")
print(f" To Add : {len(diff.ips_to_add)}")
print(f" To Remove : {len(diff.ips_to_remove)}")
if r:
status_label = r.status.upper()
if r.status == "no_change":
status_label = "IN SYNC"
print(f" STATUS : {status_label}")
if r.error:
print(f" ERROR : {r.error}")
if r.ips_added:
for ip in r.ips_added:
print(f" + {ip}")
if r.ips_removed:
for ip in r.ips_removed:
print(f" - {ip}")
print(f"\n{'='*60}\n")