55
66from __future__ import absolute_import , division , print_function
77
8- import copy
98from typing import Any , List , Optional , Set , Tuple , cast
109
1110import ansible_collections .linode .cloud .plugins .module_utils .doc_fragments .nodebalancer as docs
2019 dict_select_matching ,
2120 filter_null_values ,
2221 handle_updates ,
22+ poll_condition ,
23+ poll_for_response_status ,
2324)
2425from ansible_specdoc .objects import (
2526 FieldType ,
2829 SpecReturnValue ,
2930)
3031from linode_api4 import (
32+ ApiError ,
3133 Firewall ,
3234 NodeBalancer ,
3335 NodeBalancerConfig ,
3436 NodeBalancerNode ,
3537)
38+ from linode_api4 .polling import TimeoutContext
39+
40+ # tcp/http/https share a port namespace; udp is independent. Used to detect
41+ # which existing config is blocking a create on a given port.
42+ _TCP_FAMILY = {"tcp" , "http" , "https" }
43+
44+
45+ def _protocol_family (protocol : Optional [str ]) -> Optional [str ]:
46+ return "tcp" if protocol in _TCP_FAMILY else protocol
47+
3648
3749linode_nodes_spec = {
3850 "label" : SpecField (
329341 "client_udp_sess_throttle" ,
330342}
331343
344+ MUTABLE_CONFIG_FIELDS : set [str ] = {
345+ "algorithm" ,
346+ "check" ,
347+ "check_attempts" ,
348+ "check_body" ,
349+ "check_interval" ,
350+ "check_passive" ,
351+ "check_path" ,
352+ "check_timeout" ,
353+ "cipher_suite" ,
354+ "port" ,
355+ "protocol" ,
356+ "proxy_protocol" ,
357+ "stickiness" ,
358+ "udp_check_port" ,
359+ }
360+
332361DOCUMENTATION = r"""
333362"""
334363EXAMPLES = r"""
@@ -353,6 +382,7 @@ def __init__(self) -> None:
353382 }
354383
355384 self ._node_balancer : Optional [NodeBalancer ] = None
385+ self ._deleted_config_ids : Set [int ] = set ()
356386
357387 super ().__init__ (
358388 module_arg_spec = self .module_arg_spec ,
@@ -413,17 +443,16 @@ def _create_nodebalancer(self) -> Optional[NodeBalancer]:
413443
414444 def _create_config (
415445 self , node_balancer : NodeBalancer , config_params : dict
416- ) -> Optional [NodeBalancerConfig ]:
417- """Creates a config with the given kwargs within the given NodeBalancer"""
446+ ) -> tuple [ Optional [NodeBalancerConfig ], bool ]:
447+ """Creates a config with the given kwargs within the given NodeBalancer. """
418448
419449 try :
420- return node_balancer .config_create (** config_params )
421- except Exception as exception :
422- return self .fail (
423- msg = "failed to create nodebalancer config: {0}" .format (
424- exception
425- )
450+ return node_balancer .config_create (** config_params ), True
451+ except ApiError as err :
452+ self .fail (
453+ msg = "failed to create nodebalancer config: {0}" .format (err )
426454 )
455+ return None , False
427456
428457 def _create_node (
429458 self , config : NodeBalancerConfig , node_params : dict
@@ -441,19 +470,21 @@ def _create_node(
441470
442471 def _create_config_register (
443472 self , node_balancer : NodeBalancer , config_params : dict
444- ) -> NodeBalancerConfig :
473+ ) -> Tuple [ NodeBalancerConfig , bool ] :
445474 """Registers a create action for the given config"""
446475
447- config = self ._create_config (node_balancer , config_params )
448- self .register_action ("Created config: {0}" .format (config .id ))
476+ config , created = self ._create_config (node_balancer , config_params )
477+ if created :
478+ self .register_action ("Created config: {0}" .format (config .id ))
449479
450- return config
480+ return config , created
451481
452482 def _delete_config_register (self , config : NodeBalancerConfig ) -> None :
453483 """Registers a delete action for the given config"""
454484
455485 self .register_action ("Deleted config: {0}" .format (config .id ))
456486 config .delete ()
487+ self ._deleted_config_ids .add (config .id )
457488
458489 def _create_node_register (
459490 self , config : NodeBalancerConfig , node_params : dict
@@ -502,79 +533,152 @@ def _handle_config_nodes(
502533 for node in node_map .values ():
503534 self ._delete_node_register (node )
504535
505- @staticmethod
506- def _check_config_exists (
507- target : Set [NodeBalancerConfig ], config : dict
508- ) -> Tuple [bool , Optional [NodeBalancerConfig ]]:
509- """Returns whether a config exists in the target set"""
536+ # Wait for nodes to propagate
537+ expected_node_count = len (new_nodes )
510538
511- tmp_config = copy .deepcopy (config )
539+ def _nodes_synced () -> bool :
540+ if hasattr (config , "_nodes" ):
541+ # In the future we should fix the invalidation behavior in the Python SDK,
542+ # but this works as a workaround
543+ object .__delattr__ (config , "_nodes" )
512544
513- # These fields will return as <REDACTED> so we should not diff on them
514- tmp_config .pop ("ssl_cert" )
515- tmp_config .pop ("ssl_key" )
545+ return len (config .nodes ) == expected_node_count
516546
517- for remote_config in target :
518- config_match , remote_config_match = dict_select_matching (
519- filter_null_values (tmp_config ), remote_config ._raw_json
520- )
547+ poll_condition (_nodes_synced , step = 2 , timeout = 120 )
521548
522- if config_match == remote_config_match :
523- return True , remote_config
549+ def _find_port_conflict (
550+ self ,
551+ target : Set [NodeBalancerConfig ],
552+ config : dict [str , Any ],
553+ ) -> Optional [NodeBalancerConfig ]:
554+ """Return a remote config matching on port+protocol-family."""
524555
525- return False , None
556+ port = config .get ("port" )
557+ family = _protocol_family (config .get ("protocol" ))
526558
527- def _handle_configs (self ) -> None :
528- """Updates the configs defined in new_configs under this NodeBalancer"""
559+ for remote_config in target :
560+ if (
561+ remote_config .id in self ._deleted_config_ids
562+ or remote_config .port != port
563+ or _protocol_family (remote_config .protocol ) != family
564+ ):
565+ continue
529566
530- new_configs = self .module .params .get ("configs" ) or []
531- remote_configs = set (self ._node_balancer .configs )
567+ return remote_config
532568
533- to_create = []
534- to_update = []
535- to_delete = remote_configs
569+ return None
536570
537- for config in new_configs :
538- config_exists , remote_config = self ._check_config_exists (
539- remote_configs , config
540- )
571+ def _handle_configs (self ) -> list :
572+ """Updates the configs defined in new_configs under this NodeBalancer"""
541573
542- if not config_exists :
543- to_create . append (( config , remote_config ))
544- continue
574+ configs_param = self . module . params . get ( "configs" )
575+ if configs_param is None :
576+ return []
545577
546- if config .get ("recreate" ):
547- to_create .append ((config , remote_config ))
548- continue
578+ new_configs = configs_param
549579
550- to_update .append ((config , remote_config ))
551- to_delete .remove (remote_config )
580+ to_create , to_update , to_delete = self ._plan_configs (new_configs )
552581
553- # Remove remaining configs
554582 for config in to_delete :
555583 self ._delete_config_register (config )
556584
585+ # Wait for deletions to propagate before attempting creates; otherwise
586+ # the API returns "already using port" 400s on overlapping ports.
587+ for config in to_delete :
588+ poll_for_response_status (
589+ TimeoutContext (timeout_seconds = 120 ), config ._api_get , 404
590+ )
591+
557592 result_configs = []
558593
559- for config , remote_config in to_create :
560- new_config = self ._create_config_register (
594+ for config in to_create :
595+ new_config , _ = self ._create_config_register (
561596 self ._node_balancer , config
562597 )
563- if config .get ("nodes" ) is not None :
564- self ._handle_config_nodes (new_config , config .get ("nodes" ))
565- new_config ._api_get ()
598+ self ._sync_config_nodes (new_config , config .get ("nodes" ))
566599 result_configs .append (new_config )
567600
568601 for config , remote_config in to_update :
569- if config .get ("nodes" ) is not None :
570- self ._handle_config_nodes (remote_config , config .get ("nodes" ))
571- remote_config ._api_get ()
602+ handle_updates (
603+ remote_config ,
604+ filter_null_values (config ),
605+ MUTABLE_CONFIG_FIELDS ,
606+ self .register_action ,
607+ )
608+ self ._sync_config_nodes (remote_config , config .get ("nodes" ))
572609 result_configs .append (remote_config )
573610
574611 cast (list , self .results ["configs" ]).extend (
575612 [c ._raw_json for c in result_configs ]
576613 )
577614
615+ return result_configs
616+
617+ def _stable_configs (
618+ self , consecutive_threshold : int = 3
619+ ) -> List [NodeBalancerConfig ]:
620+ """
621+ Wait for the list endpoint to consistently show the expected config
622+ count. Workaround for API propagation behavior.
623+ """
624+ successful_requests = 0
625+ last_len = - 1
626+ last_configs : List [NodeBalancerConfig ] = []
627+
628+ def _stable () -> bool :
629+ nonlocal successful_requests , last_len , last_configs
630+ self ._node_balancer .invalidate ()
631+ last_configs = list (self ._node_balancer .configs )
632+
633+ if len (last_configs ) != last_len :
634+ successful_requests = 0
635+ last_len = len (last_configs )
636+
637+ return False
638+
639+ successful_requests += 1
640+ return successful_requests >= consecutive_threshold
641+
642+ poll_condition (_stable , step = 2 , timeout = 180 )
643+
644+ return last_configs
645+
646+ def _plan_configs (self , new_configs : list [dict ]) -> tuple [
647+ list [dict ],
648+ list [tuple [dict , NodeBalancerConfig ]],
649+ set [NodeBalancerConfig ],
650+ ]:
651+ """Partition desired configs into (create, update, delete) sets."""
652+
653+ remote_configs = set (self ._stable_configs ())
654+ to_create : list [dict ] = []
655+ to_update : list [tuple [dict , NodeBalancerConfig ]] = []
656+ to_delete = remote_configs
657+
658+ for config in new_configs :
659+ match = self ._find_port_conflict (to_delete , config )
660+
661+ if match is not None and not config .get ("recreate" ):
662+ to_update .append ((config , match ))
663+ to_delete .remove (match )
664+ continue
665+
666+ to_create .append (config )
667+
668+ return to_create , to_update , to_delete
669+
670+ def _sync_config_nodes (
671+ self ,
672+ config : NodeBalancerConfig ,
673+ nodes : Optional [List [dict ]],
674+ ) -> None :
675+ """Sync nodes against the given config if provided."""
676+
677+ if nodes is not None :
678+ self ._handle_config_nodes (config , nodes )
679+
680+ config ._api_get ()
681+
578682 def _update_nodebalancer (self ) -> None :
579683 """Handles updating the current NodeBalancer"""
580684
@@ -640,10 +744,15 @@ def exec_module(self, **kwargs: Any) -> Optional[dict]:
640744 return self .results
641745
642746 self ._handle_nodebalancer ()
643- self ._handle_configs ()
747+ result_configs = self ._handle_configs ()
748+
749+ result_configs = (
750+ result_configs
751+ if self .module .params .get ("configs" ) is not None
752+ else self ._node_balancer .configs
753+ )
644754
645- # Append all nodes to the result
646- for config in self ._node_balancer .configs :
755+ for config in result_configs :
647756 for node in config .nodes :
648757 node ._api_get ()
649758 cast (list , self .results ["nodes" ]).append (node ._raw_json )
0 commit comments