From 24d505964b2613a8c7359b56ef8c6ad03eee56bf Mon Sep 17 00:00:00 2001 From: David Parker Date: Sat, 25 Oct 2025 12:06:07 +0100 Subject: [PATCH 1/6] [minor] Add Slack utilities --- .pre-commit-config.yaml | 6 +- .secrets.baseline | 22 ++++- bin/mas-devops-notify-slack | 61 +++++++++++++ setup.py | 8 +- src/mas/devops/slack.py | 168 ++++++++++++++++++++++++++++++++++++ test/src/test_slack.py | 23 +++++ 6 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 bin/mas-devops-notify-slack create mode 100644 src/mas/devops/slack.py create mode 100644 test/src/test_slack.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 056db463..7e714ef2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,15 +2,15 @@ default_language_version: python: python repos: - repo: https://github.com/hhatto/autopep8 - rev: v2.3.1 + rev: v2.3.2 hooks: - id: autopep8 - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 7.3.0 hooks: - id: flake8 - repo: https://github.com/ibm/detect-secrets - rev: 0.13.1+ibm.62.dss + rev: 0.13.1+ibm.64.dss hooks: - id: detect-secrets args: [--baseline, .secrets.baseline, --use-all-plugins, --fail-on-unaudited] diff --git a/.secrets.baseline b/.secrets.baseline index 6f2d7170..c78d4efa 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2025-09-30T07:33:15Z", + "generated_at": "2025-10-25T11:05:55Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -77,6 +77,24 @@ } ], "results": { + "bin/mas-devops-notify-slack": [ + { + "hashed_secret": "053f5ed451647be0bbb6f67b80d6726808cad97e", + "is_secret": false, + "is_verified": false, + "line_number": 27, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "765d3d433049b64dc1292181b213854947cfc6e5", + "is_secret": false, + "is_verified": false, + "line_number": 35, + "type": "Secret Keyword", + "verified_result": null + } + ], "src/mas/devops/templates/ibm-entitlement-dockerconfig.json.j2": [ { "hashed_secret": "d2e2ab0f407e4ee3cf2ab87d61c31b25a74085e5", @@ -156,7 +174,7 @@ } ] }, - "version": "0.13.1+ibm.62.dss", + "version": "0.13.1+ibm.64.dss", "word_list": { "file": null, "hash": null diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack new file mode 100644 index 00000000..dd5884d3 --- /dev/null +++ b/bin/mas-devops-notify-slack @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +# ***************************************************************************** +# Copyright (c) 2025 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** + +import argparse +import os +import sys +from mas.devops.slack import SlackUtil + + +def notifyProvisionFyre(rc: int) -> bool: + name = os.getenv("CLUSTER_NAME", None) + if name is None: + print("CLUSTER_NAME env var must all be set") + + if rc == 0: + url = os.getenv("OCP_CLUSTER_URL", None) + username = os.getenv("OCP_USERNAME", None) + password = os.getenv("OCP_PASSWORD", None) + + if url is None or username is None or password is None: + print("OCP_CLUSTER_URL, OCP_USERNAME, and OCP_PASSWORD env vars must all be set") + + message = [ + SlackUtil.buildSection(f":glyph-ok: *Your IBM DevIT Fyre OCP cluster ({name}) is ready to use*"), + SlackUtil.buildSection(f"{url}"), + SlackUtil.buildSection(f"- Username: `{username}`\n - Password: `{password}`") + ] + else: + message = [ + SlackUtil.buildSection(f":glyph-fail: *Your IBM DevIT Fyre OCP cluster ({name}) failed to deploy*"), + ] + + response = SlackUtil.postMessageBlocks("#bot-test", message) + + return response.data.get("ok", False) + + +if __name__ == "__main__": + SLACK_TOKEN = os.getenv("SLACK_TOKEN", None) + if SLACK_TOKEN is None: + sys.exit(0) + + # Initialize the properties we need + parser = argparse.ArgumentParser() + + # Primary Options + parser.add_argument("--action", required=True) + parser.add_argument("--rc", required=True, type=int) + args, unknown = parser.parse_known_args() + + if args.action == "ocp-provision-fyre": + notifyProvisionFyre(args.rc) diff --git a/setup.py b/setup.py index 79f8d274..9469f777 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2025 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -62,7 +62,8 @@ def get_version(rel_path): 'jinja2', # BSD License 'jinja2-base64-filters', # MIT License 'semver', # BSD License - 'boto3' # Apache Software License + 'boto3', # Apache Software License + 'slack_sdk', # MIT License ], extras_require={ 'dev': [ @@ -90,6 +91,7 @@ def get_version(rel_path): scripts=[ 'bin/mas-devops-db2-validate-config', 'bin/mas-devops-create-initial-users-for-saas', - 'bin/mas-devops-saas-job-cleaner' + 'bin/mas-devops-saas-job-cleaner', + 'bin/mas-devops-notify-slack', ] ) diff --git a/src/mas/devops/slack.py b/src/mas/devops/slack.py new file mode 100644 index 00000000..c318d281 --- /dev/null +++ b/src/mas/devops/slack.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 + +# ***************************************************************************** +# Copyright (c) 2025 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** + +import os +from slack_sdk import WebClient +from slack_sdk.web.slack_response import SlackResponse + +import logging + +logger = logging.getLogger(__name__) + + +class SlackUtilMeta(type): + def __init__(cls, *args, **kwargs): + # Exposed by the client() property method + cls._client = None + + @property + def client(cls) -> WebClient: + if cls._client is not None: + return cls._client + else: + SLACK_TOKEN = os.getenv("SLACK_TOKEN") + if SLACK_TOKEN is None: + logger.warning("SLACK_TOKEN is not set") + raise Exception("SLACK_TOKEN is not set") + else: + cls._client = WebClient(token=SLACK_TOKEN) + return cls._client + + # Post message to Slack + # ----------------------------------------------------------------------------- + def postMessageBlocks(cls, channelName: str, messageBlocks: list, threadId: str = None) -> SlackResponse: + if threadId is None: + logger.debug(f"Posting {len(messageBlocks)} block message to {channelName} in Slack") + response = cls.client.chat_postMessage( + channel=channelName, + blocks=messageBlocks, + text="Summary text unavailable", + mrkdwn=True, + parse="none", + unfurl_links=False, + unfurl_media=False, + link_names=True, + as_user=True, + ) + else: + logger.debug(f"Posting {len(messageBlocks)} block message to {channelName} on thread {threadId} in Slack") + response = cls.client.chat_postMessage( + channel=channelName, + thread_ts=threadId, + blocks=messageBlocks, + text="Summary text unavailable", + mrkdwn=True, + parse="none", + unfurl_links=False, + unfurl_media=False, + link_names=True, + as_user=True, + ) + + if not response["ok"]: + logger.warning(response.data) + logger.warning("Failed to call Slack API") + return response + + def postMessageText(cls, channelName, message, attachments=None, threadId=None): + if threadId is None: + logger.debug(f"Posting message to {channelName} in Slack") + response = cls.client.chat_postMessage( + channel=channelName, + text=message, + attachments=attachments, + mrkdwn=True, + parse="none", + unfurl_links=False, + unfurl_media=False, + link_names=True, + as_user=True, + ) + else: + logger.debug(f"Posting message to {channelName} on thread {threadId} in Slack") + response = cls.client.chat_postMessage( + channel=channelName, + thread_ts=threadId, + text=message, + attachments=attachments, + mrkdwn=True, + parse="none", + unfurl_links=False, + unfurl_media=False, + link_names=True, + as_user=True, + ) + + if not response["ok"]: + logger.warning(response.data) + logger.warning("Failed to call Slack API") + return response + + def createMessagePermalink( + cls, slackResponse: SlackResponse = None, channelId: str = None, messageTimestamp: str = None, domain: str = "ibm-mas" + ) -> str: + if slackResponse is not None: + channelId = slackResponse["channel"] + messageTimestamp = slackResponse["ts"] + elif channelId is None or messageTimestamp is None: + raise Exception("Either channelId and messageTimestamp, or slackReponse params must be provided") + + return f"https://{domain}.slack.com/archives/{channelId}/p{messageTimestamp.replace('.', '')}" + + # Edit message in Slack + # ----------------------------------------------------------------------------- + def updateMessageBlocks(cls, channelName: str, threadId: str, messageBlocks: list) -> SlackResponse: + logger.debug(f"Updating {len(messageBlocks)} block message in {channelName} on thread {threadId} in Slack") + response = cls.client.chat_update( + channel=channelName, + ts=threadId, + blocks=messageBlocks, + mrkdwn=True, + parse="none", + unfurl_links=False, + unfurl_media=False, + link_names=True, + as_user=True, + ) + + if not response["ok"]: + logger.warning(response.data) + logger.warning("Failed to call Slack API") + return response + + # Build header block for Slack message + # ----------------------------------------------------------------------------- + def buildHeader(cls, title: str) -> dict: + return {"type": "header", "text": {"type": "plain_text", "text": title, "emoji": True}} + + # Build section block for Slack message + # ----------------------------------------------------------------------------- + def buildSection(cls, text: str) -> dict: + return {"type": "section", "text": {"type": "mrkdwn", "text": text}} + + # Build context block for Slack message + # ----------------------------------------------------------------------------- + def buildContext(cls, texts: list) -> dict: + elements = [] + for text in texts: + elements.append({"type": "mrkdwn", "text": text}) + + return {"type": "context", "elements": elements} + + # Build divider block for Slack message + # ----------------------------------------------------------------------------- + def buildDivider(cls) -> dict: + return {"type": "divider"} + + +class SlackUtil(metaclass=SlackUtilMeta): + pass diff --git a/test/src/test_slack.py b/test/src/test_slack.py new file mode 100644 index 00000000..f2f994db --- /dev/null +++ b/test/src/test_slack.py @@ -0,0 +1,23 @@ +# ***************************************************************************** +# Copyright (c) 2025 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** + +from mas.devops.slack import SlackUtil + + +def testSendMessage(): + response = SlackUtil.postMessageText("#bot-test", "mas-devops unittest") + + assert "channel" in response.data + assert response.data["channel"] == "C06453F9KFC" + + assert "ok" in response.data + assert response.data["ok"] is True + + assert "ts" in response.data From 58a973a0b8d4ad16a97abeceb87711818311b683 Mon Sep 17 00:00:00 2001 From: David Parker Date: Sat, 25 Oct 2025 12:12:22 +0100 Subject: [PATCH 2/6] Update test_users.py --- test/src/test_users.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/src/test_users.py b/test/src/test_users.py index 7be29e60..56690502 100644 --- a/test/src/test_users.py +++ b/test/src/test_users.py @@ -1533,7 +1533,6 @@ def test_await_mas_application_availability(user_utils, requests_mock): def json_callback(request, context): nonlocal attempt - nonlocal return_values ret = return_values[attempt] attempt = attempt + 1 return ret From f3ebdbb113be309ec70409f12a0605f2ecc7607c Mon Sep 17 00:00:00 2001 From: David Parker Date: Sat, 25 Oct 2025 12:21:58 +0100 Subject: [PATCH 3/6] Update python-package.yml --- .github/workflows/python-package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bc4618c7..670db791 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -35,6 +35,7 @@ jobs: env: OCP_TOKEN: ${{ secrets.OCP_TOKEN }} OCP_SERVER: ${{ secrets.OCP_SERVER }} + SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} run: | kubectl config set-cluster my-cluster --server=$OCP_SERVER kubectl config set-credentials my-user --token=$OCP_TOKEN From e92dc0f972b591c0a1a3f9cb3c36be8f85164ca2 Mon Sep 17 00:00:00 2001 From: David Parker Date: Sat, 25 Oct 2025 12:34:05 +0100 Subject: [PATCH 4/6] Updates --- .secrets.baseline | 6 +++--- bin/mas-devops-notify-slack | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index c78d4efa..440aabbe 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2025-10-25T11:05:55Z", + "generated_at": "2025-10-25T11:33:24Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -82,7 +82,7 @@ "hashed_secret": "053f5ed451647be0bbb6f67b80d6726808cad97e", "is_secret": false, "is_verified": false, - "line_number": 27, + "line_number": 28, "type": "Secret Keyword", "verified_result": null }, @@ -90,7 +90,7 @@ "hashed_secret": "765d3d433049b64dc1292181b213854947cfc6e5", "is_secret": false, "is_verified": false, - "line_number": 35, + "line_number": 37, "type": "Secret Keyword", "verified_result": null } diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack index dd5884d3..62c3b8b3 100644 --- a/bin/mas-devops-notify-slack +++ b/bin/mas-devops-notify-slack @@ -19,7 +19,8 @@ from mas.devops.slack import SlackUtil def notifyProvisionFyre(rc: int) -> bool: name = os.getenv("CLUSTER_NAME", None) if name is None: - print("CLUSTER_NAME env var must all be set") + print("CLUSTER_NAME env var must be set") + sys.exit(1) if rc == 0: url = os.getenv("OCP_CLUSTER_URL", None) @@ -28,15 +29,17 @@ def notifyProvisionFyre(rc: int) -> bool: if url is None or username is None or password is None: print("OCP_CLUSTER_URL, OCP_USERNAME, and OCP_PASSWORD env vars must all be set") + sys.exit(1) message = [ - SlackUtil.buildSection(f":glyph-ok: *Your IBM DevIT Fyre OCP cluster ({name}) is ready to use*"), + SlackUtil.buildSection(f":glyph-ok: *Your IBM DevIT Fyre OCP cluster ({name}) is ready*"), SlackUtil.buildSection(f"{url}"), SlackUtil.buildSection(f"- Username: `{username}`\n - Password: `{password}`") ] else: message = [ SlackUtil.buildSection(f":glyph-fail: *Your IBM DevIT Fyre OCP cluster ({name}) failed to deploy*"), + SlackUtil.buildSection("https://beta.fyre.ibm.com/development/vms") ] response = SlackUtil.postMessageBlocks("#bot-test", message) @@ -45,6 +48,7 @@ def notifyProvisionFyre(rc: int) -> bool: if __name__ == "__main__": + # If SLACK_TOKEN env var is not set then silently exit taking no action SLACK_TOKEN = os.getenv("SLACK_TOKEN", None) if SLACK_TOKEN is None: sys.exit(0) From 6f82a428cb5e8a153996412ca750e009a781d598 Mon Sep 17 00:00:00 2001 From: David Parker Date: Sat, 25 Oct 2025 12:44:27 +0100 Subject: [PATCH 5/6] Update mas-devops-notify-slack --- bin/mas-devops-notify-slack | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack index 62c3b8b3..916e9bc2 100644 --- a/bin/mas-devops-notify-slack +++ b/bin/mas-devops-notify-slack @@ -16,7 +16,7 @@ import sys from mas.devops.slack import SlackUtil -def notifyProvisionFyre(rc: int) -> bool: +def notifyProvisionFyre(channel: str, rc: int) -> bool: name = os.getenv("CLUSTER_NAME", None) if name is None: print("CLUSTER_NAME env var must be set") @@ -42,15 +42,16 @@ def notifyProvisionFyre(rc: int) -> bool: SlackUtil.buildSection("https://beta.fyre.ibm.com/development/vms") ] - response = SlackUtil.postMessageBlocks("#bot-test", message) + response = SlackUtil.postMessageBlocks(channel, message) return response.data.get("ok", False) if __name__ == "__main__": - # If SLACK_TOKEN env var is not set then silently exit taking no action + # If SLACK_TOKEN or SLACK_CHANNEL env vars are not set then silently exit taking no action SLACK_TOKEN = os.getenv("SLACK_TOKEN", None) - if SLACK_TOKEN is None: + SLACK_CHANNEL = os.getenv("SLACK_CHANNEL", None) + if SLACK_TOKEN is None or SLACK_CHANNEL is None: sys.exit(0) # Initialize the properties we need @@ -62,4 +63,4 @@ if __name__ == "__main__": args, unknown = parser.parse_known_args() if args.action == "ocp-provision-fyre": - notifyProvisionFyre(args.rc) + notifyProvisionFyre(SLACK_CHANNEL, args.rc) From 3ae52e67ec14a2602f8a7dd433943abbd61dba6f Mon Sep 17 00:00:00 2001 From: David Parker Date: Mon, 27 Oct 2025 10:21:59 +0000 Subject: [PATCH 6/6] Updates --- .secrets.baseline | 8 ++++---- bin/mas-devops-notify-slack | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 440aabbe..0ed7fabf 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2025-10-25T11:33:24Z", + "generated_at": "2025-10-27T10:20:07Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -82,15 +82,15 @@ "hashed_secret": "053f5ed451647be0bbb6f67b80d6726808cad97e", "is_secret": false, "is_verified": false, - "line_number": 28, + "line_number": 35, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "765d3d433049b64dc1292181b213854947cfc6e5", + "hashed_secret": "4f75456d6c1887d41ed176f7ad3e2cfff3fdfd91", "is_secret": false, "is_verified": false, - "line_number": 37, + "line_number": 44, "type": "Secret Keyword", "verified_result": null } diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack index 916e9bc2..ce8e1ae4 100644 --- a/bin/mas-devops-notify-slack +++ b/bin/mas-devops-notify-slack @@ -22,6 +22,13 @@ def notifyProvisionFyre(channel: str, rc: int) -> bool: print("CLUSTER_NAME env var must be set") sys.exit(1) + # Support optional metadata from standard IBM CD Toolchains environment variables + toolchainLink = "" + toolchainUrl = os.getenv("TOOLCHAIN_PIPELINERUN_URL", None) + toolchainTriggerName = os.getenv("TOOLCHAIN_TRIGGER_NAME", None) + if toolchainUrl is not None and toolchainTriggerName is not None: + toolchainLink = f" | <{toolchainUrl}|Pipeline Run>" + if rc == 0: url = os.getenv("OCP_CLUSTER_URL", None) username = os.getenv("OCP_USERNAME", None) @@ -32,18 +39,18 @@ def notifyProvisionFyre(channel: str, rc: int) -> bool: sys.exit(1) message = [ - SlackUtil.buildSection(f":glyph-ok: *Your IBM DevIT Fyre OCP cluster ({name}) is ready*"), + SlackUtil.buildHeader(f":glyph-ok: Your IBM DevIT Fyre OCP cluster ({name}) is ready"), SlackUtil.buildSection(f"{url}"), - SlackUtil.buildSection(f"- Username: `{username}`\n - Password: `{password}`") + SlackUtil.buildSection(f"- Username: `{username}`\n - Password: `{password}`"), + SlackUtil.buildSection(f"{toolchainLink}") ] else: message = [ - SlackUtil.buildSection(f":glyph-fail: *Your IBM DevIT Fyre OCP cluster ({name}) failed to deploy*"), - SlackUtil.buildSection("https://beta.fyre.ibm.com/development/vms") + SlackUtil.buildHeader(f":glyph-fail: Your IBM DevIT Fyre OCP cluster ({name}) failed to deploy"), + SlackUtil.buildSection(f"{toolchainLink}") ] response = SlackUtil.postMessageBlocks(channel, message) - return response.data.get("ok", False)