diff --git a/.secrets.baseline b/.secrets.baseline index 0ed7fabf..21a8f1c5 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2025-10-27T10:20:07Z", + "generated_at": "2025-12-15T15:57:18Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -82,7 +82,7 @@ "hashed_secret": "053f5ed451647be0bbb6f67b80d6726808cad97e", "is_secret": false, "is_verified": false, - "line_number": 35, + "line_number": 44, "type": "Secret Keyword", "verified_result": null }, @@ -90,7 +90,7 @@ "hashed_secret": "4f75456d6c1887d41ed176f7ad3e2cfff3fdfd91", "is_secret": false, "is_verified": false, - "line_number": 44, + "line_number": 53, "type": "Secret Keyword", "verified_result": null } diff --git a/bin/mas-devops-create-initial-users-for-saas b/bin/mas-devops-create-initial-users-for-saas index bac45ce9..7372f495 100644 --- a/bin/mas-devops-create-initial-users-for-saas +++ b/bin/mas-devops-create-initial-users-for-saas @@ -10,21 +10,18 @@ # # ***************************************************************************** +from mas.devops.users import MASUserUtils +from botocore.exceptions import ClientError +import boto3 +import sys +import json +import yaml from kubernetes import client, config from kubernetes.config.config_exception import ConfigException import argparse import logging import urllib3 urllib3.disable_warnings() -import yaml -import json -import sys - -import boto3 -from botocore.exceptions import ClientError - -from mas.devops.users import MASUserUtils - if __name__ == "__main__": @@ -38,7 +35,6 @@ if __name__ == "__main__": parser.add_argument("--admin-dashboard-port", required=False, default=443) parser.add_argument("--manage-api-port", required=False, default=443) - group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--initial-users-yaml-file") group.add_argument("--initial-users-secret-name") @@ -66,7 +62,6 @@ if __name__ == "__main__": admin_dashboard_port = args.admin_dashboard_port manage_api_port = args.manage_api_port - logger.info("Configuration:") logger.info("--------------") logger.info(f"mas_instance_id: {mas_instance_id}") @@ -88,7 +83,6 @@ if __name__ == "__main__": config.load_kube_config() logger.debug("Loaded kubeconfig file") - user_utils = MASUserUtils(mas_instance_id, mas_workspace_id, client.api_client.ApiClient(), coreapi_port=coreapi_port, admin_dashboard_port=admin_dashboard_port, manage_api_port=manage_api_port) if initial_users_secret_name is not None: @@ -100,7 +94,7 @@ if __name__ == "__main__": service_name='secretsmanager', ) try: - initial_users_secret = aws_sm_client.get_secret_value( # pragma: allowlist secret + initial_users_secret = aws_sm_client.get_secret_value( # pragma: allowlist secret SecretId=initial_users_secret_name ) except ClientError as e: @@ -109,7 +103,7 @@ if __name__ == "__main__": sys.exit(0) raise Exception(f"Failed to fetch secret {initial_users_secret_name}: {str(e)}") - + secret_json = json.loads(initial_users_secret['SecretString']) initial_users = user_utils.parse_initial_users_from_aws_secret_json(secret_json) elif initial_users_yaml_file is not None: @@ -117,8 +111,7 @@ if __name__ == "__main__": initial_users = yaml.safe_load(file) else: raise Exception("Something unexpected happened") - - + result = user_utils.create_initial_users_for_saas(initial_users) # if user details were sourced from an AWS SM secret, remove the completed entries from the secret @@ -133,14 +126,13 @@ if __name__ == "__main__": if has_updates: logger.info(f"Updating secret {initial_users_secret_name}") try: - aws_sm_client.update_secret( # pragma: allowlist secret + aws_sm_client.update_secret( # pragma: allowlist secret SecretId=initial_users_secret_name, SecretString=json.dumps(secret_json) ) except ClientError as e: raise Exception(f"Failed to update secret {initial_users_secret_name}: {str(e)}") - if len(result["failed"]) > 0: - failed_user_ids = list(map(lambda u : u["email"], result["failed"])) - raise Exception(f"Sync failed for the following user IDs {failed_user_ids}") \ No newline at end of file + failed_user_ids = list(map(lambda u: u["email"], result["failed"])) + raise Exception(f"Sync failed for the following user IDs {failed_user_ids}") diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack index 13ea97dc..04c71ce8 100644 --- a/bin/mas-devops-notify-slack +++ b/bin/mas-devops-notify-slack @@ -16,18 +16,27 @@ 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: +def _getClusterName() -> str: + name = os.getenv("CLUSTER_NAME", "") + if name == "": print("CLUSTER_NAME env var must be set") sys.exit(1) + return name - # Support optional metadata from standard IBM CD Toolchains environment variables - toolchainLink = "" + +def _getToolchainLink() -> str: 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>" + toolchainLink = f"<{toolchainUrl}|{toolchainTriggerName}>" + return toolchainLink + return "" + + +def notifyProvisionFyre(channels: list[str], rc: int, additionalMsg: str = None) -> bool: + """Send Slack notification about Fyre OCP cluster provisioning status.""" + name = _getClusterName() + toolchainLink = _getToolchainLink() if rc == 0: url = os.getenv("OCP_CONSOLE_URL", None) @@ -44,13 +53,47 @@ def notifyProvisionFyre(channel: str, rc: int) -> bool: SlackUtil.buildSection(f"- Username: `{username}`\n- Password: `{password}`"), SlackUtil.buildSection(f"{toolchainLink}") ] + if additionalMsg is not None: + message.append(SlackUtil.buildSection(additionalMsg)) else: message = [ SlackUtil.buildHeader(f":glyph-fail: Your IBM DevIT Fyre OCP cluster ({name}) failed to deploy"), SlackUtil.buildSection(f"{toolchainLink}") ] - response = SlackUtil.postMessageBlocks(channel, message) + response = SlackUtil.postMessageBlocks(channels, message) + if isinstance(response, list): + return all([res.data.get("ok", False) for res in response]) + return response.data.get("ok", False) + + +def notifyProvisionRoks(channels: list[str], rc: int, additionalMsg: str = None) -> bool: + """Send Slack notification about ROKS cluster provisioning status.""" + name = _getClusterName() + toolchainLink = _getToolchainLink() + + if rc == 0: + url = os.getenv("OCP_CONSOLE_URL", None) + if url is None: + print("OCP_CONSOLE_URL env var must be set") + sys.exit(1) + + message = [ + SlackUtil.buildHeader(f":glyph-ok: Your IBM Cloud ROKS cluster ({name}) is ready"), + SlackUtil.buildSection(f"{url}"), + SlackUtil.buildSection(f"{toolchainLink}") + ] + if additionalMsg is not None: + message.append(SlackUtil.buildSection(additionalMsg)) + else: + message = [ + SlackUtil.buildHeader(f":glyph-fail: Your IBM Cloud ROKS cluster ({name}) failed to deploy"), + SlackUtil.buildSection(f"{toolchainLink}") + ] + + response = SlackUtil.postMessageBlocks(channels, message) + if isinstance(response, list): + return all([res.data.get("ok", False) for res in response]) return response.data.get("ok", False) @@ -61,13 +104,20 @@ if __name__ == "__main__": if SLACK_TOKEN == "" or SLACK_CHANNEL == "": sys.exit(0) + # Parse comma-separated channel list + channelList = [ch.strip() for ch in SLACK_CHANNEL.split(",")] + # Initialize the properties we need parser = argparse.ArgumentParser() # Primary Options parser.add_argument("--action", required=True) parser.add_argument("--rc", required=True, type=int) + parser.add_argument("--msg", required=False, default=None) + args, unknown = parser.parse_known_args() if args.action == "ocp-provision-fyre": - notifyProvisionFyre(SLACK_CHANNEL, args.rc) + notifyProvisionFyre(channelList, args.rc, args.msg) + elif args.action == "ocp-provision-roks": + notifyProvisionRoks(channelList, args.rc, args.msg) diff --git a/bin/mas-devops-saas-job-cleaner b/bin/mas-devops-saas-job-cleaner index 549ce0da..374a7ef3 100644 --- a/bin/mas-devops-saas-job-cleaner +++ b/bin/mas-devops-saas-job-cleaner @@ -46,7 +46,6 @@ if __name__ == "__main__": ch.setFormatter(chFormatter) logger.addHandler(ch) - limit = args.limit label = args.label dry_run = args.dry_run diff --git a/src/mas/devops/slack.py b/src/mas/devops/slack.py index c318d281..6aa39bc0 100644 --- a/src/mas/devops/slack.py +++ b/src/mas/devops/slack.py @@ -39,73 +39,92 @@ def client(cls) -> WebClient: # 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 postMessageBlocks(cls, channelList: str | list[str], messageBlocks: list, threadId: str = None) -> SlackResponse | list[SlackResponse]: + responses: list[SlackResponse] = [] + + if isinstance(channelList, str): + channelList = [channelList] + for channel in channelList: + try: + if threadId is None: + logger.debug(f"Posting {len(messageBlocks)} block message to {channel} in Slack") + response = cls.client.chat_postMessage( + channel=channel, + 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 {channel} on thread {threadId} in Slack") + response = cls.client.chat_postMessage( + channel=channel, + 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") + responses.append(response) + except Exception as e: + logger.error(f"Fail to send a message to {channel}: {e}") + raise + + return responses if len(responses) > 1 else responses[0] + + def postMessageText(cls, channelList: str | list[str], message: str, attachments=None, threadId: str = None) -> SlackResponse | list[SlackResponse]: + responses: list[SlackResponse] = [] + + if isinstance(channelList, str): + channelList = [channelList] + + for channel in channelList: + if threadId is None: + logger.debug(f"Posting message to {channel} in Slack") + response = cls.client.chat_postMessage( + channel=channel, + 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 {channel} on thread {threadId} in Slack") + response = cls.client.chat_postMessage( + channel=channel, + 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") + responses.append(response) + + return responses if len(responses) > 1 else responses[0] def createMessagePermalink( cls, slackResponse: SlackResponse = None, channelId: str = None, messageTimestamp: str = None, domain: str = "ibm-mas" diff --git a/test/src/test_slack.py b/test/src/test_slack.py index f2f994db..8c40d809 100644 --- a/test/src/test_slack.py +++ b/test/src/test_slack.py @@ -12,7 +12,7 @@ def testSendMessage(): - response = SlackUtil.postMessageText("#bot-test", "mas-devops unittest") + response = SlackUtil.postMessageText("#bot-test", "mas-devops postMessageTest() unittest") assert "channel" in response.data assert response.data["channel"] == "C06453F9KFC" @@ -21,3 +21,16 @@ def testSendMessage(): assert response.data["ok"] is True assert "ts" in response.data + + +def testBroadcast(): + responses = SlackUtil.postMessageText(["#bot-test", "#bot-test"], "mas-devops postMessageText() broadcast unittest") + assert len(responses) == 2 + for response in responses: + 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