diff --git a/bin/mas-devops-notify-slack b/bin/mas-devops-notify-slack index 8750af3b..715c5aab 100755 --- a/bin/mas-devops-notify-slack +++ b/bin/mas-devops-notify-slack @@ -97,6 +97,300 @@ 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.""" + # Exit early if no channels provided + if not channels or len(channels) == 0: + print("No Slack channels provided - skipping pipeline start notification") + return None + + # For update pipeline, use mas-pipelines namespace (no instance ID) + # For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace + if instanceId is None or instanceId == "": + namespace = "mas-pipelines" + else: + namespace = f"mas-{instanceId}-pipelines" + + # Check if thread already exists + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName) + if threadInfo is not None: + print("Pipeline start notification already sent") + return threadInfo + + # Send pipeline started message to all channels + toolchainLink = _getToolchainLink() + instanceInfo = f"Instance ID: `{instanceId}`" if instanceId else "" + message = [ + SlackUtil.buildHeader(f"🚀 MAS {pipelineName} Pipeline Started"), + SlackUtil.buildSection(f"Pipeline Run: {pipelineName}\n{instanceInfo}\n{toolchainLink}") + ] + response = SlackUtil.postMessageBlocks(channels, message) + + # Store thread information for all channels in ConfigMap + configMapData = {"instanceId": instanceId, "pipelineName": pipelineName} + + 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, pipelineName) + SlackUtil.updateThreadConfigMap(namespace, instanceId, configMapData, pipelineName) + return SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName) + + +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.""" + # Exit early if no channels provided + if not channels or len(channels) == 0: + print("No Slack channels provided - skipping Ansible task start notification") + return False + + # For update pipeline, use mas-pipelines namespace (no instance ID) + # For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace + if instanceId is None or instanceId == "": + namespace = "mas-pipelines" + else: + namespace = f"mas-{instanceId}-pipelines" + + # Get thread information, create if doesn't exist + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName) + 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, pipelineName) + + 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.""" + # Exit early if no channels provided + if not channels or len(channels) == 0: + print("No Slack channels provided - skipping Ansible task completion notification") + return False + + # For update pipeline, use mas-pipelines namespace (no instance ID) + # For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace + if instanceId is None or instanceId == "": + namespace = "mas-pipelines" + else: + namespace = f"mas-{instanceId}-pipelines" + + # Get thread information, create if doesn't exist + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName) + 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 + + # Special case, mas-update pipeline + if namespace == "mas-pipelines" and taskName == "post-deps-update-verify-ingress": + print(f"mas-update pipeline completed with status: {rc}, sending pipeline complete message") + allSuccess: bool = notifyPipelineComplete(channels, rc, instanceId, pipelineName) + + 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.""" + # Exit early if no channels provided + if not channels or len(channels) == 0: + print("No Slack channels provided - skipping pipeline completion notification") + return False + + # For update pipeline, use mas-pipelines namespace (no instance ID) + # For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace + if instanceId is None or instanceId == "": + namespace = "mas-pipelines" + else: + namespace = f"mas-{instanceId}-pipelines" + + # Get thread information + threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName) + 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"Instance 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: {pipelineName}\n{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, pipelineName) + + 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 +406,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 +418,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/slack.py b/src/mas/devops/slack.py index a97cfd5d..27678926 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,155 @@ def buildDivider(cls) -> dict: Returns: dict: Slack block kit divider element """ + def createThreadConfigMap(cls, namespace: str, instanceId: str, pipelineRunName: 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 (can be None or empty for update pipeline) + + 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() + # For update pipeline (no instance ID), use "update" as identifier + instance_identifier = instanceId if instanceId else "update" + configmap_name = f"slack-thread-{instance_identifier}-{pipelineRunName}" + configmap = client.V1ConfigMap( + metadata=client.V1ObjectMeta( + name=configmap_name, + namespace=namespace + ), + data={ + "pipelineName": pipelineRunName, + "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, pipelineRunName: 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 (can be None or empty for update pipeline) + + 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() + # For update pipeline (no instance ID), use "update" as identifier + instance_identifier = instanceId if instanceId else "update" + configmap_name = f"slack-thread-{instance_identifier}-{pipelineRunName}" + 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}-{pipelineRunName} 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, pipelineRunName: str) -> 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 (can be None or empty for update pipeline) + 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() + # For update pipeline (no instance ID), use "update" as identifier + instance_identifier = instanceId if instanceId else "update" + configmap_name = f"slack-thread-{instance_identifier}-{pipelineRunName}" + + # 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, pipelineRunName: str) -> bool: + """ + Delete the ConfigMap containing Slack thread information. + + Parameters: + namespace (str): Kubernetes namespace containing the ConfigMap + instanceId (str): Unique identifier for the pipeline run (can be None or empty for update pipeline) + 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() + # For update pipeline (no instance ID), use "update" as identifier + instance_identifier = instanceId if instanceId else "update" + configmap_name = f"slack-thread-{instance_identifier}-{pipelineRunName}" + 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}-{pipelineRunName} 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..e28fff1b 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 @@ -475,20 +476,22 @@ def prepareRestoreSecrets(dynClient: DynamicClient, namespace: str, restoreConfi 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: +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 secrets required for MAS installation pipelines. - Creates four secrets in the specified namespace: pipeline-additional-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 - slsLicenseFile (dict, optional): SLS license file content. 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 (dict, optional): Certificate data. Defaults to None (empty secret). - podTemplates (dict, optional): Pod template 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 @@ -498,6 +501,44 @@ def prepareInstallSecrets(dynClient: DynamicClient, namespace: str, slsLicenseFi """ secretsAPI = dynClient.resources.get(api_version="v1", kind="Secret") + # 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 + # ------------------------------------------------------------------------- + # 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() + + # 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": "mas-devops-slack" + }, + "data": secret_data + } + 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 # ------------------------------------------------------------------------- # Must exist, but can be empty @@ -574,6 +615,69 @@ def prepareInstallSecrets(dynClient: DynamicClient, namespace: str, slsLicenseFi secretsAPI.create(body=podTemplates, namespace=namespace) +def prepareUpdateSlackSecrets(dynClient: DynamicClient, slack_token: str = None, slack_channel: str = None) -> None: + """ + Create or update mas-devops-slack secret in mas-pipelines namespace for update pipeline. + + Creates the slack secret in mas-pipelines namespace if it exists and slack credentials are provided. + + Parameters: + dynClient (DynamicClient): OpenShift Dynamic Client + 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 + + Raises: + NotFoundError: If namespace doesn't exist (will be caught and logged) + """ + namespace = "mas-pipelines" + + # Check if namespace exists + try: + namespaceAPI = dynClient.resources.get(api_version="v1", kind="Namespace") + namespaceAPI.get(name=namespace) + except NotFoundError: + logger.warning(f"Namespace {namespace} does not exist, skipping slack secret creation") + return + + # Only create secret if both slack_token and slack_channel are provided + if not slack_token or not slack_channel: + logger.debug("Slack token or channel not provided, skipping slack secret creation") + return + + secretsAPI = dynClient.resources.get(api_version="v1", kind="Secret") + + # Delete existing secret if it exists + try: + secretsAPI.delete(name="mas-devops-slack", namespace=namespace) + except NotFoundError: + pass + + # Create the secret with SLACK_TOKEN and SLACK_CHANNEL + secret_data = {} + + if slack_token: + secret_data["SLACK_TOKEN"] = base64.b64encode(slack_token.encode()).decode() + + if slack_channel: + secret_data["SLACK_CHANNEL"] = base64.b64encode(slack_channel.encode()).decode() + + mas_devops_secret = { + "apiVersion": "v1", + "kind": "Secret", + "type": "Opaque", + "metadata": { + "name": "mas-devops-slack" + }, + "data": secret_data + } + + secretsAPI.create(body=mas_devops_secret, namespace=namespace) + logger.info(f"Created mas-devops-slack secret in namespace {namespace}") + + def testCLI() -> None: pass # echo -n "Testing availability of $CLI_IMAGE in cluster ..." diff --git a/src/mas/devops/templates/pipelinerun-uninstall.yml.j2 b/src/mas/devops/templates/pipelinerun-uninstall.yml.j2 index 5689fd62..d1a49985 100644 --- a/src/mas/devops/templates/pipelinerun-uninstall.yml.j2 +++ b/src/mas/devops/templates/pipelinerun-uninstall.yml.j2 @@ -14,6 +14,14 @@ spec: pipeline: "0" params: +{%- if image_pull_policy is defined and image_pull_policy != "" %} + + # Image Pull Policy + # ------------------------------------------------------------------------- + - name: image_pull_policy + value: "{{ image_pull_policy }}" +{%- endif %} + - name: mas_instance_id value: {{ mas_instance_id }} - name: grafana_action diff --git a/src/mas/devops/templates/pipelinerun-update.yml.j2 b/src/mas/devops/templates/pipelinerun-update.yml.j2 index 6fe95be9..6981bae4 100644 --- a/src/mas/devops/templates/pipelinerun-update.yml.j2 +++ b/src/mas/devops/templates/pipelinerun-update.yml.j2 @@ -14,6 +14,14 @@ spec: pipeline: "0" params: +{%- if image_pull_policy is defined and image_pull_policy != "" %} + + # Image Pull Policy + # ------------------------------------------------------------------------- + - name: image_pull_policy + value: "{{ image_pull_policy }}" +{%- endif %} + # Catalog version # ------------------------------------------------------------------------- - name: mas_catalog_version diff --git a/src/mas/devops/templates/pipelinerun-upgrade.yml.j2 b/src/mas/devops/templates/pipelinerun-upgrade.yml.j2 index ba5c71ea..0d7b8af4 100644 --- a/src/mas/devops/templates/pipelinerun-upgrade.yml.j2 +++ b/src/mas/devops/templates/pipelinerun-upgrade.yml.j2 @@ -14,6 +14,14 @@ spec: pipeline: "0" params: +{%- if image_pull_policy is defined and image_pull_policy != "" %} + + # Image Pull Policy + # ------------------------------------------------------------------------- + - name: image_pull_policy + value: "{{ image_pull_policy }}" +{%- endif %} + # Target MAS Instance # ------------------------------------------------------------------------- - name: mas_instance_id diff --git a/test/src/test_slack.py b/test/src/test_slack.py index 8c40d809..a3dfab83 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,726 @@ 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 + + +# These tests are removed because None/empty instanceId is now valid for update pipeline +# See new tests: test_notifyPipelineStart_update_pipeline_no_instance_id and +# test_notifyPipelineStart_update_pipeline_empty_instance_id + + +@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 + + +# This test is removed because None instanceId is now valid for update pipeline +# See new test: test_notifyAnsibleStart_update_pipeline_no_instance_id + + +@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() + + +# This test is removed because None instanceId is now valid for update pipeline +# See new test: test_notifyAnsibleComplete_update_pipeline_no_instance_id + + +@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 + + +# This test is removed because None instanceId is now valid for update pipeline +# See new test: test_notifyPipelineComplete_update_pipeline_no_instance_id + + +@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() + + +# Tests for update pipeline (no instance ID) scenarios +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'createThreadConfigMap') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyPipelineStart_update_pipeline_no_instance_id(mock_update, mock_create, mock_post, mock_get): + """Test notifyPipelineStart for update pipeline with no instance ID""" + # First call returns None, second call returns the created thread info + thread_info = { + 'instanceId': '', + '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'], None, 'Update') + + assert result is not None + assert result == thread_info + mock_post.assert_called_once() + mock_create.assert_called_once() + # Verify that createThreadConfigMap was called with None for instanceId + assert mock_create.call_args[0][1] is None + mock_update.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyAnsibleStart_update_pipeline_no_instance_id(mock_update, mock_post, mock_get): + """Test notifyAnsibleStart for update pipeline with no instance ID""" + mock_get.return_value = { + 'instanceId': '', + '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'], 'update-catalog', None, 'Update') + + assert result is True + mock_post.assert_called_once() + mock_update.assert_called_once() + # Verify that updateThreadConfigMap was called with None for instanceId + assert mock_update.call_args[0][1] is None + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'updateMessageBlocks') +def test_notifyAnsibleComplete_update_pipeline_no_instance_id(mock_update, mock_get): + """Test notifyAnsibleComplete for update pipeline with no instance ID""" + mock_get.return_value = { + 'instanceId': '', + 'channel_0': 'C123', + 'threadId_0': '1234567890.123456', + 'task_update-catalog_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, 'update-catalog', None, 'Update') + + assert result is True + mock_update.assert_called_once() + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'deleteThreadConfigMap') +def test_notifyPipelineComplete_update_pipeline_no_instance_id(mock_delete, mock_post, mock_get): + """Test notifyPipelineComplete for update pipeline with no instance ID""" + mock_get.return_value = { + 'instanceId': '', + '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, None, 'Update') + + assert result is True + mock_post.assert_called_once() + mock_delete.assert_called_once() + # Verify that deleteThreadConfigMap was called with None for instanceId + assert mock_delete.call_args[0][1] is None + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'deleteThreadConfigMap') +def test_notifyPipelineComplete_update_pipeline_empty_instance_id(mock_delete, mock_post, mock_get): + """Test notifyPipelineComplete for update pipeline with empty string instance ID""" + mock_get.return_value = { + 'instanceId': '', + '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, '', 'Update') + + assert result is True + mock_post.assert_called_once() + mock_delete.assert_called_once() + # Verify that deleteThreadConfigMap was called with empty string for instanceId + assert mock_delete.call_args[0][1] == '' + + +@patch.object(SlackUtil, 'getThreadConfigMap') +@patch.object(SlackUtil, 'postMessageBlocks') +@patch.object(SlackUtil, 'createThreadConfigMap') +@patch.object(SlackUtil, 'updateThreadConfigMap') +def test_notifyPipelineStart_update_pipeline_multiple_channels(mock_update, mock_create, mock_post, mock_get): + """Test notifyPipelineStart for update pipeline with multiple channels and no instance ID""" + # First call returns None, second call returns the created thread info + thread_info = { + 'instanceId': '', + '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'], None, 'Update') + + 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' + # Verify namespace is mas-pipelines (not mas-None-pipelines) + assert mock_create.call_args[0][0] == 'mas-pipelines'