Skip to content

Commit d766294

Browse files
authored
Merge branch 'stable' into abhi
2 parents b6bb6d5 + 0054108 commit d766294

28 files changed

Lines changed: 3074 additions & 335 deletions

.gitignore

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@ __pycache__/
66
# Distribution / packaging
77
dist
88
*.egg-info
9-
pandoc-*-amd64.deb
10-
pandoc-*-windows-x86_64.msi
11-
pandoc-*-x86_64-macOS.pkg
12-
README.rst
139

1410
# Environments
1511
.venv/
@@ -19,5 +15,5 @@ venv/
1915
# Other
2016
kubectl.exe
2117
/build
22-
/.vscode
23-
/site
18+
/.vscode
19+
/site

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,4 @@ mas-devops-create-initial-users-for-saas \
8888
Example of initial_users secret:
8989
```json
9090
{"john.smith1@example.com":"primary,john1,smith1","john.smith2@example.com":"primary,john2,smith2","john.smith3@example.com":"secondary,john3,smith3"}
91-
```
91+
```

bin/mas-devops-notify-slack

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

9999

100+
def notifyPipelineStart(channels: list[str], instanceId: str | None = None, pipelineName: str | None = None, namespace: str | None = None) -> dict | None:
101+
"""Send Slack notification about pipeline start and create thread for all channels."""
102+
# Exit early if no channels provided
103+
if not channels or len(channels) == 0:
104+
print("No Slack channels provided - skipping pipeline start notification")
105+
return None
106+
107+
# Use provided namespace, or fall back to legacy logic for backward compatibility
108+
if namespace is None or namespace == "":
109+
# For update pipeline, use mas-pipelines namespace (no instance ID)
110+
# For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace
111+
if instanceId is None or instanceId == "":
112+
namespace = "mas-pipelines"
113+
else:
114+
namespace = f"mas-{instanceId}-pipelines"
115+
116+
# Check if thread already exists
117+
threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName)
118+
if threadInfo is not None:
119+
print("Pipeline start notification already sent")
120+
return threadInfo
121+
122+
# Send pipeline started message to all channels
123+
toolchainLink = _getToolchainLink()
124+
instanceInfo = f"Instance ID: `{instanceId}`" if instanceId else ""
125+
message = [
126+
SlackUtil.buildHeader(f"🚀 MAS {pipelineName} Pipeline Started"),
127+
SlackUtil.buildSection(f"Pipeline Run: {pipelineName}\n{instanceInfo}\n{toolchainLink}")
128+
]
129+
response = SlackUtil.postMessageBlocks(channels, message)
130+
131+
# Store thread information for all channels in ConfigMap
132+
configMapData = {"instanceId": instanceId, "pipelineName": pipelineName}
133+
134+
if isinstance(response, list):
135+
# Multiple channels - store each channel's thread info
136+
for idx, res in enumerate(response):
137+
if res.data.get("ok", False):
138+
threadId = res["ts"]
139+
channelId = res["channel"]
140+
# Store with channel-specific keys
141+
configMapData[f"channel_{idx}"] = channelId
142+
configMapData[f"threadId_{idx}"] = threadId
143+
configMapData["channel_count"] = str(len(response))
144+
else:
145+
# Single channel
146+
if response.data.get("ok", False):
147+
threadId = response["ts"]
148+
channelId = response["channel"]
149+
configMapData["channel_0"] = channelId
150+
configMapData["threadId_0"] = threadId
151+
configMapData["channel_count"] = "1"
152+
else:
153+
print("Failed to send pipeline start Slack message")
154+
return False
155+
156+
# Create ConfigMap with all channel/thread info
157+
SlackUtil.createThreadConfigMap(namespace, instanceId, pipelineName)
158+
SlackUtil.updateThreadConfigMap(namespace, instanceId, configMapData, pipelineName)
159+
return SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName)
160+
161+
162+
def notifyAnsibleStart(channels: list[str], taskName: str, instanceId: str | None = None, pipelineName: str | None = None, namespace: str | None = None) -> bool:
163+
"""Send Slack notification about Ansible task start to all channels."""
164+
# Exit early if no channels provided
165+
if not channels or len(channels) == 0:
166+
print("No Slack channels provided - skipping Ansible task start notification")
167+
return False
168+
169+
# Use provided namespace, or fall back to legacy logic for backward compatibility
170+
if namespace is None or namespace == "":
171+
# For update pipeline, use mas-pipelines namespace (no instance ID)
172+
# For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace
173+
if instanceId is None or instanceId == "":
174+
namespace = "mas-pipelines"
175+
else:
176+
namespace = f"mas-{instanceId}-pipelines"
177+
178+
# Get thread information, create if doesn't exist
179+
threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName)
180+
if threadInfo is None:
181+
print("No thread found - creating pipeline start notification")
182+
threadInfo = notifyPipelineStart(channels, instanceId, pipelineName, namespace)
183+
184+
# Get channel count
185+
channelCount = int(threadInfo.get("channel_count", "0"))
186+
if channelCount == 0:
187+
print("No channels found in thread info")
188+
return False
189+
190+
# Send task start message as thread reply to all channels
191+
taskMessage = [
192+
SlackUtil.buildSection(f"⏳ *{taskName}* - Started")
193+
]
194+
195+
allSuccess = True
196+
taskMessageData = {}
197+
198+
for idx in range(channelCount):
199+
channelId = threadInfo.get(f"channel_{idx}")
200+
threadId = threadInfo.get(f"threadId_{idx}")
201+
202+
if channelId and threadId:
203+
response = SlackUtil.postMessageBlocks(channelId, taskMessage, threadId)
204+
205+
# Save message timestamp for this channel
206+
if response.data.get("ok", False):
207+
messageTs = response.data.get("ts")
208+
if messageTs:
209+
# Store with task name and channel index as key
210+
taskMessageData[f"task_{taskName}_{idx}"] = messageTs
211+
else:
212+
allSuccess = False
213+
else:
214+
allSuccess = False
215+
216+
# Update ConfigMap with all task message timestamps
217+
if taskMessageData:
218+
SlackUtil.updateThreadConfigMap(namespace, instanceId, taskMessageData, pipelineName)
219+
220+
return allSuccess
221+
222+
223+
def notifyAnsibleComplete(channels: list[str], rc: int, taskName: str, instanceId: str | None = None, pipelineName: str | None = None, namespace: str | None = None) -> bool:
224+
"""Send Slack notification about Ansible task completion status to all channels."""
225+
# Exit early if no channels provided
226+
if not channels or len(channels) == 0:
227+
print("No Slack channels provided - skipping Ansible task completion notification")
228+
return False
229+
230+
# Use provided namespace, or fall back to legacy logic for backward compatibility
231+
if namespace is None or namespace == "":
232+
# For update pipeline, use mas-pipelines namespace (no instance ID)
233+
# For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace
234+
if instanceId is None or instanceId == "":
235+
namespace = "mas-pipelines"
236+
else:
237+
namespace = f"mas-{instanceId}-pipelines"
238+
239+
# Get thread information, create if doesn't exist
240+
threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName)
241+
if threadInfo is None:
242+
print("No thread found - creating pipeline start notification")
243+
threadInfo = notifyPipelineStart(channels, instanceId, pipelineName, namespace)
244+
245+
# Get channel count
246+
channelCount = int(threadInfo.get("channel_count", "0"))
247+
if channelCount == 0:
248+
print("No channels found in thread info")
249+
return False
250+
251+
# Determine status
252+
if rc == 0:
253+
emoji = "✅"
254+
status = "Success"
255+
else:
256+
emoji = "❌"
257+
status = "Failed"
258+
259+
allSuccess = True
260+
261+
# Update message in each channel
262+
for idx in range(channelCount):
263+
channelId = threadInfo.get(f"channel_{idx}")
264+
threadId = threadInfo.get(f"threadId_{idx}")
265+
taskMessageTs = threadInfo.get(f"task_{taskName}_{idx}")
266+
267+
if not channelId or not threadId:
268+
allSuccess = False
269+
continue
270+
271+
# Calculate task duration if we have the message timestamp
272+
durationText = ""
273+
if taskMessageTs:
274+
from datetime import datetime, timezone
275+
try:
276+
# Message timestamp is in format "1234567890.123456"
277+
startTime = float(taskMessageTs)
278+
endTime = datetime.now(timezone.utc).timestamp()
279+
duration = int(endTime - startTime)
280+
281+
hours, remainder = divmod(duration, 3600)
282+
minutes, seconds = divmod(remainder, 60)
283+
284+
if hours > 0:
285+
durationText = f" ({hours}h {minutes}m {seconds}s)"
286+
elif minutes > 0:
287+
durationText = f" ({minutes}m {seconds}s)"
288+
else:
289+
durationText = f" ({seconds}s)"
290+
except Exception as e:
291+
print(f"Failed to calculate duration for channel {idx}: {e}")
292+
293+
# Build the completion message
294+
taskMessage = [
295+
SlackUtil.buildSection(f"{emoji} *{taskName}* - {status}{durationText}")
296+
]
297+
if rc != 0:
298+
taskMessage.append(SlackUtil.buildSection(f"Return Code: `{rc}`\nCheck logs for details"))
299+
300+
# If we have the original message timestamp, update it; otherwise post new message
301+
if taskMessageTs:
302+
response = SlackUtil.updateMessageBlocks(channelId, taskMessageTs, taskMessage)
303+
if not response.data.get("ok", False):
304+
allSuccess = False
305+
else:
306+
# Fallback: post new message if task start message wasn't tracked
307+
print(f"No start message found for task {taskName} in channel {idx}, posting new completion message")
308+
response = SlackUtil.postMessageBlocks(channelId, taskMessage, threadId)
309+
if not response.data.get("ok", False):
310+
allSuccess = False
311+
312+
# Special case, mas-update pipeline
313+
if namespace == "mas-pipelines" and taskName == "post-deps-update-verify-ingress":
314+
print(f"mas-update pipeline completed with status: {rc}, sending pipeline complete message")
315+
allSuccess: bool = notifyPipelineComplete(channels, rc, instanceId, pipelineName, namespace)
316+
317+
return allSuccess
318+
319+
320+
def notifyPipelineComplete(channels: list[str], rc: int, instanceId: str | None = None, pipelineName: str | None = None, namespace: str | None = None) -> bool:
321+
"""Send Slack notification about pipeline completion to all channels and cleanup ConfigMap."""
322+
# Exit early if no channels provided
323+
if not channels or len(channels) == 0:
324+
print("No Slack channels provided - skipping pipeline completion notification")
325+
return False
326+
327+
# Use provided namespace, or fall back to legacy logic for backward compatibility
328+
if namespace is None or namespace == "":
329+
# For update pipeline, use mas-pipelines namespace (no instance ID)
330+
# For install/upgrade pipelines, use mas-{instanceId}-pipelines namespace
331+
if instanceId is None or instanceId == "":
332+
namespace = "mas-pipelines"
333+
else:
334+
namespace = f"mas-{instanceId}-pipelines"
335+
336+
# Get thread information
337+
threadInfo = SlackUtil.getThreadConfigMap(namespace, instanceId, pipelineName)
338+
if threadInfo is None:
339+
print("No thread information found - pipeline may not have started properly")
340+
return False
341+
342+
# Get channel count
343+
channelCount = int(threadInfo.get("channel_count", "0"))
344+
if channelCount == 0:
345+
print("No channels found in thread info")
346+
return False
347+
348+
startTime = threadInfo.get("startTime")
349+
350+
# Calculate duration if start time is available
351+
durationText = ""
352+
if startTime:
353+
from datetime import datetime, timezone
354+
try:
355+
start = datetime.fromisoformat(startTime.replace("Z", "+00:00"))
356+
end = datetime.now(timezone.utc)
357+
duration = end - start
358+
hours, remainder = divmod(int(duration.total_seconds()), 3600)
359+
minutes, seconds = divmod(remainder, 60)
360+
if hours > 0:
361+
durationText = f"\nTotal Duration: {hours}h {minutes}m {seconds}s"
362+
else:
363+
durationText = f"\nTotal Duration: {minutes}m {seconds}s"
364+
except Exception:
365+
pass
366+
367+
instanceInfo = f"Instance ID: `{instanceId}`" if instanceId else ""
368+
if rc == 0:
369+
emoji = "🎉"
370+
status = "Completed Successfully"
371+
additionalInfo = "\nAll tasks completed successfully"
372+
else:
373+
emoji = "💥"
374+
status = "Failed"
375+
additionalInfo = f"\nPipeline failed with return code: `{rc}`"
376+
377+
message = [
378+
SlackUtil.buildHeader(f"{emoji} MAS {pipelineName} Pipeline {status}"),
379+
SlackUtil.buildSection(f"Pipeline Run: {pipelineName}\n{instanceInfo}{durationText}{additionalInfo}")
380+
]
381+
382+
allSuccess = True
383+
384+
# Send completion message to all channels
385+
for idx in range(channelCount):
386+
channelId = threadInfo.get(f"channel_{idx}")
387+
threadId = threadInfo.get(f"threadId_{idx}")
388+
389+
if channelId and threadId:
390+
response = SlackUtil.postMessageBlocks(channelId, message, threadId)
391+
if not response.data.get("ok", False):
392+
allSuccess = False
393+
else:
394+
allSuccess = False
395+
396+
# Clean up ConfigMap
397+
SlackUtil.deleteThreadConfigMap(namespace, instanceId, pipelineName)
398+
399+
return allSuccess
400+
401+
100402
if __name__ == "__main__":
101403
# If SLACK_TOKEN or SLACK_CHANNEL env vars are not set then silently exit taking no action
102404
SLACK_TOKEN = os.getenv("SLACK_TOKEN", "")
@@ -112,12 +414,27 @@ if __name__ == "__main__":
112414

113415
# Primary Options
114416
parser.add_argument("--action", required=True)
115-
parser.add_argument("--rc", required=True, type=int)
417+
parser.add_argument("--rc", required=False, type=int)
116418
parser.add_argument("--msg", required=False, default=None)
419+
parser.add_argument("--task-name", required=False, default="")
420+
parser.add_argument("--instance-id", required=False, default=None)
421+
parser.add_argument("--pipeline-name", required=False, default=None)
422+
parser.add_argument("--namespace", required=False, default=None, help="Pipeline namespace (e.g., mas-{instanceId}-pipelines or aiservice-{instanceId}-pipelines)")
117423

118424
args, unknown = parser.parse_known_args()
119425

426+
# Use namespace from command line arg, or fall back to PIPELINE_NAMESPACE env var
427+
namespace = args.namespace if args.namespace else os.getenv("PIPELINE_NAMESPACE", None)
428+
120429
if args.action == "ocp-provision-fyre":
121430
notifyProvisionFyre(channelList, args.rc, args.msg)
122431
elif args.action == "ocp-provision-roks":
123432
notifyProvisionRoks(channelList, args.rc, args.msg)
433+
elif args.action == "pipeline-start":
434+
notifyPipelineStart(channelList, args.instance_id, args.pipeline_name, namespace)
435+
elif args.action == "ansible-start":
436+
notifyAnsibleStart(channelList, args.task_name, args.instance_id, args.pipeline_name, namespace)
437+
elif args.action == "ansible-complete":
438+
notifyAnsibleComplete(channelList, args.rc, args.task_name, args.instance_id, args.pipeline_name, namespace)
439+
elif args.action == "pipeline-complete":
440+
notifyPipelineComplete(channelList, args.rc, args.instance_id, args.pipeline_name, namespace)

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
[build-system]
22
requires = [
3-
"setuptools",
4-
"pypandoc"
3+
"setuptools"
54
]
65
build-backend = "setuptools.build_meta"

setup.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,8 @@
1414
sys.path.insert(0, 'src')
1515

1616

17-
if not os.path.exists('README.rst'):
18-
import pypandoc
19-
pypandoc.download_pandoc(targetfolder='~/bin/')
20-
pypandoc.convert_file('README.md', 'rst', outputfile='README.rst')
21-
2217
here = os.path.abspath(os.path.dirname(__file__))
23-
with open(os.path.join(here, 'README.rst'), encoding='utf-8') as f:
18+
with open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
2419
long_description = f.read()
2520

2621
# Maintain a single source of versioning
@@ -54,6 +49,7 @@ def get_version(rel_path):
5449
license='Eclipse Public License - v1.0',
5550
description='Python for Maximo Application Suite Dev/Ops',
5651
long_description=long_description,
52+
long_description_content_type='text/markdown',
5753
install_requires=[
5854
'pyyaml', # MIT License
5955
'openshift', # Apache Software License

0 commit comments

Comments
 (0)