Skip to content

Commit

Permalink
notification
Browse files Browse the repository at this point in the history
  • Loading branch information
chhsiao1981 committed Mar 20, 2024
1 parent dd05f9f commit ee1deae
Show file tree
Hide file tree
Showing 5 changed files with 396 additions and 4 deletions.
128 changes: 128 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Continuous integration testing for ChRIS Plugin.
# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration
#
# - on push and PR: run pytest
# - on push to main: build and push container images as ":latest"
# - on push to semver tag: build and push container image with tag and
# upload plugin description to https://chrisstore.co

name: build

on:
push:
branches: [ main ]
tags:
- "v?[0-9]+.[0-9]+.[0-9]+*"
pull_request:
branches: [ main ]

jobs:
build:
name: Build
if: github.event_name == 'push' || github.event_name == 'release'
runs-on: ubuntu-22.04

steps:
- name: Decide image tags
id: info
shell: python
run: |
import os
import itertools
def join_tag(t):
registry, repo, tag = t
return f'{registry}/{repo}:{tag}'.lower()
registries = ['docker.io', 'ghcr.io']
repos = ['${{ github.repository }}']
if '${{ github.ref_type }}' == 'branch':
tags = ['latest']
elif '${{ github.ref_type }}' == 'tag':
tag = '${{ github.ref_name }}'
version = tag[1:] if tag.startswith('v') else tag
tags = ['latest', version]
else:
tags = []
if '${{ github.ref_type }}' == 'tag':
local_tag = join_tag(('ghcr.io', '${{ github.repository }}', version))
else:
local_tag = join_tag(('localhost', '${{ github.repository }}', 'latest'))
product = itertools.product(registries, repos, tags)
tags_csv = ','.join(map(join_tag, product))
outputs = {
'tags_csv' : tags_csv,
'push' : 'true' if tags_csv else 'false',
'local_tag': local_tag
}
with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
for k, v in outputs.items():
out.write(f'{k}={v}\n')
- uses: actions/checkout@v3
# QEMU is used for non-x86_64 builds
- uses: docker/setup-qemu-action@v3
# buildx adds additional features to docker build
- uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host

# Here, we want to do the docker build twice:
# The first build pushes to our local registry for testing.
# The second build pushes to Docker Hub and ghcr.io
- name: Build (local only)
uses: docker/build-push-action@v3
id: docker_build
with:
context: .
file: ./Dockerfile
tags: ${{ steps.info.outputs.local_tag }}
load: true
cache-from: type=gha
- name: Login to DockerHub
if: (github.event_name == 'push' || github.event_name == 'release') && contains(steps.info.outputs.tags_csv, 'docker.io')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: (github.event_name == 'push' || github.event_name == 'release') && contains(steps.info.outputs.tags_csv, 'ghcr.io')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
if: (github.event_name == 'push' || github.event_name == 'release')
with:
context: .
file: ./Dockerfile
tags: ${{ steps.info.outputs.tags_csv }}
# linux/ppc64le not working, see https://github.com/ANTsX/ANTs/issues/1644
platforms: linux/amd64,linux/arm64
push: ${{ steps.info.outputs.push }}
cache-to: type=gha,mode=max

- name: Upload ChRIS Plugin
id: upload
if: github.ref_type == 'tag'
uses: FNNDSC/upload-chris-plugin@v1
with:
dock_image: ${{ steps.info.outputs.local_tag }}
username: ${{ secrets.CHRISPROJECT_USERNAME }}
password: ${{ secrets.CHRISPROJECT_PASSWORD }}
chris_url: https://cube.chrisproject.org/api/v1/
compute_names: host,galena

- name: Update DockerHub description
if: steps.upload.outcome == 'success'
uses: peter-evans/dockerhub-description@v3
continue-on-error: true # it is not crucial that this works
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
short-description: ${{ steps.upload.outputs.title }}
readme-filepath: ./README.md
17 changes: 17 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Python version can be changed, e.g.
FROM docker.io/python:3.11.3-slim-bullseye

LABEL org.opencontainers.image.authors="FNNDSC <dev@babyMRI.org>" \
org.opencontainers.image.title="An email plugin" \
org.opencontainers.image.description="A ChRIS Plugin for notification through mail / Slack / Element"

