diff --git a/changelog/63095.added b/changelog/63095.added
new file mode 100644
index 000000000000..1802d356fd49
--- /dev/null
+++ b/changelog/63095.added
@@ -0,0 +1 @@
+Restore the previous slack engine and deprecate it, rename replace the slack engine to slack_bolt until deprecation
diff --git a/changelog/63095.deprecated b/changelog/63095.deprecated
new file mode 100644
index 000000000000..d652a377be00
--- /dev/null
+++ b/changelog/63095.deprecated
@@ -0,0 +1 @@
+Deprecating the Salt Slack engine in favor of the Salt Slack Bolt Engine.
diff --git a/doc/ref/engines/all/index.rst b/doc/ref/engines/all/index.rst
index 461a891cc79b..b0da4230b5c8 100644
--- a/doc/ref/engines/all/index.rst
+++ b/doc/ref/engines/all/index.rst
@@ -23,6 +23,7 @@ engine modules
redis_sentinel
script
slack
+ slack_bolt_engine
sqs_events
stalekey
test
diff --git a/doc/ref/engines/all/salt.engines.slack_bolt_engine.rst b/doc/ref/engines/all/salt.engines.slack_bolt_engine.rst
new file mode 100644
index 000000000000..6eee8b4f9b6c
--- /dev/null
+++ b/doc/ref/engines/all/salt.engines.slack_bolt_engine.rst
@@ -0,0 +1,5 @@
+salt.engines.slack_bolt_engine
+==============================
+
+.. automodule:: salt.engines.slack_bolt_engine
+ :members:
diff --git a/requirements/static/ci/linux.in b/requirements/static/ci/linux.in
index a2d4f6fd9ed3..0dce07d7e086 100644
--- a/requirements/static/ci/linux.in
+++ b/requirements/static/ci/linux.in
@@ -14,3 +14,4 @@ mercurial
hglib
redis-py-cluster
python-consul
+slack_bolt
diff --git a/requirements/static/ci/py3.10/docs.txt b/requirements/static/ci/py3.10/docs.txt
index 77a818060b85..ef16245cec52 100644
--- a/requirements/static/ci/py3.10/docs.txt
+++ b/requirements/static/ci/py3.10/docs.txt
@@ -800,6 +800,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
snowballstemmer==2.1.0
diff --git a/requirements/static/ci/py3.10/lint.txt b/requirements/static/ci/py3.10/lint.txt
index 59d6ca5d12f6..161d7093c2d9 100644
--- a/requirements/static/ci/py3.10/lint.txt
+++ b/requirements/static/ci/py3.10/lint.txt
@@ -799,6 +799,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.10/linux.txt b/requirements/static/ci/py3.10/linux.txt
index 90fb6381d4d2..0f213fcb974e 100644
--- a/requirements/static/ci/py3.10/linux.txt
+++ b/requirements/static/ci/py3.10/linux.txt
@@ -858,6 +858,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==3.0.4
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.6/docs.txt b/requirements/static/ci/py3.6/docs.txt
index cb64244b4454..1a777787649a 100644
--- a/requirements/static/ci/py3.6/docs.txt
+++ b/requirements/static/ci/py3.6/docs.txt
@@ -822,6 +822,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
snowballstemmer==2.1.0
diff --git a/requirements/static/ci/py3.6/lint.txt b/requirements/static/ci/py3.6/lint.txt
index 1577a935eb8b..7cfa1e46ce93 100644
--- a/requirements/static/ci/py3.6/lint.txt
+++ b/requirements/static/ci/py3.6/lint.txt
@@ -824,6 +824,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.6/linux.txt b/requirements/static/ci/py3.6/linux.txt
index 9ba450428c0e..74b84f5bbf54 100644
--- a/requirements/static/ci/py3.6/linux.txt
+++ b/requirements/static/ci/py3.6/linux.txt
@@ -881,6 +881,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==3.0.4
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.7/docs.txt b/requirements/static/ci/py3.7/docs.txt
index a3d636ee3a7c..3b88919fb004 100644
--- a/requirements/static/ci/py3.7/docs.txt
+++ b/requirements/static/ci/py3.7/docs.txt
@@ -848,6 +848,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
snowballstemmer==2.1.0
diff --git a/requirements/static/ci/py3.7/lint.txt b/requirements/static/ci/py3.7/lint.txt
index c93994102596..25a0aee3f9cc 100644
--- a/requirements/static/ci/py3.7/lint.txt
+++ b/requirements/static/ci/py3.7/lint.txt
@@ -850,6 +850,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.7/linux.txt b/requirements/static/ci/py3.7/linux.txt
index 231c5d3db05d..76712da9e61a 100644
--- a/requirements/static/ci/py3.7/linux.txt
+++ b/requirements/static/ci/py3.7/linux.txt
@@ -902,6 +902,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==3.0.4
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.8/docs.txt b/requirements/static/ci/py3.8/docs.txt
index 418c42dd0ca2..ec9eec1a7e2a 100644
--- a/requirements/static/ci/py3.8/docs.txt
+++ b/requirements/static/ci/py3.8/docs.txt
@@ -840,6 +840,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
snowballstemmer==2.1.0
diff --git a/requirements/static/ci/py3.8/lint.txt b/requirements/static/ci/py3.8/lint.txt
index 2a16f50d47e5..7e78263cce3d 100644
--- a/requirements/static/ci/py3.8/lint.txt
+++ b/requirements/static/ci/py3.8/lint.txt
@@ -841,6 +841,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.8/linux.txt b/requirements/static/ci/py3.8/linux.txt
index 8a260f1f5985..718d1c7abb8b 100644
--- a/requirements/static/ci/py3.8/linux.txt
+++ b/requirements/static/ci/py3.8/linux.txt
@@ -892,6 +892,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==3.0.4
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.9/docs.txt b/requirements/static/ci/py3.9/docs.txt
index a45d781f0d04..c4cc4b3e90d7 100644
--- a/requirements/static/ci/py3.9/docs.txt
+++ b/requirements/static/ci/py3.9/docs.txt
@@ -841,6 +841,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
snowballstemmer==2.1.0
diff --git a/requirements/static/ci/py3.9/lint.txt b/requirements/static/ci/py3.9/lint.txt
index 8c95fce13148..18f8a72bdaec 100644
--- a/requirements/static/ci/py3.9/lint.txt
+++ b/requirements/static/ci/py3.9/lint.txt
@@ -842,6 +842,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==4.0.0
# via gitdb
sqlparse==0.4.2
diff --git a/requirements/static/ci/py3.9/linux.txt b/requirements/static/ci/py3.9/linux.txt
index 2e93b82bcedd..922bc88688c7 100644
--- a/requirements/static/ci/py3.9/linux.txt
+++ b/requirements/static/ci/py3.9/linux.txt
@@ -897,6 +897,10 @@ six==1.16.0
# vcert
# virtualenv
# websocket-client
+slack-bolt==1.15.5
+ # via -r requirements/static/ci/linux.in
+slack-sdk==3.19.5
+ # via slack-bolt
smmap==3.0.4
# via gitdb
sqlparse==0.4.2
diff --git a/salt/engines/slack.py b/salt/engines/slack.py
index 724df0a92271..969a6bcd1f25 100644
--- a/salt/engines/slack.py
+++ b/salt/engines/slack.py
@@ -3,47 +3,21 @@
.. versionadded:: 2016.3.0
-:depends: `slack_bolt `_ Python module
+:depends: `slackclient `_ Python module
.. important::
- This engine requires a Slack app and a Slack Bot user. To create a
- bot user, first go to the **Custom Integrations** page in your
- Slack Workspace. Copy and paste the following URL, and log in with
- account credentials with administrative privileges:
-
- ``https://api.slack.com/apps/new``
-
- Next, click on the ``From scratch`` option from the ``Create an app`` popup.
- Give your new app a unique name, eg. ``SaltSlackEngine``, select the workspace
- where your app will be running, and click ``Create App``.
-
- Next, click on ``Socket Mode`` and then click on the toggle button for
- ``Enable Socket Mode``. In the dialog give your Socket Mode Token a unique
- name and then copy and save the app level token. This will be used
- as the ``app_token`` parameter in the Slack engine configuration.
-
- Next, click on ``Event Subscriptions`` and ensure that ``Enable Events`` is in
- the on position. Then add the following bot events, ``message.channel``
- and ``message.im`` to the ``Subcribe to bot events`` list.
-
- Next, click on ``OAuth & Permissions`` and then under ``Bot Token Scope``, click
- on ``Add an OAuth Scope``. Ensure the following scopes are included:
-
- - ``channels:history``
- - ``channels:read``
- - ``chat:write``
- - ``commands``
- - ``files:read``
- - ``files:write``
- - ``im:history``
- - ``mpim:history``
- - ``usergroups:read``
- - ``users:read``
-
- Once all the scopes have been added, click the ``Install to Workspace`` button
- under ``OAuth Tokens for Your Workspace``, then click ``Allow``. Copy and save
- the ``Bot User OAuth Token``, this will be used as the ``bot_token`` parameter
- in the Slack engine configuration.
+ This engine requires a bot user. To create a bot user, first go to the
+ **Custom Integrations** page in your Slack Workspace. Copy and paste the
+ following URL, and replace ``myworkspace`` with the proper value for your
+ workspace:
+
+ ``https://myworkspace.slack.com/apps/manage/custom-integrations``
+
+ Next, click on the ``Bots`` integration and request installation. Once
+ approved by an admin, you will be able to proceed with adding the bot user.
+ Once the bot user has been added, you can configure it by adding an avatar,
+ setting the display name, etc. You will also at this time have access to
+ your API token, which will be needed to configure this engine.
Finally, add this bot user to a channel by switching to the channel and
using ``/invite @mybotuser``. Keep in mind that this engine will process
@@ -100,9 +74,6 @@
.. versionchanged:: 2017.7.0
Access control group support added
-.. versionchanged:: 3006.0
- Updated to use slack_bolt Python library.
-
This example uses a single group called ``default``. In addition, other groups
are being loaded from pillar data. The group names do not have any
significance, it is the users and commands defined within them that are used to
@@ -112,8 +83,7 @@
engines:
- slack:
- app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
- bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
+ token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
control: True
fire_all: False
groups_pillar_name: 'slack_engine:groups_pillar'
@@ -151,8 +121,7 @@
engines:
- slack:
groups_pillar: slack_engine_pillar
- app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
- bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
+ token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
control: True
fire_all: True
tag: salt/engines/slack
@@ -177,7 +146,6 @@
"""
import ast
-import collections
import datetime
import itertools
import logging
@@ -198,12 +166,11 @@
import salt.utils.yaml
try:
- import slack_bolt
- import slack_bolt.adapter.socket_mode
+ import slackclient
- HAS_SLACKBOLT = True
+ HAS_SLACKCLIENT = True
except ImportError:
- HAS_SLACKBOLT = False
+ HAS_SLACKCLIENT = False
log = logging.getLogger(__name__)
@@ -211,38 +178,17 @@
def __virtual__():
- if not HAS_SLACKBOLT:
- return (False, "The 'slack_bolt' Python module could not be loaded")
+ if not HAS_SLACKCLIENT:
+ return (False, "The 'slackclient' Python module could not be loaded")
return __virtualname__
class SlackClient:
- def __init__(self, app_token, bot_token, trigger_string):
+ def __init__(self, token):
self.master_minion = salt.minion.MasterMinion(__opts__)
- self.app = slack_bolt.App(token=bot_token)
- self.handler = slack_bolt.adapter.socket_mode.SocketModeHandler(
- self.app, app_token
- )
- self.handler.connect()
-
- self.app_token = app_token
- self.bot_token = bot_token
-
- self.msg_queue = collections.deque()
-
- trigger_pattern = "(^{}.*)".format(trigger_string)
-
- # Register message_trigger when we see messages that start
- # with the trigger string
- self.app.message(re.compile(trigger_pattern))(self.message_trigger)
-
- def _run_until(self):
- return True
-
- def message_trigger(self, message):
- # Add the received message to the queue
- self.msg_queue.append(message)
+ self.sc = slackclient.SlackClient(token)
+ self.slack_connect = self.sc.rtm_connect()
def get_slack_users(self, token):
"""
@@ -597,12 +543,13 @@ def just_data(m_data):
return data
for sleeps in (5, 10, 30, 60):
- if self.handler:
+ if self.slack_connect:
break
else:
# see https://api.slack.com/docs/rate-limits
log.warning(
- "Slack connection is invalid, sleeping %s",
+ "Slack connection is invalid. Server: %s, sleeping %s",
+ self.sc.server,
sleeps,
)
time.sleep(
@@ -611,51 +558,51 @@ def just_data(m_data):
else:
raise UserWarning(
"Connection to slack is still invalid, giving up: {}".format(
- self.handler
+ self.slack_connect
)
) # Boom!
- while self._run_until():
- while self.msg_queue:
- msg = self.msg_queue.popleft()
+ while True:
+ msg = self.sc.rtm_read()
+ for m_data in msg:
try:
- msg_text = self.message_text(msg)
+ msg_text = self.message_text(m_data)
except (ValueError, TypeError) as msg_err:
log.debug(
"Got an error from trying to get the message text %s", msg_err
)
- yield {"message_data": msg} # Not a message type from the API?
+ yield {"message_data": m_data} # Not a message type from the API?
continue
# Find the channel object from the channel name
- channel = msg["channel"]
- data = just_data(msg)
+ channel = self.sc.server.channels.find(m_data["channel"])
+ data = just_data(m_data)
if msg_text.startswith(trigger_string):
loaded_groups = self.get_config_groups(groups, groups_pillar_name)
if not data.get("user_name"):
log.error(
"The user %s can not be looked up via slack. What has"
" happened here?",
- msg.get("user"),
+ m_data.get("user"),
)
channel.send_message(
"The user {} can not be looked up via slack. Not"
" running {}".format(data["user_id"], msg_text)
)
- yield {"message_data": msg}
+ yield {"message_data": m_data}
continue
(allowed, target, cmdline) = self.control_message_target(
data["user_name"], msg_text, loaded_groups, trigger_string
)
+ log.debug("Got target: %s, cmdline: %s", target, cmdline)
if allowed:
- ret = {
- "message_data": msg,
- "channel": msg["channel"],
+ yield {
+ "message_data": m_data,
+ "channel": m_data["channel"],
"user": data["user_id"],
"user_name": data["user_name"],
"cmdline": cmdline,
"target": target,
}
- yield ret
continue
else:
channel.send_message(
@@ -823,48 +770,45 @@ def run_commands_from_slack_async(
outstanding = {} # set of job_id that we need to check for
- while self._run_until():
+ while True:
log.trace("Sleeping for interval of %s", interval)
time.sleep(interval)
# Drain the slack messages, up to 10 messages at a clip
count = 0
for msg in message_generator:
- if msg:
- # The message_generator yields dicts. Leave this loop
- # on a dict that looks like {'done': True} or when we've done it
- # 10 times without taking a break.
- log.trace("Got a message from the generator: %s", msg.keys())
- if count > 10:
- log.warning(
- "Breaking in getting messages because count is exceeded"
- )
- break
- if not msg:
- count += 1
- log.warning("Skipping an empty message.")
- continue # This one is a dud, get the next message
- if msg.get("done"):
- log.trace("msg is done")
- break
- if fire_all:
- log.debug("Firing message to the bus with tag: %s", tag)
- log.debug("%s %s", tag, msg)
- self.fire(
- "{}/{}".format(tag, msg["message_data"].get("type")), msg
- )
- if control and (len(msg) > 1) and msg.get("cmdline"):
- jid = self.run_command_async(msg)
- log.debug("Submitted a job and got jid: %s", jid)
- outstanding[
- jid
- ] = msg # record so we can return messages to the caller
- text_msg = "@{}'s job is submitted as salt jid {}".format(
+ # The message_generator yields dicts. Leave this loop
+ # on a dict that looks like {'done': True} or when we've done it
+ # 10 times without taking a break.
+ log.trace("Got a message from the generator: %s", msg.keys())
+ if count > 10:
+ log.warning(
+ "Breaking in getting messages because count is exceeded"
+ )
+ break
+ if not msg:
+ count += 1
+ log.warning("Skipping an empty message.")
+ continue # This one is a dud, get the next message
+ if msg.get("done"):
+ log.trace("msg is done")
+ break
+ if fire_all:
+ log.debug("Firing message to the bus with tag: %s", tag)
+ log.debug("%s %s", tag, msg)
+ self.fire("{}/{}".format(tag, msg["message_data"].get("type")), msg)
+ if control and (len(msg) > 1) and msg.get("cmdline"):
+ channel = self.sc.server.channels.find(msg["channel"])
+ jid = self.run_command_async(msg)
+ log.debug("Submitted a job and got jid: %s", jid)
+ outstanding[
+ jid
+ ] = msg # record so we can return messages to the caller
+ channel.send_message(
+ "@{}'s job is submitted as salt jid {}".format(
msg["user_name"], jid
)
- self.app.client.chat_postMessage(
- channel=msg["channel"], text=text_msg
- )
- count += 1
+ )
+ count += 1
start_time = time.time()
job_status = self.get_jobs_from_runner(
outstanding.keys()
@@ -881,7 +825,7 @@ def run_commands_from_slack_async(
log.debug("ret to send back is %s", result)
# formatting function?
this_job = outstanding[jid]
- channel = this_job["channel"]
+ channel = self.sc.server.channels.find(this_job["channel"])
return_text = self.format_return_text(result, function)
return_prefix = (
"@{}'s job `{}` (id: {}) (target: {}) returned".format(
@@ -891,19 +835,19 @@ def run_commands_from_slack_async(
this_job["target"],
)
)
- self.app.client.chat_postMessage(
- channel=channel, text=return_prefix
- )
+ channel.send_message(return_prefix)
ts = time.time()
st = datetime.datetime.fromtimestamp(ts).strftime("%Y%m%d%H%M%S%f")
filename = "salt-results-{}.yaml".format(st)
- resp = self.app.client.files_upload(
- channels=channel,
+ r = self.sc.api_call(
+ "files.upload",
+ channels=channel.id,
filename=filename,
content=return_text,
)
# Handle unicode return
- log.debug("Got back %s via the slack client", resp)
+ log.debug("Got back %s via the slack client", r)
+ resp = salt.utils.yaml.safe_load(salt.utils.json.dumps(r))
if "ok" in resp and resp["ok"] is False:
this_job["channel"].send_message(
"Error: {}".format(resp["error"])
@@ -971,8 +915,7 @@ def run_command_async(self, msg):
def start(
- app_token,
- bot_token,
+ token,
control=False,
trigger="!",
groups=None,
@@ -984,17 +927,23 @@ def start(
Listen to slack events and forward them to salt, new version
"""
- if (not bot_token) or (not bot_token.startswith("xoxb")):
+ salt.utils.versions.warn_until(
+ "Argon",
+ "This 'slack' engine will be deprecated and "
+ "will be replace by the slack_bolt engine. This new "
+ "engine will use the new Bolt library from Slack and requires "
+ "a Slack app and a Slack bot account.",
+ )
+
+ if (not token) or (not token.startswith("xoxb")):
time.sleep(2) # don't respawn too quickly
log.error("Slack bot token not found, bailing...")
raise UserWarning("Slack Engine bot token not configured")
try:
- client = SlackClient(
- app_token=app_token, bot_token=bot_token, trigger_string=trigger
- )
+ client = SlackClient(token=token)
message_generator = client.generate_triggered_messages(
- bot_token, trigger, groups, groups_pillar_name
+ token, trigger, groups, groups_pillar_name
)
client.run_commands_from_slack_async(message_generator, fire_all, tag, control)
except Exception: # pylint: disable=broad-except
diff --git a/salt/engines/slack_bolt_engine.py b/salt/engines/slack_bolt_engine.py
new file mode 100644
index 000000000000..ec21368afc2a
--- /dev/null
+++ b/salt/engines/slack_bolt_engine.py
@@ -0,0 +1,1078 @@
+"""
+An engine that reads messages from Slack and can act on them
+
+.. versionadded:: 3006
+
+:depends: `slack_bolt `_ Python module
+
+.. important::
+ This engine requires a Slack app and a Slack Bot user. To create a
+ bot user, first go to the **Custom Integrations** page in your
+ Slack Workspace. Copy and paste the following URL, and log in with
+ account credentials with administrative privileges:
+
+ ``https://api.slack.com/apps/new``
+
+ Next, click on the ``From scratch`` option from the ``Create an app`` popup.
+ Give your new app a unique name, eg. ``SaltSlackEngine``, select the workspace
+ where your app will be running, and click ``Create App``.
+
+ Next, click on ``Socket Mode`` and then click on the toggle button for
+ ``Enable Socket Mode``. In the dialog give your Socket Mode Token a unique
+ name and then copy and save the app level token. This will be used
+ as the ``app_token`` parameter in the Slack engine configuration.
+
+ Next, click on ``Event Subscriptions`` and ensure that ``Enable Events`` is in
+ the on position. Then add the following bot events, ``message.channel``
+ and ``message.im`` to the ``Subcribe to bot events`` list.
+
+ Next, click on ``OAuth & Permissions`` and then under ``Bot Token Scope``, click
+ on ``Add an OAuth Scope``. Ensure the following scopes are included:
+
+ - ``channels:history``
+ - ``channels:read``
+ - ``chat:write``
+ - ``commands``
+ - ``files:read``
+ - ``files:write``
+ - ``im:history``
+ - ``mpim:history``
+ - ``usergroups:read``
+ - ``users:read``
+
+ Once all the scopes have been added, click the ``Install to Workspace`` button
+ under ``OAuth Tokens for Your Workspace``, then click ``Allow``. Copy and save
+ the ``Bot User OAuth Token``, this will be used as the ``bot_token`` parameter
+ in the Slack engine configuration.
+
+ Finally, add this bot user to a channel by switching to the channel and
+ using ``/invite @mybotuser``. Keep in mind that this engine will process
+ messages from each channel in which the bot is a member, so it is
+ recommended to narrowly define the commands which can be executed, and the
+ Slack users which are allowed to run commands.
+
+
+This engine has two boolean configuration parameters that toggle specific
+features (both default to ``False``):
+
+1. ``control`` - If set to ``True``, then any message which starts with the
+ trigger string (which defaults to ``!`` and can be overridden by setting the
+ ``trigger`` option in the engine configuration) will be interpreted as a
+ Salt CLI command and the engine will attempt to run it. The permissions
+ defined in the various ``groups`` will determine if the Slack user is
+ allowed to run the command. The ``targets`` and ``default_target`` options
+ can be used to set targets for a given command, but the engine can also read
+ the following two keyword arguments:
+
+ - ``target`` - The target expression to use for the command
+
+ - ``tgt_type`` - The match type, can be one of ``glob``, ``list``,
+ ``pcre``, ``grain``, ``grain_pcre``, ``pillar``, ``nodegroup``, ``range``,
+ ``ipcidr``, or ``compound``. The default value is ``glob``.
+
+ Here are a few examples:
+
+ .. code-block:: text
+
+ !test.ping target=*
+ !state.apply foo target=os:CentOS tgt_type=grain
+ !pkg.version mypkg target=role:database tgt_type=pillar
+
+2. ``fire_all`` - If set to ``True``, all messages which are not prefixed with
+ the trigger string will fired as events onto Salt's ref:`event bus
+ `. The tag for these events will be prefixed with the string
+ specified by the ``tag`` config option (default: ``salt/engines/slack``).
+
+
+The ``groups_pillar_name`` config option can be used to pull group
+configuration from the specified pillar key.
+
+.. note::
+ In order to use ``groups_pillar_name``, the engine must be running as a
+ minion running on the master, so that the ``Caller`` client can be used to
+ retrieve that minion's pillar data, because the master process does not have
+ pillar data.
+
+
+Configuration Examples
+======================
+
+.. versionchanged:: 2017.7.0
+ Access control group support added
+
+.. versionchanged:: 3006.0
+ Updated to use slack_bolt Python library.
+
+This example uses a single group called ``default``. In addition, other groups
+are being loaded from pillar data. The users and commands defined within these
+groups are used to determine whether the Slack user has permission to run
+the desired command.
+
+.. code-block:: text
+
+ engines:
+ - slack:
+ app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
+ control: True
+ fire_all: False
+ groups_pillar_name: 'slack_engine:groups_pillar'
+ groups:
+ default:
+ users:
+ - '*'
+ commands:
+ - test.ping
+ - cmd.run
+ - list_jobs
+ - list_commands
+ aliases:
+ list_jobs:
+ cmd: jobs.list_jobs
+ list_commands:
+ cmd: 'pillar.get salt:engines:slack:valid_commands target=saltmaster tgt_type=list'
+ default_target:
+ target: saltmaster
+ tgt_type: glob
+ targets:
+ test.ping:
+ target: '*'
+ tgt_type: glob
+ cmd.run:
+ target: saltmaster
+ tgt_type: list
+
+This example shows multiple groups applying to different users, with all users
+having access to run test.ping. Keep in mind that when using ``*``, the value
+must be quoted, or else PyYAML will fail to load the configuration.
+
+.. code-block:: text
+
+ engines:
+ - slack:
+ groups_pillar: slack_engine_pillar
+ app_token: "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ bot_token: 'xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx'
+ control: True
+ fire_all: True
+ tag: salt/engines/slack
+ groups_pillar_name: 'slack_engine:groups_pillar'
+ groups:
+ default:
+ users:
+ - '*'
+ commands:
+ - test.ping
+ aliases:
+ list_jobs:
+ cmd: jobs.list_jobs
+ list_commands:
+ cmd: 'pillar.get salt:engines:slack:valid_commands target=saltmaster tgt_type=list'
+ gods:
+ users:
+ - garethgreenaway
+ commands:
+ - '*'
+
+"""
+
+import ast
+import collections
+import datetime
+import itertools
+import logging
+import re
+import time
+import traceback
+
+import salt.client
+import salt.loader
+import salt.minion
+import salt.output
+import salt.runner
+import salt.utils.args
+import salt.utils.event
+import salt.utils.http
+import salt.utils.json
+import salt.utils.slack
+import salt.utils.yaml
+
+try:
+ # pylint: disable=import-error
+ import slack_bolt
+ import slack_bolt.adapter.socket_mode
+
+ # pylint: enable=import-error
+
+ HAS_SLACKBOLT = True
+except ImportError:
+ HAS_SLACKBOLT = False
+
+log = logging.getLogger(__name__)
+
+__virtualname__ = "slack_bolt"
+
+
+def __virtual__():
+ if not HAS_SLACKBOLT:
+ return (False, "The 'slack_bolt' Python module could not be loaded")
+ return __virtualname__
+
+
+class SlackClient:
+ def __init__(self, app_token, bot_token, trigger_string):
+ self.master_minion = salt.minion.MasterMinion(__opts__)
+
+ self.app = slack_bolt.App(token=bot_token)
+ self.handler = slack_bolt.adapter.socket_mode.SocketModeHandler(
+ self.app, app_token
+ )
+ self.handler.connect()
+
+ self.app_token = app_token
+ self.bot_token = bot_token
+
+ self.msg_queue = collections.deque()
+
+ trigger_pattern = "(^{}.*)".format(trigger_string)
+
+ # Register message_trigger when we see messages that start
+ # with the trigger string
+ self.app.message(re.compile(trigger_pattern))(self.message_trigger)
+
+ def _run_until(self):
+ return True
+
+ def message_trigger(self, message):
+ # Add the received message to the queue
+ self.msg_queue.append(message)
+
+ def get_slack_users(self, token):
+ """
+ Get all users from Slack
+
+ :type user: str
+ :param token: The Slack token being used to allow Salt to interact with Slack.
+ """
+
+ ret = salt.utils.slack.query(function="users", api_key=token, opts=__opts__)
+ users = {}
+ if "message" in ret:
+ for item in ret["message"]:
+ if "is_bot" in item:
+ if not item["is_bot"]:
+ users[item["name"]] = item["id"]
+ users[item["id"]] = item["name"]
+ return users
+
+ def get_slack_channels(self, token):
+ """
+ Get all channel names from Slack
+
+ :type token: str
+ :param token: The Slack token being used to allow Salt to interact with Slack.
+ """
+
+ ret = salt.utils.slack.query(
+ function="rooms",
+ api_key=token,
+ # These won't be honored until https://github.com/saltstack/salt/pull/41187/files is merged
+ opts={"exclude_archived": True, "exclude_members": True},
+ )
+ channels = {}
+ if "message" in ret:
+ for item in ret["message"]:
+ channels[item["id"]] = item["name"]
+ return channels
+
+ def get_config_groups(self, groups_conf, groups_pillar_name):
+ """
+ get info from groups in config, and from the named pillar
+
+ :type group_conf: dict
+ :param group_conf:
+ The dictionary containing the groups, group members,
+ and the commands those group members have access to.
+
+ :type groups_pillar_name: str
+ :param groups_pillar_name:
+ can be used to pull group configuration from the specified pillar key.
+ """
+ # Get groups
+ # Default to returning something that'll never match
+ ret_groups = {
+ "default": {
+ "users": set(),
+ "commands": set(),
+ "aliases": {},
+ "default_target": {},
+ "targets": {},
+ }
+ }
+
+ # allow for empty groups in the config file, and instead let some/all of this come
+ # from pillar data.
+ if not groups_conf:
+ use_groups = {}
+ else:
+ use_groups = groups_conf
+ # First obtain group lists from pillars, then in case there is any overlap, iterate over the groups
+ # that come from pillars. The configuration in files on disk/from startup
+ # will override any configs from pillars. They are meant to be complementary not to provide overrides.
+ log.debug("use_groups %s", use_groups)
+ try:
+ groups_gen = itertools.chain(
+ self._groups_from_pillar(groups_pillar_name).items(), use_groups.items()
+ )
+ except AttributeError:
+ log.warning(
+ "Failed to get groups from %s: %s or from config: %s",
+ groups_pillar_name,
+ self._groups_from_pillar(groups_pillar_name),
+ use_groups,
+ )
+ groups_gen = []
+ for name, config in groups_gen:
+ log.info("Trying to get %s and %s to be useful", name, config)
+ ret_groups.setdefault(
+ name,
+ {
+ "users": set(),
+ "commands": set(),
+ "aliases": {},
+ "default_target": {},
+ "targets": {},
+ },
+ )
+ try:
+ ret_groups[name]["users"].update(set(config.get("users", [])))
+ ret_groups[name]["commands"].update(set(config.get("commands", [])))
+ ret_groups[name]["aliases"].update(config.get("aliases", {}))
+ ret_groups[name]["default_target"].update(
+ config.get("default_target", {})
+ )
+ ret_groups[name]["targets"].update(config.get("targets", {}))
+ except (IndexError, AttributeError):
+ log.warning(
+ "Couldn't use group %s. Check that targets is a dictionary and not"
+ " a list",
+ name,
+ )
+
+ log.debug("Got the groups: %s", ret_groups)
+ return ret_groups
+
+ def _groups_from_pillar(self, pillar_name):
+ """
+
+ :type pillar_name: str
+ :param pillar_name: The pillar.get syntax for the pillar to be queried.
+
+ returns a dictionary (unless the pillar is mis-formatted)
+ """
+ if pillar_name and __opts__["__role"] == "minion":
+ pillar_groups = __salt__["pillar.get"](pillar_name, {})
+ log.debug("Got pillar groups %s from pillar %s", pillar_groups, pillar_name)
+ log.debug("pillar groups is %s", pillar_groups)
+ log.debug("pillar groups type is %s", type(pillar_groups))
+ else:
+ pillar_groups = {}
+ return pillar_groups
+
+ def fire(self, tag, msg):
+ """
+ This replaces a function in main called 'fire'
+
+ It fires an event into the salt bus.
+
+ :type tag: str
+ :param tag: The tag to use when sending events to the Salt event bus.
+
+ :type msg: dict
+ :param msg: The msg dictionary to send to the Salt event bus.
+
+ """
+ if __opts__.get("__role") == "master":
+ fire_master = salt.utils.event.get_master_event(
+ __opts__, __opts__["sock_dir"]
+ ).fire_master
+ else:
+ fire_master = None
+
+ if fire_master:
+ fire_master(msg, tag)
+ else:
+ __salt__["event.send"](tag, msg)
+
+ def can_user_run(self, user, command, groups):
+ """
+ Check whether a user is in any group, including whether a group has the '*' membership
+
+ :type user: str
+ :param user: The username being checked against
+
+ :type command: str
+ :param command: The command that is being invoked (e.g. test.ping)
+
+ :type groups: dict
+ :param groups: the dictionary with groups permissions structure.
+
+ :rtype: tuple
+ :returns: On a successful permitting match, returns 2-element tuple that contains
+ the name of the group that successfully matched, and a dictionary containing
+ the configuration of the group so it can be referenced.
+
+ On failure it returns an empty tuple
+
+ """
+ log.info("%s wants to run %s with groups %s", user, command, groups)
+ for key, val in groups.items():
+ if user not in val["users"]:
+ if "*" not in val["users"]:
+ continue # this doesn't grant permissions, pass
+ if (command not in val["commands"]) and (
+ command not in val.get("aliases", {}).keys()
+ ):
+ if "*" not in val["commands"]:
+ continue # again, pass
+ log.info("Slack user %s permitted to run %s", user, command)
+ return (
+ key,
+ val,
+ ) # matched this group, return the group
+ log.info("Slack user %s denied trying to run %s", user, command)
+ return ()
+
+ def commandline_to_list(self, cmdline_str, trigger_string):
+ """
+ cmdline_str is the string of the command line
+ trigger_string is the trigger string, to be removed
+ """
+ cmdline = salt.utils.args.shlex_split(cmdline_str[len(trigger_string) :])
+ # Remove slack url parsing
+ # Translate target=
+ # to target=host.domain.net
+ cmdlist = []
+ for cmditem in cmdline:
+ pattern = r"(?P.*)(<.*\|)(?P.*)(>)(?P.*)"
+ mtch = re.match(pattern, cmditem)
+ if mtch:
+ origtext = (
+ mtch.group("begin") + mtch.group("url") + mtch.group("remainder")
+ )
+ cmdlist.append(origtext)
+ else:
+ cmdlist.append(cmditem)
+ return cmdlist
+
+ def control_message_target(
+ self, slack_user_name, text, loaded_groups, trigger_string
+ ):
+ """Returns a tuple of (target, cmdline,) for the response
+
+ Raises IndexError if a user can't be looked up from all_slack_users
+
+ Returns (False, False) if the user doesn't have permission
+
+ These are returned together because the commandline and the targeting
+ interact with the group config (specifically aliases and targeting configuration)
+ so taking care of them together works out.
+
+ The cmdline that is returned is the actual list that should be
+ processed by salt, and not the alias.
+
+ """
+
+ # Trim the trigger string from the front
+ # cmdline = _text[1:].split(' ', 1)
+ cmdline = self.commandline_to_list(text, trigger_string)
+ permitted_group = self.can_user_run(slack_user_name, cmdline[0], loaded_groups)
+ log.debug(
+ "slack_user_name is %s and the permitted group is %s",
+ slack_user_name,
+ permitted_group,
+ )
+
+ if not permitted_group:
+ return (False, None, cmdline[0])
+ if not slack_user_name:
+ return (False, None, cmdline[0])
+
+ # maybe there are aliases, so check on that
+ if cmdline[0] in permitted_group[1].get("aliases", {}).keys():
+ use_cmdline = self.commandline_to_list(
+ permitted_group[1]["aliases"][cmdline[0]].get("cmd", ""), ""
+ )
+ # Include any additional elements from cmdline
+ use_cmdline.extend(cmdline[1:])
+ else:
+ use_cmdline = cmdline
+ target = self.get_target(permitted_group, cmdline, use_cmdline)
+
+ # Remove target and tgt_type from commandline
+ # that is sent along to Salt
+ use_cmdline = [
+ item
+ for item in use_cmdline
+ if all(not item.startswith(x) for x in ("target", "tgt_type"))
+ ]
+
+ return (True, target, use_cmdline)
+
+ def message_text(self, m_data):
+ """
+ Raises ValueError if a value doesn't work out, and TypeError if
+ this isn't a message type
+
+ :type m_data: dict
+ :param m_data: The message sent from Slack
+
+ """
+ if m_data.get("type") != "message":
+ raise TypeError("This is not a message")
+ # Edited messages have text in message
+ _text = m_data.get("text", None) or m_data.get("message", {}).get("text", None)
+ try:
+ log.info("Message is %s", _text) # this can violate the ascii codec
+ except UnicodeEncodeError as uee:
+ log.warning("Got a message that I could not log. The reason is: %s", uee)
+
+ # Convert UTF to string
+ _text = salt.utils.json.dumps(_text)
+ _text = salt.utils.yaml.safe_load(_text)
+
+ if not _text:
+ raise ValueError("_text has no value")
+ return _text
+
+ def generate_triggered_messages(
+ self, token, trigger_string, groups, groups_pillar_name
+ ):
+ """
+ slack_token = string
+ trigger_string = string
+ input_valid_users = set
+ input_valid_commands = set
+
+ When the trigger_string prefixes the message text, yields a dictionary
+ of::
+
+ {
+ 'message_data': m_data,
+ 'cmdline': cmdline_list, # this is a list
+ 'channel': channel,
+ 'user': m_data['user'],
+ 'slack_client': sc
+ }
+
+ else yields {'message_data': m_data} and the caller can handle that
+
+ When encountering an error (e.g. invalid message), yields {}, the caller can proceed to the next message
+
+ When the websocket being read from has given up all its messages, yields {'done': True} to
+ indicate that the caller has read all of the relevant data for now, and should continue
+ its own processing and check back for more data later.
+
+ This relies on the caller sleeping between checks, otherwise this could flood
+ """
+ all_slack_users = self.get_slack_users(
+ token
+ ) # re-checks this if we have an negative lookup result
+ all_slack_channels = self.get_slack_channels(
+ token
+ ) # re-checks this if we have an negative lookup result
+
+ def just_data(m_data):
+ """Always try to return the user and channel anyway"""
+ if "user" not in m_data:
+ if "message" in m_data and "user" in m_data["message"]:
+ log.debug(
+ "Message was edited, "
+ "so we look for user in "
+ "the original message."
+ )
+ user_id = m_data["message"]["user"]
+ elif "comment" in m_data and "user" in m_data["comment"]:
+ log.debug("Comment was added, so we look for user in the comment.")
+ user_id = m_data["comment"]["user"]
+ else:
+ user_id = m_data.get("user")
+ channel_id = m_data.get("channel")
+ if channel_id.startswith("D"): # private chate with bot user
+ channel_name = "private chat"
+ else:
+ channel_name = all_slack_channels.get(channel_id)
+ data = {
+ "message_data": m_data,
+ "user_id": user_id,
+ "user_name": all_slack_users.get(user_id),
+ "channel_name": channel_name,
+ }
+ if not data["user_name"]:
+ all_slack_users.clear()
+ all_slack_users.update(self.get_slack_users(token))
+ data["user_name"] = all_slack_users.get(user_id)
+ if not data["channel_name"]:
+ all_slack_channels.clear()
+ all_slack_channels.update(self.get_slack_channels(token))
+ data["channel_name"] = all_slack_channels.get(channel_id)
+ return data
+
+ for sleeps in (5, 10, 30, 60):
+ if self.handler:
+ break
+ else:
+ # see https://api.slack.com/docs/rate-limits
+ log.warning(
+ "Slack connection is invalid, sleeping %s",
+ sleeps,
+ )
+ time.sleep(
+ sleeps
+ ) # respawning too fast makes the slack API unhappy about the next reconnection
+ else:
+ raise UserWarning(
+ "Connection to slack is still invalid, giving up: {}".format(
+ self.handler
+ )
+ ) # Boom!
+ while self._run_until():
+ while self.msg_queue:
+ msg = self.msg_queue.popleft()
+ try:
+ msg_text = self.message_text(msg)
+ except (ValueError, TypeError) as msg_err:
+ log.debug("Got an error trying to get the message text %s", msg_err)
+ yield {"message_data": msg} # Not a message type from the API?
+ continue
+
+ # Find the channel object from the channel name
+ channel = msg["channel"]
+ data = just_data(msg)
+ if msg_text.startswith(trigger_string):
+ loaded_groups = self.get_config_groups(groups, groups_pillar_name)
+ if not data.get("user_name"):
+ log.error(
+ "The user %s can not be looked up via slack. What has"
+ " happened here?",
+ msg.get("user"),
+ )
+ channel.send_message(
+ "The user {} can not be looked up via slack. Not"
+ " running {}".format(data["user_id"], msg_text)
+ )
+ yield {"message_data": msg}
+ continue
+ (allowed, target, cmdline) = self.control_message_target(
+ data["user_name"], msg_text, loaded_groups, trigger_string
+ )
+ if allowed:
+ ret = {
+ "message_data": msg,
+ "channel": msg["channel"],
+ "user": data["user_id"],
+ "user_name": data["user_name"],
+ "cmdline": cmdline,
+ "target": target,
+ }
+ yield ret
+ continue
+ else:
+ channel.send_message(
+ "{} is not allowed to use command {}.".format(
+ data["user_name"], cmdline
+ )
+ )
+ yield data
+ continue
+ else:
+ yield data
+ continue
+ yield {"done": True}
+
+ def get_target(self, permitted_group, cmdline, alias_cmdline):
+ """
+ When we are permitted to run a command on a target, look to see
+ what the default targeting is for that group, and for that specific
+ command (if provided).
+
+ It's possible for ``None`` or ``False`` to be the result of either, which means
+ that it's expected that the caller provide a specific target.
+
+ If no configured target is provided, the command line will be parsed
+ for target=foo and tgt_type=bar
+
+ Test for this::
+
+ h = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'},
+ 'default_target': {'target': '*', 'tgt_type': 'glob'},
+ 'targets': {'pillar.get': {'target': 'you_momma', 'tgt_type': 'list'}},
+ 'users': {'dmangot', 'jmickle', 'pcn'}}
+ f = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'},
+ 'default_target': {}, 'targets': {},'users': {'dmangot', 'jmickle', 'pcn'}}
+
+ g = {'aliases': {}, 'commands': {'cmd.run', 'pillar.get'},
+ 'default_target': {'target': '*', 'tgt_type': 'glob'},
+ 'targets': {}, 'users': {'dmangot', 'jmickle', 'pcn'}}
+
+ Run each of them through ``get_configured_target(('foo', f), 'pillar.get')`` and confirm a valid target
+
+ :type permitted_group: tuple
+ :param permitted_group: A tuple containing the group name and group configuration to check for permission.
+
+ :type cmdline: list
+ :param cmdline: The command sent from Slack formatted as a list.
+
+ :type alias_cmdline: str
+ :param alias_cmdline: An alias to a cmdline.
+
+ """
+ # Default to targeting all minions with a type of glob
+ null_target = {"target": "*", "tgt_type": "glob"}
+
+ def check_cmd_against_group(cmd):
+ """
+ Validate cmd against the group to return the target, or a null target
+
+ :type cmd: list
+ :param cmd: The command sent from Slack formatted as a list.
+ """
+ name, group_config = permitted_group
+ target = group_config.get("default_target")
+ if not target: # Empty, None, or False
+ target = null_target
+ if group_config.get("targets"):
+ if group_config["targets"].get(cmd):
+ target = group_config["targets"][cmd]
+ if not target.get("target"):
+ log.debug(
+ "Group %s is not configured to have a target for cmd %s.", name, cmd
+ )
+ return target
+
+ for this_cl in cmdline, alias_cmdline:
+ _, kwargs = self.parse_args_and_kwargs(this_cl)
+ if "target" in kwargs:
+ log.debug("target is in kwargs %s.", kwargs)
+ if "tgt_type" in kwargs:
+ log.debug("tgt_type is in kwargs %s.", kwargs)
+ return {"target": kwargs["target"], "tgt_type": kwargs["tgt_type"]}
+ return {"target": kwargs["target"], "tgt_type": "glob"}
+
+ for this_cl in cmdline, alias_cmdline:
+ checked = check_cmd_against_group(this_cl[0])
+ log.debug("this cmdline has target %s.", this_cl)
+ if checked.get("target"):
+ return checked
+ return null_target
+
+ def format_return_text(
+ self, data, function, **kwargs
+ ): # pylint: disable=unused-argument
+ """
+ Print out YAML using the block mode
+
+ :type user: dict
+ :param token: The return data that needs to be formatted.
+
+ :type user: str
+ :param token: The function that was used to generate the return data.
+ """
+ # emulate the yaml_out output formatter. It relies on a global __opts__ object which
+ # we can't obviously pass in
+ try:
+ try:
+ outputter = data[next(iter(data))].get("out")
+ except (StopIteration, AttributeError):
+ outputter = None
+ return salt.output.string_format(
+ {x: y["return"] for x, y in data.items()},
+ out=outputter,
+ opts=__opts__,
+ )
+ except Exception as exc: # pylint: disable=broad-except
+ import pprint
+
+ log.exception(
+ "Exception encountered when trying to serialize %s",
+ pprint.pformat(data),
+ )
+ return "Got an error trying to serialze/clean up the response"
+
+ def parse_args_and_kwargs(self, cmdline):
+ """
+
+ :type cmdline: list
+ :param cmdline: The command sent from Slack formatted as a list.
+
+ returns tuple of: args (list), kwargs (dict)
+ """
+ # Parse args and kwargs
+ args = []
+ kwargs = {}
+
+ if len(cmdline) > 1:
+ for item in cmdline[1:]:
+ if "=" in item:
+ (key, value) = item.split("=", 1)
+ kwargs[key] = value
+ else:
+ args.append(item)
+ return (args, kwargs)
+
+ def get_jobs_from_runner(self, outstanding_jids):
+ """
+ Given a list of job_ids, return a dictionary of those job_ids that have
+ completed and their results.
+
+ Query the salt event bus via the jobs runner. jobs.list_job will show
+ a job in progress, jobs.lookup_jid will return a job that has
+ completed.
+
+ :type outstanding_jids: list
+ :param outstanding_jids: The list of job ids to check for completion.
+
+ returns a dictionary of job id: result
+ """
+ # Can't use the runner because of https://github.com/saltstack/salt/issues/40671
+ runner = salt.runner.RunnerClient(__opts__)
+ source = __opts__.get("ext_job_cache")
+ if not source:
+ source = __opts__.get("master_job_cache")
+
+ results = {}
+ for jid in outstanding_jids:
+ # results[jid] = runner.cmd('jobs.lookup_jid', [jid])
+ if self.master_minion.returners["{}.get_jid".format(source)](jid):
+ job_result = runner.cmd("jobs.list_job", [jid])
+ jid_result = job_result.get("Result", {})
+ jid_function = job_result.get("Function", {})
+ # emulate lookup_jid's return, which is just minion:return
+ results[jid] = {
+ "data": salt.utils.json.loads(salt.utils.json.dumps(jid_result)),
+ "function": jid_function,
+ }
+
+ return results
+
+ def run_commands_from_slack_async(
+ self, message_generator, fire_all, tag, control, interval=1
+ ):
+ """
+ Pull any pending messages from the message_generator, sending each
+ one to either the event bus, the command_async or both, depending on
+ the values of fire_all and command
+
+ :type message_generator: generator of dict
+ :param message_generator: Generates messages from slack that should be run
+
+ :type fire_all: bool
+ :param fire_all: Whether to also fire messages to the event bus
+
+ :type control: bool
+ :param control: If set to True, whether Slack is allowed to control Salt.
+
+ :type tag: str
+ :param tag: The tag to send to use to send to the event bus
+
+ :type interval: int
+ :param interval: time to wait between ending a loop and beginning the next
+ """
+
+ outstanding = {} # set of job_id that we need to check for
+
+ while self._run_until():
+ log.trace("Sleeping for interval of %s", interval)
+ time.sleep(interval)
+ # Drain the slack messages, up to 10 messages at a clip
+ count = 0
+ for msg in message_generator:
+ if msg:
+ # The message_generator yields dicts. Leave this loop
+ # on a dict that looks like {'done': True} or when we've done it
+ # 10 times without taking a break.
+ log.trace("Got a message from the generator: %s", msg.keys())
+ if count > 10:
+ log.warning(
+ "Breaking in getting messages because count is exceeded"
+ )
+ break
+ if not msg:
+ count += 1
+ log.warning("Skipping an empty message.")
+ continue # This one is a dud, get the next message
+ if msg.get("done"):
+ log.trace("msg is done")
+ break
+ if fire_all:
+ log.debug("Firing message to the bus with tag: %s", tag)
+ log.debug("%s %s", tag, msg)
+ self.fire(
+ "{}/{}".format(tag, msg["message_data"].get("type")), msg
+ )
+ if control and (len(msg) > 1) and msg.get("cmdline"):
+ jid = self.run_command_async(msg)
+ log.debug("Submitted a job and got jid: %s", jid)
+ outstanding[
+ jid
+ ] = msg # record so we can return messages to the caller
+ text_msg = "@{}'s job is submitted as salt jid {}".format(
+ msg["user_name"], jid
+ )
+ self.app.client.chat_postMessage(
+ channel=msg["channel"], text=text_msg
+ )
+ count += 1
+ start_time = time.time()
+ job_status = self.get_jobs_from_runner(
+ outstanding.keys()
+ ) # dict of job_ids:results are returned
+ log.trace(
+ "Getting %s jobs status took %s seconds",
+ len(job_status),
+ time.time() - start_time,
+ )
+ for jid in job_status:
+ result = job_status[jid]["data"]
+ function = job_status[jid]["function"]
+ if result:
+ log.debug("ret to send back is %s", result)
+ # formatting function?
+ this_job = outstanding[jid]
+ channel = this_job["channel"]
+ return_text = self.format_return_text(result, function)
+ return_prefix = (
+ "@{}'s job `{}` (id: {}) (target: {}) returned".format(
+ this_job["user_name"],
+ this_job["cmdline"],
+ jid,
+ this_job["target"],
+ )
+ )
+ self.app.client.chat_postMessage(
+ channel=channel, text=return_prefix
+ )
+ ts = time.time()
+ st = datetime.datetime.fromtimestamp(ts).strftime("%Y%m%d%H%M%S%f")
+ filename = "salt-results-{}.yaml".format(st)
+ resp = self.app.client.files_upload(
+ channels=channel,
+ filename=filename,
+ content=return_text,
+ )
+ # Handle unicode return
+ log.debug("Got back %s via the slack client", resp)
+ if "ok" in resp and resp["ok"] is False:
+ this_job["channel"].send_message(
+ "Error: {}".format(resp["error"])
+ )
+ del outstanding[jid]
+
+ def run_command_async(self, msg):
+
+ """
+ :type msg: dict
+ :param msg: The message dictionary that contains the command and all information.
+
+ """
+ log.debug("Going to run a command asynchronous")
+ runner_functions = sorted(salt.runner.Runner(__opts__).functions)
+ # Parse args and kwargs
+ cmd = msg["cmdline"][0]
+
+ args, kwargs = self.parse_args_and_kwargs(msg["cmdline"])
+
+ # Check for pillar string representation of dict and convert it to dict
+ if "pillar" in kwargs:
+ kwargs.update(pillar=ast.literal_eval(kwargs["pillar"]))
+
+ # Check for target. Otherwise assume None
+ target = msg["target"]["target"]
+ # Check for tgt_type. Otherwise assume glob
+ tgt_type = msg["target"]["tgt_type"]
+ log.debug("target_type is: %s", tgt_type)
+
+ if cmd in runner_functions:
+ runner = salt.runner.RunnerClient(__opts__)
+ log.debug("Command %s will run via runner_functions", cmd)
+ # pylint is tripping
+ # pylint: disable=missing-whitespace-after-comma
+ job_id_dict = runner.asynchronous(cmd, {"arg": args, "kwarg": kwargs})
+ job_id = job_id_dict["jid"]
+
+ # Default to trying to run as a client module.
+ else:
+ log.debug(
+ "Command %s will run via local.cmd_async, targeting %s", cmd, target
+ )
+ log.debug("Running %s, %s, %s, %s, %s", target, cmd, args, kwargs, tgt_type)
+ # according to https://github.com/saltstack/salt-api/issues/164, tgt_type has changed to expr_form
+ with salt.client.LocalClient() as local:
+ job_id = local.cmd_async(
+ str(target),
+ cmd,
+ arg=args,
+ kwarg=kwargs,
+ tgt_type=str(tgt_type),
+ )
+ log.info("ret from local.cmd_async is %s", job_id)
+ return job_id
+
+
+def start(
+ app_token,
+ bot_token,
+ control=False,
+ trigger="!",
+ groups=None,
+ groups_pillar_name=None,
+ fire_all=False,
+ tag="salt/engines/slack",
+):
+ """
+ Listen to slack events and forward them to salt, new version
+
+ :type app_token: str
+ :param app_token: The Slack application token used by Salt to communicate with Slack.
+
+ :type bot_token: str
+ :param bot_token: The Slack bot token used by Salt to communicate with Slack.
+
+ :type control: bool
+ :param control: Determines whether or not commands sent from Slack with the trigger string will control Salt, defaults to False.
+
+ :type trigger: str
+ :param trigger: The string that should preface all messages in Slack that should be treated as commands to send to Salt.
+
+ :type group: str
+ :param group: The string that should preface all messages in Slack that should be treated as commands to send to Salt.
+
+ :type groups_pillar: str
+ :param group_pillars: A pillar key that can be used to pull group configuration.
+
+ :type fire_all: bool
+ :param fire_all:
+ If set to ``True``, all messages which are not prefixed with
+ the trigger string will fired as events onto Salt's ref:`event bus
+ `. The tag for these events will be prefixed with the string
+ specified by the ``tag`` config option (default: ``salt/engines/slack``).
+
+ :type tag: str
+ :param tag: The tag to prefix all events sent to the Salt event bus.
+ """
+
+ if (not bot_token) or (not bot_token.startswith("xoxb")):
+ time.sleep(2) # don't respawn too quickly
+ log.error("Slack bot token not found, bailing...")
+ raise UserWarning("Slack Engine bot token not configured")
+
+ try:
+ client = SlackClient(
+ app_token=app_token, bot_token=bot_token, trigger_string=trigger
+ )
+ message_generator = client.generate_triggered_messages(
+ bot_token, trigger, groups, groups_pillar_name
+ )
+ client.run_commands_from_slack_async(message_generator, fire_all, tag, control)
+ except Exception: # pylint: disable=broad-except
+ raise Exception("{}".format(traceback.format_exc()))
diff --git a/tests/pytests/unit/engines/test_slack.py b/tests/pytests/unit/engines/test_slack.py
index 0ab829ac99d1..c4946b51a152 100644
--- a/tests/pytests/unit/engines/test_slack.py
+++ b/tests/pytests/unit/engines/test_slack.py
@@ -4,110 +4,30 @@
import pytest
import salt.config
-import salt.engines.slack as slack_engine
-from tests.support.mock import MagicMock, call, patch
+import salt.engines.slack as slack
+from tests.support.mock import MagicMock, patch
pytestmark = [
pytest.mark.skipif(
- slack_engine.HAS_SLACKBOLT is False, reason="The slack_bolt is not installed"
+ slack.HAS_SLACKCLIENT is False, reason="The SlackClient is not installed"
)
]
-class MockRunnerClient:
- """
- Mock RunnerClient class
- """
-
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def asynchronous(self, *args, **kwargs):
- """
- Mock asynchronous method
- """
- return True
-
-
-class MockLocalClient:
- """
- Mock RunnerClient class
- """
-
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def __enter__(self, *args, **kwargs):
- return self
-
- def __exit__(self, *args, **kwargs):
- pass
-
- def cmd_async(self, *args, **kwargs):
- """
- Mock cmd_async method
- """
- return True
-
-
-class MockSlackBoltSocketMode:
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def connect(self, *args, **kwargs):
- return True
-
-
-class MockSlackBoltApp:
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- self.client = MockSlackBoltAppClient()
- self.logger = None
- self.proxy = None
-
- def message(self, *args, **kwargs):
- return MagicMock(return_value=True)
-
-
-class MockSlackBoltAppClient:
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def chat_postMessage(self, *args, **kwargs):
- return MagicMock(return_value=True)
-
- def files_upload(self, *args, **kwargs):
- return MagicMock(return_value=True)
-
-
@pytest.fixture
def configure_loader_modules():
- return {slack_engine: {}}
+ return {slack: {}}
@pytest.fixture
-def slack_client(minion_opts):
+def slack_client():
mock_opts = salt.config.DEFAULT_MINION_OPTS.copy()
- app_token = "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
- bot_token = "xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
- trigger = "!"
+ token = "xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
- with patch.dict(slack_engine.__opts__, minion_opts):
- with patch(
- "slack_bolt.App", MagicMock(autospec=True, return_value=MockSlackBoltApp())
- ):
- with patch(
- "slack_bolt.adapter.socket_mode.SocketModeHandler",
- MagicMock(autospec=True, return_value=MockSlackBoltSocketMode()),
- ):
- slack_client = slack_engine.SlackClient(app_token, bot_token, trigger)
- yield slack_client
+ with patch.dict(slack.__opts__, mock_opts):
+ with patch("slackclient.SlackClient.rtm_connect", MagicMock(return_value=True)):
+ slack_client = slack.SlackClient(token)
+ yield slack_client
def test_control_message_target(slack_client):
@@ -173,341 +93,3 @@ def test_control_message_target(slack_client):
)
assert target_commandline == _expected
-
-
-def test_run_commands_from_slack_async(slack_client):
- """
- Test slack engine: test_run_commands_from_slack_async
- """
-
- mock_job_status = {
- "20221027001127600438": {
- "data": {"minion": {"return": True, "retcode": 0, "success": True}},
- "function": "test.ping",
- }
- }
-
- message_generator = [
- {
- "message_data": {
- "client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
- "type": "message",
- "text": '!test.ping target="minion"',
- "user": "U02QY11UJ",
- "ts": "1666829486.542159",
- "blocks": [
- {
- "type": "rich_text",
- "block_id": "2vdy",
- "elements": [
- {
- "type": "rich_text_section",
- "elements": [
- {
- "type": "text",
- "text": '!test.ping target="minion"',
- }
- ],
- }
- ],
- }
- ],
- "team": "T02QY11UG",
- "channel": "C02QY11UQ",
- "event_ts": "1666829486.542159",
- "channel_type": "channel",
- },
- "channel": "C02QY11UQ",
- "user": "U02QY11UJ",
- "user_name": "garethgreenaway",
- "cmdline": ["test.ping"],
- "target": {"target": "minion", "tgt_type": "glob"},
- }
- ]
-
- mock_files_upload_resp = {
- "ok": True,
- "file": {
- "id": "F047YTDGJF9",
- "created": 1666883749,
- "timestamp": 1666883749,
- "name": "salt-results-20221027081549173603.yaml",
- "title": "salt-results-20221027081549173603",
- "mimetype": "text/plain",
- "filetype": "yaml",
- "pretty_type": "YAML",
- "user": "U0485K894PN",
- "user_team": "T02QY11UG",
- "editable": True,
- "size": 18,
- "mode": "snippet",
- "is_external": False,
- "external_type": "",
- "is_public": True,
- "public_url_shared": False,
- "display_as_bot": False,
- "username": "",
- "url_private": "",
- "url_private_download": "",
- "permalink": "",
- "permalink_public": "",
- "edit_link": "",
- "preview": "minion:\n True",
- "preview_highlight": "",
- "lines": 2,
- "lines_more": 0,
- "preview_is_truncated": False,
- "comments_count": 0,
- "is_starred": False,
- "shares": {
- "public": {
- "C02QY11UQ": [
- {
- "reply_users": [],
- "reply_users_count": 0,
- "reply_count": 0,
- "ts": "1666883749.485979",
- "channel_name": "general",
- "team_id": "T02QY11UG",
- "share_user_id": "U0485K894PN",
- }
- ]
- }
- },
- "channels": ["C02QY11UQ"],
- "groups": [],
- "ims": [],
- "has_rich_preview": False,
- "file_access": "visible",
- },
- }
-
- patch_app_client_files_upload = patch.object(
- MockSlackBoltAppClient,
- "files_upload",
- MagicMock(autospec=True, return_value=mock_files_upload_resp),
- )
- patch_app_client_chat_postMessage = patch.object(
- MockSlackBoltAppClient,
- "chat_postMessage",
- MagicMock(autospec=True, return_value=True),
- )
- patch_slack_client_run_until = patch.object(
- slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
- )
- patch_slack_client_run_command_async = patch.object(
- slack_client,
- "run_command_async",
- MagicMock(autospec=True, return_value="20221027001127600438"),
- )
- patch_slack_client_get_jobs_from_runner = patch.object(
- slack_client,
- "get_jobs_from_runner",
- MagicMock(autospec=True, return_value=mock_job_status),
- )
-
- upload_calls = call(
- channels="C02QY11UQ",
- content="minion:\n True",
- filename="salt-results-20221027090136014442.yaml",
- )
-
- chat_postMessage_calls = [
- call(
- channel="C02QY11UQ",
- text="@garethgreenaway's job is submitted as salt jid 20221027001127600438",
- ),
- call(
- channel="C02QY11UQ",
- text="@garethgreenaway's job `['test.ping']` (id: 20221027001127600438) (target: {'target': 'minion', 'tgt_type': 'glob'}) returned",
- ),
- ]
-
- #
- # test with control as True and fire_all as False
- #
- with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
- slack_client.run_commands_from_slack_async(
- message_generator=message_generator,
- fire_all=False,
- tag="salt/engines/slack",
- control=True,
- )
- app_client_files_upload.asser_has_calls(upload_calls)
- app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
-
- #
- # test with control and fire_all as True
- #
- patch_slack_client_run_until = patch.object(
- slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
- )
-
- mock_event_send = MagicMock(return_value=True)
- patch_event_send = patch.dict(
- slack_engine.__salt__, {"event.send": mock_event_send}
- )
-
- event_send_calls = [
- call(
- "salt/engines/slack/message",
- {
- "message_data": {
- "client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
- "type": "message",
- "text": '!test.ping target="minion"',
- "user": "U02QY11UJ",
- "ts": "1666829486.542159",
- "blocks": [
- {
- "type": "rich_text",
- "block_id": "2vdy",
- "elements": [
- {
- "type": "rich_text_section",
- "elements": [
- {
- "type": "text",
- "text": '!test.ping target="minion"',
- }
- ],
- }
- ],
- }
- ],
- "team": "T02QY11UG",
- "channel": "C02QY11UQ",
- "event_ts": "1666829486.542159",
- "channel_type": "channel",
- },
- "channel": "C02QY11UQ",
- "user": "U02QY11UJ",
- "user_name": "garethgreenaway",
- "cmdline": ["test.ping"],
- "target": {"target": "minion", "tgt_type": "glob"},
- },
- )
- ]
- with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_event_send, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
- slack_client.run_commands_from_slack_async(
- message_generator=message_generator,
- fire_all=True,
- tag="salt/engines/slack",
- control=True,
- )
- app_client_files_upload.asser_has_calls(upload_calls)
- app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
- mock_event_send.asser_has_calls(event_send_calls)
-
-
-def test_run_command_async(slack_client):
- """
- Test slack engine: test_run_command_async
- """
-
- msg = {
- "message_data": {
- "client_msg_id": "6c71d7f9-a44d-402f-8f9f-d1bb5b650853",
- "type": "message",
- "text": '!test.ping target="minion"',
- "user": "U02QY11UJ",
- "ts": "1667427929.764169",
- "blocks": [
- {
- "type": "rich_text",
- "block_id": "AjL",
- "elements": [
- {
- "type": "rich_text_section",
- "elements": [
- {"type": "text", "text": '!test.ping target="minion"'}
- ],
- }
- ],
- }
- ],
- "team": "T02QY11UG",
- "channel": "C02QY11UQ",
- "event_ts": "1667427929.764169",
- "channel_type": "channel",
- },
- "channel": "C02QY11UQ",
- "user": "U02QY11UJ",
- "user_name": "garethgreenaway",
- "cmdline": ["test.ping"],
- "target": {"target": "minion", "tgt_type": "glob"},
- }
-
- local_client_mock = MagicMock(autospec=True, return_value=MockLocalClient())
- patch_local_client = patch("salt.client.LocalClient", local_client_mock)
-
- local_client_cmd_async_mock = MagicMock(
- autospec=True, return_value={"jid": "20221027001127600438"}
- )
- patch_local_client_cmd_async = patch.object(
- MockLocalClient, "cmd_async", local_client_cmd_async_mock
- )
-
- expected_calls = [call("minion", "test.ping", arg=[], kwarg={}, tgt_type="glob")]
- with patch_local_client, patch_local_client_cmd_async as local_client_cmd_async:
- ret = slack_client.run_command_async(msg)
- local_client_cmd_async.assert_has_calls(expected_calls)
-
- msg = {
- "message_data": {
- "client_msg_id": "35f4783f-8913-4687-8f04-21182bcacd5a",
- "type": "message",
- "text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
- "user": "U02QY11UJ",
- "ts": "1667429460.576889",
- "blocks": [
- {
- "type": "rich_text",
- "block_id": "EAzTy",
- "elements": [
- {
- "type": "rich_text_section",
- "elements": [
- {
- "type": "text",
- "text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
- }
- ],
- }
- ],
- }
- ],
- "team": "T02QY11UG",
- "channel": "C02QY11UQ",
- "event_ts": "1667429460.576889",
- "channel_type": "channel",
- },
- "channel": "C02QY11UQ",
- "user": "U02QY11UJ",
- "user_name": "garethgreenaway",
- "cmdline": ["test.arg", "arg1", "arg2", "arg3", "key1=value1", "key2=value2"],
- "target": {"target": "*", "tgt_type": "glob"},
- }
-
- runner_client_mock = MagicMock(autospec=True, return_value=MockRunnerClient())
- patch_runner_client = patch("salt.runner.RunnerClient", runner_client_mock)
-
- runner_client_asynchronous_mock = MagicMock(
- autospec=True, return_value={"jid": "20221027001127600438"}
- )
- patch_runner_client_asynchronous = patch.object(
- MockRunnerClient, "asynchronous", runner_client_asynchronous_mock
- )
-
- expected_calls = [
- call(
- "test.arg",
- {
- "arg": ["arg1", "arg2", "arg3"],
- "kwarg": {"key1": "value1", "key2": "value2"},
- },
- )
- ]
- with patch_runner_client, patch_runner_client_asynchronous as runner_client_asynchronous:
- ret = slack_client.run_command_async(msg)
- runner_client_asynchronous.assert_has_calls(expected_calls)
diff --git a/tests/pytests/unit/engines/test_slack_bolt_engine.py b/tests/pytests/unit/engines/test_slack_bolt_engine.py
new file mode 100644
index 000000000000..de002dcabf8d
--- /dev/null
+++ b/tests/pytests/unit/engines/test_slack_bolt_engine.py
@@ -0,0 +1,516 @@
+"""
+unit tests for the slack engine
+"""
+import pytest
+
+import salt.config
+import salt.engines.slack_bolt_engine as slack_bolt_engine
+from tests.support.mock import MagicMock, call, patch
+
+pytestmark = [
+ pytest.mark.skipif(
+ slack_bolt_engine.HAS_SLACKBOLT is False,
+ reason="The slack_bolt is not installed",
+ )
+]
+
+
+class MockRunnerClient:
+ """
+ Mock RunnerClient class
+ """
+
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ def asynchronous(self, *args, **kwargs):
+ """
+ Mock asynchronous method
+ """
+ return True
+
+
+class MockLocalClient:
+ """
+ Mock RunnerClient class
+ """
+
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ def __enter__(self, *args, **kwargs):
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ pass
+
+ def cmd_async(self, *args, **kwargs):
+ """
+ Mock cmd_async method
+ """
+ return True
+
+
+class MockSlackBoltSocketMode:
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ def connect(self, *args, **kwargs):
+ return True
+
+
+class MockSlackBoltApp:
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ self.client = MockSlackBoltAppClient()
+ self.logger = None
+ self.proxy = None
+
+ def message(self, *args, **kwargs):
+ return MagicMock(return_value=True)
+
+
+class MockSlackBoltAppClient:
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ def chat_postMessage(self, *args, **kwargs):
+ return MagicMock(return_value=True)
+
+ def files_upload(self, *args, **kwargs):
+ return MagicMock(return_value=True)
+
+
+@pytest.fixture
+def configure_loader_modules():
+ return {slack_bolt_engine: {}}
+
+
+@pytest.fixture
+def slack_client(minion_opts):
+ mock_opts = salt.config.DEFAULT_MINION_OPTS.copy()
+ app_token = "xapp-x-xxxxxxxxxxx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ bot_token = "xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
+ trigger = "!"
+
+ with patch.dict(slack_bolt_engine.__opts__, minion_opts):
+ with patch(
+ "slack_bolt.App", MagicMock(autospec=True, return_value=MockSlackBoltApp())
+ ):
+ with patch(
+ "slack_bolt.adapter.socket_mode.SocketModeHandler",
+ MagicMock(autospec=True, return_value=MockSlackBoltSocketMode()),
+ ):
+ slack_client = slack_bolt_engine.SlackClient(
+ app_token, bot_token, trigger
+ )
+ yield slack_client
+
+
+def test_control_message_target(slack_client):
+ """
+ Test slack engine: control_message_target
+ """
+ trigger_string = "!"
+
+ loaded_groups = {
+ "default": {
+ "targets": {},
+ "commands": {"cmd.run", "test.ping"},
+ "default_target": {"tgt_type": "glob", "target": "*"},
+ "users": {"gareth"},
+ "aliases": {
+ "whoami": {"cmd": "cmd.run whoami"},
+ "list_pillar": {"cmd": "pillar.items"},
+ },
+ }
+ }
+
+ slack_user_name = "gareth"
+
+ # Check for correct cmdline
+ _expected = (True, {"tgt_type": "glob", "target": "*"}, ["cmd.run", "whoami"])
+ text = "!cmd.run whoami"
+ target_commandline = slack_client.control_message_target(
+ slack_user_name, text, loaded_groups, trigger_string
+ )
+
+ assert target_commandline == _expected
+
+ # Check aliases result in correct cmdline
+ text = "!whoami"
+ target_commandline = slack_client.control_message_target(
+ slack_user_name, text, loaded_groups, trigger_string
+ )
+
+ assert target_commandline == _expected
+
+ # Check pillar is overridden
+ _expected = (
+ True,
+ {"tgt_type": "glob", "target": "*"},
+ ["pillar.items", 'pillar={"hello": "world"}'],
+ )
+ text = r"""!list_pillar pillar='{"hello": "world"}'"""
+ target_commandline = slack_client.control_message_target(
+ slack_user_name, text, loaded_groups, trigger_string
+ )
+
+ assert target_commandline == _expected
+
+ # Check target is overridden
+ _expected = (
+ True,
+ {"tgt_type": "glob", "target": "localhost"},
+ ["cmd.run", "whoami"],
+ )
+ text = "!cmd.run whoami target='localhost'"
+ target_commandline = slack_client.control_message_target(
+ slack_user_name, text, loaded_groups, trigger_string
+ )
+
+ assert target_commandline == _expected
+
+
+def test_run_commands_from_slack_async(slack_client):
+ """
+ Test slack engine: test_run_commands_from_slack_async
+ """
+
+ mock_job_status = {
+ "20221027001127600438": {
+ "data": {"minion": {"return": True, "retcode": 0, "success": True}},
+ "function": "test.ping",
+ }
+ }
+
+ message_generator = [
+ {
+ "message_data": {
+ "client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
+ "type": "message",
+ "text": '!test.ping target="minion"',
+ "user": "U02QY11UJ",
+ "ts": "1666829486.542159",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "2vdy",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": '!test.ping target="minion"',
+ }
+ ],
+ }
+ ],
+ }
+ ],
+ "team": "T02QY11UG",
+ "channel": "C02QY11UQ",
+ "event_ts": "1666829486.542159",
+ "channel_type": "channel",
+ },
+ "channel": "C02QY11UQ",
+ "user": "U02QY11UJ",
+ "user_name": "garethgreenaway",
+ "cmdline": ["test.ping"],
+ "target": {"target": "minion", "tgt_type": "glob"},
+ }
+ ]
+
+ mock_files_upload_resp = {
+ "ok": True,
+ "file": {
+ "id": "F047YTDGJF9",
+ "created": 1666883749,
+ "timestamp": 1666883749,
+ "name": "salt-results-20221027081549173603.yaml",
+ "title": "salt-results-20221027081549173603",
+ "mimetype": "text/plain",
+ "filetype": "yaml",
+ "pretty_type": "YAML",
+ "user": "U0485K894PN",
+ "user_team": "T02QY11UG",
+ "editable": True,
+ "size": 18,
+ "mode": "snippet",
+ "is_external": False,
+ "external_type": "",
+ "is_public": True,
+ "public_url_shared": False,
+ "display_as_bot": False,
+ "username": "",
+ "url_private": "",
+ "url_private_download": "",
+ "permalink": "",
+ "permalink_public": "",
+ "edit_link": "",
+ "preview": "minion:\n True",
+ "preview_highlight": "",
+ "lines": 2,
+ "lines_more": 0,
+ "preview_is_truncated": False,
+ "comments_count": 0,
+ "is_starred": False,
+ "shares": {
+ "public": {
+ "C02QY11UQ": [
+ {
+ "reply_users": [],
+ "reply_users_count": 0,
+ "reply_count": 0,
+ "ts": "1666883749.485979",
+ "channel_name": "general",
+ "team_id": "T02QY11UG",
+ "share_user_id": "U0485K894PN",
+ }
+ ]
+ }
+ },
+ "channels": ["C02QY11UQ"],
+ "groups": [],
+ "ims": [],
+ "has_rich_preview": False,
+ "file_access": "visible",
+ },
+ }
+
+ patch_app_client_files_upload = patch.object(
+ MockSlackBoltAppClient,
+ "files_upload",
+ MagicMock(autospec=True, return_value=mock_files_upload_resp),
+ )
+ patch_app_client_chat_postMessage = patch.object(
+ MockSlackBoltAppClient,
+ "chat_postMessage",
+ MagicMock(autospec=True, return_value=True),
+ )
+ patch_slack_client_run_until = patch.object(
+ slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
+ )
+ patch_slack_client_run_command_async = patch.object(
+ slack_client,
+ "run_command_async",
+ MagicMock(autospec=True, return_value="20221027001127600438"),
+ )
+ patch_slack_client_get_jobs_from_runner = patch.object(
+ slack_client,
+ "get_jobs_from_runner",
+ MagicMock(autospec=True, return_value=mock_job_status),
+ )
+
+ upload_calls = call(
+ channels="C02QY11UQ",
+ content="minion:\n True",
+ filename="salt-results-20221027090136014442.yaml",
+ )
+
+ chat_postMessage_calls = [
+ call(
+ channel="C02QY11UQ",
+ text="@garethgreenaway's job is submitted as salt jid 20221027001127600438",
+ ),
+ call(
+ channel="C02QY11UQ",
+ text="@garethgreenaway's job `['test.ping']` (id: 20221027001127600438) (target: {'target': 'minion', 'tgt_type': 'glob'}) returned",
+ ),
+ ]
+
+ #
+ # test with control as True and fire_all as False
+ #
+ with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
+ slack_client.run_commands_from_slack_async(
+ message_generator=message_generator,
+ fire_all=False,
+ tag="salt/engines/slack",
+ control=True,
+ )
+ app_client_files_upload.asser_has_calls(upload_calls)
+ app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
+
+ #
+ # test with control and fire_all as True
+ #
+ patch_slack_client_run_until = patch.object(
+ slack_client, "_run_until", MagicMock(autospec=True, side_effect=[True, False])
+ )
+
+ mock_event_send = MagicMock(return_value=True)
+ patch_event_send = patch.dict(
+ slack_bolt_engine.__salt__, {"event.send": mock_event_send}
+ )
+
+ event_send_calls = [
+ call(
+ "salt/engines/slack/message",
+ {
+ "message_data": {
+ "client_msg_id": "c1d0c13d-5e78-431e-9921-4786a7d27543",
+ "type": "message",
+ "text": '!test.ping target="minion"',
+ "user": "U02QY11UJ",
+ "ts": "1666829486.542159",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "2vdy",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": '!test.ping target="minion"',
+ }
+ ],
+ }
+ ],
+ }
+ ],
+ "team": "T02QY11UG",
+ "channel": "C02QY11UQ",
+ "event_ts": "1666829486.542159",
+ "channel_type": "channel",
+ },
+ "channel": "C02QY11UQ",
+ "user": "U02QY11UJ",
+ "user_name": "garethgreenaway",
+ "cmdline": ["test.ping"],
+ "target": {"target": "minion", "tgt_type": "glob"},
+ },
+ )
+ ]
+ with patch_slack_client_run_until, patch_slack_client_run_command_async, patch_slack_client_get_jobs_from_runner, patch_event_send, patch_app_client_files_upload as app_client_files_upload, patch_app_client_chat_postMessage as app_client_chat_postMessage:
+ slack_client.run_commands_from_slack_async(
+ message_generator=message_generator,
+ fire_all=True,
+ tag="salt/engines/slack",
+ control=True,
+ )
+ app_client_files_upload.asser_has_calls(upload_calls)
+ app_client_chat_postMessage.asser_has_calls(chat_postMessage_calls)
+ mock_event_send.asser_has_calls(event_send_calls)
+
+
+def test_run_command_async(slack_client):
+ """
+ Test slack engine: test_run_command_async
+ """
+
+ msg = {
+ "message_data": {
+ "client_msg_id": "6c71d7f9-a44d-402f-8f9f-d1bb5b650853",
+ "type": "message",
+ "text": '!test.ping target="minion"',
+ "user": "U02QY11UJ",
+ "ts": "1667427929.764169",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "AjL",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {"type": "text", "text": '!test.ping target="minion"'}
+ ],
+ }
+ ],
+ }
+ ],
+ "team": "T02QY11UG",
+ "channel": "C02QY11UQ",
+ "event_ts": "1667427929.764169",
+ "channel_type": "channel",
+ },
+ "channel": "C02QY11UQ",
+ "user": "U02QY11UJ",
+ "user_name": "garethgreenaway",
+ "cmdline": ["test.ping"],
+ "target": {"target": "minion", "tgt_type": "glob"},
+ }
+
+ local_client_mock = MagicMock(autospec=True, return_value=MockLocalClient())
+ patch_local_client = patch("salt.client.LocalClient", local_client_mock)
+
+ local_client_cmd_async_mock = MagicMock(
+ autospec=True, return_value={"jid": "20221027001127600438"}
+ )
+ patch_local_client_cmd_async = patch.object(
+ MockLocalClient, "cmd_async", local_client_cmd_async_mock
+ )
+
+ expected_calls = [call("minion", "test.ping", arg=[], kwarg={}, tgt_type="glob")]
+ with patch_local_client, patch_local_client_cmd_async as local_client_cmd_async:
+ ret = slack_client.run_command_async(msg)
+ local_client_cmd_async.assert_has_calls(expected_calls)
+
+ msg = {
+ "message_data": {
+ "client_msg_id": "35f4783f-8913-4687-8f04-21182bcacd5a",
+ "type": "message",
+ "text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
+ "user": "U02QY11UJ",
+ "ts": "1667429460.576889",
+ "blocks": [
+ {
+ "type": "rich_text",
+ "block_id": "EAzTy",
+ "elements": [
+ {
+ "type": "rich_text_section",
+ "elements": [
+ {
+ "type": "text",
+ "text": "!test.arg arg1 arg2 arg3 key1=value1 key2=value2",
+ }
+ ],
+ }
+ ],
+ }
+ ],
+ "team": "T02QY11UG",
+ "channel": "C02QY11UQ",
+ "event_ts": "1667429460.576889",
+ "channel_type": "channel",
+ },
+ "channel": "C02QY11UQ",
+ "user": "U02QY11UJ",
+ "user_name": "garethgreenaway",
+ "cmdline": ["test.arg", "arg1", "arg2", "arg3", "key1=value1", "key2=value2"],
+ "target": {"target": "*", "tgt_type": "glob"},
+ }
+
+ runner_client_mock = MagicMock(autospec=True, return_value=MockRunnerClient())
+ patch_runner_client = patch("salt.runner.RunnerClient", runner_client_mock)
+
+ runner_client_asynchronous_mock = MagicMock(
+ autospec=True, return_value={"jid": "20221027001127600438"}
+ )
+ patch_runner_client_asynchronous = patch.object(
+ MockRunnerClient, "asynchronous", runner_client_asynchronous_mock
+ )
+
+ expected_calls = [
+ call(
+ "test.arg",
+ {
+ "arg": ["arg1", "arg2", "arg3"],
+ "kwarg": {"key1": "value1", "key2": "value2"},
+ },
+ )
+ ]
+ with patch_runner_client, patch_runner_client_asynchronous as runner_client_asynchronous:
+ ret = slack_client.run_command_async(msg)
+ runner_client_asynchronous.assert_has_calls(expected_calls)