bunny2fmc 1.0.8__py3-none-any.whl → 1.3.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bunny2fmc/config.py CHANGED
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  log = logging.getLogger("bunny2fmc")
8
8
  SERVICE_NAME = "bunny2fmc"
9
- CONFIG_DIR = Path.home() / ".local" / "share" / "bunny2fmc"
9
+ CONFIG_DIR = Path.home() / "bunny2fmc"
10
10
  CONFIG_FILE = CONFIG_DIR / "config.json"
11
11
  LOG_DIR = CONFIG_DIR / "logs"
12
12
 
@@ -126,3 +126,154 @@ class ConfigManager:
126
126
  except Exception as e:
127
127
  log.warning("Failed to load config: %s", e)
128
128
  return False
129
+
130
+ @staticmethod
131
+ def load_fmc_base_url() -> str:
132
+ """Load FMC base URL from config file"""
133
+ try:
134
+ if CONFIG_FILE.exists():
135
+ with open(CONFIG_FILE, "r") as f:
136
+ data = json.load(f)
137
+ return data.get("fmc_base_url")
138
+ except Exception as e:
139
+ log.warning("Failed to load config: %s", e)
140
+ return None
141
+
142
+ @staticmethod
143
+ def save_fmc_base_url(url: str):
144
+ """Save FMC base URL to config file"""
145
+ try:
146
+ ConfigManager.ensure_directories()
147
+ data = {}
148
+ if CONFIG_FILE.exists():
149
+ with open(CONFIG_FILE, "r") as f:
150
+ data = json.load(f)
151
+ data["fmc_base_url"] = url
152
+ with open(CONFIG_FILE, "w") as f:
153
+ json.dump(data, f, indent=2)
154
+ log.info("FMC base URL saved")
155
+ except Exception as e:
156
+ log.error("Failed to save config: %s", e)
157
+ raise
158
+
159
+ @staticmethod
160
+ def load_fmc_username() -> str:
161
+ """Load FMC username from config file"""
162
+ try:
163
+ if CONFIG_FILE.exists():
164
+ with open(CONFIG_FILE, "r") as f:
165
+ data = json.load(f)
166
+ return data.get("fmc_username")
167
+ except Exception as e:
168
+ log.warning("Failed to load config: %s", e)
169
+ return None
170
+
171
+ @staticmethod
172
+ def save_fmc_username(username: str):
173
+ """Save FMC username to config file"""
174
+ try:
175
+ ConfigManager.ensure_directories()
176
+ data = {}
177
+ if CONFIG_FILE.exists():
178
+ with open(CONFIG_FILE, "r") as f:
179
+ data = json.load(f)
180
+ data["fmc_username"] = username
181
+ with open(CONFIG_FILE, "w") as f:
182
+ json.dump(data, f, indent=2)
183
+ log.info("FMC username saved")
184
+ except Exception as e:
185
+ log.error("Failed to save config: %s", e)
186
+ raise
187
+
188
+ @staticmethod
189
+ def load_sync_interval_minutes() -> int:
190
+ """Load sync interval from config file"""
191
+ try:
192
+ if CONFIG_FILE.exists():
193
+ with open(CONFIG_FILE, "r") as f:
194
+ data = json.load(f)
195
+ interval = data.get("sync_interval_minutes")
196
+ return int(interval) if interval else None
197
+ except Exception as e:
198
+ log.warning("Failed to load config: %s", e)
199
+ return None
200
+
201
+ @staticmethod
202
+ def save_sync_interval_minutes(minutes: int):
203
+ """Save sync interval to config file"""
204
+ try:
205
+ ConfigManager.ensure_directories()
206
+ data = {}
207
+ if CONFIG_FILE.exists():
208
+ with open(CONFIG_FILE, "r") as f:
209
+ data = json.load(f)
210
+ data["sync_interval_minutes"] = minutes
211
+ with open(CONFIG_FILE, "w") as f:
212
+ json.dump(data, f, indent=2)
213
+ log.info("Sync interval saved")
214
+ except Exception as e:
215
+ log.error("Failed to save config: %s", e)
216
+ raise
217
+
218
+ # Default Bunny CDN API URLs
219
+ DEFAULT_BUNNY_IPV4_URL = "https://bunnycdn.com/api/system/edgeserverlist"
220
+ DEFAULT_BUNNY_IPV6_URL = "https://bunnycdn.com/api/system/edgeserverlist/ipv6"
221
+
222
+ @staticmethod
223
+ def load_bunny_ipv4_url() -> str:
224
+ """Load Bunny IPv4 URL from config file"""
225
+ try:
226
+ if CONFIG_FILE.exists():
227
+ with open(CONFIG_FILE, "r") as f:
228
+ data = json.load(f)
229
+ return data.get("bunny_ipv4_url", ConfigManager.DEFAULT_BUNNY_IPV4_URL)
230
+ except Exception as e:
231
+ log.warning("Failed to load config: %s", e)
232
+ return ConfigManager.DEFAULT_BUNNY_IPV4_URL
233
+
234
+ @staticmethod
235
+ def save_bunny_ipv4_url(url: str):
236
+ """Save Bunny IPv4 URL to config file"""
237
+ try:
238
+ ConfigManager.ensure_directories()
239
+ data = {}
240
+ if CONFIG_FILE.exists():
241
+ with open(CONFIG_FILE, "r") as f:
242
+ data = json.load(f)
243
+ data["bunny_ipv4_url"] = url
244
+ with open(CONFIG_FILE, "w") as f:
245
+ json.dump(data, f, indent=2)
246
+ log.info("Bunny IPv4 URL saved")
247
+ except Exception as e:
248
+ log.error("Failed to save config: %s", e)
249
+ raise
250
+
251
+ @staticmethod
252
+ def load_bunny_ipv6_url() -> str:
253
+ """Load Bunny IPv6 URL from config file"""
254
+ try:
255
+ if CONFIG_FILE.exists():
256
+ with open(CONFIG_FILE, "r") as f:
257
+ data = json.load(f)
258
+ return data.get("bunny_ipv6_url", ConfigManager.DEFAULT_BUNNY_IPV6_URL)
259
+ except Exception as e:
260
+ log.warning("Failed to load config: %s", e)
261
+ return ConfigManager.DEFAULT_BUNNY_IPV6_URL
262
+
263
+ @staticmethod
264
+ def save_bunny_ipv6_url(url: str):
265
+ """Save Bunny IPv6 URL to config file"""
266
+ try:
267
+ ConfigManager.ensure_directories()
268
+ data = {}
269
+ if CONFIG_FILE.exists():
270
+ with open(CONFIG_FILE, "r") as f:
271
+ data = json.load(f)
272
+ data["bunny_ipv6_url"] = url
273
+ with open(CONFIG_FILE, "w") as f:
274
+ json.dump(data, f, indent=2)
275
+ log.info("Bunny IPv6 URL saved")
276
+ except Exception as e:
277
+ log.error("Failed to save config: %s", e)
278
+ raise
279
+
@@ -0,0 +1,31 @@
1
+ """Post-install helper to copy documentation to ~/bunny2fmc/guide/."""
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ def setup_docs():
6
+ """Copy guide documentation files from package to ~/bunny2fmc/guide/."""
7
+ package_dir = Path(__file__).parent.parent
8
+ guide_dir = Path.home() / "bunny2fmc" / "guide"
9
+
10
+ try:
11
+ guide_dir.mkdir(parents=True, exist_ok=True)
12
+ except Exception as e:
13
+ print(f"⚠️ Kunne ikke oprette {guide_dir}: {e}")
14
+ return
15
+
16
+ guide_files = ['guide/PREINSTALL.md', 'guide/INSTALL.md', 'guide/QUICK_START.md', 'guide/README.md']
17
+
18
+ for guide_file in guide_files:
19
+ src = package_dir / guide_file
20
+ dst = guide_dir / Path(guide_file).name
21
+ if src.exists():
22
+ try:
23
+ shutil.copy2(src, dst)
24
+ print(f"✓ {Path(guide_file).name}")
25
+ except Exception as e:
26
+ print(f"⚠️ {Path(guide_file).name}: {e}")
27
+
28
+ print(f"\n📚 Guides: {guide_dir}/")
29
+
30
+ if __name__ == '__main__':
31
+ setup_docs()
bunny2fmc/sync_engine.py CHANGED
@@ -1,137 +1,343 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """Core sync engine: Bunny edge IPs to FMC Dynamic Objects"""
4
- import json, ipaddress, logging
3
+ """Core sync engine: Bunny edge IPs to FMC Dynamic Objects
4
+
5
+ Uses wingpy's CiscoFMC client which supports authentication via:
6
+ - Direct parameters: CiscoFMC(base_url=..., username=..., password=...)
7
+ - Environment variables: WINGPY_FMC_BASE_URL, WINGPY_FMC_USERNAME, WINGPY_FMC_PASSWORD
8
+
9
+ See: https://wingpy.automation.wingmen.dk/faq/#environment-variables
10
+ """
11
+ import json
12
+ import ipaddress
13
+ import logging
14
+ import os
5
15
  from typing import List, Set, Iterable
