Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
22 changes: 20 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": "^.secrets.baseline$",
"lines": null
},
"generated_at": "2025-09-30T07:33:15Z",
"generated_at": "2025-10-27T10:20:07Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -77,6 +77,24 @@
}
],
"results": {
"bin/mas-devops-notify-slack": [
{
"hashed_secret": "053f5ed451647be0bbb6f67b80d6726808cad97e",
"is_secret": false,
"is_verified": false,
"line_number": 35,
"type": "Secret Keyword",
"verified_result": null
},
{
"hashed_secret": "4f75456d6c1887d41ed176f7ad3e2cfff3fdfd91",
"is_secret": false,
"is_verified": false,
"line_number": 44,
"type": "Secret Keyword",
"verified_result": null
}
],
"src/mas/devops/templates/ibm-entitlement-dockerconfig.json.j2": [
{
"hashed_secret": "d2e2ab0f407e4ee3cf2ab87d61c31b25a74085e5",
Expand Down Expand Up @@ -156,7 +174,7 @@
}
]
},
"version": "0.13.1+ibm.62.dss",
"version": "0.13.1+ibm.64.dss",
"word_list": {
"file": null,
"hash": null
Expand Down
73 changes: 73 additions & 0 deletions bin/mas-devops-notify-slack
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/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(channel: str, rc: int) -> bool:
name = os.getenv("CLUSTER_NAME", None)
if name is None:
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)
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")
sys.exit(1)

message = [
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"<https://beta.fyre.ibm.com/development/vms|Fyre Dashboard>{toolchainLink}")
]
else:
message = [
SlackUtil.buildHeader(f":glyph-fail: Your IBM DevIT Fyre OCP cluster ({name}) failed to deploy"),
SlackUtil.buildSection(f"<https://beta.fyre.ibm.com/development/vms|Fyre Dashboard>{toolchainLink}")
]

response = SlackUtil.postMessageBlocks(channel, message)
return response.data.get("ok", False)


if __name__ == "__main__":
# If SLACK_TOKEN or SLACK_CHANNEL env vars are not set then silently exit taking no action
SLACK_TOKEN = os.getenv("SLACK_TOKEN", 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
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(SLACK_CHANNEL, args.rc)
8 changes: 5 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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': [
Expand Down Expand Up @@ -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',
]
)
168 changes: 168 additions & 0 deletions src/mas/devops/slack.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions test/src/test_slack.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion test/src/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down