HEX
Server: Apache
System: Linux server.enlacediseno.com 4.18.0-553.62.1.el8_10.x86_64 #1 SMP Wed Jul 16 04:08:25 EDT 2025 x86_64
User: maor (1069)
PHP: 7.3.33
Disabled: exec,passthru,shell_exec,system
Upload Files
File: //opt/saltstack/salt/lib/python3.10/site-packages/salt/returners/slack_webhook_return.py
"""
Return salt data via Slack using Incoming Webhooks

:codeauthor: `Carlos D. Álvaro <github@cdalvaro.io>`

The following fields can be set in the minion conf file:

.. code-block:: yaml

    slack_webhook.webhook (required, the webhook id. Just the part after: 'https://hooks.slack.com/services/')
    slack_webhook.success_title (optional, short title for succeeded states. By default: '{id} | Succeeded')
    slack_webhook.failure_title (optional, short title for failed states. By default: '{id} | Failed')
    slack_webhook.author_icon (optional, a URL that with a small 16x16px image. Must be of type: GIF, JPEG, PNG, and BMP)
    slack_webhook.show_tasks (optional, show identifiers for changed and failed tasks. By default: False)

Alternative configuration values can be used by prefacing the configuration.
Any values not found in the alternative configuration will be pulled from
the default location:

.. code-block:: yaml

    slack_webhook.webhook
    slack_webhook.success_title
    slack_webhook.failure_title
    slack_webhook.author_icon
    slack_webhook.show_tasks

Slack settings may also be configured as:

.. code-block:: yaml

    slack_webhook:
      webhook: T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
      success_title: '[{id}] | Success'
      failure_title: '[{id}] | Failure'
      author_icon: https://platform.slack-edge.com/img/default_application_icon.png
      show_tasks: true

    alternative.slack_webhook:
      webhook: T00000000/C00000000/YYYYYYYYYYYYYYYYYYYYYYYY
      show_tasks: false

To use the Slack returner,
append '--return slack_webhook' to the salt command.

.. code-block:: bash

    salt '*' test.ping --return slack_webhook

To use the alternative configuration,
append '--return_config alternative' to the salt command.

.. code-block:: bash

    salt '*' test.ping --return slack_webhook --return_config alternative

"""

import json
import logging
import urllib.parse

import salt.returners
import salt.utils.http
import salt.utils.yaml

log = logging.getLogger(__name__)

__virtualname__ = "slack_webhook"

UNCHANGED_KEY = "unchanged"
CHANGED_KEY = "changed"
FAILED_KEY = "failed"
TASKS_KEY = "tasks"
COUNTER_KEY = "counter"
DURATION_KEY = "duration"
TOTAL_KEY = "total"


def _get_options(ret=None):
    """
    Get the slack_webhook options from salt.
    :param ret: Salt return dictionary
    :return: A dictionary with options
    """

    defaults = {
        "success_title": "{id} | Succeeded",
        "failure_title": "{id} | Failed",
        "author_icon": "",
        "show_tasks": False,
    }

    attrs = {
        "webhook": "webhook",
        "success_title": "success_title",
        "failure_title": "failure_title",
        "author_icon": "author_icon",
        "show_tasks": "show_tasks",
    }

    _options = salt.returners.get_returner_options(
        __virtualname__,
        ret,
        attrs,
        __salt__=__salt__,
        __opts__=__opts__,
        defaults=defaults,
    )
    return _options


def __virtual__():
    """
    Return virtual name of the module.

    :return: The virtual name of the module.
    """

    return __virtualname__


def _sprinkle(config_str):
    """
    Sprinkle with grains of salt, that is
    convert "test {id} test {host} " types of strings
    :param config_str: The string to be sprinkled
    :return: The string sprinkled
    """

    parts = [x for sub in config_str.split("{") for x in sub.split("}")]
    for i in range(1, len(parts), 2):
        parts[i] = str(__grains__.get(parts[i], ""))
    return "".join(parts)


def _format_task(task):
    """
    Return a dictionary with the task ready for slack fileds
    :param task: The name of the task

    :return: A dictionary ready to be inserted in Slack fields array
    """

    return {"value": task, "short": False}


def _generate_payload(author_icon, title, report, **kwargs):
    """
    Prepare the payload for Slack
    :param author_icon: The url for the thumbnail to be displayed
    :param title: The title of the message
    :param report: A dictionary with the report of the Salt function
    :return: The payload ready for Slack
    """

    event_rtn = kwargs.get("event_rtn", False)

    if event_rtn is True:
        author_name = report["id"]
    else:
        author_name = _sprinkle("{id}")

    title = _sprinkle(title)

    text = "Function: {}\n".format(report.get("function"))
    if len(report.get("arguments", [])) > 0:
        text += "Function Args: {}\n".format(str(list(map(str, report["arguments"]))))

    text += "JID: {}\n".format(report.get("jid"))

    if TOTAL_KEY in report:
        text += f"Total: {report[TOTAL_KEY]}\n"

    if DURATION_KEY in report:
        text += f"Duration: {float(report[DURATION_KEY]):.2f} secs"

    attachments = [
        {
            "fallback": title,
            "color": "#272727",
            "author_name": author_name,
            "author_link": _sprinkle("{localhost}"),
            "author_icon": author_icon,
            "title": "Success: {}".format(str(report["success"])),
            "text": text,
        }
    ]

    if UNCHANGED_KEY in report:
        # Unchanged
        attachments.append(
            {
                "color": "good",
                "title": "Unchanged: {}".format(
                    report[UNCHANGED_KEY].get(COUNTER_KEY, 0)
                ),
            }
        )

        # Changed
        changed = {
            "color": "warning",
            "title": f"Changed: {report[CHANGED_KEY].get(COUNTER_KEY, 0)}",
        }

        if len(report[CHANGED_KEY].get(TASKS_KEY, [])) > 0:
            changed["fields"] = list(map(_format_task, report[CHANGED_KEY][TASKS_KEY]))

        attachments.append(changed)

        # Failed
        failed = {
            "color": "danger",
            "title": f"Failed: {report[FAILED_KEY].get(COUNTER_KEY, None)}",
        }

        if len(report[FAILED_KEY].get(TASKS_KEY, [])) > 0:
            failed["fields"] = list(map(_format_task, report[FAILED_KEY][TASKS_KEY]))

        attachments.append(failed)

    else:
        attachments.append(
            {
                "color": "good" if report["success"] else "danger",
                "title": "Return: {}".format(report.get("return", None)),
            }
        )

    payload = {"attachments": attachments}

    return payload


