├── .rustfmt.toml ├── .gitignore ├── .dockerignore ├── src ├── lib.rs ├── templates.rs ├── cli.rs ├── k8s.rs ├── server.rs └── services │ ├── slack.rs │ └── mod.rs ├── docs ├── content │ ├── assets │ │ └── demo.gif │ ├── compatibility.md │ ├── services │ │ ├── slack │ │ │ ├── manifest.yaml │ │ │ └── index.md │ │ └── index.md │ ├── quickstart.md │ └── index.md ├── requirements.txt ├── overrides │ └── main.html ├── Dockerfile ├── ext.py └── Makefile ├── plugin.yaml ├── LICENSE ├── Cargo.toml ├── README.md ├── Dockerfile ├── .github └── workflows │ ├── ci.yml │ └── cd.yml ├── Makefile ├── mkdocs.yaml ├── manifests ├── workflow.yaml └── install.yaml ├── tests └── integration.rs └── Cargo.lock /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | target/ 3 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod k8s; 2 | pub mod server; 3 | pub mod services; 4 | pub mod templates; 5 | -------------------------------------------------------------------------------- /docs/content/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kjagiello/hermes/HEAD/docs/content/assets/demo.gif -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mike==1.1.2 2 | mkdocs-macros-plugin==0.6.3 3 | mkdocs-material-extensions==1.0.3 4 | mkdocs-material==8.1.3 5 | mkdocs==1.2.3 6 | toml==0.10.2 7 | -------------------------------------------------------------------------------- /docs/content/compatibility.md: -------------------------------------------------------------------------------- 1 | !!! warning "Compatibilty warning" 2 | 3 | Hermes requires Argo Workflows 3.3+ which comes with support for [template 4 | executor plugins](https://argoproj.github.io/argo-workflows/executor_plugins/). 5 | -------------------------------------------------------------------------------- /docs/content/services/slack/manifest.yaml: -------------------------------------------------------------------------------- 1 | _metadata: 2 | major_version: 1 3 | minor_version: 1 4 | display_information: 5 | name: Hermes 6 | features: 7 | bot_user: 8 | display_name: Hermes 9 | always_online: true 10 | oauth_config: 11 | scopes: 12 | bot: 13 | - chat:write 14 | - chat:write.customize 15 | settings: 16 | org_deploy_enabled: false 17 | socket_mode_enabled: false 18 | token_rotation_enabled: false 19 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | {% if config.extra.hermes_version == "dev" %} 5 | This document is for the development version of Hermes, which can be 6 | significantly different from previous releases. For older releases, use the 7 | version selector below. 8 | {% else %} 9 | You are viewing an outdated version of the documentation. 10 | 11 | Click here to go to latest. 12 | 13 | {% endif %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.9-slim-buster 2 | 3 | ARG HERMES_VERSION 4 | ENV HERMES_VERSION $HERMES_VERSION 5 | 6 | ENV PIP_PIP_VERSION 21.3.1 7 | ENV APT_GIT_VERSION 1:2.20.* 8 | 9 | RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ 10 | set -x && apt-get update && apt-get install --no-install-recommends -y \ 11 | git=$APT_GIT_VERSION 12 | 13 | WORKDIR /app 14 | ADD docs/requirements.txt . 15 | RUN --mount=type=cache,target=/root/.cache/pip \ 16 | set -x && \ 17 | pip install pip==$PIP_PIP_VERSION && \ 18 | pip install -r requirements.txt 19 | -------------------------------------------------------------------------------- /plugin.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: hermes 5 | labels: 6 | workflows.argoproj.io/configmap-type: ExecutorPlugin 7 | workflows.argoproj.io/version: '>= v3.3' 8 | data: 9 | sidecar.container: | 10 | name: hermes 11 | image: ghcr.io/kjagiello/hermes:0.1.0 12 | imagePullPolicy: IfNotPresent 13 | command: ['-p', '3030'] 14 | ports: 15 | - containerPort: 3030 16 | resources: 17 | limits: 18 | cpu: 200m 19 | memory: 64Mi 20 | requests: 21 | cpu: 100m 22 | memory: 32Mi 23 | securityContext: 24 | runAsNonRoot: true 25 | runAsUser: 1000 26 | -------------------------------------------------------------------------------- /docs/ext.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import quote_plus 3 | 4 | 5 | def define_env(env): 6 | docs_dir = env.variables.config["docs_dir"] 7 | env.variables["hermes_version"] = ( 8 | "master" 9 | if (version := env.variables["extra"]["hermes_version"]) == "dev" 10 | else version 11 | ) 12 | 13 | @env.macro 14 | def raw_github_url(path: str) -> str: 15 | tpl = env.variables["github_raw_url_tpl"] 16 | return tpl.format(version=env.variables["hermes_version"], path=path) 17 | 18 | @env.macro 19 | def import_file(path: str) -> str: 20 | with open(Path(docs_dir) / Path(path), "r") as f: 21 | return f.read() 22 | 23 | @env.filter 24 | def urlencode(val: str) -> str: 25 | "Reverse a string (and uppercase)" 26 | return quote_plus(val) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Krzysztof Jagiello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "argo-hermes" 3 | version = "0.1.0" 4 | description = "Notifications plugin for your Argo workflows" 5 | authors = ["Krzysztof Jagiello "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/kjagiello/hermes" 9 | categories = ["web-programming::http-server"] 10 | keywords = ["argo", "argo-workflows", "notifications", "slack"] 11 | edition = "2021" 12 | 13 | [[bin]] 14 | name = "hermes" 15 | path = "src/cli.rs" 16 | doc = false 17 | 18 | [profile.dev] 19 | split-debuginfo = "unpacked" 20 | 21 | [profile.release] 22 | lto = true 23 | codegen-units = 1 24 | 25 | [dependencies] 26 | k8s-openapi = { version = "0.13.1", default-features = false, features = ["v1_22"] } 27 | kube = { version = "0.65.0", features = ["client"] } 28 | reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } 29 | tokio = { version = "1", features = ["full"] } 30 | warp = "0.3" 31 | serde = { version = "~1.0", features = ["derive"] } 32 | serde_json = "1.0" 33 | async-trait = "0.1.52" 34 | lazy_static = "1.4.0" 35 | handlebars = "4.1.6" 36 | parking_lot = "0.11.2" 37 | as-any = "0.2.1" 38 | base64 = "0.13.0" 39 | clap = "3.0.7" 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hermes -- notifications for Argo Workflows 2 | 3 | Hermes aims to provide a streamlined way of sending notifications to various 4 | messaging services from your [Argo Workflows](https://argoproj.github.io/argo-workflows/) 5 | pipelines. 6 | 7 | ![demo](https://user-images.githubusercontent.com/74944/147889011-6917d13d-dea2-47e5-96bf-5f0d83064816.gif) 8 | 9 | ## Features 10 | 11 | - **Easy to use** – Hermes is a [template executor 12 | plugin](https://github.com/argoproj/argo-workflows/pull/7256). Once 13 | installed, Argo Workflows will automatically provide a Hermes instance for you to 14 | interact with from your workflow. 15 | - **Template system** – keep a centralized set of reusable notification 16 | templates and use them freely in your workflows. 17 | - **In-place updates** – avoid clutter in your channels by updating existing 18 | messages and keep the history of changes in a thread under the notification 19 | message instead. 20 | - **Multiple recipient support** – do you need to send notifications to different 21 | channels or services from a single workflow? No problem. 22 | 23 | ## Supported services 24 | 25 | - Slack 26 | 27 | ## Documentation 28 | 29 | Visit the [documentation](https://kjagiello.github.io/hermes) to learn how to 30 | install and use Hermes. 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.58-buster as builder 2 | 3 | RUN mkdir /hermes /volume 4 | WORKDIR /hermes 5 | 6 | # Rust lacks a straightforward way to only install dependencies, so we have to fake the existence 7 | # of the project in order for this to work. The idea is basically to build a separate layer with 8 | # only the dependencies, so that we don't have to reinstall them on every source code change. 9 | # Related issue: https://github.com/rust-lang/cargo/issues/2644 10 | RUN mkdir src && touch src/lib.rs && echo "fn main() {}" > src/cli.rs 11 | COPY ./Cargo.toml . 12 | COPY ./Cargo.lock . 13 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 14 | --mount=type=cache,sharing=private,target=/hermes/target \ 15 | cargo build --release 16 | 17 | # Build the source code without installing the dependencies. In order to make rust pick up changes 18 | # in the source files, we have to bump their "date modified". 19 | COPY ./src/ ./src/ 20 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 21 | --mount=type=cache,sharing=private,target=/hermes/target \ 22 | find src/ -type f -exec touch {} + \ 23 | && cargo build --release \ 24 | && ls -la ./target/release \ 25 | && cp ./target/release/hermes /volume 26 | 27 | FROM gcr.io/distroless/cc 28 | COPY --from=builder /volume/hermes / 29 | ENTRYPOINT ["/hermes"] 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | check: 8 | name: Check 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout sources 12 | uses: actions/checkout@v2 13 | 14 | - name: Install toolchain 15 | uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: nightly 19 | override: true 20 | 21 | - name: Run cargo check 22 | uses: actions-rs/cargo@v1 23 | with: 24 | command: check 25 | 26 | test: 27 | name: Test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout sources 31 | uses: actions/checkout@v2 32 | 33 | - name: Install toolchain 34 | uses: actions-rs/toolchain@v1 35 | with: 36 | toolchain: nightly 37 | profile: minimal 38 | override: true 39 | 40 | - name: Run cargo test 41 | uses: actions-rs/cargo@v1 42 | with: 43 | command: test 44 | 45 | lint: 46 | name: Lint 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout sources 50 | uses: actions/checkout@v2 51 | 52 | - name: Install toolchain 53 | uses: actions-rs/toolchain@v1 54 | with: 55 | profile: minimal 56 | toolchain: nightly 57 | override: true 58 | components: rustfmt 59 | 60 | - name: Run cargo fmt 61 | uses: actions-rs/cargo@v1 62 | with: 63 | command: fmt 64 | args: -- --check 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := latest 2 | IMAGE_NAMESPACE := ghcr.io/kjagiello 3 | IMAGE_NAME := hermes 4 | 5 | KUBECTX := $(shell [[ "`which kubectl`" != '' ]] && kubectl config current-context || echo none) 6 | K3D := $(shell [[ "$(KUBECTX)" == "k3d-"* ]] && echo true || echo false) 7 | K3D_CLUSTER_NAME ?= k3s-default 8 | SED := $(shell [[ "`which gsed`" != '' ]] && echo "gsed" || echo "sed") 9 | 10 | require_var = $(if $(value $(1)),,$(error $(1) not set)) 11 | 12 | .PHONY: build-image 13 | build-image: PLATFORM=linux/amd64 14 | build-image: 15 | docker buildx install 16 | docker build \ 17 | --load \ 18 | --platform $(PLATFORM) \ 19 | --cache-from "type=local,src=/tmp/.buildx-cache" \ 20 | --cache-to "type=local,dest=/tmp/.buildx-cache" \ 21 | -t $(IMAGE_NAMESPACE)/$(IMAGE_NAME):$(VERSION) \ 22 | . 23 | 24 | .PHONY: install-image 25 | install-image: build-image 26 | if [ $(K3D) = true ]; then k3d image import -c $(K3D_CLUSTER_NAME) $(IMAGE_NAMESPACE)/$(IMAGE_NAME):$(VERSION); fi 27 | 28 | .PHONY: install 29 | install: SLACK_TOKEN= 30 | install: install-image 31 | $(call require_var,SLACK_TOKEN) 32 | envsubst < manifests/install.yaml | kubectl apply -f - 33 | 34 | .PHONY: update-version 35 | update-version: VERSION= 36 | update-version: 37 | $(call require_var,VERSION) 38 | @$(SED) -i -E "s/^version = \".+\"/version = \"$$VERSION\"/g" Cargo.toml 39 | @$(SED) -i -E "0,/version = \".+\"/s//version = \"$$VERSION\"/" Cargo.lock 40 | @$(SED) -i -E "s/ghcr\.io\/.+:[^\s]+/ghcr.io\/kjagiello\/hermes:$$VERSION/" plugin.yaml 41 | @echo "The version has updated to $$VERSION" 42 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | IMAGE_NAME := hermes-docs:latest 2 | 3 | # Extract the app version from Cargo.toml as default 4 | HERMES_VERSION ?= $(shell \ 5 | cargo metadata \ 6 | --format-version 1 \ 7 | --no-deps \ 8 | --manifest-path ../Cargo.toml \ 9 | | jq -r '.packages[0].version' \ 10 | ) 11 | 12 | ifeq ($(HERMES_VERSION),) 13 | $(error Could not determine the version of Hermes) 14 | endif 15 | 16 | DOCKER_RUN_PORT := 8000 17 | DOCKER_RUN_MOUNTS := -v $(CURDIR):/app/docs \ 18 | -v $(CURDIR)/../plugin.yaml:/app/plugin.yaml \ 19 | -v $(CURDIR)/../mkdocs.yaml:/app/mkdocs.yaml 20 | DOCKER_RUN_OPTS := --rm \ 21 | $(DOCKER_RUN_MOUNTS) \ 22 | -p $(DOCKER_RUN_PORT):8000 \ 23 | -e HERMES_VERSION=$(HERMES_VERSION) 24 | 25 | .PHONY: build 26 | build: 27 | docker buildx install 28 | docker build \ 29 | --load \ 30 | --cache-from "type=local,src=/tmp/.buildx-cache" \ 31 | --cache-to "type=local,dest=/tmp/.buildx-cache" \ 32 | --build-arg HERMES_VERSION=$(HERMES_VERSION) \ 33 | -f ./Dockerfile \ 34 | -t $(IMAGE_NAME) \ 35 | .. 36 | 37 | .PHONY: serve 38 | serve: build 39 | docker run $(DOCKER_RUN_OPTS) $(IMAGE_NAME) mkdocs serve --dev-addr 0.0.0.0:8000 40 | 41 | .PHONY: deploy 42 | deploy: DOCKER_RUN_OPTS += -v $(CURDIR)/../.git:/app/.git 43 | deploy: build 44 | docker run $(DOCKER_RUN_OPTS) $(IMAGE_NAME) \ 45 | mike deploy -F mkdocs.yaml \ 46 | $(OPTS) $(VERSION) $(ALIAS) 47 | @if [ -n "$(SET_DEFAULT)" ]; then \ 48 | docker run $(DOCKER_RUN_OPTS) $(IMAGE_NAME) \ 49 | mike set-default -F mkdocs.yaml $(VERSION); \ 50 | fi 51 | 52 | .PHONY: shell 53 | shell: build 54 | docker run -it $(DOCKER_RUN_OPTS) $(IMAGE_NAME) bash 55 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: Hermes 2 | site_description: Notifications for your Argo Workflows 3 | site_url: https://kjagiello.github.io/hermes/ 4 | 5 | repo_name: kjagiello/hermes 6 | repo_url: https://github.com/kjagiello/hermes 7 | edit_uri: "" 8 | 9 | docs_dir: docs/content 10 | 11 | theme: 12 | name: material 13 | palette: 14 | scheme: "default" 15 | primary: "black" 16 | accent: "indigo" 17 | icon: 18 | logo: material/send 19 | features: 20 | - content.code.annotate 21 | - navigation.tracking 22 | - navigation.tabs 23 | - navigation.tabs.sticky 24 | - navigation.instant 25 | - navigation.sections 26 | custom_dir: docs/overrides 27 | 28 | nav: 29 | - Home: 30 | - Introduction: "index.md" 31 | - Quickstart: "quickstart.md" 32 | - Services: 33 | - Introduction to services: "services/index.md" 34 | - Supported services: 35 | - Slack: "services/slack/index.md" 36 | 37 | extra: 38 | version: 39 | provider: mike 40 | social: 41 | - icon: fontawesome/brands/github 42 | link: https://github.com/kjagiello/hermes 43 | github_raw_url_tpl: https://raw.githubusercontent.com/kjagiello/hermes/{version}/{path} 44 | hermes_version: !ENV HERMES_VERSION 45 | 46 | markdown_extensions: 47 | - admonition 48 | - pymdownx.details 49 | - pymdownx.highlight 50 | - pymdownx.inlinehilite 51 | - pymdownx.snippets: 52 | check_paths: true 53 | - pymdownx.superfences 54 | - pymdownx.tabbed: 55 | alternate_style: true 56 | - pymdownx.emoji: 57 | emoji_index: !!python/name:materialx.emoji.twemoji 58 | emoji_generator: !!python/name:materialx.emoji.to_svg 59 | - attr_list 60 | - def_list 61 | - md_in_html 62 | 63 | plugins: 64 | - search 65 | - macros: 66 | module_name: docs/ext 67 | include_dir: docs/ 68 | -------------------------------------------------------------------------------- /manifests/workflow.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Workflow 3 | metadata: 4 | generateName: notifications-test- 5 | spec: 6 | entrypoint: main 7 | templates: 8 | - name: main 9 | steps: 10 | - - name: setup-notifications 11 | template: hermes-setup 12 | 13 | - - name: pre-notification 14 | template: hermes-notify 15 | arguments: 16 | parameters: 17 | - name: message 18 | value: "Deployment started :hourglass_flowing_sand:" 19 | 20 | - - name: hello 21 | template: hello 22 | 23 | - - name: post-notification 24 | template: hermes-notify 25 | arguments: 26 | parameters: 27 | - name: message 28 | value: "Deployment succeeded :white_check_mark:" 29 | 30 | - name: hermes-setup 31 | plugin: 32 | hermes: 33 | setup: 34 | alias: default 35 | service: slack 36 | config: 37 | token: slack-token 38 | icon_emoji: ":rocket:" 39 | 40 | - name: hermes-notify 41 | inputs: 42 | parameters: 43 | - name: message 44 | plugin: 45 | hermes: 46 | notify: 47 | target: default 48 | template: hermes-template-slack-default 49 | config: 50 | channel: kjagiello-sandbox 51 | context: 52 | message: "{{inputs.parameters.message}}" 53 | app: hermes 54 | env: prod 55 | revision_sha: "deadbeef" 56 | revision_url: "https://google.com" 57 | log_url: "https://google.com" 58 | 59 | - name: hello 60 | container: 61 | image: docker/whalesay 62 | command: [cowsay] 63 | args: ["hello world"] 64 | -------------------------------------------------------------------------------- /src/templates.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use std::collections::HashMap; 3 | use std::fmt; 4 | use std::sync::Arc; 5 | 6 | pub enum TemplateError { 7 | /// The template was not found 8 | NotFound, 9 | /// The template does not comply with the expected format of the template (a key -> template 10 | /// mapping) 11 | InvalidFormat(String), 12 | /// Any other error that might happen during the retrieval, i.e. failing to retrieve a 13 | /// ConfigMap from Kubernetes 14 | GenericError(String), 15 | } 16 | 17 | impl fmt::Display for TemplateError { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 19 | match &*self { 20 | TemplateError::NotFound => write!(f, "Template not found"), 21 | TemplateError::InvalidFormat(s) => write!(f, "Invalid template format: {}", s), 22 | TemplateError::GenericError(s) => write!(f, "{}", s), 23 | } 24 | } 25 | } 26 | 27 | /// A notification template 28 | /// 29 | /// A simple template can consist of multiple sub-templates. This allows for the services to have 30 | /// more flexibility in how to handle the notifications. For example, the provided Slack service 31 | /// is expecting a "primary" and a "secondary" template, where the primary one is used to 32 | /// create/update a detailed channel message and the "secondary" to provide a less detailed message 33 | /// in the thread under the channel message. 34 | pub type Template = HashMap; 35 | 36 | /// Provides a way to fetch templates 37 | #[async_trait] 38 | pub trait TemplateRegistry: Sync + Send { 39 | /// Retrieves a template 40 | /// 41 | /// # Arguments 42 | /// 43 | /// * `name` - The name of the template to retrieve 44 | async fn get(&self, name: &str) -> Result, TemplateError>; 45 | } 46 | 47 | pub type TemplateRegistryRef = Arc; 48 | -------------------------------------------------------------------------------- /docs/content/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | {% include "content/compatibility.md" %} 4 | 5 | ## Install Hermes 6 | 7 | Install Hermes by creating following the ConfigMap in your cluster: 8 | 9 | === "plugin.yaml" 10 | 11 | ```yaml 12 | --8<-- "plugin.yaml" 13 | ``` 14 | 15 | === "kubectl" 16 | 17 | ```yaml 18 | kubectl apply -f \ 19 | {{ raw_github_url("plugin.yaml") }} 20 | ``` 21 | 22 | !!! hint 23 | 24 | Keep in mind that template executor plugins run as containers within a 25 | single pod, thus port collisions can occur. If your encounter this issue, 26 | you might have to adjust the port in the plugin manifest of Hermes. 27 | 28 | ## Service account 29 | 30 | Authentication tokens for the different services are passed to Hermes as 31 | secrets, which in turn requires that Hermes is able to fetch them using the 32 | Kubernetes API. Argo Workflows, by default, uses a service account with limited 33 | permissions, so in order to successfully run Hermes you will have to create a 34 | custom Role for your workflow that grants the `get` permission to the secrets 35 | needed by Hermes. 36 | 37 | See an example below: 38 | 39 | ```yaml 40 | --- 41 | # Role 42 | apiVersion: rbac.authorization.k8s.io/v1 43 | kind: Role 44 | metadata: 45 | name: workflow-role 46 | rules: 47 | # Pod get/watch is used to identify the container IDs of the current pod. 48 | # Pod patch is used to annotate the step's outputs back to controller (e.g. artifact location). 49 | - apiGroups: 50 | - "" 51 | verbs: 52 | - get 53 | - watch 54 | - patch 55 | resources: 56 | - pods 57 | # Logs get/watch are used to get the pods logs for script outputs, and for log archival 58 | - apiGroups: 59 | - "" 60 | verbs: 61 | - get 62 | - watch 63 | resources: 64 | - pods/log 65 | # Access to secrets 66 | - apiGroups: 67 | - "" 68 | verbs: 69 | - get 70 | resources: 71 | - secrets 72 | resourceNames: 73 | # List your secrets here 74 | - ... 75 | 76 | --- 77 | # RoleBinding 78 | apiVersion: rbac.authorization.k8s.io/v1 79 | kind: RoleBinding 80 | metadata: 81 | name: workflow-permissions 82 | roleRef: 83 | apiGroup: rbac.authorization.k8s.io 84 | kind: Role 85 | name: workflow-role 86 | subject: 87 | kind: ServiceAccount 88 | name: workflow-sa 89 | 90 | --- 91 | # ServiceAccount 92 | apiVersion: v1 93 | kind: ServiceAccount 94 | metadata: 95 | name: workflow-sa 96 | ``` 97 | 98 | ## What's next? 99 | 100 | Now that Hermes is installed it is time to take a look on how to send some 101 | notifications. In order to do that, let's get yourself familiarized with 102 | [services](services/index.md). 103 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use argo_hermes::k8s::templates::K8sTemplateRegistry; 2 | use argo_hermes::server::filters; 3 | use argo_hermes::services::registries::DefaultServiceRegistry; 4 | use clap::{App, Arg}; 5 | use std::io; 6 | use std::net::SocketAddr; 7 | use std::net::ToSocketAddrs; 8 | use std::process; 9 | 10 | const PKG_NAME: &str = env!("CARGO_PKG_NAME"); 11 | const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); 12 | const PKG_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); 13 | 14 | fn main() -> io::Result<()> { 15 | let matches = App::new(PKG_NAME) 16 | .bin_name(PKG_NAME) 17 | .version(PKG_VERSION) 18 | .author(PKG_AUTHORS) 19 | .about("Notifications for your Argo Workflows.") 20 | .arg( 21 | Arg::new("host") 22 | .short('h') 23 | .takes_value(true) 24 | .help("Host to bind Hermes to [default: 0.0.0.0]"), 25 | ) 26 | .arg( 27 | Arg::new("port") 28 | .short('p') 29 | .takes_value(true) 30 | .help("Port to bind Hermes to [default: 3030]"), 31 | ) 32 | .get_matches(); 33 | let port: u16 = matches 34 | .value_of("port") 35 | .unwrap_or("3030") 36 | .parse() 37 | .unwrap_or_else(|_| { 38 | println!("Specified port is not in the valid range (1-65535)"); 39 | process::exit(1); 40 | }); 41 | let addr: SocketAddr = { 42 | let default_host = format!("0.0.0.0:{}", port); 43 | matches 44 | .value_of("host") 45 | .map(|host| format!("{}:{}", host, port)) 46 | .unwrap_or(default_host) 47 | .to_socket_addrs() 48 | .unwrap_or_else(|err| { 49 | println!("Specified host is not valid: {}", err); 50 | process::exit(1); 51 | }) 52 | .next() 53 | .unwrap_or_else(|| { 54 | println!("The given host was not resolvable"); 55 | process::exit(1); 56 | }) 57 | }; 58 | serve(addr); 59 | Ok(()) 60 | } 61 | 62 | #[tokio::main] 63 | async fn serve(addr: SocketAddr) { 64 | let service_registry = DefaultServiceRegistry::with_default_services(); 65 | let template_registry = K8sTemplateRegistry::new() 66 | .await 67 | .expect("Failed to init k8s template registry"); 68 | 69 | let api = filters::routes(service_registry, template_registry); 70 | let (addr, server) = warp::serve(api) 71 | .try_bind_with_graceful_shutdown(addr, async { 72 | tokio::signal::ctrl_c() 73 | .await 74 | .expect("http_server: Failed to listen for CRTL+c"); 75 | println!("Shutting down the server"); 76 | }) 77 | .unwrap_or_else(|e| { 78 | println!("Failed to start the server: {}", e); 79 | std::process::exit(1); 80 | }); 81 | 82 | println!("Starting the server at {:?}", addr); 83 | tokio::task::spawn(server).await.expect("hello "); 84 | } 85 | -------------------------------------------------------------------------------- /manifests/install.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: hermes-template-slack-default 5 | data: 6 | primary: | 7 | { 8 | "text": "{{message}}", 9 | "blocks": [ 10 | { 11 | "type": "section", 12 | "text": { 13 | "type": "mrkdwn", 14 | "text": "{{message}}" 15 | } 16 | }, 17 | { 18 | "type": "section", 19 | "fields": [ 20 | { 21 | "type": "mrkdwn", 22 | "text": "*Application*\n{{app}}" 23 | }, 24 | { 25 | "type": "mrkdwn", 26 | "text": "*Environment*\n{{env}}" 27 | } 28 | ] 29 | }, 30 | { 31 | "type": "section", 32 | "fields": [ 33 | { 34 | "type": "mrkdwn", 35 | "text": "*Revision*\n<{{revision_url}}|{{revision_sha}}>" 36 | } 37 | ] 38 | }, 39 | { 40 | "type": "context", 41 | "elements": [ 42 | { 43 | "type": "mrkdwn", 44 | "text": "<{{log_url}}|View pipeline logs>" 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | secondary: | 51 | {"text": "{{message}}"} 52 | --- 53 | apiVersion: v1 54 | kind: Secret 55 | metadata: 56 | name: slack-token 57 | stringData: 58 | token: $SLACK_TOKEN 59 | --- 60 | kind: Role 61 | apiVersion: rbac.authorization.k8s.io/v1 62 | metadata: 63 | namespace: argo 64 | name: secret-access 65 | rules: 66 | - apiGroups: [""] 67 | resources: ["secrets"] 68 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 69 | --- 70 | kind: RoleBinding 71 | apiVersion: rbac.authorization.k8s.io/v1 72 | metadata: 73 | name: secret-access-binding 74 | namespace: argo 75 | subjects: 76 | - kind: ServiceAccount 77 | name: default 78 | namespace: argo 79 | roleRef: 80 | kind: Role 81 | name: secret-access 82 | apiGroup: rbac.authorization.k8s.io 83 | --- 84 | apiVersion: v1 85 | kind: ConfigMap 86 | metadata: 87 | name: hermes 88 | labels: 89 | workflows.argoproj.io/configmap-type: ExecutorPlugin 90 | annotations: 91 | workflows.argoproj.io/description: | 92 | This plugin sends a Slack message. 93 | You must create a secret: 94 | ```yaml 95 | apiVersion: v1 96 | kind: Secret 97 | metadata: 98 | name: slack-executor-plugin 99 | stringData: 100 | URL: https://hooks.slack.com/services/.../.../... 101 | ``` 102 | Example: 103 | ```yaml 104 | apiVersion: argoproj.io/v1alpha1 105 | kind: Workflow 106 | metadata: 107 | generateName: slack-example- 108 | spec: 109 | entrypoint: main 110 | templates: 111 | - name: main 112 | plugin: 113 | slack: 114 | text: "{{workflow.name}} finished!" 115 | ``` 116 | workflows.argoproj.io/version: '>= v3.3' 117 | data: 118 | sidecar.container: | 119 | name: hermes 120 | image: ghcr.io/kjagiello/hermes:latest 121 | imagePullPolicy: IfNotPresent 122 | ports: 123 | - containerPort: 3030 124 | resources: 125 | limits: 126 | cpu: 200m 127 | memory: 64Mi 128 | requests: 129 | cpu: 100m 130 | memory: 32Mi 131 | securityContext: 132 | runAsNonRoot: true 133 | runAsUser: 1000 134 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | # Hermes – notifications for Argo Workflows 2 | 3 | Hermes aims to provide a streamlined way of sending notifications to various 4 | messaging services from your [Argo Workflows](https://argoproj.github.io/argo-workflows/) 5 | pipelines. 6 | 7 |
8 | ![demo](assets/demo.gif) 9 |
An example of a Slack notification sent using Hermes
10 |
11 | 12 | ## Features 13 | 14 | - **Easy to use** – Hermes is a [template executor 15 | plugin](https://github.com/argoproj/argo-workflows/pull/7256). Once 16 | installed, Argo Workflows will automatically provide a Hermes instance for you to 17 | interact with from your workflow. 18 | - **Template system** – keep a centralized set of reusable notification 19 | templates and use them freely in your workflows. 20 | - **In-place updates** – avoid clutter in your channels by updating existing 21 | messages and keep the history of changes in a thread under the notification 22 | message instead. 23 | - **Multiple recipient support** – do you need to send notifications to different 24 | channels or services from a single workflow? No problem. 25 | 26 | ## Quickstart 27 | 28 | {% include "content/compatibility.md" %} 29 | 30 | Keen to take Hermes for a spin? Go ahead and visit the [quickstart guide](quickstart.md). 31 | 32 | ## Usage example 33 | 34 | In case you need some more convincing before you give Hermes a chance, take a 35 | look at an example workflow that sends the notifications shown in the demo 36 | above. 37 | 38 | ```yaml 39 | {% raw %} 40 | apiVersion: argoproj.io/v1alpha1 41 | kind: Workflow 42 | metadata: 43 | generateName: notifications-test- 44 | spec: 45 | entrypoint: main 46 | templates: 47 | - name: main 48 | steps: 49 | - - name: setup-notifications 50 | template: hermes-setup 51 | 52 | - - name: pre-notification 53 | template: hermes-notify 54 | arguments: 55 | parameters: 56 | - name: message 57 | value: "Deployment started :hourglass_flowing_sand:" 58 | 59 | - - name: hello 60 | template: hello 61 | 62 | - - name: post-notification 63 | template: hermes-notify 64 | arguments: 65 | parameters: 66 | - name: message 67 | value: "Deployment succeeded :white_check_mark:" 68 | 69 | - name: hermes-setup 70 | plugin: 71 | hermes: 72 | setup: 73 | alias: default 74 | service: slack 75 | config: 76 | token: slack-token 77 | icon_emoji: ":rocket:" 78 | 79 | - name: hermes-notify 80 | inputs: 81 | parameters: 82 | - name: message 83 | plugin: 84 | hermes: 85 | notify: 86 | target: default 87 | template: hermes-template-slack-default 88 | config: 89 | channel: sandbox 90 | context: 91 | message: "{{inputs.parameters.message}}" 92 | app: hermes 93 | env: prod 94 | revision_sha: "deadbeef" 95 | revision_url: "http://github.com/..." 96 | log_url: "http://github.com/..." 97 | 98 | - name: hello 99 | container: 100 | image: docker/whalesay 101 | command: [cowsay] 102 | args: ["hello world"] 103 | {% endraw %} 104 | ``` 105 | 106 | If this managed to catch your interest, learn how to setup Hermes using the [quickstart guide](quickstart.md). 107 | -------------------------------------------------------------------------------- /src/k8s.rs: -------------------------------------------------------------------------------- 1 | use kube::api::Api; 2 | use kube::Client; 3 | 4 | pub mod templates { 5 | use super::*; 6 | use crate::templates::{Template, TemplateError, TemplateRegistry}; 7 | use async_trait::async_trait; 8 | use k8s_openapi::api::core::v1::ConfigMap; 9 | use parking_lot::Mutex; 10 | use std::collections::HashMap; 11 | use std::sync::Arc; 12 | 13 | /// A template registry backed by the Kubernetes ConfigMaps 14 | /// 15 | /// As there is no practical reason (AFAICS) for the templates to change during a workflow run, 16 | /// they are fetched only once and then cached for the lifetime of the process. 17 | pub struct K8sTemplateRegistry { 18 | templates: Arc>>>, 19 | client: Client, 20 | } 21 | 22 | impl K8sTemplateRegistry { 23 | pub async fn new() -> Result, String> { 24 | Ok(Arc::from(Self { 25 | templates: Arc::new(Mutex::new(HashMap::new())), 26 | client: Client::try_default() 27 | .await 28 | .map_err(|e| format!("Kubernetes client error: {:#?}", e))?, 29 | })) 30 | } 31 | } 32 | 33 | #[async_trait] 34 | impl TemplateRegistry for K8sTemplateRegistry { 35 | /// Retrieves a ConfigMap template 36 | /// 37 | /// # Arguments 38 | /// 39 | /// * `name` - The name of the ConfigMap to retrieve the template from 40 | async fn get(&self, name: &str) -> Result, TemplateError> { 41 | // Check for the template in the cache 42 | let cached_template = { 43 | let registry = self.templates.lock(); 44 | registry.get(name).cloned() 45 | }; 46 | let (cache, template) = match cached_template { 47 | Some(t) => (false, t), 48 | None => { 49 | // Template was not found in the cache. Retrieve the ConfigMap 50 | let client: Api = Api::default_namespaced(self.client.clone()); 51 | let raw_template = client 52 | .get(name) 53 | .await 54 | .map_err(|e| { 55 | TemplateError::GenericError(format!( 56 | "Failed to retrieve ConfigMap: {}", 57 | e 58 | )) 59 | }) 60 | .and_then(|cm| { 61 | cm.data.ok_or_else(|| { 62 | TemplateError::GenericError("ConfigMap missing data".into()) 63 | }) 64 | })?; 65 | let template: Arc