Skip to content

Commit a1ce79e

Browse files
[patch] cli notification
1 parent 33f3d82 commit a1ce79e

File tree

2 files changed

+256
-0
lines changed

2 files changed

+256
-0
lines changed

bin/mas-devops-notify-slack

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,133 @@ def notifyProvisionRoks(channels: list[str], rc: int, additionalMsg: str | None
9797
return response.data.get("ok", False)
9898

9999

100+
def notifyAnsibleComplete(channels: list[str], rc: int, taskName: str, pipelineName: str, instanceId: str | None = None) -> bool:
101+
"""Send Slack notification about Ansible task completion status."""
102+
namespace = os.getenv("PIPELINE_NAMESPACE", "")
103+
pipelineRunName = os.getenv("PIPELINERUN_NAME", "")
104+
105+
if namespace == "" or pipelineRunName == "":
106+
print("PIPELINE_NAMESPACE and PIPELINERUN_NAME env vars must be set")
107+
sys.exit(1)
108+
109+
# Check if this is the first task (no ConfigMap exists yet)
110+
threadInfo = SlackUtil.getThreadConfigMap(namespace, pipelineRunName)
111+
112+
if threadInfo is None:
113+
# This is the first task - send pipeline started message
114+
toolchainLink = _getToolchainLink()
115+
instanceInfo = f"\nInstance ID: `{instanceId}`" if instanceId else ""
116+
117+
message = [
118+
SlackUtil.buildHeader(f"🚀 MAS {pipelineName.title()} Pipeline Started"),
119+
SlackUtil.buildSection(f"Pipeline Run: `{pipelineRunName}`{instanceInfo}\n{toolchainLink}")
120+
]
121+
122+
response = SlackUtil.postMessageBlocks(channels[0], message)
123+
124+
if response.data.get("ok", False):
125+
threadId = response["ts"]
126+
channelId = response["channel"]
127+
128+
# Store thread information in ConfigMap
129+
SlackUtil.createThreadConfigMap(namespace, pipelineRunName, channelId, threadId, pipelineName)
130+
threadInfo = {
131+
"threadId": threadId,
132+
"channelId": channelId,
133+
"pipelineName": pipelineName
134+
}
135+
else:
136+
print("Failed to send initial Slack message")
137+
return False
138+
139+
# Send task completion message as thread reply
140+
threadId = threadInfo.get("threadId")
141+
channelId = threadInfo.get("channelId")
142+
143+
if rc == 0:
144+
emoji = "✅"
145+
status = "Success"
146+
else:
147+
emoji = "❌"
148+
status = "Failed"
149+
150+
taskMessage = [
151+
SlackUtil.buildSection(f"{emoji} **{taskName}** - {status}")
152+
]
153+
154+
if rc != 0:
155+
taskMessage.append(SlackUtil.buildSection(f"Return Code: `{rc}`\nCheck logs for details"))
156+
157+
response = SlackUtil.postMessageBlocks(channelId, taskMessage, threadId)
158+
159+
if isinstance(response, list):
160+
return all([res.data.get("ok", False) for res in response])
161+
return response.data.get("ok", False)
162+
163+
164+
def notifyPipelineComplete(channels: list[str], rc: int, pipelineName: str, instanceId: str | None = None) -> bool:
165+
"""Send Slack notification about pipeline completion and cleanup ConfigMap."""
166+
namespace = os.getenv("PIPELINE_NAMESPACE", "")
167+
pipelineRunName = os.getenv("PIPELINERUN_NAME", "")
168+
169+
if namespace == "" or pipelineRunName == "":
170+
print("PIPELINE_NAMESPACE and PIPELINERUN_NAME env vars must be set")
171+
sys.exit(1)
172+
173+
# Get thread information
174+
threadInfo = SlackUtil.getThreadConfigMap(namespace, pipelineRunName)
175+
176+
if threadInfo is None:
177+
print("No thread information found - pipeline may not have started properly")
178+
return False
179+
180+
threadId = threadInfo.get("threadId")
181+
channelId = threadInfo.get("channelId")
182+
startTime = threadInfo.get("startTime")
183+
184+
# Calculate duration if start time is available
185+
durationText = ""
186+
if startTime:
187+
from datetime import datetime
188+
try:
189+
start = datetime.fromisoformat(startTime.replace("Z", "+00:00"))
190+
end = datetime.utcnow()
191+
duration = end - start
192+
hours, remainder = divmod(int(duration.total_seconds()), 3600)
193+
minutes, seconds = divmod(remainder, 60)
194+
if hours > 0:
195+
durationText = f"\nTotal Duration: {hours}h {minutes}m {seconds}s"
196+
else:
197+
durationText = f"\nTotal Duration: {minutes}m {seconds}s"
198+
except:
199+
pass
200+
201+
instanceInfo = f"\nInstance ID: `{instanceId}`" if instanceId else ""
202+
203+
if rc == 0:
204+
emoji = "🎉"
205+
status = "Completed Successfully"
206+
additionalInfo = "\nAll tasks completed successfully"
207+
else:
208+
emoji = "💥"
209+
status = "Failed"
210+
additionalInfo = f"\nPipeline failed with return code: `{rc}`"
211+
212+
message = [
213+
SlackUtil.buildHeader(f"{emoji} MAS {pipelineName.title()} Pipeline {status}"),
214+
SlackUtil.buildSection(f"Pipeline Run: `{pipelineRunName}`{instanceInfo}{durationText}{additionalInfo}")
215+
]
216+
217+
response = SlackUtil.postMessageBlocks(channelId, message, threadId)
218+
219+
# Clean up ConfigMap
220+
SlackUtil.deleteThreadConfigMap(namespace, pipelineRunName)
221+
222+
if isinstance(response, list):
223+
return all([res.data.get("ok", False) for res in response])
224+
return response.data.get("ok", False)
225+
226+
100227
if __name__ == "__main__":
101228
# If SLACK_TOKEN or SLACK_CHANNEL env vars are not set then silently exit taking no action
102229
SLACK_TOKEN = os.getenv("SLACK_TOKEN", "")
@@ -114,10 +241,17 @@ if __name__ == "__main__":
114241
parser.add_argument("--action", required=True)
115242
parser.add_argument("--rc", required=True, type=int)
116243
parser.add_argument("--msg", required=False, default=None)
244+
parser.add_argument("--task-name", required=False, default="")
245+
parser.add_argument("--pipeline-name", required=False, default="")
246+
parser.add_argument("--instance-id", required=False, default=None)
117247

118248
args, unknown = parser.parse_known_args()
119249

120250
if args.action == "ocp-provision-fyre":
121251
notifyProvisionFyre(channelList, args.rc, args.msg)
122252
elif args.action == "ocp-provision-roks":
123253
notifyProvisionRoks(channelList, args.rc, args.msg)
254+
elif args.action == "ansible-complete":
255+
notifyAnsibleComplete(channelList, args.rc, args.task_name, args.pipeline_name, args.instance_id)
256+
elif args.action == "pipeline-complete":
257+
notifyPipelineComplete(channelList, args.rc, args.pipeline_name, args.instance_id)

src/mas/devops/slack.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,128 @@ def buildDivider(cls) -> dict:
270270
Returns:
271271
dict: Slack block kit divider element
272272
"""
273+
def createThreadConfigMap(cls, namespace: str, pipelineRunName: str, channelId: str, threadId: str, pipelineName: str) -> bool:
274+
"""
275+
Create a ConfigMap to store Slack thread information for a pipeline run.
276+
277+
Parameters:
278+
namespace (str): Kubernetes namespace for the ConfigMap
279+
pipelineRunName (str): Unique identifier for the pipeline run
280+
channelId (str): Slack channel ID where the thread was created
281+
threadId (str): Slack thread timestamp
282+
pipelineName (str): Name of the pipeline (install/update/upgrade/uninstall)
283+
284+
Returns:
285+
bool: True if ConfigMap was created successfully, False otherwise
286+
"""
287+
try:
288+
from kubernetes import client, config
289+
from datetime import datetime
290+
291+
# Load Kubernetes configuration
292+
try:
293+
config.load_incluster_config()
294+
except:
295+
config.load_kube_config()
296+
297+
v1 = client.CoreV1Api()
298+
299+
configmap_name = f"slack-thread-{pipelineRunName}"
300+
configmap = client.V1ConfigMap(
301+
metadata=client.V1ObjectMeta(
302+
name=configmap_name,
303+
namespace=namespace
304+
),
305+
data={
306+
"threadId": threadId,
307+
"channelId": channelId,
308+
"pipelineName": pipelineName,
309+
"startTime": datetime.utcnow().isoformat() + "Z"
310+
}
311+
)
312+
313+
v1.create_namespaced_config_map(namespace=namespace, body=configmap)
314+
logger.info(f"Created ConfigMap {configmap_name} in namespace {namespace}")
315+
return True
316+
317+
except Exception as e:
318+
logger.error(f"Failed to create ConfigMap: {e}")
319+
return False
320+
321+
def getThreadConfigMap(cls, namespace: str, pipelineRunName: str) -> dict | None:
322+
"""
323+
Retrieve Slack thread information from a ConfigMap.
324+
325+
Parameters:
326+
namespace (str): Kubernetes namespace containing the ConfigMap
327+
pipelineRunName (str): Unique identifier for the pipeline run
328+
329+
Returns:
330+
dict | None: Dictionary containing threadId, channelId, pipelineName, and startTime, or None if not found
331+
"""
332+
try:
333+
from kubernetes import client, config
334+
335+
# Load Kubernetes configuration
336+
try:
337+
config.load_incluster_config()
338+
except:
339+
config.load_kube_config()
340+
341+
v1 = client.CoreV1Api()
342+
configmap_name = f"slack-thread-{pipelineRunName}"
343+
344+
configmap = v1.read_namespaced_config_map(name=configmap_name, namespace=namespace)
345+
logger.debug(f"Retrieved ConfigMap {configmap_name} from namespace {namespace}")
346+
return configmap.data
347+
348+
except client.exceptions.ApiException as e:
349+
if e.status == 404:
350+
logger.debug(f"ConfigMap slack-thread-{pipelineRunName} not found in namespace {namespace}")
351+
else:
352+
logger.error(f"Failed to retrieve ConfigMap: {e}")
353+
return None
354+
except Exception as e:
355+
logger.error(f"Failed to retrieve ConfigMap: {e}")
356+
return None
357+
358+
def deleteThreadConfigMap(cls, namespace: str, pipelineRunName: str) -> bool:
359+
"""
360+
Delete the ConfigMap containing Slack thread information.
361+
362+
Parameters:
363+
namespace (str): Kubernetes namespace containing the ConfigMap
364+
pipelineRunName (str): Unique identifier for the pipeline run
365+
366+
Returns:
367+
bool: True if ConfigMap was deleted successfully, False otherwise
368+
"""
369+
try:
370+
from kubernetes import client, config
371+
372+
# Load Kubernetes configuration
373+
try:
374+
config.load_incluster_config()
375+
except:
376+
config.load_kube_config()
377+
378+
v1 = client.CoreV1Api()
379+
configmap_name = f"slack-thread-{pipelineRunName}"
380+
381+
v1.delete_namespaced_config_map(name=configmap_name, namespace=namespace)
382+
logger.info(f"Deleted ConfigMap {configmap_name} from namespace {namespace}")
383+
return True
384+
385+
except client.exceptions.ApiException as e:
386+
if e.status == 404:
387+
logger.warning(f"ConfigMap slack-thread-{pipelineRunName} not found in namespace {namespace}")
388+
else:
389+
logger.error(f"Failed to delete ConfigMap: {e}")
390+
return False
391+
except Exception as e:
392+
logger.error(f"Failed to delete ConfigMap: {e}")
393+
return False
394+
273395
return {"type": "divider"}
274396

275397

0 commit comments

Comments
 (0)