Skip to content

Commit a6dd3f8

Browse files
TPT-4417: Resolve nodebalancer module ordering and sync issues; support in-place config updates (#778)
* Address nodebalancer module ordering and sync issues * make format * fix lint * Fix remaining issues * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * various fixes * oops * Fix UDP issue * Add support for in-place update logic * Fix regression * Address copilot feedback --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent db91b6d commit a6dd3f8

3 files changed

Lines changed: 237 additions & 81 deletions

File tree

plugins/module_utils/linode_helper.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,32 @@ def __attempt_delete() -> bool:
660660
)
661661

662662

663+
def poll_for_response_status(
664+
timeout_ctx: TimeoutContext, func: Callable[[], None], *statuses: int
665+
):
666+
"""
667+
Polls a given function until it raises an ApiError with one of the
668+
specified response statuses.
669+
"""
670+
671+
def __attempt() -> bool:
672+
try:
673+
func()
674+
except ApiError as err:
675+
if err.status in statuses:
676+
return True
677+
678+
raise err
679+
680+
return False
681+
682+
poll_condition(
683+
__attempt,
684+
step=4,
685+
timeout=timeout_ctx.seconds_remaining,
686+
)
687+
688+
663689
def api_filter_constructor_for_aclp_monitor_services(
664690
params: Dict[str, Any],
665691
) -> Dict[str, Any]:

plugins/modules/nodebalancer.py

Lines changed: 169 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from __future__ import absolute_import, division, print_function
77

8-
import copy
98
from typing import Any, List, Optional, Set, Tuple, cast
109

1110
import ansible_collections.linode.cloud.plugins.module_utils.doc_fragments.nodebalancer as docs
@@ -20,6 +19,8 @@
2019
dict_select_matching,
2120
filter_null_values,
2221
handle_updates,
22+
poll_condition,
23+
poll_for_response_status,
2324
)
2425
from ansible_specdoc.objects import (
2526
FieldType,
@@ -28,11 +29,22 @@
2829
SpecReturnValue,
2930
)
3031
from 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

3749
linode_nodes_spec = {
3850
"label": SpecField(
@@ -329,6 +341,23 @@
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+
332361
DOCUMENTATION = r"""
333362
"""
334363
EXAMPLES = 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

Comments
 (0)