ARG SRCDIR=/usr/local/src/pl-notification
RUN mkdir -p ${SRCDIR}
WORKDIR ${SRCDIR}

COPY . .
RUN pip install "." \
&& cd /
WORKDIR /

CMD ["notification"]
222 changes: 222 additions & 0 deletions notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
#!/usr/bin/env python

from pathlib import Path
from argparse import ArgumentParser, Namespace

import os
import os.path

import yaml
import json

import smtplib
import requests
from email.message import EmailMessage
import urllib

import time

from chris_plugin import chris_plugin
from pflog import pflog

# XXX Make sure that your cfg filename starts with '.'
_CFG_FILENAME = '.notification.yaml'

_DEFAULT_ELEMENT_HOST = 'fedora.ems.host'


parser = ArgumentParser(description='A ChRIS Plugin for notification through mail / Slack / Element')
parser.add_argument('-c', '--content', type=str, required=False, default='', help=f'Content of the notification. Optionally specified in [inputdir]/{_CFG_FILENAME} if not specified in argument.')

parser.add_argument('-t', '--title', type=str, required=False, default='', help=f'[Optional] Title of the notification. Optionally specified in [inputdir]/{_CFG_FILENAME} if not specified in argument.')

parser.add_argument('-s', '--slack-url', type=str, required=False, default='',
help=f'[Optional] slack-url if we want to send notification through Slack. Optionally specified in [inputdir]/{_CFG_FILENAME} if not specified in argument.')

parser.add_argument('-e', '--element-room', type=str, required=False, default='',
help=f'[Optional] element-room (ex: ![room-id]:fedora.im) if we want to send notification through Element. Optionally specified in [inputdir]/{_CFG_FILENAME} if not specified in argument.')

parser.add_argument('-E', '--element-token', type=str, required=False, default='',
help=f'[Optional] element-token if we want to send notification through Element. Required if element-url exists. Optionally specified in [inputdir]/{_CFG_FILENAME} if not specified in argument.')

parser.add_argument('--element-host', type=str, required=False, default=f'{_DEFAULT_ELEMENT_HOST}',
help=f'[Optional] element-host if we want to send notification through Element. [inputdir]/{_CFG_FILENAME} is with higher priority than command-line based config.')


parser.add_argument('-r', '--rcpt', type=str, required=False, default='',
help=f'[Optional] comma separated email receipients if we want to send notification through email. Optionally specified in [inputdir]/{_CFG_FILENAME} if not specified in argument.')

parser.add_argument('-S', '--sender', type=str, required=False, default='noreply@fnndsc.org', help=f'sender (From) in email. [inputdir]/{_CFG_FILENAME} is with higher priority than command-line based config.')
parser.add_argument('-M', '--mail-server', type=str, required=False, default='postfix.postfix.svc.k8s.galena.fnndsc', help=f'email server. [inputdir]/{_CFG_FILENAME} is with higher priority than command-line based config.')


@chris_plugin(
parser=parser,
title='A ChRIS plugin for notification through mail / Slack / Element',
category='', # ref. https://chrisstore.co/plugins
min_memory_limit='500Mi', # supported units: Mi, Gi
min_cpu_limit='1000m', # millicores, e.g. "1000m" = 1 CPU core
min_gpu_limit=0, # set min_gpu_limit=1 to enable GPU
)
@pflog.tel_logTime(
event='notification',
log='Notification',
)
def main(options: Namespace, inputdir: Path, outputdir: Path):
inputdir_str = str(inputdir)
outputdir_str = str(outputdir)
print(f'inputdir: {inputdir_str} outputdir: {outputdir_str}')

yaml_cfg = {}
yaml_filename = os.sep.join([inputdir_str, _CFG_FILENAME])
if os.path.exists(yaml_filename):
with open(yaml_filename, 'r') as f:
yaml_cfg = yaml.full_load(f)

content = _cfg_or_arg(options.content, yaml_cfg, 'content', f'Notification: not in [inputdir]/{_CFG_FILENAME} and no --content')

