diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b6cfc57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +venv +opsbotcli \ No newline at end of file diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..03d9663 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,46 @@ +name: Publish Docker image +on: + push: + tags: + - 'v*' +jobs: + release: + name: Create new release + runs-on: ubuntu-latest + steps: + + - name: Check out the repo + uses: actions/checkout@v2 + + - name: Push to Docker Hub + uses: docker/build-push-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: maibornwolff/opsbot + tag_with_ref: true + + - name: Prepare helm chart + env: + GITHUB_REF: ${{ github.ref }} + run: | + sed -i 's/0.0.1/'"${GITHUB_REF//refs\/tags\/v}"'/' deploy/helm/opsbot/Chart.yaml + sed -i 's/v0.0.1/'"${GITHUB_REF//refs\/tags\/}"'/' deploy/helm/opsbot/values.yaml + + - name: Publish helm chart + uses: stefanprodan/helm-gh-pages@master + with: + charts_dir: deploy/helm + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2444f70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,227 @@ +# Created by .ignore support plugin (hsz.mobi) +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33096f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.7-slim + +COPY requirements.txt /requirements.txt +RUN pip install -r /requirements.txt +COPY opsbot /opsbot + +EXPOSE 5000 + +CMD ["python" , "-m", "opsbot.main"] diff --git a/README.md b/README.md index 701b977..28b00a3 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ -# opsbot \ No newline at end of file +# Opsbot + +The Opsbot started as a small Microsoft Teams bot, whose sole purpose was to select a random pitiable team member to be responsible for operation and defects on that day. +Later on some more features were added like checking alertmanager or creating Jira subtasks. + +Since this bot might be useful also for other teams we decided to refactor the bot to make it modular and to be configurable. + +The repo also contains deployment templates to make the start easy. +If you work with kubernetes, have a look at the included helm template and the helmfile. +You only need to add some configurations, and you are ready to start. + +At the moment most of the texts the bot sends out are still in German. + +## Installation + +To use the OpsBot you need to configure a new Bot in Microsoft Teams and have the backend service deployed somewhere publicly reachable. +The Bot in Teams has to be configured with the public URL of the backend service. + +After you have installed both the Bot in MS Teams, and the backend Service you need to initialize the Opsbot. +This will set the Channel you issued the init command in as the default channel for the Opsbot: + + @Opsbot init + +You can then ask the Opsbot for a complete list of all possible commands: + + @Opsbot help + +### Teams Bot + +To configure a bot in Microsoft Teams you use the Teams app "App Studio". +There you can create the configuration (A json manifest file) for the Bot and install it into your teams channel. +In `deploy/ms_teams` you find an example of such a manifest file. You can import and modify the provided one or create your own. +In both cases you will need to create a new Bot id and password in App Studio and set those in your Backend configuration. + +### Backend + +The backend service is provided as a dockerfile which is available at [Dockerhub](https://hub.docker.com/r/maibornwolff/opsbot). +You can use this one or build your own from this repository. In any case you have to deploy the service somewhere publicly available. + +#### Kubernetes + +For kubernetes deployment a helm template is included in the project under `deploy/helm/opsbot` + and hosted as helm repository `https://maibornwolff.github.io/opsbot/` via github pages. + +You can install this chart with + + $ helm repo add maibornwolff-opsbot https://MaibornWolff.github.io/opsbot + $ helm install my-release maibornwolff-opsbot/opsbot --values my-values.yaml + +To further simplify the deployment even more there is also a predefined helmfile (deploy/helm/opsbot) available. +You can configure the deployment of the backend service and also provide the Opsbot configuration in one file. +It is also possible to add custom plugins which will get deployed in a configmap and added to the Opsbot container. + +## Configuration + +The Bot is configured with a Yaml file. The path Opsbot looks for this file defaults to `./opsbot_config.yaml` but can be overwritten by setting the environment variable `OPSBOT_CONFIG_FILE`. + +You can also set any of the configuration values via environment variables. +E.g. if you do not want to set the Bot password parameter `teams.app_password` in the config file, just use the environment variable `TEAMS_APP_PASSWORD` instead. + +Most functionality of the Opsbot are provided by different plugins. Some plugins are contained in the OpsBot core but you can also add your own plugins. (See custom plugins below). + +These plugins are currently included in OpsBot: + +| Plugin | Description | +|---|---| +| Operations | Team member can be registered with this plugin. Every day the plugin chooses an operations responsible for the day and announces him or her in the Chat. | +| Sayings | A plugin that reacts to unknown commands with an insult. | +| Jira | This plugin can check Jira for Defect tickets and it can create subtasks for an existing ticket. | +| Alerts | Checks an alertmanager for active alerts. | + +This is an overview of all possible configuration parameters: + +```yaml + +timezone: "Europe/Berlin" # The current time zone + +deactivate_plugins: # A list of plugins that should be deactivated + +woman: # A list of female team members. + +additional_plugin_dir: # Directory with additional plugins + +teams: + app_id: # The Bot App ID + app_password: # the Bot password + +persistence: # The persistence plugin to use. Currently available: file | configmap + plugin: file + path: persistence.yaml + -------- + # plugin: configmap + # configmap_name: + # configmap_namespace: + +actions: # Configuration for the different plugins + + operations: + override_user: # A username that can override the normal daily queue + how_to_link: # An optional link to a description of the operator + quotes: # A list of quotes the operator for the day gets + - "Remember, the force will be with you, always." + - "Do. Or do not. There is no try." + + sayings: + insults: # A list of insults the opsbot uses as response for unknown commands + - "That is why you fail." + - "...why?" + + jira: + base_url: + username: + password: + defects: + filter_id: # The filter id + link_defects: # Link to defect filter + subtasks: + project_id: # The project id + issue_type: # Jira internal number of the Issue type that should be created + + alerts: + base_url: +``` + +### Channels + +Opsbot supports multiple Channels. The default is always the one the `init` command was issued in. +You can then tell Opsbot to send certain types of messages to another channel by sending this command: + + @Opsbot channel register defects + +The channel type 'defects' that is used for this example is configured in the [Jira plugin](opsbot/plugins/actions/jira.py) when it sends out scheduled messages. +If there is no channel set for a type, the default one is used. There is also a deregister command to remove the association: + + @Opsbot defects deregister channel + +#### Available channels +| Plugin | Channel type | +|---|---| +| Jira | defects | +| Alerts | alerts | + + +## Local development + +For local development Opsbot can be started on your machine. To start you need Python >= 3.7 and virtualenv installed. +You then need to initialize the `venv` dir with the `init_venv.sh` script. + +Then you only need to create the `opsbot_config.yaml` file, and you can run Opsbot by `run_local.sh`. + +### CLI + +To simplify the local development of the bot there is also a small CLI tool with which you can talk to the bot while it is running on your local machine. + +The CLI sends messages to the Opsbot at `localhost:5000`. In these messages the URL the Bot is going to reply to is set to `localhost:1234`. +To receive those the CLI tool spins up a simple Flask server that listens on that port and echoes the responses of Opsbot. + +The CLI can be started with `./run_cli.sh` and understands the following commands: + +* start: Start of restart the flask server +* stop: Stop the flask server +* send "": Send a message to opsbot +* quit/exit: Exit the CLI + +## Custom plugins + +Opsbot can be extended with new features by adding custom plugins. A custom plugin must extend the abstract `ActionPlugin` or `PersistencePlugin` class and implement their required methods. +In the configuration `additional_plugin_dir` then needs to be set to the directory containing the custom plugin. + +Example: + +```python +from opsbot.plugins.actions import ActionPlugin + +class MyCustomActionPlugin(ActionPlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + self.add_scheduled_job(self._scheduled, 'cron', id='some_id', day_of_week='mon-fri', hour=8, minute=0) + + def init_hooks(self): + self.register_messagehook_regex(r"((MyCommand)|(mycommand))", self._response) + + def _response(self, activity, mentions): + self.send_reply(f"Send response to the thread the command was issued.", activity) + + def _scheduled(self, activity, mentions): + self.send_message(f"Send message to a channel. Either a named channel or the default one", channel_type='myChannelType') +``` diff --git a/deploy/helm/opsbot/.helmignore b/deploy/helm/opsbot/.helmignore new file mode 100644 index 0000000..50af031 --- /dev/null +++ b/deploy/helm/opsbot/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/helm/opsbot/Chart.yaml b/deploy/helm/opsbot/Chart.yaml new file mode 100644 index 0000000..d5a1a91 --- /dev/null +++ b/deploy/helm/opsbot/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Kubernetes +name: opsbot +version: 0.0.1 diff --git a/deploy/helm/opsbot/templates/NOTES.txt b/deploy/helm/opsbot/templates/NOTES.txt new file mode 100644 index 0000000..bc8105f --- /dev/null +++ b/deploy/helm/opsbot/templates/NOTES.txt @@ -0,0 +1,21 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "opsbot.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo https://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "opsbot.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "opsbot.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo https://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "opsbot.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit https://127.0.0.1:8080 to use your application" + kubectl port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/deploy/helm/opsbot/templates/_helpers.tpl b/deploy/helm/opsbot/templates/_helpers.tpl new file mode 100644 index 0000000..ebde5db --- /dev/null +++ b/deploy/helm/opsbot/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "opsbot.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "opsbot.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "opsbot.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/deploy/helm/opsbot/templates/certificate.yaml b/deploy/helm/opsbot/templates/certificate.yaml new file mode 100644 index 0000000..23cca17 --- /dev/null +++ b/deploy/helm/opsbot/templates/certificate.yaml @@ -0,0 +1,17 @@ +{{- if (and .Values.ingress.enabled .Values.ingress.certificate.enabled) -}} +{{- range .Values.ingress.tls }} +apiVersion: certmanager.k8s.io/v1alpha1 +kind: Certificate +metadata: + name: {{ .secretName }} +spec: + dnsNames: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod + secretName: {{ .secretName }} +{{- end }} +{{- end }} diff --git a/deploy/helm/opsbot/templates/configmap.yaml b/deploy/helm/opsbot/templates/configmap.yaml new file mode 100644 index 0000000..9ca2d0a --- /dev/null +++ b/deploy/helm/opsbot/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "opsbot.fullname" . }} + labels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + helm.sh/chart: {{ include "opsbot.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +data: + config.yaml: |- + {{- toYaml .Values.configuration | nindent 4 }} diff --git a/deploy/helm/opsbot/templates/configmap_plugins.yaml b/deploy/helm/opsbot/templates/configmap_plugins.yaml new file mode 100644 index 0000000..5905381 --- /dev/null +++ b/deploy/helm/opsbot/templates/configmap_plugins.yaml @@ -0,0 +1,16 @@ +{{ if .Values.additional_plugins}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "opsbot.fullname" . }}-plugins + labels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + helm.sh/chart: {{ include "opsbot.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +data: + {{- range .Values.additional_plugins }} + {{ .name }}: | + {{ .content | nindent 4 }} + {{ end }} +{{ end }} diff --git a/deploy/helm/opsbot/templates/deployment.yaml b/deploy/helm/opsbot/templates/deployment.yaml new file mode 100644 index 0000000..b913fc1 --- /dev/null +++ b/deploy/helm/opsbot/templates/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "opsbot.fullname" . }} + labels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + helm.sh/chart: {{ include "opsbot.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/configmap_plugins: {{ include (print $.Template.BasePath "/configmap_plugins.yaml") . | sha256sum }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 5000 + protocol: TCP + livenessProbe: + httpGet: + path: /health + port: http + readinessProbe: + httpGet: + path: /health + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /config + readOnly: true + {{- if .Values.additional_plugins}} + - name: plugins + mountPath: /additional_plugins + {{- end }} + env: + - name: OPSBOT_CONFIG_FILE + value: /config/config.yaml + {{- if .Values.additional_plugins}} + - name: ADDITIONAL_PLUGIN_DIR + value: /additional_plugins + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "opsbot.fullname" . }} + {{- if .Values.additional_plugins}} + - name: plugins + configMap: + name: {{ include "opsbot.fullname" . }}-plugins + {{- end }} diff --git a/deploy/helm/opsbot/templates/ingress.yaml b/deploy/helm/opsbot/templates/ingress.yaml new file mode 100644 index 0000000..fdeba15 --- /dev/null +++ b/deploy/helm/opsbot/templates/ingress.yaml @@ -0,0 +1,39 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "opsbot.fullname" . -}} +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + helm.sh/chart: {{ include "opsbot.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }} + servicePort: http + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/opsbot/templates/service.yaml b/deploy/helm/opsbot/templates/service.yaml new file mode 100644 index 0000000..f445454 --- /dev/null +++ b/deploy/helm/opsbot/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "opsbot.fullname" . }} + labels: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + helm.sh/chart: {{ include "opsbot.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "opsbot.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/deploy/helm/opsbot/values.yaml b/deploy/helm/opsbot/values.yaml new file mode 100644 index 0000000..d36f4d0 --- /dev/null +++ b/deploy/helm/opsbot/values.yaml @@ -0,0 +1,50 @@ + +replicaCount: 1 + +image: + repository: maibornwolff/opsbot + tag: v0.0.1 + pullPolicy: IfNotPresent + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 5000 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + + certificate: + enabled: false + clusterIssuer: + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/deploy/helmfile/custom_plugins/my_custom_plugin.py b/deploy/helmfile/custom_plugins/my_custom_plugin.py new file mode 100644 index 0000000..6d61749 --- /dev/null +++ b/deploy/helmfile/custom_plugins/my_custom_plugin.py @@ -0,0 +1,21 @@ +from typing import List + +from opsbot.plugins.actions import ActionPlugin, Command + + +class MyCustomActionPlugin(ActionPlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + self.add_scheduled_job(self._scheduled, 'cron', id='some_id', day_of_week='mon-fri', hour=8, minute=0) + + def get_commands(self) -> List[Command]: + return [ + Command(r"((MyCommand)|(mycommand))", self._response, "mycommand: My custom command") + ] + + def _response(self, activity, mentions): + self.send_reply(f"Send response to the thread the command was issued.", activity) + + def _scheduled(self, activity, mentions): + self.send_message(f"Send message to a channel. Either a named channel or the default one", channel_type='myChannelType') diff --git a/deploy/helmfile/helmfile.yaml b/deploy/helmfile/helmfile.yaml new file mode 100644 index 0000000..9006c8b --- /dev/null +++ b/deploy/helmfile/helmfile.yaml @@ -0,0 +1,59 @@ + +repositories: + - name: maibornwolff-opsbot + url: https://MaibornWolff.github.io/opsbot + +releases: + + - name: opsbot + chart: maibornwolff-opsbot/opsbot + # version: 1.0.0 # Specify specific chart version + namespace: some-namespace + tillerNamespace: some-namespace + + values: + + - ingress: + enabled: true + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + hosts: + - host: + paths: + - / + tls: + - secretName: opsbot-tls + hosts: + - + certificate: + enabled: true + clusterIssuer: letsencrypt-prod + + - resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + + # Read additional plugins from custom_plugins directory. + - additional_plugins: + {{- range $index, $file := ( exec "bash" (list "-c" "echo -n custom_plugins/*.py") | splitList " " ) }} + - name: "{{ base $file }}" + content: | + {{ readFile $file | nindent 10 }} + {{ end }} + + - configuration: + + teams: + app_id: "APP_ID" + app_password: "PASSWORD" + + persistence: + plugin: configmap + configmap_name: opsbot-configuration + configmap_namespace: some-namespace + + etc... diff --git a/deploy/ms_teams/manifest.json b/deploy/ms_teams/manifest.json new file mode 100644 index 0000000..76251a1 --- /dev/null +++ b/deploy/ms_teams/manifest.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.7/MicrosoftTeams.schema.json", + "manifestVersion": "1.7", + "version": "1.0.0", + "id": "", + "packageName": "com.maibornwolff.opsbot", + "developer": { + "name": "Your Name", + "websiteUrl": "https://example.com", + "privacyUrl": "https://example.com", + "termsOfUseUrl": "https://example.com" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "OpsBot", + "full": "OpsBot" + }, + "description": { + "short": "OpsBot", + "full": "OpsBot" + }, + "accentColor": "#40497E", + "bots": [ + { + "botId": "", + "scopes": [ + "team" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "" + ] +} \ No newline at end of file diff --git a/init_venv.sh b/init_venv.sh new file mode 100644 index 0000000..457d445 --- /dev/null +++ b/init_venv.sh @@ -0,0 +1,7 @@ + +venv/bin/python3 --version 2>/dev/null +if [[ $? -ne 0 ]]; then + virtualenv venv +fi + +venv/bin/pip3 install --upgrade -r requirements.txt diff --git a/opsbot/__init__.py b/opsbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opsbot/config/__init__.py b/opsbot/config/__init__.py new file mode 100644 index 0000000..a016c53 --- /dev/null +++ b/opsbot/config/__init__.py @@ -0,0 +1,58 @@ +import logging +import os +import sys + +import oyaml + +from .constants import OPSBOT_CONFIG_FILE_ENV, OPSBOT_CONFIG_FILE_DEFAULT + +logger = logging.getLogger() + +_config = None + + +def _load_config(): + path = os.environ.get(OPSBOT_CONFIG_FILE_ENV, OPSBOT_CONFIG_FILE_DEFAULT) + if not os.path.exists(path): + logger.critical(f"Opsbot config file not found at path: '{path}'") + sys.exit(-1) + + with open(path) as f: + return oyaml.safe_load(f.read()) + + +def _get_config_value_from_yaml(key): + global _config + if not _config: + _config = _load_config() + + ptr = _config + for var in key.split('.'): + if ptr and var in ptr: + ptr = ptr[var] + else: + return None + return ptr + + +def _get_config_value_from_env(key): + return os.environ.get(key.replace('.', '_').upper()) + + +def get_config_value(key, default=None, fail_if_missing=False): + """ + Retrieve a value from the opsbot config. Dict levels are dot separated in the key. + :param key: the configuration key. + :param default: Default value if key not found in config. + :param fail_if_missing: If true and key is missing in config, log error and exit app. + :return: the configured value or None. + """ + value = _get_config_value_from_env(key) + if not value: + value = _get_config_value_from_yaml(key) + if not value: + value = default + if not value and fail_if_missing: + logger.critical(f"Required configuration '{key}' is missing.") + exit(-1) + return value diff --git a/opsbot/config/constants.py b/opsbot/config/constants.py new file mode 100644 index 0000000..e06191c --- /dev/null +++ b/opsbot/config/constants.py @@ -0,0 +1,10 @@ +import os +from pathlib import Path + +CORE_PLUGINS = ["help"] + +OPSBOT_CONFIG_FILE_ENV = "OPSBOT_CONFIG_FILE" +OPSBOT_CONFIG_FILE_DEFAULT = f"{os.getcwd()}/opsbot_config.yaml" + +APP_DIR = Path(__file__).parent.parent +ROOT_DIR = APP_DIR.parent diff --git a/opsbot/logging_setup.py b/opsbot/logging_setup.py new file mode 100644 index 0000000..b2c92dd --- /dev/null +++ b/opsbot/logging_setup.py @@ -0,0 +1,76 @@ +import json +import logging +import threading +import os +import traceback +from datetime import datetime +from logging.config import dictConfig +from distutils.util import strtobool +context_data = threading.local() + + +def configure_logging(): + if bool(strtobool(os.environ.get('OPSBOT_LOCAL', 'False'))): + formatter = 'text' + else: + formatter = 'json' + + dictConfig({ + 'version': 1, + 'formatters': { + 'json': { + '()': 'opsbot.logging_setup.JsonFormatter' + }, + 'text': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + } + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': formatter, + 'level': 'DEBUG', + 'stream': 'ext://sys.stdout' + } + }, + 'root': { + 'level': 'DEBUG', + 'handlers': ['console'] + } + }) + for handler in logging.root.handlers: + handler.addFilter(HealthCheckFilter()) + + +class HealthCheckFilter(logging.Filter): + def filter(self, record): + try: + return 'GET /health' not in record.msg + except: + return True + + +class JsonFormatter(logging.Formatter): + + def __init__(self): + logging.Formatter.__init__(self) + self.version = '0.0.1' + + def format(self, record): + try: + message = record.msg % record.args + except: + message = str(record.msg) + entry = dict( + timestamp=datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + level=record.levelname, + logger=record.name, + message=message, + thread=record.threadName, + app_name="opsbot", + app_version=self.version, + ) + entry['class'] = record.pathname + if record.exc_info: + entry['exception'] = ''.join(traceback.format_exception(*record.exc_info)) + return json.dumps(entry) diff --git a/opsbot/main.py b/opsbot/main.py new file mode 100644 index 0000000..43397d6 --- /dev/null +++ b/opsbot/main.py @@ -0,0 +1,7 @@ +from opsbot.logging_setup import configure_logging +from .opsbot import OpsBot + +if __name__ == "__main__": + configure_logging() + opsbot = OpsBot() + opsbot.run(port=5000, debug=False) diff --git a/opsbot/opsbot.py b/opsbot/opsbot.py new file mode 100644 index 0000000..563f17e --- /dev/null +++ b/opsbot/opsbot.py @@ -0,0 +1,84 @@ +import atexit +import re + +from apscheduler.schedulers.background import BackgroundScheduler + +from .teams import TeamsBot + + +class OpsBot(TeamsBot): + def __init__(self): + super(OpsBot, self).__init__("opsbot") + self._scheduler = self._init_scheduler() + self._init_hooks() + self.plugins.init_action_plugins() + + def _init_hooks(self): + self.register_messagehook_regex(r".*help.*", self.help) + self.register_messagehook_regex(r"init", self.init_message) + self.register_messagehook_regex(r"channel register.*", self.register_channel) + self.register_messagehook_regex(r"channel unregister.*", self.unregister_channel) + + def register_channel(self, activity, mentions): + pattern = re.compile(r".*channel register\s+(\w+)\s*") + try: + channel_type = pattern.match(activity.text).groups()[0] + self._conversation_channels[channel_type] = activity.conversation.id.split(";")[0] + self.send_reply(f"Nachrichten vom Typ '{channel_type}' werden ab jetzt in diesen Channel gepostet.", activity) + self._save_system_config() + except: + self.send_reply("Ich habe dich nicht verstanden", activity) + + def unregister_channel(self, activity, mentions): + pattern = re.compile(r".*channel unregister\s+(\w+)\s*") + try: + channel_type = pattern.match(activity.text).groups()[0] + if channel_type in self._conversation_channels: + del self._conversation_channels[channel_type] + self.send_reply(f"Nachrichten vom Typ '{channel_type}' werden ab jetzt in den Default Channel gepostet.", activity) + self._save_system_config() + else: + self.send_reply(f"Für Nachrichten Typ '{channel_type}' ist kein Channel registriert.", activity) + except: + self.send_reply("Ich habe dich nicht verstanden", activity) + + def help(self, activity, mentions): + help_text = """ +Ich verstehe die folgenden Kommandos: +* register channel XX: Den aktuellen Kanal für Nachrichten vom Typ XX konfigurieren. +* unregister channel XX: Die Kanalzuordnung für Typ XX entfernen. +""" + for _, plugin in self.plugins.actions().items(): + for command in plugin.get_commands(): + if command.help_text: + help_text += f"* {command.help_text}\n" + help_text += f"* help: Gibt diese Hilfe aus" + self.send_reply(help_text, activity) + + def _init_scheduler(self): + scheduler = BackgroundScheduler() + scheduler.start() + atexit.register(scheduler.shutdown) + return scheduler + + def init_message(self, activity, mentions): + self._update_bot_infos(activity) + self.send_reply("Hallo zusammen. Ich bin der OpsBot.", reply_to=activity) + + def save_plugin_variable(self, plugin_type, plugin_name, key, value): + def init_key_if_missing(_dict, _key): + if _key not in _dict: + _dict[_key] = dict() + + if self.read_plugin_variable(plugin_type, plugin_name, key) != value: + init_key_if_missing(self._config, 'plugins') + init_key_if_missing(self._config['plugins'], plugin_type) + init_key_if_missing(self._config['plugins'][plugin_type], plugin_name) + self._config['plugins'][plugin_type][plugin_name][key] = value + self._save_system_config() + + def read_plugin_variable(self, plugin_type, plugin_name, key): + try: + return self._config['plugins'][plugin_type][plugin_name][key] + except KeyError: + return None diff --git a/opsbot/plugins/__init__.py b/opsbot/plugins/__init__.py new file mode 100644 index 0000000..e766847 --- /dev/null +++ b/opsbot/plugins/__init__.py @@ -0,0 +1,35 @@ +import inspect +import logging +from abc import ABC, abstractmethod +from ntpath import basename, splitext +from typing import List + +from ..config import get_config_value + + +class OpsbotPlugin(ABC): + + def __init__(self, opsbot): + from ..opsbot import OpsBot + self._opsbot: OpsBot = opsbot + self.logger = logging.getLogger(self.plugin_name()) + + @classmethod + def type(cls): + return inspect.getmodule(cls.__base__).__package__.split('.')[-1] + + @classmethod + def plugin_name(cls): + return splitext(basename(inspect.getfile(cls)))[0] + + @staticmethod + def required_configs() -> List[str]: + return [] + + def read_config_value(self, key): + return get_config_value(self._config_key(key)) + + @classmethod + @abstractmethod + def _config_key(cls, key): + pass diff --git a/opsbot/plugins/actions/__init__.py b/opsbot/plugins/actions/__init__.py new file mode 100644 index 0000000..d51e862 --- /dev/null +++ b/opsbot/plugins/actions/__init__.py @@ -0,0 +1,78 @@ +from abc import abstractmethod +from collections import Callable +from dataclasses import dataclass +from typing import Optional, List + +from .. import OpsbotPlugin +from ...utils.time_utils import TIMEZONE + + +@dataclass +class Command: + command_regexp: Optional[str] # If None the hook is used for unknown commands. Used in sayings plugin. + function: Callable + help_text: Optional[str] + + +class ActionPlugin(OpsbotPlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + for hook in self.get_commands(): + if hook.command_regexp: + opsbot.register_messagehook_regex(hook.command_regexp, hook.function) + else: + opsbot.register_messagehook_unknown(hook.function) + + @abstractmethod + def get_commands(self) -> List[Command]: + pass + + @classmethod + def _config_key(cls, key): + return f"{cls.type()}.{cls.plugin_name()}.{key}" + + def call_plugin_method(self, plugin_name, method_name, default=None): + try: + return getattr(self._opsbot.plugins.actions()[plugin_name], method_name)() + except: + return default + + def send_error_response(self, msg, ex=None, reply_to=None): + if ex: + msg = f"{msg}: {str(ex)}" + self.logger.exception(msg) + else: + self.logger.error(msg) + if reply_to: + self.send_reply(msg, reply_to=reply_to) + else: + self.send_message(msg) + + def add_scheduled_job(self, func, trigger, id, **trigger_args): + self._opsbot._scheduler.add_job(func, trigger, timezone=TIMEZONE, id=f"{self.plugin_name()}_{id}", **trigger_args) + + def send_reply(self, reply, reply_to, mentions=None): + self._opsbot.send_reply(reply, reply_to, mentions) + + def send_message(self, msg, channel_type=None, mentions=None): + self._opsbot.send_message(msg, mentions=mentions, channel_type=channel_type) + + def register_messagehook_regex(self, regex, message_func): + self._opsbot.register_messagehook_regex(regex, message_func) + + def register_messagehook_unknown(self, message_func): + self._opsbot.register_messagehook_unknown(message_func) + + def register_messagehook_func(self, matcher_func, message_func): + self._opsbot.register_messagehook_func(matcher_func, message_func) + + def save_variable(self, key, value): + self._opsbot.save_plugin_variable(self.type(), self.plugin_name(), key, value) + + def read_variable(self, key, default=None): + val = self._opsbot.read_plugin_variable(self.type(), self.plugin_name(), key) + if val: + return val + else: + return default diff --git a/opsbot/plugins/actions/alerts.py b/opsbot/plugins/actions/alerts.py new file mode 100644 index 0000000..47c32cb --- /dev/null +++ b/opsbot/plugins/actions/alerts.py @@ -0,0 +1,89 @@ +import json +from datetime import datetime, timedelta +from typing import List + +import requests + +from . import Command +from ...plugins.actions import ActionPlugin +from ...utils.time_utils import next_workday, is_today_a_workday, now + + +class AlertsActionPlugin(ActionPlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + self.add_scheduled_job(self.daily_next, 'cron', id='daily_next', day_of_week='mon-fri', hour=8, minute=0) + self.add_scheduled_job(self.daily_preview, 'cron', id='daily_preview', day_of_week='mon-fri', hour=17, minute=0) + self._alertmanager_url = self.read_config_value('base_url') + + def get_commands(self) -> List[Command]: + return [Command(r"alerts", self.reply_alerts, "alerts: Gib eine Liste der aktuellen Alerts aus")] + + @staticmethod + def required_configs(): + return ['base_url'] + + def reply_alerts(self, activity, mentions): + self.inform_alerts(reply_to=activity) + + def daily_next(self): + """Called each morning by scheduler to announce for the day""" + if not is_today_a_workday(): + return + self.inform_alerts() + + def daily_preview(self): + """Called each evening by scheduler to announce for the next day""" + if not is_today_a_workday(): + return + next_day = next_workday() + days = (next_day - now().date()).days - 1 + duration = 13 + days * 24 + try: + self.silence_non_critical_alerts(duration=duration) + except Exception as ex: + self.logger.info(ex) + + def silence_non_critical_alerts(self, duration, start_offset=1): + start = datetime.utcnow() + timedelta(hours=start_offset) + end = start + timedelta(hours=duration) + start_ts = start.isoformat() + "Z" + end_ts = end.isoformat() + "Z" + silence = {"id": "", "createdBy": "bot", "comment": "nightly silence", "startsAt": start_ts, "endsAt": end_ts, + "matchers": [{"name": "critical", "value": "no", "isRegex": False}]} + response = requests.post(self._alertmanager_url + "silences", data=json.dumps(silence)) + if not response.ok: + self.logger.info(response) + self.logger.info(response.text) + + def inform_alerts(self, reply_to=None): + self.logger.info("Inform alerts") + try: + alerts = self.get_list_of_alerts() + unique_alerts = list(set(alerts)) + self.logger.info(alerts) + if len(unique_alerts) > 3: + text = "Es gibt jede Menge aktive Alerts in Prometheus (so viele, dass ich sie hier nicht aufzähle)." + elif len(unique_alerts) > 0: + text = "Es gibt aktive Alerts in Prometheus: %s " % ', '.join(unique_alerts) + else: + self.logger.info("No alerts. Not sending") + return + person_today = self.call_plugin_method('operations', 'current', default='general') + text = f"{person_today} {text}." + if reply_to: + self.send_reply(text, mentions=[person_today], reply_to=reply_to) + else: + self.send_message(text, mentions=[person_today]) + except Exception as ex: + self.send_error_response("Failed to retrieve alerts", ex, reply_to) + + def get_list_of_alerts(self): + response = requests.get(self._alertmanager_url + "alerts?silenced=false&inhibited=false") + if not response.ok: + self.logger.info(response) + self.logger.info(response.text) + return [] + data = response.json()["data"] + return [a["labels"]["alertname"] for a in data] diff --git a/opsbot/plugins/actions/jira.py b/opsbot/plugins/actions/jira.py new file mode 100644 index 0000000..a4dc806 --- /dev/null +++ b/opsbot/plugins/actions/jira.py @@ -0,0 +1,248 @@ +import re +import traceback +from dataclasses import dataclass, field +from typing import List, Dict + +import requests + +from . import ActionPlugin, Command +from ...utils.time_utils import now, is_today_a_workday + +COMMENTS_IN_BRACES = re.compile(r"\(.*\)") +COLOR_MARKER = re.compile(r"\{color[^}]*\}") + +DEFECTS_CHANNEL_TYPE = "defects" + + +class JiraActionPlugin(ActionPlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + self.add_scheduled_job(self.check_jira, 'cron', id='regular_check', day_of_week='mon-fri', hour="9-17", minute="*/10") + self.add_scheduled_job(self.daily_next, 'cron', id='daily_next', day_of_week='mon-fri', hour=8, minute=0) + self._jira_base_url = self.read_config_value('base_url') + self._jira_auth = (self.read_config_value('username'), self.read_config_value('password')) + self._jira_filter_id = self.read_config_value('defects.filter_id') + self._link_defects = self.read_config_value('defects.link_defects') + self._subtask_project_id = self.read_config_value('subtasks.project_id') + self._subtask_issue_type = self.read_config_value('subtasks.issue_type') + + @staticmethod + def required_configs() -> List[str]: + return ['base_url', 'username', 'password'] + + def get_commands(self) -> List[Command]: + return [ + Command(r".*gen\s+subtasks.*", self.generate_subtasks, "gen subtasks XXX-XXXX: Liest Tasks aus dem JIRA-Ticket aus und erzeugt Subtasks"), + Command(r".*show\s+tasks.*", self.collect_subtasks, "show tasks XXX-XXXX: Listet die im Jira-Ticket erkannten Tasks auf"), + Command(r".*fix.*", self.fix_ticket, "fix XXX-XXXX: Behebe das Problem"), + Command(r"defects", self.show_defects, "defects: Gibt aktuelle Defects aus"), + ] + + def daily_next(self): + """Called each morning by scheduler to announce for the day""" + if not is_today_a_workday(): + return + self.check_jira(daily=True) + + def generate_subtasks(self, activity, mentions): + pattern = re.compile(r".*subtasks\s+(\S+).*") + text = activity.text.lower().strip() + if pattern.match(text): + ticket_name = pattern.match(text).groups()[0] + else: + self.logger.info(f"Could not match: {text}") + self.send_reply("%s ist keine gültige Ticket-Nummer." % text, activity) + return + if "-" not in ticket_name: + self.send_reply("%s ist keine gültige Ticket-Nummer." % ticket_name, activity) + return + try: + self.send_reply("Einen Moment...", activity) + self.create_subtasks(ticket_name) + self.send_reply("Subtasks sind angelegt. Viel Spaß beim Implementieren. Möge die Macht mit dir sein.", activity) + except Exception as ex: + traceback.print_exc() + self.send_reply("Ein Problem ist aufgetreten: %s." % str(ex), activity) + + def collect_subtasks(self, activity, mentions): + pattern = re.compile(r".*tasks\s+(\S+).*") + if pattern.match(activity.text.lower()): + ticket_name = pattern.match(activity.text.lower()).groups()[0] + else: + self.logger.info(f"Could not match: {activity.text.lower()}") + self.send_reply(f"Could not match: {activity.text.lower()}", activity) + return + if "-" not in ticket_name: + self.send_reply("%s ist keine gültige Ticket-Nummer." % ticket_name, activity) + return + try: + ticket = self.retrieve_tasks_from_ticket(ticket_name) + self.send_reply("Folgende Tasks kann ich im Ticket-Text finden: %s" % ' | '.join(ticket.subtasks), activity) + except Exception as ex: + traceback.print_exc() + self.send_reply("Ein Problem ist aufgetreten: %s." % str(ex), activity) + + def fix_ticket(self, activity, mentions): + self.send_reply("Damn it, Jim. I'm a bot, not an engineer!", activity) + + def show_defects(self, activity, mentions): + if not self.check_jira(True): + self.send_reply("Derzeit keine Defects. Viel Spaß beim Arbeiten.", activity) + + def check_jira(self, daily=False): + if not is_today_a_workday(): + return + try: + issues = self.check_filter() + self.save_variable("last_check", now().timestamp()) + if not issues: + self.save_variable("issues", issues) + return False + known_issues = self.read_variable("issues", []) + inform_about = list() + for issue in issues: + known = issue["key"] in known_issues + if not known: + inform_about.append(issue) + if inform_about: + self.inform_about_defects(inform_about) + issue_list = [i["key"] for i in issues] + self.save_variable("issues", issue_list) + return True + except Exception as ex: + self.logger.exception("Error while checking JIRA") + last_check = self.read_variable("last_check", 0) + if now().timestamp() - last_check >= 60 * 60: + self.report_error(str(ex)) + self.save_variable("last_check", now().timestamp()) + return True + + def check_filter(self): + response = requests.get(f"{self._jira_base_url}/rest/api/2/filter/%s" % self._jira_filter_id, auth=self._jira_auth) + response.raise_for_status() + + url = response.json()["searchUrl"] + response = requests.get(url, auth=self._jira_auth) + if response.ok: + issues = response.json()["issues"] + return [dict(key=i["key"], status=i.get("fields", dict()).get("status", dict()).get("name", "UNKNOWN"), + priority=i.get("fields", dict()).get("priority", dict()).get("name", "UNKNOWN")) for i in issues] + + def inform_about_defects(self, issues): + for issue in issues: + self.send_message( + f'Neuer Defect {issue["key"]} {self._jira_base_url}/browse/{issue["key"]} mit Priority "{issue["priority"]}" im Status "{issue["status"]}".\r\n', + channel_type=DEFECTS_CHANNEL_TYPE) + + def report_error(self, reason): + person_today = self.call_plugin_method("operations", "current", default='general') + self.send_message( + f'{person_today} Ich konnte JIRA nicht prüfen. ({reason}). Bitte prüfe selbst ob es neue Defects gibt: {self._link_defects}', + channel_type=DEFECTS_CHANNEL_TYPE, mentions=[person_today]) + + def create_subtasks(self, ticket): + ticket = self.retrieve_tasks_from_ticket(ticket) + for task in ticket.subtasks: + self.create_subtask(ticket, task) + + def create_subtask(self, ticket, text): + data = dict( + fields=dict( + project=dict(id=self._subtask_project_id), + summary=text, + issuetype=dict(id=self._subtask_issue_type), + parent=dict(key=ticket.key.upper()), + components=ticket.components, + ) + ) + print("Creating ticket for {} with summary '{}'".format(ticket.key, text)) + response = requests.post(f"{self._jira_base_url}/rest/api/2/issue/", json=data, auth=self._jira_auth) + print(response.text, flush=True) + if not response.ok: + raise Exception("Failed to create subtask: {} {}".format(response.status_code, response.text)) + + def retrieve_tasks_from_ticket(self, ticket): + ticket = self.retrieve_ticket(ticket) + tasks = list(extract_subtasks(ticket.text)) + ticket.subtasks = tasks + return ticket + + def retrieve_ticket(self, ticket): + response = requests.get(f"{self._jira_base_url}/rest/api/2/issue/" + ticket, auth=self._jira_auth) + if not response.ok: + print(response.text, flush=True) + raise Exception("Failed to get ticket") + data = response.json() + if not "fields" in data: + raise Exception("JIRA API response does not contain fields.") + if not "description" in data["fields"]: + raise Exception("JIRA API response does not contain ticket description field.") + text = data["fields"]["description"] + # assignee = data["fields"].get("assignee", dict()).get("key") + jira_components = data["fields"].get("components", list()) + components = [dict(id=c["id"], name=c["name"]) for c in jira_components] + return JiraTicket(ticket, text, "", components) + + +def extract_subtasks(text): + if "h4. Tasks" not in text and "h1. Tasks" not in text: + raise Exception("No tasks in ticket text found") + text = text.replace("\r\n", "\n") + if "h4. Tasks" in text: + tasks_text = text.split("h4. Tasks")[1] + else: + tasks_text = text.split("h1. Tasks")[1] + if "**" in tasks_text: + current_main_task = "" + found_subtask = True + for line in tasks_text.split("\n"): + line = line.strip() + if line == "" or line[0] != "*": + continue + if line.startswith("h4.") or line.startswith("h1."): + break + list_prefix, task = line.split(" ", 1) + task = task.strip() + task = task.replace("*", "") + if list_prefix == "*": + if not found_subtask: + yield current_main_task + current_main_task = task + found_subtask = False + elif list_prefix == "**": + if current_main_task == "": + raise Exception("Task list did not start with first-level list item") + found_subtask = True + task = _clean_task(task) + yield current_main_task + ": " + task + elif list_prefix[0] == "*" and not found_subtask: + raise Exception("No second-level list item after first-level list item") + if current_main_task != "" and not found_subtask: + yield current_main_task + else: + for line in tasks_text.split("\n"): + line = line.strip() + if line == "" or line[0] != "*": + continue + if line.startswith("h4.") or line.startswith("h1."): + break + line = line[1:].strip() + line = _clean_task(line) + yield line + + +def _clean_task(task): + task = COMMENTS_IN_BRACES.sub("", task) + task = COLOR_MARKER.sub("", task) + task = task.replace("*", "") + return task.strip() + + +@dataclass +class JiraTicket: + key: str + text: str + assignee: str + components: List[Dict] + subtasks: List[str] = field(default_factory=list) diff --git a/opsbot/plugins/actions/operations.py b/opsbot/plugins/actions/operations.py new file mode 100644 index 0000000..1194f9a --- /dev/null +++ b/opsbot/plugins/actions/operations.py @@ -0,0 +1,247 @@ +import re +from datetime import datetime +from typing import List + +from . import Command +from ...config import get_config_value +from ...plugins.actions import ActionPlugin +from ...utils.cyclic_list import CyclicList +from ...utils.time_utils import is_it_late, is_today_a_workday, next_workday, now, next_workday_string, DATE_FORMAT + +OPERATIONS_CHANNEL_TYPE = "operations" + + +def format_gender(name): + return "die" if name in get_config_value('woman', []) else "der" + + +class OperationsActionPlugin(ActionPlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + self._members = CyclicList([], dynamic=True) + self._members.load_state(self.read_variable("members", dict())) + self._vacations = self.read_variable("vacations", list()) + self._user_override = self.read_variable("override", None) + self._sayings_responsible_today = CyclicList(self.read_config_value('quotes')) + self._sayings_responsible_today.load_state(self.read_variable("sayings_responsible_today", dict())) + self._override_user = self.read_config_value('override_user') + self._how_to_link = self.read_config_value('how_to_link') + self.add_scheduled_job(self.daily_next, 'cron', id='daily_next', day_of_week='mon-fri', hour=8, minute=0) + self.add_scheduled_job(self.daily_preview, 'cron', id='daily_preview', day_of_week='mon-fri', hour=17, minute=0) + + def get_commands(self) -> List[Command]: + return [ + Command(r"((register)|(add))", self.register, "register @user: Person in die Rotation mit aufnehmen"), + Command(r"((unregister)|(remove))", self.unregister, "unregister @user: Person aus der Rotation entfernen"), + Command(r"((next)|(weiter))", self.next, "next / weiter [@user]: Rotation weiterschalten, wenn Person angegeben auf diese Person"), + Command(r"((heute)|(current)|(today)|(who)|(wer))", self.print_current, "heute / today / wer: Gibt aus wer heute Betriebsverantwortlicher ist"), + Command(r"((morgen)|(tomorrow))", self.print_tomorrow, "morgen / tomorrow: Gibt aus wer am naechsten Werktag Betriebsverantwortlicher sein wird"), + Command(r"(shibboleet)|(shiboleet)|(shibolet)", self.override, "shibboleet: Selbsterklärend ;-)"), + Command(r".*[uU]rlaub.*", self.add_vacation, + "'Urlaub am dd.mm.yyyy', 'Urlaub von dd.mm.yyyy bis dd.mm.yyyy' oder 'Urlaub dd.mm.yyyy - dd.mm.yyyy' [@user]: Trägt Urlaub ein, optional für eine andere Person"), + ] + + @staticmethod + def required_configs(): + return ['quotes'] + + def register(self, activity, mentions): + if not mentions: + mentions = [activity.from_property.name] + for mention in mentions: + if self._members.add_element(mention): + self.send_reply("%(name)s Willkommen in der Rotation" % dict(name=mention), reply_to=activity, mentions=[mention]) + else: + self.send_reply("%(name)s Dich kenne ich schon" % dict(name=mention), reply_to=activity, mentions=[mention]) + self.save_user_config() + + def unregister(self, activity, mentions): + if not mentions: + mention = activity.from_property.name + self.send_reply("%(name)s Feigling. So einfach kommst du mir nicht aus" % dict(name=mention), reply_to=activity, mentions=[mention]) + return + for mention in mentions: + self._members.remove_element(mention) + self.send_reply("%(name)s Du bist frei" % dict(name=mention), reply_to=activity, mentions=[mention]) + self.save_user_config() + + def override(self, activity, mentions): + """Command @OpsBot shiboleet""" + if not self._override_user or activity.from_property.name != self._override_user: + self.send_reply("Du hast hier nichts zu sagen!", reply_to=activity) + return + if is_it_late(): + self._user_override = "tomorrow" + self.send_reply("Aye, aye. %s Du bist vom Haken." % self._members.peek(), reply_to=activity, mentions=[self._members.peek()]) + else: + self._user_override = "today" + self.send_reply("Captain auf der Brücke! %s Du bist vom Haken." % self._members.get(), reply_to=activity, mentions=[self._members.get()]) + self.save_user_config() + + def next(self, activity, mentions): + """Command @OpsBot weiter|next""" + its_late = is_it_late() + if mentions: + member = self._members.goto(mentions[0]) + if not member: + self.send_reply("Dieser User ist mir nicht bekannt", reply_to=activity) + return + if its_late: + self._members.previous() + else: + if its_late: + self._members.next() + self.select_next_tomorrow() + else: + self.select_next_today() + + if its_late: + self.inform_tomorrow(reply_to=activity) + else: + self.inform_today(reply_to=activity) + self.save_user_config() + + def current(self): + if self._user_override == "today" and self._override_user: + return self._override_user + return self._members.get() + + def _operator_text(self): + if self._how_to_link: + return f'Betriebsverantwortliche' + else: + return "Betriebsverantwortliche" + + def print_current(self, activity, mentions): + member = self.current() + msg = f'Hey {member} Du bist heute {format_gender(member)} {self._operator_text()}.' + + self.send_reply(msg, reply_to=activity, mentions=[member]) + + def daily_next(self): + """Called each morning by scheduler to announce for the day""" + if not is_today_a_workday(): + return + if self._user_override == "tomorrow": + self._user_override = "today" + self.select_next_today() + self.inform_today() + self.save_user_config() + + def daily_preview(self): + """Called each evening by scheduler to announce for the next day""" + if not is_today_a_workday(): + return + if self._user_override == "today": + self._user_override = None + self.select_next_tomorrow() + self.inform_tomorrow() + self.save_user_config() + + def print_tomorrow(self, activity, mention): + self.inform_tomorrow(activity) + + def inform_today(self, reply_to=None): + if self._user_override == "today" and self._override_user: + member = self._override_user + else: + member = self._members.get() + msg = f'Hey {member} Du bist heute {format_gender(member)} {self._operator_text()}. {self._sayings_responsible_today.next()}' + if reply_to: + self.send_reply(msg, mentions=[member], reply_to=reply_to) + else: + self.send_message(msg, mentions=[member], channel_type=OPERATIONS_CHANNEL_TYPE) + + def inform_tomorrow(self, reply_to=None): + if self._user_override == "tomorrow" and self._override_user: + member = self._override_user + else: + member = self._members.peek() + tomorrow = next_workday_string() + msg = f'Hey {member} Du wirst {tomorrow} {format_gender(member)} {self._operator_text()} sein.' + if reply_to: + self.send_reply(msg, mentions=[member], reply_to=reply_to) + else: + self.send_message(msg, mentions=[member], channel_type=OPERATIONS_CHANNEL_TYPE) + + def add_vacation(self, activity, mentions): + if mentions: + name = mentions[0] + else: + name = activity.from_property.name + text = activity.text + try: + r_1 = re.compile(r".*[uU]rlaub am (\d{1,2}\.\d{1,2}\.\d{4}).*") + r_2 = re.compile(r".*[uU]rlaub vo[nm] (\d{1,2}\.\d{1,2}\.\d{4}) bis (\d{1,2}\.\d{1,2}\.\d{4}).*") + r_3 = re.compile(r".*[uU]rlaub (\d{1,2}\.\d{1,2}\.\d{4}) - (\d{1,2}\.\d{1,2}\.\d{4}).*") + if r_1.match(text): + from_string = r_1.match(text).groups()[0] + to_string = from_string + elif r_2.match(text): + groups = r_2.match(text).groups() + from_string = groups[0] + to_string = groups[1] + elif r_3.match(text): + groups = r_3.match(text).groups() + from_string = groups[0] + to_string = groups[1] + else: + self.send_reply( + "Ich habe dich nicht verstanden. Ich verstehe die folgenden Formate: 'Urlaub am dd.mm.yyyy', 'Urlaub von dd.mm.yyyy bis dd.mm.yyyy' oder 'Urlaub dd.mm.yyyy - dd.mm.yyyy'", + reply_to=activity, mentions=[name]) + return + self._vacations.append((name, from_string, to_string)) + self.send_reply("Alles klar.", reply_to=activity) + self.save_user_config() + except Exception as ex: + self.logger.info(ex) + self.send_reply( + "Ich habe dich nicht verstanden. Ich verstehe die folgenden Formate: 'Urlaub am dd.mm.yyyy', 'Urlaub von dd.mm.yyyy bis dd.mm.yyyy' oder 'Urlaub dd.mm.yyyy - dd.mm.yyyy'", + reply_to=activity, mentions=[]) + + def select_next_today(self): + date_obj = now().date() + n = 0 + max_n = self._members.size() + ok = False + while not ok and n < max_n: + ok = True + member = self._members.next() + for vacation in self._vacations: + if vacation[0] != member: + continue + date_from = datetime.strptime(vacation[1], DATE_FORMAT).date() + date_to = datetime.strptime(vacation[2], DATE_FORMAT).date() + if date_from <= date_obj <= date_to: + ok = False + n += 1 + return member + + def select_next_tomorrow(self): + date_obj = next_workday() + n = 1 + max_n = self._members.size() + ok = False + member = self._members.peek() + while not ok and n < max_n: + ok = True + member = self._members.peek() + for vacation in self._vacations: + if vacation[0] != member: + continue + date_from = datetime.strptime(vacation[1], DATE_FORMAT).date() + date_to = datetime.strptime(vacation[2], DATE_FORMAT).date() + if date_from <= date_obj <= date_to: + ok = False + continue + n += 1 + if not ok: + member = self._members.next() + return member + + def save_user_config(self): + self.save_variable("sayings_responsible_today", self._sayings_responsible_today.get_state()) + self.save_variable("members", self._members.get_state()) + self.save_variable("vacations", self._vacations) + self.save_variable("override", self._user_override) diff --git a/opsbot/plugins/actions/sayings.py b/opsbot/plugins/actions/sayings.py new file mode 100644 index 0000000..7e337b2 --- /dev/null +++ b/opsbot/plugins/actions/sayings.py @@ -0,0 +1,25 @@ +from typing import List + +from . import ActionPlugin, Command +from ...utils.cyclic_list import CyclicList + + +class SayingsActionPlugin(ActionPlugin): + + def get_commands(self) -> List[Command]: + return [Command(None, self._unknown, None)] + + def __init__(self, opsbot): + super().__init__(opsbot) + self._sayings_unknown_command = CyclicList(self.read_config_value('insults')) + self._sayings_unknown_command.load_state(self.read_variable("sayings_state")) + + @staticmethod + def required_configs() -> List[str]: + return ['insults'] + + def _unknown(self, activity, mentions): + self.logger.info(f"Unknown command: '{activity.text}', mentions: {mentions}") + insult = self._sayings_unknown_command.next() + self.save_variable("sayings_state", self._sayings_unknown_command.get_state()) + self.send_reply(insult, activity) diff --git a/opsbot/plugins/persistence/__init__.py b/opsbot/plugins/persistence/__init__.py new file mode 100644 index 0000000..e9a3857 --- /dev/null +++ b/opsbot/plugins/persistence/__init__.py @@ -0,0 +1,19 @@ +from abc import abstractmethod +from typing import Dict + +from .. import OpsbotPlugin + + +class PersistencePlugin(OpsbotPlugin): + + @abstractmethod + def read_state(self) -> Dict: + pass + + @abstractmethod + def persist_state(self, state): + pass + + @classmethod + def _config_key(cls, key): + return f"{cls.type()}.{key}" diff --git a/opsbot/plugins/persistence/configmap.py b/opsbot/plugins/persistence/configmap.py new file mode 100644 index 0000000..486c971 --- /dev/null +++ b/opsbot/plugins/persistence/configmap.py @@ -0,0 +1,66 @@ +import logging +import os +from typing import List + +import oyaml +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubernetes.config import load_incluster_config, load_kube_config +from requests.packages.urllib3 import disable_warnings + +from . import PersistencePlugin + +disable_warnings() + +logging.getLogger("kubernetes.client.rest").setLevel(logging.INFO) + + +def _load_kubernetes_config(): + if os.environ.get("KUBERNETES_SERVICE_HOST") is not None: + load_incluster_config() + else: + load_kube_config() + + +class ConfigmapPersistencePlugin(PersistencePlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + _load_kubernetes_config() + self._kubernetes_client = client.CoreV1Api() + self._configmap_name = self.read_config_value('configmap_name') + self._configmap_namespace = self.read_config_value('configmap_namespace') + + @staticmethod + def required_configs() -> List[str]: + return ['configmap_name', 'configmap_namespace'] + + def _create_configmap(self): + configmap = client.V1ConfigMap( + api_version="v1", + kind="ConfigMap", + metadata=dict(name=self._configmap_name), + data=dict() + ) + self._kubernetes_client.create_namespaced_config_map(self._configmap_namespace, configmap) + + def read_state(self): + try: + data = self._kubernetes_client.read_namespaced_config_map(self._configmap_name, self._configmap_namespace, pretty=False, exact=False, export=True).data + if data and 'yaml_data' in data: + return oyaml.safe_load(data['yaml_data']) + else: + return {} + except ApiException as e: + if e.status == 404: + self._create_configmap() + return {} + else: + self.logger.critical(f"Error while reading state from configmap '{self._configmap_name}' in namespace '{self._configmap_namespace}'") + raise e + + def persist_state(self, state): + try: + self._kubernetes_client.patch_namespaced_config_map(self._configmap_name, self._configmap_namespace, {'data': {"yaml_data": oyaml.safe_dump(state)}}) + except ApiException as e: + self.logger.error(f"Error while writing state to configmap '{self._configmap_name}' in namespace '{self._configmap_namespace}': {str(e)}") diff --git a/opsbot/plugins/persistence/file.py b/opsbot/plugins/persistence/file.py new file mode 100644 index 0000000..380ca85 --- /dev/null +++ b/opsbot/plugins/persistence/file.py @@ -0,0 +1,31 @@ +import os +from typing import Dict + +import oyaml + +from . import PersistencePlugin + + +class FilePersistencePlugin(PersistencePlugin): + + def __init__(self, opsbot): + super().__init__(opsbot) + self._path = self.read_config_value('path') + + @staticmethod + def required_configs(): + return ['path'] + + def read_state(self) -> Dict: + if os.path.exists(self._path): + self.logger.info(f"Load state from file '{self._path}'") + with open(self._path, 'r') as f: + return oyaml.load(f, Loader=oyaml.Loader) + else: + self.logger.warning(f"State file '{self._path}' not found") + return dict() + + def persist_state(self, state): + self.logger.info(f"Write state to file '{self._path}'") + with open(self._path, 'w') as f: + oyaml.dump(state, f, indent=4) diff --git a/opsbot/plugins/plugin_loader.py b/opsbot/plugins/plugin_loader.py new file mode 100644 index 0000000..f9174b7 --- /dev/null +++ b/opsbot/plugins/plugin_loader.py @@ -0,0 +1,118 @@ +import importlib +import logging +import pkgutil +import sys +from inspect import isclass, isabstract +from typing import Dict, Type + +from . import OpsbotPlugin +from .actions import ActionPlugin +from .persistence import PersistencePlugin +from ..config import get_config_value +from ..config.constants import APP_DIR + +logger = logging.getLogger() + + +def _find_plugin_class_in_module(module, base_class): + for i in dir(module): + attribute = getattr(module, i) + if isclass(attribute) and issubclass(attribute, base_class) and attribute != base_class: + if not isabstract(attribute): + return attribute + else: + raise PluginAbstractException(f"{attribute} does not implement all abstract methods.") + raise PluginNotFoundException(f"No class extending '{base_class.__name__}' found in module '{module}'") + + +def _find_plugin_classes(plugin_type, base_class): + path = f"{APP_DIR}/plugins/{plugin_type}" + packages = pkgutil.iter_modules(path=[path]) + modules = [importlib.import_module(f"opsbot.plugins.{plugin_type}.{name}") for (_, name, _) in packages] + types = [] + for module in modules: + try: + types.append(_find_plugin_class_in_module(module, base_class)) + except Exception as e: + logger.warning(f"Plugin could not be loaded: {str(e)}") + return types + + +def _find_external_plugin_modules(path): + sys.path.append(path) + packages = pkgutil.iter_modules(path=[path]) + return [importlib.import_module(name) for (_, name, _) in packages] + + +def _are_required_configs_set(cls: Type[OpsbotPlugin], level): + required_vars = [cls._config_key(c) for c in cls.required_configs()] + for required in required_vars: + if not get_config_value(required): + logger.log(level, f"{cls.__base__.__name__} '{cls.plugin_name()}' requires configurations: {required_vars}") + return False + return True + + +class PluginLoader(object): + + def __init__(self, opsbot): + self._opsbot = opsbot + self._action_classes = _find_plugin_classes('actions', ActionPlugin) + persistence_classes = _find_plugin_classes('persistence', PersistencePlugin) + + external_plugin_path = get_config_value('additional_plugin_dir') + if external_plugin_path: + external_modules = _find_external_plugin_modules(external_plugin_path) + for external_module in external_modules: + try: + try: + self._action_classes.append(_find_plugin_class_in_module(external_module, ActionPlugin)) + continue + except PluginNotFoundException: + pass + try: + persistence_classes.append(_find_plugin_class_in_module(external_module, PersistencePlugin)) + continue + except PluginNotFoundException: + pass + logger.warning(f"Plugin could not be loaded. Has unknown type") + except Exception as e: + logger.warning(f"Plugin could not be loaded: {str(e)}") + + self._persistence = self._init_persistence_plugin(persistence_classes) + self._action_plugins = dict() + + def _init_persistence_plugin(self, persistence_classes): + persistence_plugin_name = get_config_value('persistence.plugin', fail_if_missing=True) + for persistence_class in persistence_classes: + if persistence_class.plugin_name() == persistence_plugin_name: + if not _are_required_configs_set(persistence_class, logging.CRITICAL): + exit(-1) + persistence_plugin = persistence_class(self._opsbot) + logger.info(f"Initialized persistence plugin '{persistence_class.plugin_name()}'") + return persistence_plugin + logger.critical(f"Persistence plugin '{persistence_plugin_name}' not found.") + exit(-1) + + def init_action_plugins(self): + actions = dict() + for action_class in self._action_classes: + if action_class.plugin_name() not in get_config_value('deactivate_plugins', []): + if _are_required_configs_set(action_class, logging.WARNING): + actions[action_class.plugin_name()] = action_class(self._opsbot) + logger.info(f"Initialized action plugin '{action_class.plugin_name()}'") + self._action_plugins = actions + + def persistence(self) -> PersistencePlugin: + return self._persistence + + def actions(self) -> Dict[str, ActionPlugin]: + return self._action_plugins + + +class PluginNotFoundException(Exception): + pass + + +class PluginAbstractException(Exception): + pass diff --git a/opsbot/teams.py b/opsbot/teams.py new file mode 100644 index 0000000..c0c2f5b --- /dev/null +++ b/opsbot/teams.py @@ -0,0 +1,222 @@ +# coding=utf-8 +import asyncio +import logging +import re +import traceback + +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, Mention, ConversationAccount +from botframework.connector import ConnectorClient +from botframework.connector.auth import MicrosoftAppCredentials, JwtTokenValidation, SimpleCredentialProvider +from flask import Flask, request + +from .config import get_config_value +from .plugins.plugin_loader import PluginLoader + +logger = logging.getLogger() + + +class TeamsBot(object): + def __init__(self, name): + self.name = name + self._messagehook_unknown = None + self._messagehooks = list() + self._app_id = get_config_value('teams.app_id', fail_if_missing=True) + self._app_password = get_config_value('teams.app_password', fail_if_missing=True) + self.plugins = PluginLoader(self) + self._config = self.plugins.persistence().read_state() + bot_config = self._config.get("bot_config", dict()) + self._service_url = bot_config.get("service_url") + self._conversations = bot_config.get("conversations", dict()) + self._current_channel = bot_config.get("current_channel") + self._current_bot_id = bot_config.get("current_bot_id") + self._channel_data = bot_config.get("channel_data") + self._user_map = bot_config.get("user_map", dict()) + self._conversation_channels = bot_config.get("conversation_channels", dict()) + self._init_routes() + + def _init_routes(self): + self._flask_app = Flask(__name__) + self._flask_app.add_url_rule('/api/message', "message", self.message_received, methods=['POST']) + self._flask_app.add_url_rule('/health', "health", self.health, methods=['GET']) + self._flask_app.add_url_rule('/', "index", self.index_page, methods=['GET']) + + def _register_conversation(self, conversation, conversation_type): + self._conversations[conversation_type] = conversation.__dict__["id"].split(";")[0] + + def send_reply(self, text, reply_to, mentions=None): + self.__send(text, reply_to.conversation, mentions) + + def send_message(self, text, channel_type, mentions=None): + if channel_type in self._conversation_channels: + channel_id = self._conversation_channels[channel_type] + else: + channel_id = self._channel_data['channel']['id'] + conversation = ConversationAccount(is_group=True, id=channel_id, conversation_type="channel") + self.__send(text, conversation, mentions) + + def __send(self, text, conversation, mentions=None): + logger.info(f"Sending message: {text}") + entities = list() + if mentions is None: + mentions = list() + for name in mentions: + user_id = self._user_map.get(name) + if not user_id: + logger.info("User not found: %s" % name) + continue + mention = Mention(mentioned=ChannelAccount(id=user_id, name=name), text="%s" % name, + type="mention") + entities.append(mention) + + credentials = MicrosoftAppCredentials(self._app_id, self._app_password) + connector = ConnectorClient(credentials, base_url=self._service_url) + + reply = Activity( + type=ActivityTypes.message, + channel_id=self._current_channel, + conversation=conversation, + from_property=ChannelAccount(id=self._current_bot_id["id"], name=self._current_bot_id["name"]), + entities=entities, + text=text, + service_url=self._service_url) + + response = connector.conversations.send_to_conversation(reply.conversation.id, reply) + logger.info(response) + + def message_received(self): + """ handles incoming messages """ + logger.info(f"Received message: \n {request.get_json()}") + activity = Activity.deserialize(request.get_json()) + authorization = request.headers.get("Authorization") + + # if not self._handle_authentication(authorization, activity): + # logger.info("Authorization failed. Not processing request") + # return "" + self._service_url = activity.service_url + try: + if activity.type == ActivityTypes.message.value: + self._update_user_map(activity) + mentions = self._extract_mentions(activity) + message_text = activity.text + matched = False + for match_type, matcher, func in self._messagehooks: + if match_type == "REGEX": + if matcher.findall(message_text.lower()): + func(activity, mentions) + matched = True + break + elif match_type == "FUNC": + if matcher(message_text): + func(activity, mentions) + matched = True + break + if not matched and self._messagehook_unknown: + self._messagehook_unknown(activity, mentions) + self._save_system_config() + except Exception as e: + traceback.print_exc() + try: + self.send_reply("Es ist ein Fehler aufgetreten: %s" % e, reply_to=activity) + except: + traceback.print_exc() + return "" + + def _handle_authentication(self, authorization, activity): + credential_provider = SimpleCredentialProvider(self._app_id, self._app_password) + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(JwtTokenValidation.assert_valid_activity( + activity, authorization, credential_provider)) + return True + except Exception as ex: + logger.info(ex) + return False + finally: + loop.close() + + def _update_bot_infos(self, activity): + self._current_channel = activity.channel_id + self._current_bot_id = activity.recipient.__dict__ + self._channel_data = activity.channel_data + + def _extract_mentions(self, activity): + mentions = list() + for entitiy in activity.entities: + if entitiy.type == "mention": + mentioned = entitiy.__dict__["additional_properties"]["mentioned"] + user_id = mentioned["id"] + name = mentioned["name"] + if name.lower() == self.name.lower(): + continue + mentions.append(name) + return mentions + + def _update_user_map(self, activity): + for mention in activity.entities: + if mention.type == "mention": + mentioned = mention.__dict__["additional_properties"]["mentioned"] + user_id = mentioned["id"] + name = mentioned["name"] + self._user_map[name] = user_id + mentioned = activity.from_property.__dict__ + user_id = mentioned["id"] + name = mentioned["name"] + self._user_map[name] = user_id + + def register_messagehook_regex(self, regex, message_func): + regex_matcher = re.compile(regex) + self._messagehooks.append(("REGEX", regex_matcher, message_func)) + + def register_messagehook_func(self, matcher_func, message_func): + self._messagehooks.append(("FUNC", matcher_func, message_func)) + + def register_messagehook_unknown(self, message_func): + self._messagehook_unknown = message_func + + def messagehook_regex(self, regex): + def decorator(message_func): + self.register_messagehook_regex(regex, message_func) + return message_func + + return decorator + + def messagehook_func(self, matcher_func): + def decorator(message_func): + self.register_messagehook_func(matcher_func, message_func) + return message_func + + return decorator + + def messagehook_unknown(self, func=None): + def decorator(message_func): + self.register_messagehook_unknown(message_func) + return message_func + + if func: + return decorator(func) + else: + return decorator + + def _save_system_config(self): + bot_config = dict() + bot_config["service_url"] = self._service_url + bot_config["conversations"] = self._conversations + bot_config["current_channel"] = self._current_channel + bot_config["current_bot_id"] = self._current_bot_id + bot_config["channel_data"] = self._channel_data + bot_config["user_map"] = self._user_map + bot_config["conversation_channels"] = self._conversation_channels + self._config["bot_config"] = bot_config + self.plugins.persistence().persist_state(self._config) + + def health(self): + return "OK" + + def index_page(self): + return "This is Opsbot" + + def get_app(self): + return self._flask_app + + def run(self, port=5000, debug=False): + self._flask_app.run(host='0.0.0.0', port=port, debug=debug, threaded=True) diff --git a/opsbot/utils/cyclic_list.py b/opsbot/utils/cyclic_list.py new file mode 100644 index 0000000..399723b --- /dev/null +++ b/opsbot/utils/cyclic_list.py @@ -0,0 +1,77 @@ +#coding=utf-8 +from dataclasses import dataclass, field +from typing import List, Any + + +@dataclass +class CyclicList(object): + elements: List[Any] + dynamic: bool = False + current_index: int = field(init=False, default=-1) + + def size(self): + return len(self.elements) + + def add_element(self, value): + already_exists = value in self.elements + if not already_exists: + self.elements.append(value) + self.dynamic = True + return not already_exists + + def remove_element(self, value): + if value in self.elements: + self.elements.remove(value) + + def _increment(self): + idx = self.current_index + 1 + return idx % len(self.elements) + + def _decrement(self): + idx = self.current_index - 1 + return idx % len(self.elements) + + def previous(self): + self.current_index = self._decrement() + return self.elements[self.current_index] + + def next(self): + self.current_index = self._increment() + return self.elements[self.current_index] + + def peek(self): + return self.elements[self._increment()] + + def peek_n(self, n): + idx = self.current_index + n + idx = idx % len(self.elements) + return self.elements[idx] + + def get(self): + return self.elements[self.current_index] + + def goto(self, element): + try: + self.current_index = self.elements.index(element) + return self.elements[self.current_index] + except ValueError: + return None + + def back(self): + self.current_index = self._increment() + return self.elements[self.current_index] + + def get_state(self): + state = dict(index=self.current_index) + if self.dynamic: + state["elements"] = self.elements + return state + + def load_state(self, state): + if not state: + return + if "index" in state: + self.current_index = state["index"] + if "elements" in state: + self.dynamic = True + self.elements = state["elements"] diff --git a/opsbot/utils/time_utils.py b/opsbot/utils/time_utils.py new file mode 100644 index 0000000..5855b9d --- /dev/null +++ b/opsbot/utils/time_utils.py @@ -0,0 +1,55 @@ +from datetime import timedelta, datetime + +import holidays +import pytz + +from ..config import get_config_value + +DATE_FORMAT = "%d.%m.%Y" + +TIMEZONE = pytz.timezone(get_config_value('timezone', default="Europe/Berlin")) + +WEEKDAYS = { + 1: "Montag", + 2: "Dienstag", + 3: "Mittwoch", + 4: "Donnerstag", + 5: "Freitag", + 6: "Samstag", + 7: "Sonntag", +} +HOLIDAYS = holidays.CountryHoliday('DE', prov="BY", state=None) + + +def now(): + return datetime.now(tz=TIMEZONE) + + +def is_day_a_workday(date_obj): + return date_obj.isoweekday() < 6 and date_obj.date() not in HOLIDAYS + + +def is_today_a_workday(): + return is_day_a_workday(now()) + + +def next_workday(): + next_day = now() + timedelta(days=1) + while not is_day_a_workday(next_day): + next_day = next_day + timedelta(days=1) + return next_day.date() + + +def next_workday_string(): + tomorrow = now() + timedelta(days=1) + if is_day_a_workday(tomorrow): + return "morgen" + next_day = tomorrow + while not is_day_a_workday(next_day): + next_day = next_day + timedelta(days=1) + return "am " + WEEKDAYS[next_day.isoweekday()] + + +def is_it_late(): + return now().hour >= 17 or now().isoweekday() >= 6 + diff --git a/opsbotcli/__init__.py b/opsbotcli/__init__.py new file mode 100644 index 0000000..11b782f --- /dev/null +++ b/opsbotcli/__init__.py @@ -0,0 +1,4 @@ +from opsbotcli.cli import main + +if __name__ == "__main__": + main() diff --git a/opsbotcli/cli.py b/opsbotcli/cli.py new file mode 100644 index 0000000..8b714b1 --- /dev/null +++ b/opsbotcli/cli.py @@ -0,0 +1,116 @@ +import subprocess + +import click +import requests +from click_shell import shell + + +def get_message(message): + return { + "attachments": [ + { + "content": "
OpsBot guten abend herr opsbot
\\n
", + "contentType": "text/html" + } + ], + "channelData": { + "channel": { + "id": "19:7f139b7a485e46a6b32f1053ab863fcf@thread.skype" + }, + "team": { + "id": "19:7f139b7a485e46a6b32f1053ab863fcf@thread.skype" + }, + "teamsChannelId": "19:7f139b7a485e46a6b32f1053ab863fcf@thread.skype", + "teamsTeamId": "19:7f139b7a485e46a6b32f1053ab863fcf@thread.skype", + "tenant": { + "id": "7eb9feac-5602-4a4c-918a-7e7cb4f26040" + } + }, + "channelId": "msteams", + "conversation": { + "conversationType": "channel", + "id": "19:7f139b7a485e46a6b32f1053ab863fcf@thread.skype;messageid=1583942402382", + "isGroup": True, + "tenantId": "db4b360f-e32f-4ada-aaac-4d44bddfb5f8" + }, + "entities": [ + { + "mentioned": { + "id": "28:42c4b01f-8d4c-4852-b823-6fa4272f2d64", + "name": "OpsBot" + }, + "text": "OpsBot", + "type": "mention" + }, + { + "country": "DE", + "locale": "de-DE", + "platform": "Mac", + "type": "clientInfo" + } + ], + "from": { + "aadObjectId": "4aac1b15-9c77-477b-a36a-3b6c6987123c", + "id": "29:14bGwtoLMTb7E0VrhSC7JXCDEAo-Juwdz0RiaAfPRqz75Yv9pJCr5_FQt6w4cV5em728coBVjNuOVkvX9Ut9TA", + "name": "Some User" + }, + "id": "1583960742386", + "localTimestamp": "2020-03-11T22:00:42.5811168+01:00", + "locale": "de-DE", + "recipient": { + "id": "28:42c4b01f-8d4c-4852-b823-6fa4222f2d64", + "name": "OpsBot" + }, + "serviceUrl": "http://localhost:1234", + "text": f"OpsBot {message}\\n", + "textFormat": "plain", + "timestamp": "2020-03-11T21:00:42.5811168Z", + "type": "message" + } + + +def _start_server(): + global p + click.echo("start server") + p = subprocess.Popen(['venv/bin/python', 'opsbotcli/server.py']) + + +def _stop_server(_=None): + global p + if p: + p.terminate() + p = None + click.echo("server stopped") + + +@shell(prompt='opsbot-cli > ', intro='Starting opsbot cli...', on_finished=_stop_server) +def opsbot_cli(): + pass + + +@opsbot_cli.command() +@click.argument('message', required=True) +def send(message): + r = requests.post("http://localhost:5000/api/message", json=get_message(message)) + if r.status_code > 204: + print(f"Request error: {r.status_code}: {r.reason}") + + +@opsbot_cli.command() +def start(): + _stop_server() + _start_server() + + +@opsbot_cli.command() +def stop(): + _stop_server() + + +def main(): + _start_server() + opsbot_cli() + + +if __name__ == '__main__': + main() diff --git a/opsbotcli/server.py b/opsbotcli/server.py new file mode 100644 index 0000000..1974372 --- /dev/null +++ b/opsbotcli/server.py @@ -0,0 +1,18 @@ +import logging + +from flask import Flask, request + +log = logging.getLogger('werkzeug') +log.setLevel(logging.ERROR) + +app = Flask(__name__) + + +@app.route("/v3/conversations//activities", methods=['POST']) +def conversations(gk): + print(f"Opsbot: {request.json['text']}") + return {}, 200 + + +if __name__ == "__main__": + app.run('0.0.0.0', 1234) diff --git a/pycodestyle.cfg b/pycodestyle.cfg new file mode 100644 index 0000000..e9587a9 --- /dev/null +++ b/pycodestyle.cfg @@ -0,0 +1,2 @@ +[pycodestyle] +max-line-length = 160 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6e79fd5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +Flask==1.1.2 +requests==2.24.0 +botbuilder-core==4.10.1 +botbuilder-schema==4.10.1 +botframework-connector==4.10.1 +APScheduler==3.6.3 +kubernetes==11.0.0 +holidays==0.10.3 +beautifulsoup4==4.9.3 +oyaml==1.0 +click-shell[readline]==2.0 +pytz==2020.1 diff --git a/run_cli.sh b/run_cli.sh new file mode 100644 index 0000000..bbbfee7 --- /dev/null +++ b/run_cli.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +venv/bin/python -m opsbotcli.__init__ diff --git a/run_local.sh b/run_local.sh new file mode 100644 index 0000000..870c7be --- /dev/null +++ b/run_local.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +export OPSBOT_LOCAL=true + +venv/bin/python -m opsbot.main