16
+
6
17
  import requests
7
18
  from xml.etree import ElementTree as ET
8
19
  from wingpy import CiscoFMC
9
20
 
10
21
  log = logging.getLogger("bunny2fmc")
22
+
11
23
  BUNNY_IPV4_URL = "https://bunnycdn.com/api/system/edgeserverlist"
12
24
  BUNNY_IPV6_URL = "https://bunnycdn.com/api/system/edgeserverlist/ipv6"
13
25
 
26
+
14
27
  def _parse_possible_formats(body: str) -> List[str]:
28
+ """Parse Bunny API response (XML, JSON, or plain text)"""
29
+ # Try XML first
15
30
  try:
16
31
  root = ET.fromstring(body)
17
32
  vals = [el.text.strip() for el in root.findall(".//{*}string") if el.text]
18
- if vals: return vals
19
- except ET.ParseError: pass
33
+ if vals:
34
+ return vals
35
+ except ET.ParseError:
36
+ pass
37
+
38
+ # Try JSON
20
39
  try:
21
40
  data = json.loads(body)
22
- if isinstance(data, list): return [str(x).strip() for x in data if str(x).strip()]
23
- except json.JSONDecodeError: pass
41
+ if isinstance(data, list):
42
+ return [str(x).strip() for x in data if str(x).strip()]
43
+ except json.JSONDecodeError:
44
+ pass
45
+
46
+ # Fallback to plain text (one IP per line)
24
47
  return [ln.strip() for ln in body.splitlines() if ln.strip()]
