104104 description:
105105 - Additional keyword arguments passed to the Catalyst Center C(get_device_list) API call.
106106 - Use this to pre-filter devices by family, role, management IP, etc.
107+ - Keys C(offset) and C(limit) are reserved for pagination and will be stripped if provided.
107108 type: dict
108109 default: {}
109110 api_page_size:
145146"""
146147
147148EXAMPLES = r"""
148- # Minimal configuration
149+ # Minimal configuration (use ansible-vault for the password in production)
149150plugin: cisco.catalystcenter.catalystcenter
150151host: catalyst.example.com
151152username: admin
152- password: secret
153+ password: "{{ vault_cc_password }}"
153154validate_certs: false
154155
155156# With environment variables (CATALYSTCENTER_HOST, CATALYSTCENTER_USERNAME, etc.)
215216except ImportError :
216217 HAS_CATALYSTCENTERSDK = False
217218
219+ _PAGINATION_RESERVED_KEYS = frozenset (("offset" , "limit" ))
218220
219221_NETWORK_OS_MAP = {
220222 "ios" : {
255257 ord ("." ): "_" ,
256258}
257259
260+ _EXPECTED_DATA_KEYS = frozenset (("devices" , "sites" , "device_site_map" , "tags" ))
261+
258262
259263class InventoryModule (BaseInventoryPlugin , Constructable , Cacheable ):
260264 """Cisco Catalyst Center dynamic inventory plugin."""
@@ -289,6 +293,17 @@ def parse(self, inventory, loader, path, cache=True):
289293 except KeyError :
290294 cache_needs_update = True
291295
296+ if data is not None :
297+ missing = _EXPECTED_DATA_KEYS - set (data .keys ())
298+ if missing :
299+ self .display .warning (
300+ "Cached inventory data is missing keys {0}, refetching" .format (
301+ sorted (missing )
302+ )
303+ )
304+ data = None
305+ cache_needs_update = True
306+
292307 if data is None :
293308 data = self ._fetch_all_data ()
294309
@@ -319,7 +334,7 @@ def _get_client(self):
319334 verify = self .get_option ("validate_certs" ),
320335 debug = self .get_option ("debug" ),
321336 )
322- except Exception as e :
337+ except ( ApiError , ConnectionError , OSError ) as e :
323338 raise AnsibleError (
324339 "Failed to connect to Catalyst Center at {0}:{1}: {2}" .format (
325340 host , port , to_native (e )
@@ -351,33 +366,39 @@ def _fetch_devices(self, client):
351366 device_filters = self .get_option ("device_filters" )
352367 include_aps = self .get_option ("include_aps" )
353368
354- try :
355- count_resp = client .devices .get_device_count ()
356- device_count = count_resp .response
357- except (ApiError , Exception ) as e :
358- raise AnsibleParserError (
359- "Failed to get device count: {0}" .format (to_native (e ))
369+ safe_filters = {
370+ k : v for k , v in device_filters .items ()
371+ if k not in _PAGINATION_RESERVED_KEYS
372+ }
373+ if len (safe_filters ) != len (device_filters ):
374+ self .display .warning (
375+ "device_filters contained reserved keys {0} which were "
376+ "stripped to avoid breaking pagination" .format (
377+ sorted (set (device_filters ) & _PAGINATION_RESERVED_KEYS )
378+ )
360379 )
361380
362- pages = max (1 , math .ceil (device_count / page_size ))
363381 all_devices = []
382+ offset = 1
364383
365- for page in range (pages ):
366- offset = page * page_size + 1
384+ while True :
367385 try :
368386 resp = client .devices .get_device_list (
369387 offset = offset ,
370388 limit = page_size ,
371- ** device_filters
389+ ** safe_filters
372390 )
373391 page_devices = resp .response if resp .response else []
374- except ( ApiError , Exception ) as e :
392+ except ApiError as e :
375393 raise AnsibleParserError (
376394 "Failed to get device list (offset={0}): {1}" .format (
377395 offset , to_native (e )
378396 )
379397 )
380398
399+ if not page_devices :
400+ break
401+
381402 for device in page_devices :
382403 device_dict = self ._device_to_dict (device )
383404
@@ -388,6 +409,11 @@ def _fetch_devices(self, client):
388409
389410 all_devices .append (device_dict )
390411
412+ if len (page_devices ) < page_size :
413+ break
414+
415+ offset += page_size
416+
391417 return all_devices
392418
393419 def _device_to_dict (self , device ):
@@ -402,18 +428,37 @@ def _device_to_dict(self, device):
402428 if not k .startswith ("_" )
403429 }
404430 except TypeError :
405- return dict (device )
431+ self .display .warning (
432+ "Unexpected device object type ({0}), "
433+ "attempting dict() conversion" .format (type (device ).__name__ )
434+ )
435+ try :
436+ return dict (device )
437+ except (TypeError , ValueError ) as e :
438+ raise AnsibleParserError (
439+ "Cannot convert device to dict: {0}" .format (to_native (e ))
440+ )
406441
407442 def _fetch_site_topology (self , client ):
408443 try :
409444 resp = client .topology .get_site_topology ()
410445 sites = resp .response .sites if resp .response and resp .response .sites else []
411- except ( ApiError , Exception ) as e :
446+ except ApiError as e :
412447 raise AnsibleParserError (
413448 "Failed to get site topology: {0}" .format (to_native (e ))
414449 )
415450
416- return [self ._site_to_dict (s ) for s in sites ]
451+ result = []
452+ for s in sites :
453+ site_dict = self ._site_to_dict (s )
454+ if not site_dict .get ("id" ) or not site_dict .get ("name" ):
455+ self .display .warning (
456+ "Skipping site with missing id or name: {0}" .format (site_dict )
457+ )
458+ continue
459+ result .append (site_dict )
460+
461+ return result
417462
418463 def _site_to_dict (self , site ):
419464 if hasattr (site , "to_dict" ):
@@ -434,7 +479,7 @@ def _fetch_device_site_map(self, client):
434479 try :
435480 resp = client .topology .get_physical_topology ()
436481 nodes = resp .response .nodes if resp .response and resp .response .nodes else []
437- except ( ApiError , Exception ) as e :
482+ except ApiError as e :
438483 raise AnsibleParserError (
439484 "Failed to get physical topology: {0}" .format (to_native (e ))
440485 )
@@ -463,11 +508,16 @@ def _fetch_device_tags(self, client, device_ids):
463508 if not device_ids :
464509 return tags
465510
511+ device_id_set = set (device_ids )
512+
466513 try :
467514 resp = client .tag .get_tag ()
468515 all_tags = resp .response if resp .response else []
469- except (ApiError , Exception ):
470- self .display .warning ("Failed to fetch tags, skipping tag-based grouping" )
516+ except ApiError as e :
517+ self .display .warning (
518+ "Failed to fetch tags from Catalyst Center, "
519+ "skipping tag-based grouping: {0}" .format (to_native (e ))
520+ )
471521 return tags
472522
473523 for tag_obj in all_tags :
@@ -485,15 +535,21 @@ def _fetch_device_tags(self, client, device_ids):
485535 id = tag_id , member_type = "networkdevice"
486536 )
487537 members = members_resp .response if members_resp .response else []
488- except (ApiError , Exception ):
538+ except ApiError as e :
539+ self .display .warning (
540+ "Failed to fetch members for tag '{0}' (id={1}), "
541+ "this tag will be missing from inventory: {2}" .format (
542+ tag_name , tag_id , to_native (e )
543+ )
544+ )
489545 continue
490546
491547 for member in members :
492548 member_dict = member if isinstance (member , dict ) else (
493549 member .to_dict () if hasattr (member , "to_dict" ) else vars (member )
494550 )
495551 member_id = member_dict .get ("instanceUuid" , "" )
496- if member_id in device_ids :
552+ if member_id in device_id_set :
497553 tags .setdefault (member_id , []).append (tag_name )
498554
499555 return tags
@@ -505,13 +561,15 @@ def _populate(self, data):
505561 tags = data .get ("tags" , {})
506562
507563 strict = self .get_option ("strict" )
564+ skipped_count = 0
508565
509566 if self .get_option ("group_by_site" ):
510567 self ._build_site_groups (sites )
511568
512569 for device in devices :
513570 hostname = self ._get_hostname (device )
514571 if not hostname :
572+ skipped_count += 1
515573 continue
516574
517575 self .inventory .add_host (hostname )
@@ -571,6 +629,13 @@ def _populate(self, data):
571629 self .get_option ("keyed_groups" ), host_vars , hostname , strict = strict
572630 )
573631
632+ if skipped_count :
633+ self .display .warning (
634+ "Skipped {0} device(s) with no usable hostname. "
635+ "Consider using hostname_source: managementIpAddress "
636+ "or hostname_source: id" .format (skipped_count )
637+ )
638+
574639 def _get_hostname (self , device ):
575640 source = self .get_option ("hostname_source" )
576641 hostname = device .get (source , "" )
@@ -631,8 +696,11 @@ def _build_site_groups(self, sites):
631696 if parent_group and parent_group != child_group :
632697 try :
633698 self .inventory .add_child (parent_group , child_group )
634- except Exception :
635- pass
699+ except AnsibleError as e :
700+ self .display .warning (
701+ "Could not nest site group '{0}' under '{1}': "
702+ "{2}" .format (child_group , parent_group , to_native (e ))
703+ )
636704
637705 def _normalize_site_name (self , site ):
638706 name = site .get ("name" , "unknown" )
0 commit comments