diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack index 8750af3b..f0f152af 100755 --- a/bin/mas-devops-notify-slack +++ b/bin/mas-devops-notify-slack @@ -97,6 +97,267 @@ def notifyProvisionRoks(channels: list[str], rc: int, additionalMsg: str | None return response.data.get("ok", False) +def notifyPipelineStart(channels: list[str], instanceId: str | None = None, pipelineName: str | None = None) -> dict | None: + """Send Slack notification about pipeline start and create thread for all channels.""" + namespace = f"mas-{instanceId}-pipelines" + if instanceId is None or instanceId == "": + print("instanceId must be set") + sys.exit(1) + + # Check if thread already exists + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId) + if threadInfo is not None: + print("Pipeline start notification already sent") + return threadInfo + + # Send pipeline started message to all channels + toolchainLink = _getToolchainLink() + instanceInfo = f"\nInstance ID: `{instanceId}`" if instanceId else "" + message = [ + SlackUtil.buildHeader(f"🚀 MAS {pipelineName} Pipeline Started"), + SlackUtil.buildSection(f"Pipeline Run: {instanceInfo}\n{toolchainLink}") + ] + response = SlackUtil.postMessageBlocks(channels, message) + + # Store thread information for all channels in ConfigMap + configMapData = {"instanceId": instanceId} + + if isinstance(response, list): + # Multiple channels - store each channel's thread info + for idx, res in enumerate(response): + if res.data.get("ok", False): + threadId = res["ts"] + channelId = res["channel"] + # Store with channel-specific keys + configMapData[f"channel_{idx}"] = channelId + configMapData[f"threadId_{idx}"] = threadId + configMapData["channel_count"] = str(len(response)) + else: + # Single channel + if response.data.get("ok", False): + threadId = response["ts"] + channelId = response["channel"] + configMapData["channel_0"] = channelId + configMapData["threadId_0"] = threadId + configMapData["channel_count"] = "1" + else: + print("Failed to send pipeline start Slack message") + return False + + # Create ConfigMap with all channel/thread info + SlackUtil.createThreadConfigMap(namespace, "", "", instanceId) + SlackUtil.updateThreadConfigMap(namespace, instanceId, configMapData) + return SlackUtil.getThreadConfigMap(namespace, instanceId) + + +def notifyAnsibleStart(channels: list[str], taskName: str, instanceId: str | None = None, pipelineName: str | None = None) -> bool: + """Send Slack notification about Ansible task start to all channels.""" + namespace = f"mas-{instanceId}-pipelines" + if instanceId is None or instanceId == "": + print("instanceId must be set") + sys.exit(1) + + # Get thread information, create if doesn't exist + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId) + if threadInfo is None: + print("No thread found - creating pipeline start notification") + threadInfo = notifyPipelineStart(channels, instanceId, pipelineName) + + # Get channel count + channelCount = int(threadInfo.get("channel_count", "0")) + if channelCount == 0: + print("No channels found in thread info") + return False + + # Send task start message as thread reply to all channels + taskMessage = [ + SlackUtil.buildSection(f"⏳ **{taskName}** - Started") + ] + + allSuccess = True + taskMessageData = {} + + for idx in range(channelCount): + channelId = threadInfo.get(f"channel_{idx}") + threadId = threadInfo.get(f"threadId_{idx}") + + if channelId and threadId: + response = SlackUtil.postMessageBlocks(channelId, taskMessage, threadId) + + # Save message timestamp for this channel + if response.data.get("ok", False): + messageTs = response.data.get("ts") + if messageTs: + # Store with task name and channel index as key + taskMessageData[f"task_{taskName}_{idx}"] = messageTs + else: + allSuccess = False + else: + allSuccess = False + + # Update ConfigMap with all task message timestamps + if taskMessageData: + SlackUtil.updateThreadConfigMap(namespace, instanceId, taskMessageData) + + return allSuccess + + +def notifyAnsibleComplete(channels: list[str], rc: int, taskName: str, instanceId: str | None = None, pipelineName: str | None = None) -> bool: + """Send Slack notification about Ansible task completion status to all channels.""" + namespace = f"mas-{instanceId}-pipelines" + if instanceId is None or instanceId == "": + print("instanceId must be set") + sys.exit(1) + + # Get thread information, create if doesn't exist + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId) + if threadInfo is None: + print("No thread found - creating pipeline start notification") + threadInfo = notifyPipelineStart(channels, instanceId, pipelineName) + + # Get channel count + channelCount = int(threadInfo.get("channel_count", "0")) + if channelCount == 0: + print("No channels found in thread info") + return False + + # Determine status + if rc == 0: + emoji = "✅" + status = "Success" + else: + emoji = "❌" + status = "Failed" + + allSuccess = True + + # Update message in each channel + for idx in range(channelCount): + channelId = threadInfo.get(f"channel_{idx}") + threadId = threadInfo.get(f"threadId_{idx}") + taskMessageTs = threadInfo.get(f"task_{taskName}_{idx}") + + if not channelId or not threadId: + allSuccess = False + continue + + # Calculate task duration if we have the message timestamp + durationText = "" + if taskMessageTs: + from datetime import datetime, timezone + try: + # Message timestamp is in format "1234567890.123456" + startTime = float(taskMessageTs) + endTime = datetime.now(timezone.utc).timestamp() + duration = int(endTime - startTime) + + hours, remainder = divmod(duration, 3600) + minutes, seconds = divmod(remainder, 60) + + if hours > 0: + durationText = f" ({hours}h {minutes}m {seconds}s)" + elif minutes > 0: + durationText = f" ({minutes}m {seconds}s)" + else: + durationText = f" ({seconds}s)" + except Exception as e: + print(f"Failed to calculate duration for channel {idx}: {e}") + + # Build the completion message + taskMessage = [ + SlackUtil.buildSection(f"{emoji} **{taskName}** - {status}{durationText}") + ] + if rc != 0: + taskMessage.append(SlackUtil.buildSection(f"Return Code: `{rc}`\nCheck logs for details")) + + # If we have the original message timestamp, update it; otherwise post new message + if taskMessageTs: + response = SlackUtil.updateMessageBlocks(channelId, taskMessageTs, taskMessage) + if not response.data.get("ok", False): + allSuccess = False + else: + # Fallback: post new message if task start message wasn't tracked + print(f"No start message found for task {taskName} in channel {idx}, posting new completion message") + response = SlackUtil.postMessageBlocks(channelId, taskMessage, threadId) + if not response.data.get("ok", False): + allSuccess = False + + return allSuccess + + +def notifyPipelineComplete(channels: list[str], rc: int, instanceId: str | None = None, pipelineName: str | None = None) -> bool: + """Send Slack notification about pipeline completion to all channels and cleanup ConfigMap.""" + namespace = f"mas-{instanceId}-pipelines" + if instanceId is None or instanceId == "": + print("instanceId must be set") + sys.exit(1) + + # Get thread information + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId) + if threadInfo is None: + print("No thread information found - pipeline may not have started properly") + return False + + # Get channel count + channelCount = int(threadInfo.get("channel_count", "0")) + if channelCount == 0: + print("No channels found in thread info") + return False + + startTime = threadInfo.get("startTime") + + # Calculate duration if start time is available + durationText = "" + if startTime: + from datetime import datetime, timezone + try: + start = datetime.fromisoformat(startTime.replace("Z", "+00:00")) + end = datetime.now(timezone.utc) + duration = end - start + hours, remainder = divmod(int(duration.total_seconds()), 3600) + minutes, seconds = divmod(remainder, 60) + if hours > 0: + durationText = f"\nTotal Duration: {hours}h {minutes}m {seconds}s" + else: + durationText = f"\nTotal Duration: {minutes}m {seconds}s" + except Exception: + pass + + instanceInfo = f"\nInstance ID: `{instanceId}`" if instanceId else "" + if rc == 0: + emoji = "🎉" + status = "Completed Successfully" + additionalInfo = "\nAll tasks completed successfully" + else: + emoji = "💥" + status = "Failed" + additionalInfo = f"\nPipeline failed with return code: `{rc}`" + + message = [ + SlackUtil.buildHeader(f"{emoji} MAS {pipelineName} Pipeline {status}"), + SlackUtil.buildSection(f"Pipeline Run: {instanceInfo}{durationText}{additionalInfo}") + ] + + allSuccess = True + + # Send completion message to all channels + for idx in range(channelCount): + channelId = threadInfo.get(f"channel_{idx}") + threadId = threadInfo.get(f"threadId_{idx}") + + if channelId and threadId: + response = SlackUtil.postMessageBlocks(channelId, message, threadId) + if not response.data.get("ok", False): + allSuccess = False + else: + allSuccess = False + + # Clean up ConfigMap + SlackUtil.deleteThreadConfigMap(namespace, instanceId) + + return allSuccess + + 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", "") @@ -112,8 +373,11 @@ if __name__ == "__main__": # Primary Options parser.add_argument("--action", required=True) - parser.add_argument("--rc", required=True, type=int) + parser.add_argument("--rc", required=False, type=int) parser.add_argument("--msg", required=False, default=None) + parser.add_argument("--task-name", required=False, default="") + parser.add_argument("--instance-id", required=False, default=None) + parser.add_argument("--pipeline-name", required=False, default=None) args, unknown = parser.parse_known_args() @@ -121,3 +385,11 @@ if __name__ == "__main__": notifyProvisionFyre(channelList, args.rc, args.msg) elif args.action == "ocp-provision-roks": notifyProvisionRoks(channelList, args.rc, args.msg) + elif args.action == "pipeline-start": + notifyPipelineStart(channelList, args.instance_id, args.pipeline_name) + elif args.action == "ansible-start": + notifyAnsibleStart(channelList, args.task_name, args.instance_id, args.pipeline_name) + elif args.action == "ansible-complete": + notifyAnsibleComplete(channelList, args.rc, args.task_name, args.instance_id, args.pipeline_name) + elif args.action == "pipeline-complete": + notifyPipelineComplete(channelList, args.rc, args.instance_id, args.pipeline_name) diff --git a/src/mas/devops/data/catalogs/v9-290129-ppc64le.yaml b/src/mas/devops/data/catalogs/v9-290129-ppc64le.yaml new file mode 100644 index 00000000..adcb2163 --- /dev/null +++ b/src/mas/devops/data/catalogs/v9-290129-ppc64le.yaml @@ -0,0 +1,60 @@ +--- +# Case bundle configuration for IBM Maximo Operator Catalog 260129 (PPC) +# ----------------------------------------------------------------------------- +# In the future this won't be necessary as we'll be able to mirror from the +# catalog itself, but not everything in the catalog supports this yet (including MAS) +# so we need to use the CASE bundle mirror process still. + +catalog_digest: sha256:20ebe4e08614f5eb34afebc92345368c87f81d21f9212baa78ea3725d2bc2f0b + +ocp_compatibility: +- 4.16 +- 4.17 +- 4.18 +- 4.19 + +uds_version: 2.0.12 # Operator version 2.0.12 # sticking to 2.0.12 version # Please do Not Change +sls_version: 3.12.5 # Operator version 3.12.5 (https://github.ibm.com/maximoappsuite/ibm-sls/releases) +tsm_version: 1.7.2 # Operator version 1.7.2 (https://github.ibm.com/maximoappsuite/ibm-truststore-mgr/releases) +db2u_version: 7.5.1+20251217.121408.18568 # Operator version 110509.0.7 to find the version 7.5.1+20251217.121408.18568, search db2u-operator digest on repo (https://github.com/IBM/cloud-pak/tree/master/repo/case/ibm-db2uoperator) + +# Maximo Application Suite +# ----------------------------------------------------------------------------- +mas_core_version: + 9.2.x-feature: 9.2.0-pre.stable_9887 # Updated + 9.1.x: 9.1.8 # Updated + 9.0.x: 9.0.19 # Updated + 8.10.x: "" # Not Supported + 8.11.x: "" # Not Supported +mas_manage_version: + 9.2.x-feature: 9.2.0-pre.stable_10282 # Updated + 9.1.x: 9.1.8 # Updated + 9.0.x: 9.0.21 # Updated + 8.10.x: "" # Not Supported + 8.11.x: "" # Not Supported + +# Extra Images for UDS +# ------------------------------------------------------------------------------ +uds_extras_version: 1.5.0 + +# Extra Images for Mongo +# ------------------------------------------------------------------------------ +mongo_extras_version_default: 8.0.17 + +# Variables used to mirror additional mongo image versions +mongo_extras_version_4: 4.4.21 +mongo_extras_version_5: 5.0.23 +mongo_extras_version_6: 6.0.12 +mongo_extras_version_7: 7.0.12 +mongo_extras_version_8: 8.0.17 + +editorial: + whats_new: + - title: '**MongoDb v8.0.17** support Running `mas update` will automatically upgrade existing MongoDbCommunity instances to MongoDb version v8.0.17 this requires MongoDb version to be on v7 or later.' + details: [] + - title: '**Security updates and bug fixes**' + details: + - IBM Maximo Application Suite Core Platform v9.0 and v9.1 + - IBM Maximo Manage v9.0 and v9.1 + - IBM Suite License Service v3.12 + known_issues: [] diff --git a/src/mas/devops/slack.py b/src/mas/devops/slack.py index a97cfd5d..1458bb9f 100644 --- a/src/mas/devops/slack.py +++ b/src/mas/devops/slack.py @@ -13,7 +13,8 @@ import os from slack_sdk import WebClient from slack_sdk.web.slack_response import SlackResponse - +from kubernetes import client, config +from datetime import datetime, timezone import logging logger = logging.getLogger(__name__) @@ -270,6 +271,147 @@ def buildDivider(cls) -> dict: Returns: dict: Slack block kit divider element """ + def createThreadConfigMap(cls, namespace: str, channelId: str, threadId: str, instanceId: str) -> bool: + """ + Create a ConfigMap to store Slack thread information for a pipeline run. + + Parameters: + namespace (str): Kubernetes namespace for the ConfigMap + channelId (str): Slack channel ID where the thread was created + threadId (str): Slack thread timestamp + instanceId (str): Name of the Mas Instance ID + + Returns: + bool: True if ConfigMap was created successfully, False otherwise + """ + try: + # Load Kubernetes configuration + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + v1 = client.CoreV1Api() + configmap_name = f"slack-thread-{instanceId}" + configmap = client.V1ConfigMap( + metadata=client.V1ObjectMeta( + name=configmap_name, + namespace=namespace + ), + data={ + "threadId": threadId, + "channelId": channelId, + "instanceId": instanceId, + "startTime": datetime.now(timezone.utc) + } + ) + v1.create_namespaced_config_map(namespace=namespace, body=configmap) + logger.info(f"Created ConfigMap {configmap_name} in namespace {namespace}") + return True + except Exception as e: + logger.error(f"Failed to create ConfigMap: {e}") + return False + + def getThreadConfigMap(cls, namespace: str, instanceId: str) -> dict | None: + """ + Retrieve Slack thread information from a ConfigMap. + + Parameters: + namespace (str): Kubernetes namespace containing the ConfigMap + instanceId (str): Unique identifier for the pipeline run + + Returns: + dict | None: Dictionary containing threadId, channelId, pipelineName, and startTime, or None if not found + """ + try: + # Load Kubernetes configuration + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + v1 = client.CoreV1Api() + configmap_name = f"slack-thread-{instanceId}" + configmap = v1.read_namespaced_config_map(name=configmap_name, namespace=namespace) + logger.debug(f"Retrieved ConfigMap {configmap_name} from namespace {namespace}") + return configmap.data + except client.exceptions.ApiException as e: + if e.status == 404: + logger.debug(f"ConfigMap slack-thread-{instanceId} not found in namespace {namespace}") + else: + logger.error(f"Failed to retrieve ConfigMap: {e}") + return None + except Exception as e: + logger.error(f"Failed to retrieve ConfigMap: {e}") + return None + + def updateThreadConfigMap(cls, namespace: str, instanceId: str, updates: dict) -> bool: + """ + Update the ConfigMap with additional data (e.g., task message timestamps). + + Parameters: + namespace (str): Kubernetes namespace containing the ConfigMap + instanceId (str): Unique identifier for the pipeline run + updates (dict): Dictionary of key-value pairs to add/update in the ConfigMap + + Returns: + bool: True if ConfigMap was updated successfully, False otherwise + """ + try: + # Load Kubernetes configuration + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + v1 = client.CoreV1Api() + configmap_name = f"slack-thread-{instanceId}" + + # Get existing ConfigMap + configmap = v1.read_namespaced_config_map(name=configmap_name, namespace=namespace) + + # Update data + if configmap.data is None: + configmap.data = {} + configmap.data.update(updates) + + # Patch the ConfigMap + v1.patch_namespaced_config_map(name=configmap_name, namespace=namespace, body=configmap) + logger.debug(f"Updated ConfigMap {configmap_name} in namespace {namespace}") + return True + except Exception as e: + logger.error(f"Failed to update ConfigMap: {e}") + return False + + def deleteThreadConfigMap(cls, namespace: str, instanceId: str) -> bool: + """ + Delete the ConfigMap containing Slack thread information. + + Parameters: + namespace (str): Kubernetes namespace containing the ConfigMap + pipelineRunName (str): Unique identifier for the pipeline run + + Returns: + bool: True if ConfigMap was deleted successfully, False otherwise + """ + try: + # Load Kubernetes configuration + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + + v1 = client.CoreV1Api() + configmap_name = f"slack-thread-{instanceId}" + v1.delete_namespaced_config_map(name=configmap_name, namespace=namespace) + logger.info(f"Deleted ConfigMap {configmap_name} from namespace {namespace}") + return True + except client.exceptions.ApiException as e: + if e.status == 404: + logger.warning(f"ConfigMap slack-thread-{instanceId} not found in namespace {namespace}") + else: + logger.error(f"Failed to delete ConfigMap: {e}") + return False + except Exception as e: + logger.error(f"Failed to delete ConfigMap: {e}") + return False return {"type": "divider"} diff --git a/src/mas/devops/tekton.py b/src/mas/devops/tekton.py index 96054c99..fd6aea20 100644 --- a/src/mas/devops/tekton.py +++ b/src/mas/devops/tekton.py @@ -10,6 +10,7 @@ import logging import yaml +import base64 from datetime import datetime from os import path @@ -435,17 +436,22 @@ def prepareAiServicePipelinesNamespace(dynClient: DynamicClient, instanceId: str logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") -def prepareRestoreSecrets(dynClient: DynamicClient, namespace: str, restoreConfigs: dict = None): +def prepareInstallSecrets(dynClient: DynamicClient, namespace: str, slsLicenseFile: str = None, additionalConfigs: dict = None, certs: str = None, podTemplates: str = None, slack_token: str = None, slack_channel: str = None) -> None: """ - Create or update secret required for MAS Restore pipeline. + Create or update secrets required for MAS installation pipelines. - Creates secret in the specified namespace: - - pipeline-restore-configs + Creates five secrets in the specified namespace: mas-devops-slack, pipeline-additional-configs, + pipeline-sls-entitlement, pipeline-certificates, and pipeline-pod-templates. Parameters: dynClient (DynamicClient): OpenShift Dynamic Client - namespace (str): The namespace to create secrets in - restoreConfigs (dict, optional): configuration data for restore. Defaults to None (empty secret). + namespace (str): The namespace to create secrets in (format: mas-{instance_id}-pipelines) + slsLicenseFile (str, optional): SLS license file content. Defaults to None (empty secret). + additionalConfigs (dict, optional): Additional configuration data. Defaults to None (empty secret). + certs (str, optional): Certificate data. Defaults to None (empty secret). + podTemplates (str, optional): Pod template data. Defaults to None (empty secret). + slack_token (str, optional): Slack bot token for notifications. Defaults to None. + slack_channel (str, optional): Slack channel ID for notifications. Defaults to None. Returns: None @@ -455,48 +461,43 @@ def prepareRestoreSecrets(dynClient: DynamicClient, namespace: str, restoreConfi """ secretsAPI = dynClient.resources.get(api_version="v1", kind="Secret") - # 1. Secret/pipeline-restore-configs + # Extract instance ID from namespace (format: mas-{instance_id}-pipelines) + instance_id = None + if namespace.startswith("mas-") and namespace.endswith("-pipelines"): + instance_id = namespace[4:-10] # Remove "mas-" prefix and "-pipelines" suffix + + # 0. Secret/mas-devops-slack # ------------------------------------------------------------------------- - # Must exist, but can be empty - try: - secretsAPI.delete(name="pipeline-restore-configs", namespace=namespace) - except NotFoundError: - pass + # Create mas-devops-slack secret with MAS_INSTANCE_ID, SLACK_TOKEN, and SLACK_CHANNEL keys + if instance_id: + try: + secretsAPI.delete(name="mas-devops-slack", namespace=namespace) + except NotFoundError: + pass + + secret_data = { + "MAS_INSTANCE_ID": base64.b64encode(instance_id.encode()).decode() + } + + # Add slack_token if provided + if slack_token: + secret_data["SLACK_TOKEN"] = base64.b64encode(slack_token.encode()).decode() - if restoreConfigs is None: - restoreConfigs = { + # Add slack_channel if provided + if slack_channel: + secret_data["SLACK_CHANNEL"] = base64.b64encode(slack_channel.encode()).decode() + + mas_devops_secret = { "apiVersion": "v1", "kind": "Secret", "type": "Opaque", "metadata": { - "name": "pipeline-restore-configs" - } + "name": "mas-devops-slack" + }, + "data": secret_data } - secretsAPI.create(body=restoreConfigs, namespace=namespace) - - -def prepareInstallSecrets(dynClient: DynamicClient, namespace: str, slsLicenseFile: dict | None = None, additionalConfigs: dict | None = None, certs: dict | None = None, podTemplates: dict | None = None) -> None: - """ - Create or update secrets required for MAS installation pipelines. - - Creates four secrets in the specified namespace: pipeline-additional-configs, - pipeline-sls-entitlement, pipeline-certificates, and pipeline-pod-templates. - - Parameters: - dynClient (DynamicClient): OpenShift Dynamic Client - namespace (str): The namespace to create secrets in - slsLicenseFile (dict, optional): SLS license file content. Defaults to None (empty secret). - additionalConfigs (dict, optional): Additional configuration data. Defaults to None (empty secret). - certs (dict, optional): Certificate data. Defaults to None (empty secret). - podTemplates (dict, optional): Pod template data. Defaults to None (empty secret). - - Returns: - None - - Raises: - NotFoundError: If secrets cannot be created - """ - secretsAPI = dynClient.resources.get(api_version="v1", kind="Secret") + secretsAPI.create(body=mas_devops_secret, namespace=namespace) + logger.info(f"Created mas-devops-slack secret with MAS_INSTANCE_ID={instance_id} in namespace {namespace}") # 1. Secret/pipeline-additional-configs # ------------------------------------------------------------------------- diff --git a/test/src/test_slack.py b/test/src/test_slack.py index 8c40d809..d489cc91 100644 --- a/test/src/test_slack.py +++ b/test/src/test_slack.py @@ -8,8 +8,18 @@ # # ***************************************************************************** +from importlib.machinery import SourceFileLoader +import os +import sys +import pytest +from unittest.mock import Mock, patch from mas.devops.slack import SlackUtil +# Import functions from the notify-slack script +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../bin')) +script_path = os.path.join(os.path.dirname(__file__), '../../bin/mas-devops-notify-slack') +notify_slack = SourceFileLoader('notify_slack', script_path).load_module() + def testSendMessage(): response = SlackUtil.postMessageText("#bot-test", "mas-devops postMessageTest() unittest") @@ -34,3 +44,586 @@ def testBroadcast(): assert response.data["ok"] is True assert "ts" in response.data + + +# Tests for _getClusterName function +def test_getClusterName_success(): + """Test _getClusterName returns cluster name when env var is set""" + with patch.dict(os.environ, {'CLUSTER_NAME': 'test-cluster'}): + result = notify_slack._getClusterName() + assert result == 'test-cluster' + + +def test_getClusterName_missing(): + """Test _getClusterName exits when CLUSTER_NAME is not set""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(SystemExit) as exc_info: + notify_slack._getClusterName() + assert exc_info.value.code == 1 + + +def test_getClusterName_empty(): + """Test _getClusterName exits when CLUSTER_NAME is empty""" + with patch.dict(os.environ, {'CLUSTER_NAME': ''}): + with pytest.raises(SystemExit) as exc_info: + notify_slack._getClusterName() + assert exc_info.value.code == 1 + + +# Tests for _getToolchainLink function +def test_getToolchainLink_both_set(): + """Test _getToolchainLink returns formatted link when both env vars are set""" + with patch.dict(os.environ, { + 'TOOLCHAIN_PIPELINERUN_URL': 'https://example.com/pipeline', + 'TOOLCHAIN_TRIGGER_NAME': 'test-trigger' + }): + result = notify_slack._getToolchainLink() + assert result == '' + + +def test_getToolchainLink_url_only(): + """Test _getToolchainLink returns empty string when only URL is set""" + with patch.dict(os.environ, {'TOOLCHAIN_PIPELINERUN_URL': 'https://example.com/pipeline'}, clear=True): + result = notify_slack._getToolchainLink() + assert result == '' + + +def test_getToolchainLink_trigger_only(): + """Test _getToolchainLink returns empty string when only trigger name is set""" + with patch.dict(os.environ, {'TOOLCHAIN_TRIGGER_NAME': 'test-trigger'}, clear=True): + result = notify_slack._getToolchainLink() + assert result == '' + + +def test_getToolchainLink_none_set(): + """Test _getToolchainLink returns empty string when neither env var is set""" + with patch.dict(os.environ, {}, clear=True): + result = notify_slack._getToolchainLink() + assert result == '' + + +# Tests for notifyProvisionFyre function +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionFyre_success(mock_post): + """Test notifyProvisionFyre with successful provisioning (rc=0)""" + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CLUSTER_NAME': 'test-cluster', + 'OCP_CONSOLE_URL': 'https://console.example.com', + 'OCP_USERNAME': 'admin', + 'OCP_PASSWORD': 'password123' # pragma: allowlist secret + }): + result = notify_slack.notifyProvisionFyre(['#test-channel'], 0) + assert result is True + mock_post.assert_called_once() + call_args = mock_post.call_args + assert len(call_args[0][1]) == 4 # 4 message blocks + + +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionFyre_success_with_additional_msg(mock_post): + """Test notifyProvisionFyre with successful provisioning and additional message""" + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CLUSTER_NAME': 'test-cluster', + 'OCP_CONSOLE_URL': 'https://console.example.com', + 'OCP_USERNAME': 'admin', + 'OCP_PASSWORD': 'password123' # pragma: allowlist secret + }): + result = notify_slack.notifyProvisionFyre(['#test-channel'], 0, 'Additional info') + assert result is True + call_args = mock_post.call_args + assert len(call_args[0][1]) == 5 # 5 message blocks with additional message + + +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionFyre_failure(mock_post): + """Test notifyProvisionFyre with failed provisioning (rc!=0)""" + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_post.return_value = mock_response + + with patch.dict(os.environ, {'CLUSTER_NAME': 'test-cluster'}): + result = notify_slack.notifyProvisionFyre(['#test-channel'], 1) + assert result is True + call_args = mock_post.call_args + assert len(call_args[0][1]) == 2 # 2 message blocks for failure + + +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionFyre_multiple_channels(mock_post): + """Test notifyProvisionFyre with multiple channels""" + mock_response1 = Mock() + mock_response1.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_response2 = Mock() + mock_response2.data = {'ok': True, 'channel': 'C456', 'ts': '1234567890.123457'} + mock_post.return_value = [mock_response1, mock_response2] + + with patch.dict(os.environ, {'CLUSTER_NAME': 'test-cluster'}): + result = notify_slack.notifyProvisionFyre(['#channel1', '#channel2'], 1) + assert result is True + + +def test_notifyProvisionFyre_missing_env_vars(): + """Test notifyProvisionFyre exits when required env vars are missing for success case""" + with patch.dict(os.environ, {'CLUSTER_NAME': 'test-cluster'}, clear=True): + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyProvisionFyre(['#test-channel'], 0) + assert exc_info.value.code == 1 + + +# Tests for notifyProvisionRoks function +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionRoks_success(mock_post): + """Test notifyProvisionRoks with successful provisioning (rc=0)""" + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CLUSTER_NAME': 'test-cluster', + 'OCP_CONSOLE_URL': 'https://console.example.com' + }): + result = notify_slack.notifyProvisionRoks(['#test-channel'], 0) + assert result is True + mock_post.assert_called_once() + call_args = mock_post.call_args + assert len(call_args[0][1]) == 3 # 3 message blocks + + +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionRoks_success_with_additional_msg(mock_post): + """Test notifyProvisionRoks with successful provisioning and additional message""" + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CLUSTER_NAME': 'test-cluster', + 'OCP_CONSOLE_URL': 'https://console.example.com' + }): + result = notify_slack.notifyProvisionRoks(['#test-channel'], 0, 'Extra details') + assert result is True + call_args = mock_post.call_args + assert len(call_args[0][1]) == 4 # 4 message blocks with additional message + + +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyProvisionRoks_failure(mock_post): + """Test notifyProvisionRoks with failed provisioning (rc!=0)""" + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_post.return_value = mock_response + + with patch.dict(os.environ, {'CLUSTER_NAME': 'test-cluster'}): + result = notify_slack.notifyProvisionRoks(['#test-channel'], 1) + assert result is True + call_args = mock_post.call_args + assert len(call_args[0][1]) == 2 # 2 message blocks for failure + + +def test_notifyProvisionRoks_missing_url(): + """Test notifyProvisionRoks exits when OCP_CONSOLE_URL is missing for success case""" + with patch.dict(os.environ, {'CLUSTER_NAME': 'test-cluster'}, clear=True): + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyProvisionRoks(['#test-channel'], 0) + assert exc_info.value.code == 1 + + +# Tests for notifyPipelineStart function +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'createThreadConfigMap') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyPipelineStart_new_thread(mock_update, mock_create, mock_post, mock_get): + """Test notifyPipelineStart creates new thread when none exists""" + # First call returns None, second call returns the created thread info + thread_info = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_get.side_effect = [None, thread_info] + mock_response = Mock() + mock_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_response.__getitem__ = lambda self, key: mock_response.data[key] if key in ['ts', 'channel'] else None + mock_post.return_value = mock_response + + result = notify_slack.notifyPipelineStart(['#test-channel'], 'test-instance', 'Install') + + assert result is not None + assert result == thread_info + mock_post.assert_called_once() + mock_create.assert_called_once() + mock_update.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +def test_notifyPipelineStart_existing_thread(mock_get): + """Test notifyPipelineStart returns existing thread info""" + existing_thread = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_get.return_value = existing_thread + + result = notify_slack.notifyPipelineStart(['#test-channel'], 'test-instance', 'Install') + + assert result == existing_thread + + +def test_notifyPipelineStart_missing_instance_id(): + """Test notifyPipelineStart exits when instanceId is missing""" + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyPipelineStart(['#test-channel'], None, 'Install') + assert exc_info.value.code == 1 + + +def test_notifyPipelineStart_empty_instance_id(): + """Test notifyPipelineStart exits when instanceId is empty""" + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyPipelineStart(['#test-channel'], '', 'Install') + assert exc_info.value.code == 1 + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'createThreadConfigMap') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyPipelineStart_multiple_channels(mock_update, mock_create, mock_post, mock_get): + """Test notifyPipelineStart with multiple channels""" + # First call returns None, second call returns the created thread info + thread_info = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_1': 'C456', + 'threadId_1': '1234567890.123457', + 'channel_count': '2' + } + mock_get.side_effect = [None, thread_info] + mock_response1 = Mock() + mock_response1.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_response1.__getitem__ = lambda self, key: mock_response1.data[key] if key in ['ts', 'channel'] else None + mock_response2 = Mock() + mock_response2.data = {'ok': True, 'channel': 'C456', 'ts': '1234567890.123457'} + mock_response2.__getitem__ = lambda self, key: mock_response2.data[key] if key in ['ts', 'channel'] else None + mock_post.return_value = [mock_response1, mock_response2] + + result = notify_slack.notifyPipelineStart(['#channel1', '#channel2'], 'test-instance', 'Install') + + assert result is not None + # Verify that channel_count is set to 2 + update_call_args = mock_update.call_args[0][2] + assert update_call_args['channel_count'] == '2' + + +# Tests for notifyAnsibleStart function +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyAnsibleStart_success(mock_update, mock_post, mock_get): + """Test notifyAnsibleStart sends task start message""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_response = Mock() + mock_response.data = {'ok': True, 'ts': '1234567890.123457'} + mock_post.return_value = mock_response + + result = notify_slack.notifyAnsibleStart(['#test-channel'], 'install-mas', 'test-instance', 'Install') + + assert result is True + mock_post.assert_called_once() + mock_update.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyAnsibleStart_creates_thread_if_missing(mock_update, mock_post, mock_get): + """Test notifyAnsibleStart creates pipeline thread if it doesn't exist""" + # First call returns None (no thread), second call returns None (checking again in notifyPipelineStart), + # third call returns thread info (after creation), fourth call returns thread info (for ansible start) + thread_info = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_get.side_effect = [None, None, thread_info, thread_info] + + # Mock for notifyPipelineStart's postMessageBlocks call + mock_pipeline_response = Mock() + mock_pipeline_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_pipeline_response.__getitem__ = lambda self, key: mock_pipeline_response.data[key] if key in ['ts', 'channel'] else None + + # Mock for notifyAnsibleStart's postMessageBlocks call + mock_task_response = Mock() + mock_task_response.data = {'ok': True, 'ts': '1234567890.123457'} + + mock_post.side_effect = [mock_pipeline_response, mock_task_response] + + with patch.object(SlackUtil, 'createThreadConfigMap'): + result = notify_slack.notifyAnsibleStart(['#test-channel'], 'install-mas', 'test-instance', 'Install') + + assert result is True + assert mock_post.call_count == 2 # Once for pipeline start, once for task start + + +def test_notifyAnsibleStart_missing_instance_id(): + """Test notifyAnsibleStart exits when instanceId is missing""" + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyAnsibleStart(['#test-channel'], 'task-name', None, 'Install') + assert exc_info.value.code == 1 + + +@patch.object(SlackUtil, 'getThreadConfigMap') +def test_notifyAnsibleStart_no_channels(mock_get): + """Test notifyAnsibleStart returns False when no channels found""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_count': '0' + } + + result = notify_slack.notifyAnsibleStart(['#test-channel'], 'task-name', 'test-instance', 'Install') + + assert result is False + + +# Tests for notifyAnsibleComplete function +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'updateMessageBlocks') +def test_notifyAnsibleComplete_success(mock_update, mock_get): + """Test notifyAnsibleComplete with successful task (rc=0)""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'task_install-mas_0': '1234567890.123457', + 'channel_count': '1' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_update.return_value = mock_response + + result = notify_slack.notifyAnsibleComplete(['#test-channel'], 0, 'install-mas', 'test-instance', 'Install') + + assert result is True + mock_update.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'updateMessageBlocks') +def test_notifyAnsibleComplete_failure(mock_update, mock_get): + """Test notifyAnsibleComplete with failed task (rc!=0)""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'task_install-mas_0': '1234567890.123457', + 'channel_count': '1' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_update.return_value = mock_response + + result = notify_slack.notifyAnsibleComplete(['#test-channel'], 1, 'install-mas', 'test-instance', 'Install') + + assert result is True + # Verify failure message includes return code + call_args = mock_update.call_args[0][2] + assert len(call_args) == 2 # Should have 2 blocks for failure (status + error details) + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyAnsibleComplete_no_start_message(mock_post, mock_get): + """Test notifyAnsibleComplete posts new message when start message not found""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_post.return_value = mock_response + + result = notify_slack.notifyAnsibleComplete(['#test-channel'], 0, 'install-mas', 'test-instance', 'Install') + + assert result is True + mock_post.assert_called_once() + + +def test_notifyAnsibleComplete_missing_instance_id(): + """Test notifyAnsibleComplete exits when instanceId is missing""" + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyAnsibleComplete(['#test-channel'], 0, 'task-name', None, 'Install') + assert exc_info.value.code == 1 + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +def test_notifyAnsibleComplete_creates_thread_if_missing(mock_post, mock_get): + """Test notifyAnsibleComplete creates pipeline thread if it doesn't exist""" + # First call returns None (no thread), second call returns None (checking again in notifyPipelineStart), + # third call returns thread info (after creation), fourth call returns thread info (for ansible complete) + thread_info = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_get.side_effect = [None, None, thread_info, thread_info] + + # Mock for notifyPipelineStart's postMessageBlocks call + mock_pipeline_response = Mock() + mock_pipeline_response.data = {'ok': True, 'channel': 'C123', 'ts': '1234567890.123456'} + mock_pipeline_response.__getitem__ = lambda self, key: mock_pipeline_response.data[key] if key in ['ts', 'channel'] else None + + # Mock for notifyAnsibleComplete's postMessageBlocks call + mock_complete_response = Mock() + mock_complete_response.data = {'ok': True} + + mock_post.side_effect = [mock_pipeline_response, mock_complete_response] + + with patch.object(SlackUtil, 'createThreadConfigMap'), patch.object(SlackUtil, 'updateThreadConfigMap'): + result = notify_slack.notifyAnsibleComplete(['#test-channel'], 0, 'install-mas', 'test-instance', 'Install') + + assert result is True + assert mock_post.call_count == 2 # Once for pipeline start, once for task complete + + +# Tests for notifyPipelineComplete function +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'deleteThreadConfigMap') +def test_notifyPipelineComplete_success(mock_delete, mock_post, mock_get): + """Test notifyPipelineComplete with successful pipeline (rc=0)""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_post.return_value = mock_response + + result = notify_slack.notifyPipelineComplete(['#test-channel'], 0, 'test-instance', 'Install') + + assert result is True + mock_post.assert_called_once() + mock_delete.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'deleteThreadConfigMap') +def test_notifyPipelineComplete_failure(mock_delete, mock_post, mock_get): + """Test notifyPipelineComplete with failed pipeline (rc!=0)""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_post.return_value = mock_response + + result = notify_slack.notifyPipelineComplete(['#test-channel'], 1, 'test-instance', 'Install') + + assert result is True + mock_delete.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +def test_notifyPipelineComplete_no_thread_info(mock_get): + """Test notifyPipelineComplete returns False when no thread info found""" + mock_get.return_value = None + + result = notify_slack.notifyPipelineComplete(['#test-channel'], 0, 'test-instance', 'Install') + + assert result is False + + +def test_notifyPipelineComplete_missing_instance_id(): + """Test notifyPipelineComplete exits when instanceId is missing""" + with pytest.raises(SystemExit) as exc_info: + notify_slack.notifyPipelineComplete(['#test-channel'], 0, None, 'Install') + assert exc_info.value.code == 1 + + +@patch.object(SlackUtil, 'getThreadConfigMap') +def test_notifyPipelineComplete_no_channels(mock_get): + """Test notifyPipelineComplete returns False when no channels found""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_count': '0' + } + + result = notify_slack.notifyPipelineComplete(['#test-channel'], 0, 'test-instance', 'Install') + + assert result is False + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'deleteThreadConfigMap') +def test_notifyPipelineComplete_multiple_channels(mock_delete, mock_post, mock_get): + """Test notifyPipelineComplete with multiple channels""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_1': 'C456', + 'threadId_1': '1234567890.123457', + 'channel_count': '2' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_post.return_value = mock_response + + result = notify_slack.notifyPipelineComplete(['#channel1', '#channel2'], 0, 'test-instance', 'Install') + + assert result is True + assert mock_post.call_count == 2 + mock_delete.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'deleteThreadConfigMap') +def test_notifyPipelineComplete_with_duration(mock_delete, mock_post, mock_get): + """Test notifyPipelineComplete includes duration when startTime is available""" + mock_get.return_value = { + 'instanceId': 'test-instance', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'channel_count': '1', + 'startTime': '2026-03-10T18:00:00Z' + } + mock_response = Mock() + mock_response.data = {'ok': True} + mock_post.return_value = mock_response + + result = notify_slack.notifyPipelineComplete(['#test-channel'], 0, 'test-instance', 'Install') + + assert result is True + # Verify that postMessageBlocks was called + mock_post.assert_called_once() + mock_delete.assert_called_once()