25
48
 
26
- def fetch_bunny_ips(include_ipv6: bool, verify_ssl: bool = False) -> Set[str]:
49
+
50
+ def normalize_ip(ip_str: str) -> str:
51
+ """
52
+ Normalize IP address to plain IP format (no CIDR for single hosts).
53
+
54
+ FMC Dynamic Object mappings store IPs without CIDR notation for /32 and /128.
55
+ - Input: "1.2.3.4/32" or "1.2.3.4" → Output: "1.2.3.4"
56
+ - Input: "2001:db8::1/128" → Output: "2001:db8::1"
57
+ - Input: "10.0.0.0/24" → Output: "10.0.0.0/24" (keep CIDR for subnets)
58
+ """
59
+ ip_str = ip_str.strip()
60
+
61
+ if "/" in ip_str:
62
+ try:
63
+ net = ipaddress.ip_network(ip_str, strict=False)
64
+ # If it's a single host (/32 or /128), return just the IP
65
+ if (net.version == 4 and net.prefixlen == 32) or \
66
+ (net.version == 6 and net.prefixlen == 128):
67
+ return str(net.network_address)
68
+ # Otherwise keep the CIDR notation
69
+ return str(net)
70
+ except ValueError:
71
+ return ip_str
72
+ else:
73
+ # Already plain IP
74
+ return ip_str
75
+
76
+
77
+ def fetch_bunny_ips(include_ipv6: bool, verify_ssl: bool = False, ipv4_url: str = None, ipv6_url: str = None) -> Set[str]:
78
+ """Fetch current Bunny CDN edge server IP addresses"""
27
79
  sess = requests.Session()
28
80
  ips: Set[str] = set()
29
- endpoints = [BUNNY_IPV4_URL] + ([BUNNY_IPV6_URL] if include_ipv6 else [])
81
+ bunny_v4 = ipv4_url or BUNNY_IPV4_URL
82
+ bunny_v6 = ipv6_url or BUNNY_IPV6_URL
83
+ endpoints = [bunny_v4] + ([bunny_v6] if include_ipv6 else [])
84
+
30
85
  for url in endpoints:
31
86
  log.info("Fetching %s", url)
32
87
  try:
33
- r = sess.get(url, timeout=60, verify=verify_ssl, headers={"Accept": "application/xml, application/json, text/plain"})
88
+ r = sess.get(
89
+ url,
90
+ timeout=60,
91
+ verify=verify_ssl,
92
+ headers={"Accept": "application/xml, application/json, text/plain"},
93
+ )
34
94
  r.raise_for_status()
