Files
brandon 5b76457ad8 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.
2026-05-01 09:46:04 -05:00

320 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
NSX Security Group IP Sync
===========================
Syncs effective IP members from Security Groups on a source NSX manager to
static IP entries on matching groups on a target NSX manager.
The source manager is the source of truth: IPs present in source but missing
on target are added; IPs on target not in source are removed.
Usage examples
--------------
# Sync with a config file (recommended for automation):
python main.py --config config.yaml
# Dry-run / WhatIf (no changes written):
python main.py --config config.yaml --whatif
# Export source + target snapshots and diff before syncing:
python main.py --config config.yaml --export json
# Interactive group selection:
python main.py --source-host nsx1.example.com --target-host nsx2.example.com \\
--source-user admin --target-user admin --interactive
# Silent mode for automation (logs to file only):
python main.py --config config.yaml --silent --log-file logs/sync.log
"""
import argparse
import logging
import os
import sys
import yaml
# ---------------------------------------------------------------------------
# Logging setup (called before imports that use logger)
# ---------------------------------------------------------------------------
def setup_logging(silent: bool, log_file: str | None, debug: bool) -> None:
level = logging.DEBUG if debug else (logging.WARNING if silent else logging.INFO)
handlers: list = []
if not silent:
handlers.append(logging.StreamHandler(sys.stdout))
if log_file:
os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True)
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
if not handlers:
handlers.append(logging.NullHandler())
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s %(message)s",
handlers=handlers,
force=True,
)
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_yaml_config(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
raw = fh.read()
# Expand ${ENV_VAR} patterns
import re
def _expand(m):
val = os.environ.get(m.group(1), "")
if not val:
logging.warning("Environment variable '%s' is not set.", m.group(1))
return val
raw = re.sub(r"\$\{([^}]+)\}", _expand, raw)
return yaml.safe_load(raw)
def build_config(args: argparse.Namespace) -> dict:
"""Merge YAML config file and CLI flags (CLI takes precedence)."""
cfg: dict = {}
if args.config:
cfg = load_yaml_config(args.config)
def _cli(key, section=None, default=None):
"""Return CLI arg value if set, else config value, else default."""
cli_val = getattr(args, key.replace("-", "_"), None)
if cli_val is not None:
return cli_val
if section and section in cfg:
return cfg[section].get(key.replace("-", "_"), default)
return cfg.get(key.replace("-", "_"), default)
source_host = _cli("source_host", "source") or args.__dict__.get("source_host")
target_host = _cli("target_host", "target") or args.__dict__.get("target_host")
config = {
"source": {
"host": source_host or cfg.get("source", {}).get("host"),
"username": args.source_user or cfg.get("source", {}).get("username"),
"password": args.source_pass
or os.environ.get("NSX_SOURCE_PASSWORD")
or cfg.get("source", {}).get("password"),
"verify_ssl": not args.no_verify_ssl
if args.no_verify_ssl is not None
else cfg.get("source", {}).get("verify_ssl", True),
"domain": args.source_domain or cfg.get("source", {}).get("domain", "default"),
},
"target": {
"host": target_host or cfg.get("target", {}).get("host"),
"username": args.target_user or cfg.get("target", {}).get("username"),
"password": args.target_pass
or os.environ.get("NSX_TARGET_PASSWORD")
or cfg.get("target", {}).get("password"),
"verify_ssl": not args.no_verify_ssl
if args.no_verify_ssl is not None
else cfg.get("target", {}).get("verify_ssl", True),
"domain": args.target_domain or cfg.get("target", {}).get("domain", "default"),
},
"groups": args.groups or cfg.get("groups", []),
"options": {
"whatif": args.whatif or cfg.get("options", {}).get("whatif", False),
"silent": args.silent or cfg.get("options", {}).get("silent", False),
"debug": args.debug or cfg.get("options", {}).get("debug", False),
"log_file": args.log_file or cfg.get("options", {}).get("log_file"),
"export": args.export or cfg.get("options", {}).get("export"),
"export_dir": args.export_dir or cfg.get("options", {}).get("export_dir", "exports"),
"interactive": args.interactive or cfg.get("options", {}).get("interactive", False),
"workers": args.workers or cfg.get("options", {}).get("workers", 4),
},
}
return config
def validate_config(cfg: dict) -> list[str]:
errors = []
for side in ("source", "target"):
s = cfg.get(side, {})
if not s.get("host"):
errors.append(f"Missing {side} host.")
if not s.get("username"):
errors.append(f"Missing {side} username.")
if not s.get("password"):
errors.append(f"Missing {side} password.")
return errors
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Sync NSX Security Group IPs between two NSX managers.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("--config", metavar="FILE",
help="Path to YAML config file.")
conn = p.add_argument_group("Connection (override config)")
conn.add_argument("--source-host", metavar="HOST",
help="Source NSX manager hostname or IP.")
conn.add_argument("--source-user", metavar="USER",
help="Source NSX manager username.")
conn.add_argument("--source-pass", metavar="PASS",
help="Source NSX manager password (prefer NSX_SOURCE_PASSWORD env var).")
conn.add_argument("--source-domain", metavar="DOMAIN", default=None,
help="Source policy domain (default: 'default').")
conn.add_argument("--target-host", metavar="HOST",
help="Target NSX manager hostname or IP.")
conn.add_argument("--target-user", metavar="USER",
help="Target NSX manager username.")
conn.add_argument("--target-pass", metavar="PASS",
help="Target NSX manager password (prefer NSX_TARGET_PASSWORD env var).")
conn.add_argument("--target-domain", metavar="DOMAIN", default=None,
help="Target policy domain (default: 'default').")
conn.add_argument("--no-verify-ssl", action="store_true", default=None,
help="Disable SSL certificate verification (self-signed certs).")
sync = p.add_argument_group("Sync options")
sync.add_argument("--groups", nargs="+", metavar="GROUP",
help="Security group names to sync (space-separated).")
sync.add_argument("--whatif", action="store_true",
help="Show what would change without making any modifications.")
sync.add_argument("--interactive", action="store_true",
help="Launch interactive group selection TUI.")
sync.add_argument("--workers", type=int, default=None, metavar="N",
help="Number of concurrent API workers (default: 4).")
out = p.add_argument_group("Output / export")
out.add_argument("--export", choices=["json", "csv", "both"],
help="Export source/target snapshots and diff report.")
out.add_argument("--export-dir", metavar="DIR", default=None,
help="Directory for export files (default: exports/).")
out.add_argument("--silent", action="store_true",
help="Suppress console output (for automation).")
out.add_argument("--log-file", metavar="FILE",
help="Write logs to this file.")
out.add_argument("--debug", action="store_true",
help="Enable verbose debug logging.")
return p
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = build_parser()
args = parser.parse_args()
# Quick early exit: no args at all → show help
if len(sys.argv) == 1:
parser.print_help()
return 0
# Config must be built before we can know silent/log_file
cfg = build_config(args)
opts = cfg["options"]
setup_logging(
silent=opts["silent"],
log_file=opts["log_file"],
debug=opts["debug"],
)
log = logging.getLogger(__name__)
# Deferred imports (after logging is configured)
from nsx_sync.nsx_client import NSXClient
from nsx_sync.sync_engine import SyncEngine
from nsx_sync.exporter import export_full_report
from nsx_sync.interactive import select_groups
errors = validate_config(cfg)
if errors:
for e in errors:
print(f"[ERROR] {e}", file=sys.stderr)
return 1
src_cfg = cfg["source"]
tgt_cfg = cfg["target"]
source = NSXClient(
host=src_cfg["host"],
username=src_cfg["username"],
password=src_cfg["password"],
verify_ssl=src_cfg["verify_ssl"],
label=f"source({src_cfg['host']})",
)
target = NSXClient(
host=tgt_cfg["host"],
username=tgt_cfg["username"],
password=tgt_cfg["password"],
verify_ssl=tgt_cfg["verify_ssl"],
label=f"target({tgt_cfg['host']})",
)
# Resolve group list
group_names: list[str] = list(cfg["groups"] or [])
if opts["interactive"]:
group_names = select_groups(
source=source,
domain=src_cfg["domain"],
preselected=group_names,
)
if not group_names:
return 0
if not group_names:
print("[ERROR] No groups specified. Use --groups, --config, or --interactive.",
file=sys.stderr)
return 1
log.info(
"Starting sync: %s%s | %d group(s) | whatif=%s",
src_cfg["host"], tgt_cfg["host"], len(group_names), opts["whatif"],
)
engine = SyncEngine(
source=source,
target=target,
source_domain=src_cfg["domain"],
target_domain=tgt_cfg["domain"],
max_workers=opts["workers"],
)
diffs, results = engine.sync(group_names, whatif=opts["whatif"])
# Print summary unless silent
if not opts["silent"]:
SyncEngine.print_summary(diffs, results, whatif=opts["whatif"])
# Export if requested
if opts["export"]:
written = export_full_report(
source_client=source,
target_client=target,
group_names=group_names,
diffs=diffs,
output_dir=opts["export_dir"],
fmt=opts["export"],
source_domain=src_cfg["domain"],
target_domain=tgt_cfg["domain"],
)
if not opts["silent"]:
print("Export files written:")
for key, path in written.items():
print(f" {key}: {path}")
# Exit code: 0 = success, 2 = one or more groups had errors
had_errors = any(r.status == "error" for r in results)
return 2 if had_errors else 0
if __name__ == "__main__":
sys.exit(main())