diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1e99947 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3bf4922 --- /dev/null +++ b/Dockerfile @@ -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 " \ + 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"] diff --git a/notification.py b/notification.py new file mode 100644 index 0000000..ade4125 --- /dev/null +++ b/notification.py @@ -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'
[BOT]{title}
') + text_list.append(f'[BOT][{title}]') + else: + formatted_text_list.append('
[BOT]
') + 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() diff --git a/notification.yaml b/notification.yaml new file mode 100644 index 0000000..994f8dc --- /dev/null +++ b/notification.yaml @@ -0,0 +1,12 @@ +title: '' +content: '' + +slack-url: '' + +element-room: '' +element-token: '' +element-host: '' + +rcpt: '' +sender: '' +mailserver: '' diff --git a/setup.py b/setup.py index 108fcb9..7de0643 100644 --- a/setup.py +++ b/setup.py @@ -6,17 +6,30 @@ setuptools.setup( name='pl-notification', version='0.0.1', - author='', - author_email='', - description='', + author='FNNDSC', + author_email='dev@babyMRI.org', + description='A ChRIS Plugin for notification through mail / Slack / Element', long_description=long_description, long_description_content_type='text/markdown', url='', - packages=setuptools.find_packages(exclude=['test*']), + py_modules=['notification'], + license='MIT', + entry_points={ + 'console_scripts': [ + 'notification = notification:main', + ] + }, install_requires=[ + 'chris_plugin', + 'pflog', + 'pyyaml', ], classifiers=[ + 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', 'Operating System :: OS Independent', + 'Topic :: Scientific/Engineering', + 'Topic :: Scientific/Engineering :: Bio-Informatics', + 'Topic :: Scientific/Engineering :: Medical Science Apps.' ], )