1"""
2Loop state
3
4Allows for looping over execution modules.
5
6.. versionadded:: 2017.7.0
7
8In both examples below, the execution module function ``boto_elb.get_instance_health``
9returns a list of dicts. The condition checks the ``state``-key of the first dict
10in the returned list and compares its value to the string `InService`.
11
12.. code-block:: yaml
13
14    wait_for_service_to_be_healthy:
15      loop.until:
16        - name: boto_elb.get_instance_health
17        - condition: m_ret[0]['state'] == 'InService'
18        - period: 5
19        - timeout: 20
20        - m_args:
21          - {{ elb }}
22        - m_kwargs:
23            keyid: {{ access_key }}
24            key: {{ secret_key }}
25            instances: "{{ instance }}"
26
27.. warning::
28
29    This state allows arbitrary python code to be executed through the condition
30    parameter which is literally evaluated within the state. Please use caution.
31
32.. versionchanged:: 3000
33
34A version that does not use eval is now available. It uses either the python ``operator``
35to compare the result of the function called in ``name``, which can be one of the
36following: lt, le, eq (default), ne, ge, gt.
37Alternatively, `compare_operator` can be filled with a function from an execution
38module in ``__salt__`` or ``__utils__`` like the example below.
39The function :py:func:`data.subdict_match <salt.utils.data.subdict_match>` checks if the
40``expected`` expression matches the data returned by calling the ``name`` function
41(with passed ``args`` and ``kwargs``).
42
43.. code-block:: yaml
44
45    Wait for service to be healthy:
46      loop.until_no_eval:
47        - name: boto_elb.get_instance_health
48        - expected: '0:state:InService'
49        - compare_operator: data.subdict_match
50        - period: 5
51        - timeout: 20
52        - args:
53          - {{ elb }}
54        - kwargs:
55            keyid: {{ access_key }}
56            key: {{ secret_key }}
57            instances: "{{ instance }}"
58"""
59
60
61import logging
62import operator
63import sys
64import time
65
66# Initialize logging
67log = logging.getLogger(__name__)
68
69# Define the module's virtual name
70__virtualname__ = "loop"
71
72
73def __virtual__():
74    return True
75
76
77def until(name, m_args=None, m_kwargs=None, condition=None, period=1, timeout=60):
78    """
79    Loop over an execution module until a condition is met.
80
81    :param str name: The name of the execution module
82    :param list m_args: The execution module's positional arguments
83    :param dict m_kwargs: The execution module's keyword arguments
84    :param str condition: The condition which must be met for the loop to break.
85        This should contain ``m_ret`` which is the return from the execution module.
86    :param period: The number of seconds to wait between executions
87    :type period: int or float
88    :param timeout: The timeout in seconds
89    :type timeout: int or float
90    """
91    ret = {"name": name, "changes": {}, "result": False, "comment": ""}
92
93    if m_args is None:
94        m_args = ()
95    if m_kwargs is None:
96        m_kwargs = {}
97
98    if name not in __salt__:
99        ret["comment"] = "Cannot find module {}".format(name)
100    elif condition is None:
101        ret["comment"] = "An exit condition must be specified"
102    elif not isinstance(period, (int, float)):
103        ret["comment"] = "Period must be specified as a float in seconds"
104    elif not isinstance(timeout, (int, float)):
105        ret["comment"] = "Timeout must be specified as a float in seconds"
106    elif __opts__["test"]:
107        ret["comment"] = "The execution module {} will be run".format(name)
108        ret["result"] = None
109    else:
110        if m_args is None:
111            m_args = []
112        if m_kwargs is None:
113            m_kwargs = {}
114
115        timeout = time.time() + timeout
116        while time.time() < timeout:
117            m_ret = __salt__[name](*m_args, **m_kwargs)
118            if eval(condition):  # pylint: disable=W0123
119                ret["result"] = True
120                ret["comment"] = "Condition {} was met".format(condition)
121                break
122            time.sleep(period)
123        else:
124            ret["comment"] = "Timed out while waiting for condition {}".format(
125                condition
126            )
127    return ret
128
129
130def until_no_eval(
131    name,
132    expected,
133    compare_operator="eq",
134    timeout=60,
135    period=1,
136    init_wait=0,
137    args=None,
138    kwargs=None,
139):
140    """
141    Generic waiter state that waits for a specific salt function to produce an
142    expected result.
143    The state fails if the function does not exist or raises an exception,
144    or does not produce the expected result within the allotted retries.
145
146    :param str name: Name of the module.function to call
147    :param expected: Expected return value. This can be almost anything.
148    :param str compare_operator: Operator to use to compare the result of the
149        module.function call with the expected value. This can be anything present
150        in __salt__ or __utils__. Will be called with 2 args: result, expected.
151    :param timeout: Abort after this amount of seconds (excluding init_wait).
152    :type timeout: int or float
153    :param period: Time (in seconds) to wait between attempts.
154    :type period: int or float
155    :param init_wait: Time (in seconds) to wait before trying anything.
156    :type init_wait: int or float
157    :param list args: args to pass to the salt module.function.
158    :param dict kwargs: kwargs to pass to the salt module.function.
159
160    .. versionadded:: 3000
161
162    """
163    ret = {"name": name, "comment": "", "changes": {}, "result": False}
164    if name not in __salt__:
165        ret["comment"] = 'Module.function "{}" is unavailable.'.format(name)
166    elif not isinstance(period, (int, float)):
167        ret["comment"] = "Period must be specified as a float in seconds"
168    elif not isinstance(timeout, (int, float)):
169        ret["comment"] = "Timeout must be specified as a float in seconds"
170    elif compare_operator in __salt__:
171        comparator = __salt__[compare_operator]
172    elif compare_operator in __utils__:
173        comparator = __utils__[compare_operator]
174    elif not hasattr(operator, compare_operator):
175        ret["comment"] = 'Invalid operator "{}" supplied.'.format(compare_operator)
176    else:
177        comparator = getattr(operator, compare_operator)
178    if __opts__["test"]:
179        ret["result"] = None
180        ret["comment"] = 'Would have waited for "{}" to produce "{}".'.format(
181            name, expected
182        )
183    if ret["comment"]:
184        return ret
185
186    if init_wait:
187        time.sleep(init_wait)
188    if args is None:
189        args = []
190    if kwargs is None:
191        kwargs = {}
192
193    res_archive = []
194    current_attempt = 0
195    timeout = time.time() + timeout
196    while time.time() < timeout:
197        current_attempt += 1
198        try:
199            res = __salt__[name](*args, **kwargs)
200        except Exception:  # pylint: disable=broad-except
201            (exc_type, exc_value, _) = sys.exc_info()
202            ret["comment"] = "Exception occurred while executing {}: {}:{}".format(
203                name, exc_type, exc_value
204            )
205            break
206        res_archive.append(res)
207        cmp_res = comparator(res, expected)
208        log.debug(
209            "%s:until_no_eval:\n"
210            "\t\tAttempt %s, result: %s, expected: %s, compare result: %s",
211            __name__,
212            current_attempt,
213            res,
214            expected,
215            cmp_res,
216        )
217        if cmp_res:
218            ret["result"] = True
219            ret["comment"] = "Call provided the expected results in {} attempts".format(
220                current_attempt
221            )
222            break
223        time.sleep(period)
224    else:
225        ret[
226            "comment"
227        ] = "Call did not produce the expected result after {} attempts".format(
228            current_attempt
229        )
230        log.debug(
231            "%s:until_no_eval:\n\t\tResults of all attempts: %s",
232            __name__,
233            res_archive,
234        )
235    return ret
236