95
+
35
96
  for c in _parse_possible_formats(r.text):
36
97
  try:
37
- if "/" in c: net = ipaddress.ip_network(c, strict=False)
98
+ # Parse and normalize the IP
99
+ if "/" in c:
100
+ net = ipaddress.ip_network(c, strict=False)
38
101
  else:
39
102
  ip = ipaddress.ip_address(c)
40
- net = ipaddress.ip_network(f"{ip}/{32 if ip.version == 4 else 128}", strict=False)
41
- ips.add(str(net))
42
- except ValueError: log.warning("Ignoring invalid Bunny entry: %r", c)
103
+ net = ipaddress.ip_network(
104
+ f"{ip}/{32 if ip.version == 4 else 128}", strict=False
105
+ )
106
+ # Normalize: single hosts without /32 or /128
107
+ ips.add(normalize_ip(str(net)))
108
+ except ValueError:
109
+ log.warning("Ignoring invalid Bunny entry: %r", c)
43
110
  except Exception as e:
44
111
  log.error("Error fetching %s: %s", url, e)
45
112
  raise
46
- if not ips: raise RuntimeError("No IPs fetched from Bunny")
113
+
114
+ if not ips:
115
+ raise RuntimeError("No IPs fetched from Bunny")
47
116
  return ips
48
117
 
49
- def get_all(fmc: CiscoFMC, path: str) -> List[dict]:
50
- items: List[dict] = []
51
- offset = 0
52
- while True:
53
- r = fmc.get(path + f"?offset={offset}&limit=1000&expanded=false")
54
- r.raise_for_status()
55
- batch = (r.json() or {}).get("items", [])
56
- items.extend(batch)
57
- if len(batch) < 1000: break
58
- offset += 1000
59
- return items
60
118
 
61
119
  def chunked(iterable: Iterable[str], size: int) -> Iterable[List[str]]:
120
+ """Split iterable into chunks of given size"""
62
121
  buf = []
63
122
  for x in iterable:
64
123
  buf.append(x)
65
- if len(buf) == size: yield buf; buf = []
66
- if buf: yield buf
124
+ if len(buf) == size:
125
+ yield buf
126
+ buf = []
127
+ if buf:
128
+ yield buf
129
+
130
+
131
+ def find_or_create_dynamic_object(
132
+ fmc: CiscoFMC, name: str, description: str = "", dry_run: bool = False
133
+ ) -> dict:
134
+ """Find existing Dynamic Object or create a new one"""
135
+ # Use wingpy's get_all for automatic pagination
136
+ items = fmc.get_all(
137
+ "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects"
138
+ )
67
139
 
68
- def find_or_create_dynamic_object(fmc: CiscoFMC, name: str, description: str = "", dry_run: bool = False) -> dict:
69
- items = get_all(fmc, "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects")
70
140
  obj = next((it for it in items if it.get("name") == name), None)
71
141
  if obj:
72
142
  log.info("Found existing Dynamic Object: %s (id=%s)", name, obj.get("id"))
73
143
  return obj
74
- payload = {"type": "DynamicObject", "name": name, "objectType": "IP", "description": description or "Managed automatically from BunnyCDN edge server list."}
144
+
145
+ # Create new Dynamic Object
146
+ payload = {
147
+ "type": "DynamicObject",
148
+ "name": name,
149
+ "objectType": "IP",
150
+ "description": description
151
+ or "Managed automatically from BunnyCDN edge server list.",
152
+ }
153
+
75
154
  if dry_run:
76
155
  log.info("[DRY_RUN] Would create Dynamic Object '%s'.", name)
77
- return {"id": "DRYRUN-ID", "name": name, "type": "DynamicObject", "objectType": "IP"}
78
- r = fmc.post("/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects", data=payload)
156
+ return {
157
+ "id": "DRYRUN-ID",
158
+ "name": name,
159
+ "type": "DynamicObject",
160
+ "objectType": "IP",
161
+ }
162
+
163
+ r = fmc.post(
164
+ "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects", data=payload
165
+ )
79
166
  r.raise_for_status()
80
167
  result = r.json()
81
168
  log.info("Created Dynamic Object: %s (id=%s)", name, result.get("id"))
82
169
  return result
83
170
 
171
+
84
172
  def get_current_mappings(fmc: CiscoFMC, obj_id: str) -> Set[str]:
85
- r = fmc.get("/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects/{objectId}/mappings", path_params={"objectId": obj_id})
86
- r.raise_for_status()
87
- data = r.json() or {}
88
- if isinstance(data, dict):
89
- if "mappings" in data and isinstance(data["mappings"], list):
90
- return set(str(x).strip() for x in data["mappings"] if str(x).strip())
91
- if "items" in data and isinstance(data["items"], list):
92
- out = set()
93
- for it in data["items"]:
94
- if isinstance(it, str): out.add(it.strip())
95
- elif isinstance(it, dict) and "value" in it: out.add(str(it["value"]).strip())
96
- return out
97
- if isinstance(data, list): return set(str(x).strip() for x in data if str(x).strip())
98
- return set()
99
-
100
- def post_mappings_update(fmc: CiscoFMC, add: List[str], remove: List[str], obj_id: str, chunk_size: int = 500, dry_run: bool = False):
173
+ """
174
+ Get current IP mappings for a Dynamic Object.
175
+
176
+ FMC returns paginated results with format:
177
+ {
178
+ "items": [{"mapping": "1.2.3.4"}, {"mapping": "5.6.7.8"}],
179
+ "paging": {"count": 594, "offset": 0, "limit": 25}
180
+ }
181
+
182
+ We need to fetch all pages and extract the "mapping" field.
183
+ """
184
+ mappings: Set[str] = set()
185
+ offset = 0
186
+ limit = 1000 # Max per page
187
+
188
+ while True:
189
+ r = fmc.get(
190
+ "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects/{objectId}/mappings",
191
+ path_params={"objectId": obj_id},
192
+ params={"offset": offset, "limit": limit},
193
+ )
194
+ r.raise_for_status()
195
+ data = r.json() or {}
196
+
197
+ items = data.get("items", [])
198
+ for item in items:
199
+ if isinstance(item, dict):
200
+ # FMC format: {"mapping": "1.2.3.4"}
201
+ if "mapping" in item:
202
+ mappings.add(normalize_ip(str(item["mapping"])))
203
+ # Alternative format: {"value": "1.2.3.4"}
204
+ elif "value" in item:
205
+ mappings.add(normalize_ip(str(item["value"])))
206
+ elif isinstance(item, str):
207
+ mappings.add(normalize_ip(item))
208
+
209
+ # Check if there are more pages
210
+ paging = data.get("paging", {})
211
+ total_count = paging.get("count", 0)
212
+
213
+ if offset + len(items) >= total_count:
214
+ break
215
+ offset += limit
216
+
217
+ log.debug("Retrieved %d current mappings from FMC", len(mappings))
218
+ return mappings
219
+
220
+
221
+ def post_mappings_update(
222
+ fmc: CiscoFMC,
223
+ add: List[str],
224
+ remove: List[str],
225
+ obj_id: str,
226
+ chunk_size: int = 500,
227
+ bunny_ipv4_url: str = None,
228
+ bunny_ipv6_url: str = None,
229
+ dry_run: bool = False,
230
+ ):
231
+ """Add/remove IP mappings in chunks"""
101
232
  if dry_run:
102
233
  log.info("[DRY_RUN] Would ADD %d and REMOVE %d mappings.", len(add), len(remove))
103
234
  return
235
+
104
236
  endpoint = "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjectmappings"
237
+
105
238
  for batch in chunked(add, chunk_size):