title = _arg_or_cfg(options.title, yaml_cfg, 'title')

slack_url = _arg_or_cfg(options.slack_url, yaml_cfg, 'slack-url')

element_room = _arg_or_cfg(options.element_room, yaml_cfg, 'element-room')
element_token = _arg_or_cfg(options.element_token, yaml_cfg, 'element-token')
element_host = _cfg_or_arg(options.element_host, yaml_cfg, 'element-host')

rcpt = _arg_or_cfg(options.rcpt, yaml_cfg, 'rcpt')
sender = _cfg_or_arg(options.sender, yaml_cfg, 'sender')
mail_server = _cfg_or_arg(options.mail_server, yaml_cfg, 'mail-server')

if slack_url:
_send_slack(title=title, content=content, url=slack_url)

if element_room and element_token:
_send_element(title=title, content=content, room=element_room, token=element_token, host=element_host)

if rcpt:
_send_email(title=title, content=content, rcpt=rcpt, mail_server=mail_server, sender=sender)


def _arg_or_cfg(arg_val: str, cfg: dict, index: str, err_msg: str = '') -> str:
if arg_val:
return arg_val

cfg_val = cfg.get(index, '')
if cfg_val:
return cfg_val

if not err_msg:
return ''

raise RuntimeError(err_msg)


def _cfg_or_arg(arg_val: str, cfg: dict, index: str, err_msg: str = '') -> str:
cfg_val = cfg.get(index, '')
if cfg_val:
return cfg_val

if arg_val:
return arg_val

if not err_msg:
return ''

raise RuntimeError(err_msg)


def _send_slack(title: str, content: str, url: str):
text_list = []
if title:
text_list.append(f'*{title}*')
if content:
text_list.append(content)
text = '\n'.join(text_list)
the_data = {
'text': text,
}

headers = {
'Content-Type': 'application/json',
}

resp = requests.post(url, data=json.dumps(the_data), headers=headers)
if resp.status_code == 200:
return

raise resp.raise_for_status()


def _send_element(title: str, content: str, room: str, token: str, host=f'{_DEFAULT_ELEMENT_HOST}'):
if not host:
host = _DEFAULT_ELEMENT_HOST

formatted_text_list = []
text_list = []
if title:
formatted_text_list.append(f'<h6>[BOT]{title}</h6>')
text_list.append(f'[BOT][{title}]')
else:
formatted_text_list.append('<h6>[BOT]</h6>')
text_list.append('[BOT]')

if content:
formatted_text_list.append(content)
text_list.append(content)

formatted_text = ''.join(formatted_text_list)
text = ' '.join(text_list)

the_data = {
'formatted_body': formatted_text,
'body': text,
'msgtype': 'm.text',
'format': 'org.matrix.custom.html',
}

headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, Content-Type, Authorization',
}

txn_id = 'chhsiaobot' + str(time.time() * 1000)
percent_room = _parse_element_room(room)
url = f'https://{host}/_matrix/client/v3/rooms/{percent_room}/send/m.room.message/{txn_id}?access_token={token}'

resp = requests.put(url, data=json.dumps(the_data), headers=headers)
if resp.status_code == 200:
return

raise resp.raise_for_status()


def _parse_element_room(room: str) -> str:
if room.startswith('%21'):
return room

if not room.startswith('!'):
room = '!' + room

return urllib.parse.quote(room, safe='/', encoding=None, errors=None)


def _send_email(title: str, content: str, rcpt: str, mail_server: str, sender: str):
if not rcpt:
raise RuntimeError(f'Email: no --rcpt and not in [inputdir]/{_CFG_FILENAME}')

msg = EmailMessage()
msg['Subject'] = title
msg['From'] = sender
msg['To'] = rcpt
msg.set_content(content)

s = smtplib.SMTP(mail_server)
s.send_message(msg)
s.quit()


if __name__ == '__main__':
main()
12 changes: 12 additions & 0 deletions notification.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
title: ''
content: ''

slack-url: ''

element-room: ''
element-token: ''
element-host: ''

rcpt: ''
sender: ''
mailserver: ''
Loading

0 comments on commit ee1deae

Please sign in to comment.