def _process_state(returns):
    """
    Process the received output state
    :param returns A dictionary with the returns of the recipe
    :return A dictionary with Unchanges, Changed and Failed tasks
    """

    sorted_data = sorted(returns.items(), key=lambda s: s[1].get("__run_num__", 0))

    n_total = 0
    n_failed = 0
    n_changed = 0
    duration = 0.0

    changed_tasks = []
    failed_tasks = []

    # gather stats
    for state, data in sorted_data:
        # state: module, stateid, name, function
        _, stateid, _, _ = state.split("_|-")
        task = "{filename}.sls | {taskname}".format(
            filename=str(data.get("__sls__")), taskname=stateid
        )

        if not data.get("result", True):
            n_failed += 1
            failed_tasks.append(task)

        if data.get("changes", {}):
            n_changed += 1
            changed_tasks.append(task)

        n_total += 1
        try:
            duration += float(data.get("duration", 0.0))
        except ValueError:
            pass

    n_unchanged = n_total - n_failed - n_changed

    return {
        TOTAL_KEY: n_total,
        UNCHANGED_KEY: {COUNTER_KEY: n_unchanged},
        CHANGED_KEY: {COUNTER_KEY: n_changed, TASKS_KEY: changed_tasks},
        FAILED_KEY: {COUNTER_KEY: n_failed, TASKS_KEY: failed_tasks},
        DURATION_KEY: duration / 1000,
    }


def _state_return(ret):
    """
    Return True if ret is a Salt state return
    :param ret: The Salt return
    """

    ret_data = ret.get("return")
    if not isinstance(ret_data, dict):
        return False

    return ret_data and "__id__" in next(iter(ret_data.values()))


def _generate_report(ret, show_tasks):
    """
    Generate a report of the Salt function
    :param ret: The Salt return
    :param show_tasks: Flag to show the name of the changed and failed states
    :return: The report
    """

    report = {
        "id": ret.get("id"),
        "success": True if ret.get("retcode", 1) == 0 else False,
        "function": ret.get("fun"),
        "arguments": ret.get("fun_args", []),
        "jid": ret.get("jid"),
    }

    ret_return = ret.get("return")
    if _state_return(ret):
        ret_return = _process_state(ret_return)
        if not show_tasks:
            del ret_return[CHANGED_KEY][TASKS_KEY]
            del ret_return[FAILED_KEY][TASKS_KEY]
    elif isinstance(ret_return, dict):
        ret_return = {"return": f"\n{salt.utils.yaml.safe_dump(ret_return, indent=2)}"}
    else:
        ret_return = {"return": ret_return}

    report.update(ret_return)

    return report


def _post_message(webhook, author_icon, title, report, **kwargs):
    """
    Send a message to a Slack room through a webhook
    :param webhook:     The url of the incoming webhook
    :param author_icon: The thumbnail image to be displayed on the right side of the message
    :param title:       The title of the message
    :param report:      The report of the function state
    :return:            Boolean if message was sent successfully
    """

    event_rtn = kwargs.get("event_rtn", False)

    payload = _generate_payload(author_icon, title, report, event_rtn=event_rtn)

    data = urllib.parse.urlencode({"payload": json.dumps(payload, ensure_ascii=False)})

    webhook_url = urllib.parse.urljoin("https://hooks.slack.com/services/", webhook)
    query_result = salt.utils.http.query(webhook_url, "POST", data=data)

    # Sometimes the status is not available, so status 200 is assumed when it is not present
    if (
        query_result.get("body", "failed") == "ok"
        and query_result.get("status", 200) == 200
    ):
        return True
    else:
        log.error("Slack incoming webhook message post result: %s", query_result)
        return {"res": False, "message": query_result.get("body", query_result)}


def returner(ret, **kwargs):
    """
    Send a slack message with the data through a webhook
    :param ret: The Salt return
    :return: The result of the post
    """

    event_rtn = kwargs.get("event_rtn", False)

    _options = _get_options(ret)

    webhook = _options.get("webhook", None)
    show_tasks = _options.get("show_tasks")
    author_icon = _options.get("author_icon")

    if not webhook or webhook == "":
        log.error("%s.webhook not defined in salt config", __virtualname__)
        return

    report = _generate_report(ret, show_tasks)

    if report.get("success"):
        title = _options.get("success_title")
    else:
        title = _options.get("failure_title")

    slack = _post_message(webhook, author_icon, title, report, event_rtn=event_rtn)

    return slack


def event_return(events):
    """
    Send event data to returner function
    :param events: The Salt event return
    :return: The result of the post
    """

    results = None

    for event in events:
        ret = event.get("data", False)

        if (
            ret
            and "saltutil.find_job" not in ret["fun"]
            or "salt/auth" not in ret["tag"]
        ):
            results = returner(ret, event_rtn=True)

    return results