106
239
  payload = {"add": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
107
240
  r = fmc.post(endpoint, data=payload)
108
241
  r.raise_for_status()
109
242
  log.info("Added %d mappings.", len(batch))
243
+
110
244
  for batch in chunked(remove, chunk_size):
111
245
  payload = {"remove": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
112
246
  r = fmc.post(endpoint, data=payload)
113
247
  r.raise_for_status()
114
248
  log.info("Removed %d mappings.", len(batch))
115
249
 
116
- def sync(fmc_base_url: str, fmc_username: str, fmc_password: str, fmc_dynamic_name: str, include_ipv6: bool = False, verify_ssl: bool = False, dry_run: bool = False, chunk_size: int = 500) -> dict:
250
+
251
+ def sync(
252
+ fmc_base_url: str,
253
+ fmc_username: str,
254
+ fmc_password: str,
255
+ fmc_dynamic_name: str,
256
+ include_ipv6: bool = False,
257
+ verify_ssl: bool = False,
258
+ dry_run: bool = False,
259
+ chunk_size: int = 500,
260
+ bunny_ipv4_url: str = None,
261
+ bunny_ipv6_url: str = None,
262
+ ) -> dict:
263
+ """
264
+ Main sync function: Fetches Bunny IPs and updates FMC Dynamic Object.
265
+
266
+ Authentication is handled by wingpy's CiscoFMC client:
267
+ - First request: authenticates with username/password to get access token
268
+ - Subsequent requests: uses access token automatically
269
+ - Token refresh: handled automatically by wingpy
270
+
271
+ See: https://wingpy.automation.wingmen.dk/faq/#session-maintenance
272
+ """
117
273
  try:
118
274
  log.info("Starting sync for Dynamic Object: %s", fmc_dynamic_name)
119
- bunny_nets = fetch_bunny_ips(include_ipv6, verify_ssl)
275
+
276
+ # Fetch current Bunny CDN IPs
277
+ bunny_nets = fetch_bunny_ips(include_ipv6, verify_ssl, bunny_ipv4_url, bunny_ipv6_url)
120
278
  log.info("Bunny: %d networks", len(bunny_nets))
121
- fmc = CiscoFMC(base_url=fmc_base_url, username=fmc_username, password=fmc_password, verify=verify_ssl)
122
- dyn = find_or_create_dynamic_object(fmc, fmc_dynamic_name, description="Dynamic Object auto-managed from BunnyCDN edge server list.", dry_run=dry_run)
279
+
280
+ # Connect to FMC using wingpy
281
+ # wingpy handles token-based authentication automatically:
282
+ # - First call: POST /api/fmc_platform/v1/auth/generatetoken with Basic Auth
283
+ # - Returns X-auth-access-token (valid 30 min)
284
+ # - All subsequent calls use this token
285
+ # See: https://wingpy.automation.wingmen.dk/user-guide/fmc/
286
+ fmc = CiscoFMC(
287
+ base_url=fmc_base_url,
288
+ username=fmc_username,
289
+ password=fmc_password,
290
+ verify=verify_ssl,
291
+ )
292
+
293
+ # Find or create the Dynamic Object
294
+ dyn = find_or_create_dynamic_object(
295
+ fmc,
296
+ fmc_dynamic_name,
297
+ description="Dynamic Object auto-managed from BunnyCDN edge server list.",
298
+ dry_run=dry_run,
299
+ )
123
300
  dyn_id = dyn["id"]
124
301
  log.info("Dynamic Object: %s (id=%s)", fmc_dynamic_name, dyn_id)
302
+
303
+ # Get current mappings and calculate diff
125
304
  current = get_current_mappings(fmc, dyn_id)
126
305
  desired = bunny_nets
127
- to_add, to_remove = sorted(desired - current), sorted(current - desired)
128
- log.info("Current: %d, Desired: %d, +Add: %d, -Remove: %d", len(current), len(desired), len(to_add), len(to_remove))
306
+ to_add = sorted(desired - current)
307
+ to_remove = sorted(current - desired)
308
+
309
+ log.info(
310
+ "Current: %d, Desired: %d, +Add: %d, -Remove: %d",
311
+ len(current),
312
+ len(desired),
313
+ len(to_add),
314
+ len(to_remove),
315
+ )
316
+
129
317
  if not to_add and not to_remove:
130
318
  log.info("No changes needed.")
131
- return {"status": "success", "message": "No changes needed", "added": 0, "removed": 0, "total_current": len(current), "total_desired": len(desired)}
319
+ return {
320
+ "status": "success",
321
+ "message": "No changes needed",
322
+ "added": 0,
323
+ "removed": 0,
324
+ "total_current": len(current),
325
+ "total_desired": len(desired),
326
+ }
327
+
328
+ # Apply changes
132
329
  post_mappings_update(fmc, to_add, to_remove, dyn_id, chunk_size, dry_run)
133
330
  log.info("Sync completed successfully.")
134
- return {"status": "success", "message": "Sync completed", "added": len(to_add), "removed": len(to_remove), "total_current": len(current), "total_desired": len(desired)}
331
+
332
+ return {
333
+ "status": "success",
334
+ "message": "Sync completed",
335
+ "added": len(to_add),
336
+ "removed": len(to_remove),
337
+ "total_current": len(current),
338
+ "total_desired": len(desired),
339
+ }
340
+
135
341
  except Exception as e:
136
342
  log.error("Sync failed: %s", str(e), exc_info=True)
137
343
  return {"status": "error", "message": str(e), "added": 0, "removed": 0}