1"""
2Test States
3===========
4
5Provide test case states that enable easy testing of things to do with state
6calls, e.g. running, calling, logging, output filtering etc.
7
8.. code-block:: yaml
9
10    always-passes-with-any-kwarg:
11      test.nop:
12        - name: foo
13        - something: else
14        - foo: bar
15
16    always-passes:
17      test.succeed_without_changes:
18        - name: foo
19
20    always-fails:
21      test.fail_without_changes:
22        - name: foo
23
24    always-changes-and-succeeds:
25      test.succeed_with_changes:
26        - name: foo
27
28    always-changes-and-fails:
29      test.fail_with_changes:
30        - name: foo
31
32    my-custom-combo:
33      test.configurable_test_state:
34        - name: foo
35        - changes: True
36        - result: False
37        - comment: bar.baz
38        - warnings: A warning
39
40    is-pillar-foo-present-and-bar-is-int:
41      test.check_pillar:
42        - present:
43            - foo
44        - integer:
45            - bar
46
47You may also use these states for controlled failure in state definitions, for example if certain conditions in
48pillar or grains do not apply. The following state definition will fail with a message "OS not supported!" when
49`grains['os']` is neither Ubuntu nor CentOS:
50
51.. code-block:: jinja
52
53    {% if grains['os'] in ['Ubuntu', 'CentOS'] %}
54
55    # Your state definitions go here
56
57    {% else %}
58    failure:
59      test.fail_without_changes:
60        - name: "OS not supported!"
61        - failhard: True
62    {% endif %}
63
64"""
65
66import random
67
68import salt.utils.data
69from salt.exceptions import SaltInvocationError
70from salt.state import _gen_tag
71
72
73def nop(name, **kwargs):
74    """
75    .. versionadded:: 2015.8.1
76
77    A no-op state that does nothing. Useful in conjunction with the ``use``
78    requisite, or in templates which could otherwise be empty due to jinja
79    rendering.
80
81    name
82        A unique string to serve as the state's ID
83    """
84    return succeed_without_changes(name)
85
86
87def succeed_without_changes(name, **kwargs):  # pylint: disable=unused-argument
88    """
89    .. versionadded:: 2014.7.0
90
91    Returns successful
92
93    name
94        A unique string to serve as the state's ID
95    """
96    comment = kwargs.get("comment", "Success!")
97
98    ret = {"name": name, "changes": {}, "result": True, "comment": comment}
99    return ret
100
101
102def fail_without_changes(name, **kwargs):  # pylint: disable=unused-argument
103    """
104    .. versionadded:: 2014.7.0
105
106    Returns failure
107
108    name
109        A unique string to serve as the state's ID
110    """
111    comment = kwargs.get("comment", "Failure!")
112
113    ret = {"name": name, "changes": {}, "result": False, "comment": comment}
114
115    if __opts__["test"]:
116        ret["result"] = False
117        ret["comment"] = "If we weren't testing, this would be a failure!"
118
119    return ret
120
121
122def succeed_with_changes(name, **kwargs):  # pylint: disable=unused-argument
123    """
124    .. versionadded:: 2014.7.0
125
126    Returns ``True`` with an non-empty ``changes`` dictionary. Useful for
127    testing requisites.
128
129    name
130        A unique string to serve as the state's ID
131    """
132    comment = kwargs.get("comment", "Success!")
133
134    ret = {"name": name, "changes": {}, "result": True, "comment": comment}
135
136    # Following the docs as written here
137    # https://docs.saltproject.io/ref/states/writing.html#return-data
138    ret["changes"] = {
139        "testing": {"old": "Unchanged", "new": "Something pretended to change"}
140    }
141
142    if __opts__["test"]:
143        ret["result"] = None
144        ret["comment"] = "If we weren't testing, this would be successful with changes"
145
146    return ret
147
148
149def fail_with_changes(name, **kwargs):  # pylint: disable=unused-argument
150    """
151    .. versionadded:: 2014.7.0
152
153    Returns ``False`` with an non-empty ``changes`` dictionary. Useful for
154    testing requisites.
155
156    name
157        A unique string to serve as the state's ID
158    """
159    comment = kwargs.get("comment", "Failure!")
160
161    ret = {"name": name, "changes": {}, "result": False, "comment": comment}
162
163    # Following the docs as written here
164    # https://docs.saltproject.io/ref/states/writing.html#return-data
165    ret["changes"] = {
166        "testing": {"old": "Unchanged", "new": "Something pretended to change"}
167    }
168
169    if __opts__["test"]:
170        ret["result"] = None
171        ret["comment"] = "If we weren't testing, this would be failed with changes"
172
173    return ret
174
175
176def configurable_test_state(name, changes=True, result=True, comment="", warnings=None):
177    """
178    .. versionadded:: 2014.7.0
179
180    A configurable test state which allows for more control over the return
181    data
182
183    name
184        A unique string to serve as the state's ID
185
186    changes : True
187        Controls whether or not the state reports that there were changes.
188        There are three supported values for this argument:
189
190        - If ``True``, the state will report changes
191        - If ``False``, the state will report no changes
192        - If ``"Random"``, the state will randomly report either changes or no
193          changes.
194
195    result : True
196        Controls the result for for the state. Like ``changes``, there are
197        three supported values for this argument:
198
199        - If ``True``, the state will report a ``True`` result
200        - If ``False``, the state will report a ``False`` result
201        - If ``"Random"``, the state will randomly report either ``True``
202
203        .. note::
204            The result will be reported as ``None`` if *all* of the following
205            are true:
206
207            1. The state is being run in test mode (i.e. ``test=True`` on the
208            CLI)
209
210            2. ``result`` is ``True`` (either explicitly, or via being set to
211               ``"Random"``)
212
213            3. ``changes`` is ``True`` (either explicitly, or via being set to
214               ``"Random"``)
215
216    comment : ""
217        Comment field field for the state. By default, this is an empty string.
218
219    warnings
220        A string (or a list of strings) to fill the warnings field with.
221        Default is None
222
223        .. versionadded:: 3000
224    """
225    ret = {"name": name, "changes": {}, "result": False, "comment": comment}
226    change_data = {
227        "testing": {"old": "Unchanged", "new": "Something pretended to change"}
228    }
229
230    if changes is True:
231        # If changes is True we place our dummy change dictionary into it.
232        # Following the docs as written here
233        # https://docs.saltproject.io/ref/states/writing.html#return-data
234        ret["changes"] = change_data
235    elif changes is False:
236        # Don't modify changes from the "ret" dict set above
237        pass
238    else:
239        if str(changes).lower() == "random":
240            if random.choice((True, False)):
241                # Following the docs as written here
242                # https://docs.saltproject.io/ref/states/writing.html#return-data
243                ret["changes"] = change_data
244        else:
245            err = (
246                "You have specified the state option 'changes' with "
247                "invalid arguments. It must be either "
248                "True, False, or 'Random'"
249            )
250            raise SaltInvocationError(err)
251
252    if isinstance(result, bool):
253        ret["result"] = result
254    else:
255        if str(result).lower() == "random":
256            ret["result"] = random.choice((True, False))
257        else:
258            raise SaltInvocationError(
259                "You have specified the state option "
260                "'result' with invalid arguments. It must "
261                "be either True, False, or "
262                "'Random'"
263            )
264
265    if warnings is None:
266        pass
267    elif isinstance(warnings, str):
268        ret["warnings"] = [warnings]
269    elif isinstance(warnings, list):
270        ret["warnings"] = warnings
271    else:
272        raise SaltInvocationError(
273            "You have specified the state option "
274            "'warnings' with invalid arguments. It must "
275            "be a string or a list of strings"
276        )
277
278    if __opts__["test"]:
279        ret["result"] = True if changes is False else None
280        ret["comment"] = "This is a test" if not comment else comment
281
282    return ret
283
284
285def show_notification(name, text=None, **kwargs):
286    """
287    .. versionadded:: 2015.8.0
288
289    Simple notification using text argument.
290
291    name
292        A unique string to serve as the state's ID
293
294    text
295        Text to return in the comment field
296    """
297
298    if not text:
299        raise SaltInvocationError("Missing required argument text.")
300
301    ret = {"name": name, "changes": {}, "result": True, "comment": text}
302
303    return ret
304
305
306def mod_watch(name, sfun=None, **kwargs):
307    """
308    Call this function via a watch statement
309
310    .. versionadded:: 2014.7.0
311
312    Any parameters in the state return dictionary can be customized by adding
313    the keywords ``result``, ``comment``, and ``changes``.
314
315    .. code-block:: yaml
316
317        this_state_will_return_changes:
318          test.succeed_with_changes
319
320        this_state_will_NOT_return_changes:
321          test.succeed_without_changes
322
323        this_state_is_watching_another_state:
324          test.succeed_without_changes:
325            - comment: 'This is a custom comment'
326            - watch:
327              - test: this_state_will_return_changes
328              - test: this_state_will_NOT_return_changes
329
330        this_state_is_also_watching_another_state:
331          test.succeed_without_changes:
332            - watch:
333              - test: this_state_will_NOT_return_changes
334    """
335    has_changes = []
336    if "__reqs__" in __low__:
337        for req in __low__["__reqs__"]["watch"]:
338            tag = _gen_tag(req)
339            if __running__[tag]["changes"]:
340                has_changes.append("{state}: {__id__}".format(**req))
341
342    ret = {
343        "name": name,
344        "result": kwargs.get("result", True),
345        "comment": kwargs.get("comment", "Watch statement fired."),
346        "changes": kwargs.get("changes", {"Requisites with changes": has_changes}),
347    }
348    return ret
349
350
351def _check_key_type(key_str, key_type=None):
352    """
353    Helper function to get pillar[key_str] and
354    check if its type is key_type
355
356    Returns None if the pillar key is missing.
357    If present True or False depending on match
358    of the values type.
359
360    Can't check for None.
361    """
362    value = __salt__["pillar.get"](key_str, None)
363    if value is None:
364        return None
365    elif key_type is not None and not isinstance(value, key_type):
366        return False
367    else:
368        return True
369
370
371def _if_str_then_list(listing):
372    """
373    Checks if its argument is a list or a str.
374    A str will be turned into a list with the
375    str as its only element.
376    """
377    if isinstance(listing, str):
378        return [salt.utils.stringutils.to_unicode(listing)]
379    elif not isinstance(listing, list):
380        raise TypeError
381    return salt.utils.data.decode_list(listing)
382
383
384def check_pillar(
385    name,
386    present=None,
387    boolean=None,
388    integer=None,
389    string=None,
390    listing=None,
391    dictionary=None,
392    verbose=False,
393):
394    """
395    Checks the presence and, optionally, the type of given keys in Pillar
396
397    Supported kwargs for types are:
398    - boolean (bool)
399    - integer (int)
400    - string (str)
401    - listing (list)
402    - dictionary (dict)
403
404    Checking for None type pillars is not implemented yet.
405
406    .. code-block:: yaml
407
408        is-pillar-foo-present-and-bar-is-int:
409          test.check_pillar:
410            - present:
411                - foo
412            - integer:
413                - bar
414    """
415    if not (present or boolean or integer or string or listing or dictionary):
416        raise SaltInvocationError("Missing required argument text.")
417
418    present = present or []
419    boolean = boolean or []
420    integer = integer or []
421    string = string or []
422    listing = listing or []
423    dictionary = dictionary or []
424
425    ret = {"name": name, "changes": {}, "result": True, "comment": ""}
426    checks = {}
427    fine = {}
428    failed = {}
429    # for those we don't check the type:
430    present = _if_str_then_list(present)
431    checks[None] = present
432    # those should be bool:
433    boolean = _if_str_then_list(boolean)
434    checks[bool] = boolean
435    # those should be int:
436
437    # those should be integer:
438    integer = _if_str_then_list(integer)
439    checks[int] = integer
440    # those should be str:
441    string = _if_str_then_list(string)
442    checks[(str,)] = string
443    # those should be list:
444    listing = _if_str_then_list(listing)
445    checks[list] = listing
446    # those should be dict:
447    dictionary = _if_str_then_list(dictionary)
448    checks[dict] = dictionary
449
450    for key_type, keys in checks.items():
451        for key in keys:
452            result = _check_key_type(key, key_type)
453            if result is None:
454                failed[key] = None
455                ret["result"] = False
456            elif not result:
457                failed[key] = key_type
458                ret["result"] = False
459            elif verbose:
460                fine[key] = key_type
461
462    for key, key_type in failed.items():
463        comment = 'Pillar key "{}" '.format(key)
464        if key_type is None:
465            comment += "is missing.\n"
466        else:
467            comment += "is not {}.\n".format(key_type)
468        ret["comment"] += comment
469
470    if verbose and fine:
471        comment = "Those keys passed the check:\n"
472        for key, key_type in fine.items():
473            comment += "- {} ({})\n".format(key, key_type)
474        ret["comment"] += comment
475
476    return ret
477