- 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.
320 lines
12 KiB
Python
320 lines
12 KiB
Python
#!/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())
|