├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── enhancement_proposal.md └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yml ├── .readthedocs.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── OWNERS ├── README.md ├── VERSION ├── bot ├── adapter.go ├── server.go ├── server_test.go └── slack │ ├── slack.go │ ├── slack_test.go │ ├── verify.go │ └── verify_test.go ├── catalog ├── install.yaml ├── templates │ ├── app-created.yaml │ ├── app-deleted.yaml │ ├── app-deployed.yaml │ ├── app-health-degraded.yaml │ ├── app-sync-failed.yaml │ ├── app-sync-running.yaml │ ├── app-sync-status-unknown.yaml │ └── app-sync-succeeded.yaml └── triggers │ ├── on-created.yaml │ ├── on-deleted.yaml │ ├── on-deployed.yaml │ ├── on-health-degraded.yaml │ ├── on-sync-failed.yaml │ ├── on-sync-running.yaml │ ├── on-sync-status-unknown.yaml │ └── on-sync-succeeded.yaml ├── cmd ├── bot.go ├── controller.go ├── main.go └── tools │ └── tools.go ├── codecov.yml ├── controller ├── controller.go └── controller_test.go ├── docs └── index.md ├── expr ├── expr.go ├── expr_test.go ├── repo │ ├── repo.go │ └── repo_test.go ├── shared │ ├── appdetail.go │ ├── commit.go │ ├── helmappspec.go │ ├── helmfileparameter.go │ └── helmparameter.go ├── strings │ ├── strings.go │ └── strings_test.go ├── sync │ ├── sync.go │ └── sync_test.go └── time │ ├── time.go │ └── time_test.go ├── go.mod ├── go.sum ├── hack ├── gen │ └── main.go ├── set-docs-redirects.sh └── tools.go ├── manifests ├── bot │ ├── argocd-notifications-bot-deployment.yaml │ ├── argocd-notifications-bot-role.yaml │ ├── argocd-notifications-bot-rolebinding.yaml │ ├── argocd-notifications-bot-sa.yaml │ ├── argocd-notifications-bot-service.yaml │ └── kustomization.yaml ├── controller │ ├── argocd-notifications-cm.yaml │ ├── argocd-notifications-controller-deployment.yaml │ ├── argocd-notifications-controller-metrics-service.yaml │ ├── argocd-notifications-controller-role.yaml │ ├── argocd-notifications-controller-rolebinding.yaml │ ├── argocd-notifications-controller-sa.yaml │ ├── argocd-notifications-secret.yaml │ └── kustomization.yaml ├── install-bot.yaml └── install.yaml ├── mkdocs.yml ├── pkg └── README.md ├── shared ├── argocd │ ├── mocks │ │ └── service.go │ └── service.go ├── k8s │ ├── clients.go │ ├── cmd.go │ └── informers.go └── settings │ ├── legacy.go │ ├── legacy_test.go │ └── settings.go └── testing ├── client.go └── testing.go /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .idea 3 | notifiers-secret.yaml 4 | manifests 5 | README.md -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Reproducible bug report 3 | about: Create a reproducible bug report. Not for support requests. 4 | labels: 'bug' 5 | --- 6 | ## Summary 7 | 8 | What happened/what you expected to happen? 9 | 10 | ## Diagnostics 11 | 12 | What Kubernetes provider are you using? 13 | 14 | What version of Argo CD and Argo CD Notifications are you running? 15 | 16 | ``` 17 | # Paste the logs from the notification controller 18 | 19 | # Logs for the entire controller: 20 | kubectl logs -n argocd deployment/argocd-notifications-controller 21 | ``` 22 | 23 | --- 24 | 25 | **Message from the maintainers**: 26 | 27 | Impacted by this bug? Give it a 👍. We prioritise the issues with the most 👍. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: New service integration request 5 | url: https://github.com/argoproj/notifications-engine/issues/new 6 | about: Create ticket in https://github.com/argoproj/notifications-engine repository 7 | - name: Have you read the docs? 8 | url: https://argocd-notifications.readthedocs.io/ 9 | about: Much help can be found in the docs 10 | - name: Ask a question 11 | url: https://github.com/argoproj-labs/argocd-notifications/discussions/new 12 | about: Ask a question or start a discussion about notifications 13 | - name: Chat on Slack 14 | url: https://argoproj.github.io/community/join-slack 15 | about: Maybe chatting with the community can help 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement_proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement proposal 3 | about: Propose an enhancement for this project 4 | labels: 'enhancement' 5 | --- 6 | # Summary 7 | 8 | What change needs making? 9 | 10 | # Use Cases 11 | 12 | When would you use this? 13 | 14 | --- 15 | 16 | **Message from the maintainers**: 17 | 18 | Impacted by this bug? Give it a 👍. We prioritise the issues with the most 👍. 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | - 'release-*' 8 | pull_request: 9 | branches: 10 | - 'master' 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@master 18 | - uses: actions/cache@v1 19 | with: 20 | path: ~/go/pkg/mod 21 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 22 | restore-keys: | 23 | ${{ runner.os }}-go- 24 | - uses: actions/setup-go@v1 25 | with: 26 | go-version: '1.16.2' 27 | - name: Add ~/go/bin to PATH 28 | run: | 29 | echo "/home/runner/go/bin" >> $GITHUB_PATH 30 | - name: Validate code generation 31 | run: | 32 | set -xo pipefail 33 | go mod download && go mod tidy && make generate 34 | git diff --exit-code -- . 35 | - run: make test 36 | - uses: golangci/golangci-lint-action@v2 37 | with: 38 | version: v1.38.0 39 | args: --timeout 5m 40 | env: 41 | GOROOT: "" 42 | - uses: codecov/codecov-action@v1 43 | with: 44 | file: ./coverage.out 45 | publish: 46 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 47 | runs-on: ubuntu-latest 48 | steps: 49 | - 50 | name: Checkout repo 51 | uses: actions/checkout@master 52 | - 53 | name: Build image tag 54 | run: echo "IMAGE_TAG=$(cat ./VERSION)-${GITHUB_SHA::8}" >> $GITHUB_ENV 55 | - 56 | name: Set up QEMU 57 | uses: docker/setup-qemu-action@v1 58 | - 59 | name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v1 61 | - 62 | name: Login to GitHub Container Registry 63 | uses: docker/login-action@v1 64 | with: 65 | registry: ghcr.io 66 | username: ${{ secrets.GH_USERNAME }} 67 | password: ${{ secrets.GH_PAT }} 68 | - 69 | name: Login to Docker Hub 70 | uses: docker/login-action@v1 71 | with: 72 | username: ${{ secrets.DOCKERHUB_USERNAME }} 73 | password: ${{ secrets.DOCKERHUB_TOKEN }} 74 | - 75 | name: Build and push 76 | id: docker_build 77 | uses: docker/build-push-action@v2 78 | with: 79 | platforms: linux/amd64,linux/arm64,linux/arm 80 | push: true 81 | tags: | 82 | ghcr.io/argoproj-labs/argocd-notifications:latest 83 | ghcr.io/argoproj-labs/argocd-notifications:${{ env.IMAGE_TAG }} 84 | argoprojlabs/argocd-notifications:latest 85 | file: ./Dockerfile 86 | build-args: | 87 | IMAGE_TAG=${{ env.IMAGE_TAG }} 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@master 11 | - uses: actions/cache@v1 12 | with: 13 | path: ~/go/pkg/mod 14 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 15 | restore-keys: | 16 | ${{ runner.os }}-go- 17 | - uses: actions/setup-go@v1 18 | with: 19 | go-version: '1.16.2' 20 | - run: make test 21 | - uses: golangci/golangci-lint-action@v2 22 | with: 23 | version: v1.29 24 | args: --timeout 5m 25 | env: 26 | GOROOT: "" 27 | - uses: codecov/codecov-action@v1 28 | with: 29 | file: ./coverage.out 30 | publish: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - 34 | name: Checkout repo 35 | uses: actions/checkout@master 36 | - 37 | name: Build image tag 38 | run: echo "IMAGE_TAG=v$(cat ./VERSION)" >> $GITHUB_ENV 39 | - 40 | name: Set up QEMU 41 | uses: docker/setup-qemu-action@v1 42 | - 43 | name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v1 45 | - 46 | name: Login to GitHub Container Registry 47 | uses: docker/login-action@v1 48 | with: 49 | registry: ghcr.io 50 | username: ${{ secrets.GH_USERNAME }} 51 | password: ${{ secrets.GH_PAT }} 52 | - 53 | name: Login to Docker Hub 54 | uses: docker/login-action@v1 55 | with: 56 | username: ${{ secrets.DOCKERHUB_USERNAME }} 57 | password: ${{ secrets.DOCKERHUB_TOKEN }} 58 | - 59 | name: Build and push 60 | id: docker_build 61 | uses: docker/build-push-action@v2 62 | with: 63 | platforms: linux/amd64,linux/arm64,linux/arm 64 | push: true 65 | tags: | 66 | argoprojlabs/argocd-notifications:${{ env.IMAGE_TAG }} 67 | ghcr.io/argoproj-labs/argocd-notifications:${{ env.IMAGE_TAG }} 68 | file: ./Dockerfile 69 | build-args: | 70 | IMAGE_TAG=${{ env.IMAGE_TAG }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | notifiers-secret.yaml 3 | site 4 | coverage.out 5 | .vscode 6 | dist 7 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 2m 3 | skip-dirs: 4 | - .../../mocks 5 | - hack 6 | - testing 7 | linters: 8 | enable: 9 | - vet 10 | - deadcode 11 | - goimports 12 | - varcheck 13 | - structcheck 14 | - ineffassign 15 | - unconvert 16 | - unparam 17 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.12" 7 | formats: all 8 | mkdocs: 9 | fail_on_warning: false 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.2.0 (2021-11-17) 4 | 5 | ### Features 6 | 7 | * feat: Subscribe to all triggers at once (#202) 8 | * feat: Add .strings.ReplaceAll expression (#332) 9 | * feat: Add .sync.GetInfoItem expression that simplifies retrieving operation info items by name 10 | * feat: Plaintext connection to repo-server with ability disable TLS (#281) 11 | * feat: Dynamic ConfigMap name and Secret name (#77) 12 | * feat: Configurable path for slack bot (#94) 13 | * feat: Support rocketchat 14 | * feat: Support google chat 15 | * feat: Support alertmanager 16 | * feat: Support pushover 17 | * feat: Support email SendHtml 18 | * feat: Add message aggregation feature by slack threads API 19 | * feat: Add summary field into teams message 20 | * feat: Support Markdown parse mode in telegram 21 | 22 | ### Bug Fixes 23 | 24 | * fix: syntax error in Teams notifications (#271) 25 | * fix: service account rbac issue , add namespace support for informer (#322) 26 | * fix: add annotations nil check 27 | * fix: add expr error log 28 | 29 | ### Other 30 | 31 | * Move notification providers to notifications-engine library 32 | 33 | ## v1.1.0 (2021-04-17) 34 | 35 | ### Features 36 | 37 | * feat: ArgoCD Notifications for Created, Deleted status (#231) 38 | * feat: improve oncePer evaluate (#228) 39 | * feat: support change timezone (#226) 40 | * feat: support mattermost integration (#212) 41 | * feat: support telegram private channel (#207) 42 | * feat: GitHub App integration (#180) 43 | * feat: MS Teams integration (#181) 44 | 45 | ### Bug Fixes 46 | 47 | * fix: merging secrets into service config not working (fixes #208) 48 | * fix: update cached informer object instead of reloading app to avoid duplicated notifications (#204) 49 | * fix: static configmap and secret binding (#136) 50 | 51 | ## v1.0.2 (2020-02-17) 52 | 53 | * fix: revision changes only if someone run sync operation or changes are detected (#157) 54 | * fix: if app has no subscriptions, then nothing to process (#174) 55 | * fix:improve annotation iterate (#159) 56 | 57 | ## v1.0.1 (2020-01-20) 58 | 59 | * fix: the on-deployed trigger sends multiple notifications (#154) 60 | 61 | ## v1.0.0 (2020-01-19) 62 | 63 | ### Features 64 | 65 | * feat: triggers with multiple conditions and multiple templates per condition 66 | * feat: support `oncePer` trigger property that allows sending notification "once per" app field value (#60) 67 | * feat: add support for proxy settings (#42) 68 | * feat: support self-signed certificates in all HTTP based integrations (#61) 69 | * feat: subscription support specifying message template 70 | * feat: support Telegram notifications (#49) 71 | 72 | ### Bug Fixes 73 | 74 | * Failed notifications affect multiple subscribers (#79) 75 | 76 | ### Refactor 77 | 78 | * Built-in triggers/templates replaced with triggers/templates "catalog" (#56) 79 | * `config.yaml` and `notifiers.yaml` configs split into multiple ConfigMap keys (#76) 80 | * `trigger.enabled` field is replaced with `defaultTriggers` setting 81 | * Replace `template.body`, `template.title` fields with `template.message` and `template.email.subject` fields 82 | 83 | ## v0.7.0 (2020-05-10) 84 | 85 | ### Features 86 | 87 | * feat: support default subscriptions 88 | * feat: support loading commit metadata (#87) 89 | * feat: add controller prometheus metrics (#86) 90 | * feat: log http request/response in every notifier (#83) 91 | * feat: add CLI debugging commands (#81) 92 | 93 | ### Bug Fixes 94 | 95 | * fix: don't append slash to webhook url (#70) (#85) 96 | * fix: improve settings parsing errors (#84) 97 | * fix: use strategic merge patch to merge built-in and user provided config (#74) 98 | * fix: ensure slack attachment properly formatted json object (#73) 99 | 100 | ## v0.6.1 (2020-03-20) 101 | 102 | ### Features 103 | 104 | * feat: support default subscriptions 105 | * fix: ensure slack attachment properly formatted json object 106 | 107 | ## v0.6.0 (2020-03-13) 108 | 109 | ### Features 110 | 111 | * feat: support sending the generic webhook request 112 | * feat: Grafana annotation notifier ( thanks to [nhuray](https://github.com/nhuray) ) 113 | 114 | ### Bug Fixes 115 | 116 | * fix: wait for next reconciliation after sync ( thanks to [sboschman](https://github.com/sboschman) ) 117 | 118 | ## v0.5.0 (2020-03-01) 119 | 120 | ### Features 121 | * feat: support managing subscriptions using Slack bot 122 | * feat: support `time.Now()` and `time.Parse(...)` in trigger condition ( thanks to [@HatsuneMiku3939](https://github.com/HatsuneMiku3939) ) 123 | * feat: Add icon emoij and icon url support for Slack messages ( thanks to [sboschman](https://github.com/sboschman) ) 124 | * feat: Introduce sprig functions to templates( thanks to [imranismail](https://github.com/imranismail) ) 125 | 126 | ### Bug Fixes 127 | * fix: fix null pointer dereference error while config parsing 128 | 129 | ## v0.4.2 (2020-02-03) 130 | 131 | ### Bug Fixes 132 | * fix: fix null pointer dereference error while config parsing 133 | 134 | ## v0.4.1 (2020-01-26) 135 | 136 | ### Bug Fixes 137 | * fix: notification config parse (#19) ( thanks to [@yutachaos](https://github.com/yutachaos) ! ) 138 | 139 | ## v0.4.0 (2020-01-24) 140 | 141 | * Opsgenie support (thanks to [Dominik Münch](https://github.com/muenchdo)) 142 | * Slack message blocks and attachments support 143 | 144 | ## v0.3.0 (2020-01-13) 145 | 146 | ### Features 147 | * Trigger specific subscriptions 148 | 149 | ### Other 150 | * Move repo and docker image to https://github.com/argoproj-labs/argocd-notifications 151 | 152 | ## v0.2.1 (2019-12-29) 153 | 154 | ### Bug Fixes 155 | * built-in triggers are disabled by default 156 | 157 | ## v0.2.0 (2019-12-26) 158 | 159 | ### Features 160 | * support setting hot reload 161 | * embed built-in triggers/templates into binary instead of default config map 162 | * support enabling/disabling triggers 163 | * support customizing built-in triggers/templates 164 | * add on-sync-running/on-sync-succeeded triggers and templates 165 | 166 | ### Bug Fixes 167 | * fix sending same notification twice 168 | 169 | ### Other 170 | * use `scratch` as a base image 171 | 172 | ## v0.1.0 (2019-12-14) 173 | 174 | First MVP: 175 | - email, slack notifications 176 | - subscribe at application/project level 177 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.16.2 as builder 2 | 3 | RUN apt-get update && apt-get install ca-certificates 4 | 5 | WORKDIR /src 6 | 7 | ARG TARGETOS 8 | ARG TARGETARCH 9 | ARG TARGETPLATFORM 10 | ARG BUILDPLATFORM 11 | 12 | COPY go.mod /src/go.mod 13 | COPY go.sum /src/go.sum 14 | 15 | RUN go mod download 16 | 17 | # Perform the build 18 | COPY . . 19 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /app/argocd-notifications ./cmd 20 | RUN ln -s /app/argocd-notifications /app/argocd-notifications-backend 21 | 22 | FROM scratch 23 | 24 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 25 | COPY --from=builder /app/argocd-notifications /app/argocd-notifications 26 | COPY --from=builder /app/argocd-notifications-backend /app/argocd-notifications-backend 27 | 28 | # User numeric user so that kubernetes can assert that the user id isn't root (0). 29 | # We are also using the root group (the 0 in 1000:0), it doesn't have any 30 | # privileges, as opposed to the root user. 31 | USER 1000:0 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2017-2018 The Argo Authors 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION?=$(shell cat VERSION) 2 | IMAGE_TAG?=v$(VERSION) 3 | IMAGE_PREFIX?=argoprojlabs 4 | DOCKER_PUSH?=false 5 | 6 | .PHONY: test 7 | test: 8 | go test ./... -coverprofile=coverage.out -race 9 | 10 | .PHONY: lint 11 | lint: 12 | golangci-lint run 13 | 14 | .PHONY: docs 15 | docs: 16 | rm -rf vendor && go mod vendor && mkdir -p docs/services 17 | cp -r vendor/github.com/argoproj/notifications-engine/docs/services/* docs/services && rm docs/services/*.go && rm -rf vendor 18 | go run github.com/argoproj-labs/argocd-notifications/hack/gen docs 19 | 20 | .PHONY: catalog 21 | catalog: 22 | go run github.com/argoproj-labs/argocd-notifications/hack/gen catalog 23 | 24 | .PHONY: manifests 25 | manifests: 26 | kustomize build manifests/controller > manifests/install.yaml 27 | kustomize build manifests/bot > manifests/install-bot.yaml 28 | 29 | .PHONY: tools 30 | tools: 31 | go install github.com/golang/mock/mockgen@v1.5.0 32 | 33 | .PHONY: generate 34 | generate: manifests docs catalog tools 35 | go generate ./... 36 | 37 | .PHONY: build 38 | build: 39 | ifeq ($(RELEASE), true) 40 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./dist/argocd-notifications-linux-amd64 ./cmd 41 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-w -s" -o ./dist/argocd-notifications-linux-arm64 ./cmd 42 | CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags="-w -s" -o ./dist/argocd-notifications-linux-arm ./cmd 43 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-w -s" -o ./dist/argocd-notifications-darwin-amd64 ./cmd 44 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-w -s" -o ./dist/argocd-notifications-windows-amd64.exe ./cmd 45 | else 46 | CGO_ENABLED=0 go build -ldflags="-w -s" -o ./dist/argocd-notifications ./cmd 47 | endif 48 | 49 | .PHONY: image 50 | image: 51 | docker build -t $(IMAGE_PREFIX)/argocd-notifications:$(IMAGE_TAG) . 52 | @if [ "$(DOCKER_PUSH)" = "true" ] ; then docker push $(IMAGE_PREFIX)/argocd-notifications:$(IMAGE_TAG) ; fi 53 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | owners: 2 | - alexmt 3 | 4 | approvers: 5 | - alexmt 6 | 7 | reviewers: 8 | - alexmt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![codecov](https://codecov.io/gh/argoproj-labs/argocd-notifications/branch/master/graph/badge.svg)](https://codecov.io/gh/argoproj-labs/argocd-notifications) 2 | 3 | # Argo CD Notifications is now part of Argo CD 4 | 5 | **This project has moved to the main Argo CD repository** 6 | 7 | This repository is no longer active. The Argo CD notifications project is now merged with [Argo CD](https://github.com/argoproj/argo-cd) and released along with it. 8 | Further development will happen there. 9 | See [https://argo-cd.readthedocs.io/en/latest/operator-manual/notifications/](https://argo-cd.readthedocs.io/en/latest/operator-manual/notifications/) for more details. 10 | 11 | # Argo CD Notifications - OLD README 12 | 13 | Argo CD Notifications continuously monitors Argo CD applications and provides a flexible way to notify 14 | users about important changes in the applications state. The project includes a bundle of useful 15 | built-in triggers and notification templates, integrates with various notification services such as 16 | ☑ Slack, ☑ SMTP, ☑ Opsgenie, ☑ Telegram and anything else using custom webhooks. 17 | 18 | ![demo](./docs/demo.gif) 19 | 20 | # Why use Argo CD Notifications? 21 | 22 | The Argo CD Notifications is not the only way to monitor Argo CD application. You might leverage Prometheus 23 | metrics and [Grafana Alerts](https://grafana.com/docs/grafana/latest/alerting/rules/) or projects 24 | like [bitnami-labs/kubewatch](https://github.com/bitnami-labs/kubewatch) and 25 | [argo-kube-notifier](https://github.com/argoproj-labs/argo-kube-notifier). The advantage of Argo CD Notifications is that 26 | it is focused on Argo CD use cases and ultimately provides a better user experience. 27 | 28 | # Old Documentation 29 | 30 | Go to the complete [documentation](https://argoproj-labs.github.io/argocd-notifications/) to learn more. 31 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.3.0 2 | -------------------------------------------------------------------------------- /bot/adapter.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import "net/http" 4 | 5 | type ListSubscriptions struct { 6 | } 7 | 8 | type UpdateSubscription struct { 9 | App string 10 | Project string 11 | Trigger string 12 | } 13 | 14 | type Command struct { 15 | Service string 16 | Recipient string 17 | ListSubscriptions *ListSubscriptions 18 | Subscribe *UpdateSubscription 19 | Unsubscribe *UpdateSubscription 20 | } 21 | 22 | // Adapter encapsulates integration with the notification service 23 | type Adapter interface { 24 | // Parses requested command 25 | Parse(r *http.Request) (Command, error) 26 | // Sends formatted response 27 | SendResponse(content string, w http.ResponseWriter) 28 | } 29 | -------------------------------------------------------------------------------- /bot/server.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/argoproj/notifications-engine/pkg/subscriptions" 12 | 13 | "github.com/argoproj-labs/argocd-notifications/shared/k8s" 14 | 15 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/apimachinery/pkg/types" 17 | "k8s.io/client-go/dynamic" 18 | ) 19 | 20 | type Server interface { 21 | Serve(port int) error 22 | AddAdapter(path string, adapter Adapter) 23 | } 24 | 25 | func NewServer(dynamicClient dynamic.Interface, namespace string) *server { 26 | return &server{ 27 | mux: http.NewServeMux(), 28 | appClient: k8s.NewAppClient(dynamicClient, namespace), 29 | appProjClient: k8s.NewAppProjClient(dynamicClient, namespace), 30 | } 31 | } 32 | 33 | type server struct { 34 | appClient dynamic.ResourceInterface 35 | appProjClient dynamic.ResourceInterface 36 | mux *http.ServeMux 37 | } 38 | 39 | func copyStringMap(in map[string]string) map[string]string { 40 | out := make(map[string]string) 41 | for k, v := range in { 42 | out[k] = v 43 | } 44 | return out 45 | } 46 | 47 | func (s *server) handler(adapter Adapter) func(http.ResponseWriter, *http.Request) { 48 | return func(w http.ResponseWriter, r *http.Request) { 49 | cmd, err := adapter.Parse(r) 50 | if err != nil { 51 | adapter.SendResponse(err.Error(), w) 52 | return 53 | } 54 | if res, err := s.execute(cmd); err != nil { 55 | adapter.SendResponse(fmt.Sprintf("cannot execute command: %v", err), w) 56 | } else { 57 | adapter.SendResponse(res, w) 58 | } 59 | } 60 | } 61 | 62 | func (s *server) execute(cmd Command) (string, error) { 63 | switch { 64 | case cmd.ListSubscriptions != nil: 65 | return s.listSubscriptions(cmd.Service, cmd.Recipient) 66 | case cmd.Subscribe != nil: 67 | return s.updateSubscription(cmd.Service, cmd.Recipient, true, *cmd.Subscribe) 68 | case cmd.Unsubscribe != nil: 69 | return s.updateSubscription(cmd.Service, cmd.Recipient, false, *cmd.Unsubscribe) 70 | default: 71 | return "", errors.New("unknown command") 72 | } 73 | } 74 | 75 | func annotationsPatch(old map[string]string, new map[string]string) map[string]*string { 76 | patch := map[string]*string{} 77 | for k := range new { 78 | val := new[k] 79 | if val != old[k] { 80 | patch[k] = &val 81 | } 82 | } 83 | for k := range old { 84 | if _, ok := new[k]; !ok { 85 | patch[k] = nil 86 | } 87 | } 88 | return patch 89 | } 90 | 91 | func (s *server) updateSubscription(service string, recipient string, subscribe bool, opts UpdateSubscription) (string, error) { 92 | var name string 93 | var client dynamic.ResourceInterface 94 | switch { 95 | case opts.App != "": 96 | name = opts.App 97 | client = s.appClient 98 | case opts.Project != "": 99 | name = opts.Project 100 | client = s.appProjClient 101 | default: 102 | return "", errors.New("either application or project name must be specified") 103 | } 104 | obj, err := client.Get(context.Background(), name, v1.GetOptions{}) 105 | if err != nil { 106 | return "", err 107 | } 108 | oldAnnotations := copyStringMap(obj.GetAnnotations()) 109 | annotations := subscriptions.NewAnnotations(obj.GetAnnotations()) 110 | if subscribe { 111 | annotations.Subscribe(opts.Trigger, service, recipient) 112 | } else { 113 | annotations.Unsubscribe(opts.Trigger, service, recipient) 114 | } 115 | annotationsPatch := annotationsPatch(oldAnnotations, annotations) 116 | if len(annotationsPatch) > 0 { 117 | patch := map[string]map[string]interface{}{ 118 | "metadata": { 119 | "annotations": annotationsPatch, 120 | }, 121 | } 122 | patchData, err := json.Marshal(patch) 123 | if err != nil { 124 | return "", err 125 | } 126 | _, err = client.Patch(context.Background(), name, types.MergePatchType, patchData, v1.PatchOptions{}) 127 | if err != nil { 128 | return "", err 129 | } 130 | } 131 | 132 | return "subscription updated", nil 133 | } 134 | 135 | func (s *server) listSubscriptions(service string, recipient string) (string, error) { 136 | appList, err := s.appClient.List(context.Background(), v1.ListOptions{}) 137 | if err != nil { 138 | return "", err 139 | } 140 | var apps []string 141 | for _, app := range appList.Items { 142 | if subscriptions.NewAnnotations(app.GetAnnotations()).Has(service, recipient) { 143 | apps = append(apps, fmt.Sprintf("%s/%s", app.GetNamespace(), app.GetName())) 144 | } 145 | } 146 | appProjList, err := s.appProjClient.List(context.Background(), v1.ListOptions{}) 147 | if err != nil { 148 | return "", err 149 | } 150 | var appProjs []string 151 | for _, appProj := range appProjList.Items { 152 | if subscriptions.NewAnnotations(appProj.GetAnnotations()).Has(service, recipient) { 153 | appProjs = append(appProjs, fmt.Sprintf("%s/%s", appProj.GetNamespace(), appProj.GetName())) 154 | } 155 | } 156 | response := fmt.Sprintf("The %s has no subscriptions.", recipient) 157 | if len(apps) > 0 || len(appProjs) > 0 { 158 | response = fmt.Sprintf("The %s is subscribed to %d applications and %d projects.", 159 | recipient, len(apps), len(appProjs)) 160 | if len(apps) > 0 { 161 | response = fmt.Sprintf("%s\nApplications: %s.", response, strings.Join(apps, ", ")) 162 | } 163 | if len(appProjs) > 0 { 164 | response = fmt.Sprintf("%s\nProjects: %s.", response, strings.Join(appProjs, ", ")) 165 | } 166 | } 167 | return response, nil 168 | } 169 | 170 | func (s *server) AddAdapter(pattern string, adapter Adapter) { 171 | s.mux.HandleFunc(pattern, s.handler(adapter)) 172 | } 173 | 174 | func (s *server) Serve(port int) error { 175 | return http.ListenAndServe(fmt.Sprintf(":%d", port), s.mux) 176 | } 177 | -------------------------------------------------------------------------------- /bot/server_test.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/argoproj/notifications-engine/pkg/subscriptions" 7 | 8 | . "github.com/argoproj-labs/argocd-notifications/testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "k8s.io/utils/pointer" 13 | ) 14 | 15 | func TestListRecipients_NoSubscriptions(t *testing.T) { 16 | client := NewFakeClient() 17 | s := NewServer(client, TestNamespace) 18 | 19 | response, err := s.listSubscriptions("slack", "general") 20 | 21 | assert.NoError(t, err) 22 | 23 | assert.Contains(t, "The general has no subscriptions.", response) 24 | } 25 | 26 | func TestListSubscriptions_HasAppSubscription(t *testing.T) { 27 | client := NewFakeClient( 28 | NewApp("foo"), 29 | NewApp("bar", WithAnnotations(map[string]string{subscriptions.SubscribeAnnotationKey("my-trigger", "slack"): "general"}))) 30 | s := NewServer(client, TestNamespace) 31 | 32 | response, err := s.listSubscriptions("slack", "general") 33 | 34 | assert.NoError(t, err) 35 | 36 | assert.Contains(t, response, "Applications: default/bar") 37 | } 38 | 39 | func TestListSubscriptions_HasAppProjSubscription(t *testing.T) { 40 | client := NewFakeClient( 41 | NewApp("foo"), 42 | NewProject("bar", WithAnnotations(map[string]string{subscriptions.SubscribeAnnotationKey("my-trigger", "slack"): "general"}))) 43 | s := NewServer(client, TestNamespace) 44 | 45 | response, err := s.listSubscriptions("slack", "general") 46 | 47 | assert.NoError(t, err) 48 | 49 | assert.Contains(t, response, "Projects: default/bar") 50 | } 51 | 52 | func TestUpdateSubscription_SubscribeToApp(t *testing.T) { 53 | client := NewFakeClient(NewApp("foo", WithAnnotations(map[string]string{ 54 | subscriptions.SubscribeAnnotationKey("my-trigger", "slack"): "channel1", 55 | }))) 56 | 57 | var patches []map[string]interface{} 58 | AddPatchCollectorReactor(client, &patches) 59 | 60 | s := NewServer(client, TestNamespace) 61 | 62 | resp, err := s.updateSubscription("slack", "channel2", true, UpdateSubscription{App: "foo", Trigger: "my-trigger"}) 63 | assert.NoError(t, err) 64 | assert.Equal(t, "subscription updated", resp) 65 | assert.Len(t, patches, 1) 66 | 67 | val, _, _ := unstructured.NestedString(patches[0], "metadata", "annotations", subscriptions.SubscribeAnnotationKey("my-trigger", "slack")) 68 | assert.Equal(t, val, "channel1;channel2") 69 | } 70 | 71 | func TestUpdateSubscription_SubscribeToAppTrigger(t *testing.T) { 72 | client := NewFakeClient(NewApp("foo", WithAnnotations(map[string]string{ 73 | subscriptions.SubscribeAnnotationKey("my-trigger", "slack"): "channel1", 74 | }))) 75 | 76 | var patches []map[string]interface{} 77 | AddPatchCollectorReactor(client, &patches) 78 | 79 | s := NewServer(client, TestNamespace) 80 | 81 | resp, err := s.updateSubscription("slack", "channel2", true, UpdateSubscription{App: "foo", Trigger: "on-sync-failed"}) 82 | assert.NoError(t, err) 83 | assert.Equal(t, "subscription updated", resp) 84 | assert.Len(t, patches, 1) 85 | 86 | patch := patches[0] 87 | val, _, _ := unstructured.NestedString(patch, "metadata", "annotations", subscriptions.SubscribeAnnotationKey("on-sync-failed", "slack")) 88 | assert.Equal(t, "channel2", val) 89 | } 90 | 91 | func TestCopyStringMap(t *testing.T) { 92 | in := map[string]string{"key": "val"} 93 | out := copyStringMap(in) 94 | assert.Equal(t, in, out) 95 | assert.False(t, &in == &out) 96 | } 97 | 98 | func TestAnnotationsPatch(t *testing.T) { 99 | oldAnnotations := map[string]string{"key1": "val1", "key2": "val2", "key3": "val3"} 100 | newAnnotations := map[string]string{"key2": "val2-updated", "key3": "val3", "key4": "val4"} 101 | patch := annotationsPatch(oldAnnotations, newAnnotations) 102 | assert.Equal(t, map[string]*string{ 103 | "key1": nil, 104 | "key2": pointer.StringPtr("val2-updated"), 105 | "key4": pointer.StringPtr("val4"), 106 | }, patch) 107 | } 108 | -------------------------------------------------------------------------------- /bot/slack/slack.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | texttemplate "text/template" 13 | 14 | "github.com/argoproj-labs/argocd-notifications/bot" 15 | 16 | slackclient "github.com/slack-go/slack" 17 | ) 18 | 19 | func NewSlackAdapter(verifier RequestVerifier) *slack { 20 | return &slack{verifier: verifier} 21 | } 22 | 23 | type slack struct { 24 | verifier RequestVerifier 25 | } 26 | 27 | func mustTemplate(text string) *texttemplate.Template { 28 | return texttemplate.Must(texttemplate.New("usage").Parse(text)) 29 | } 30 | 31 | var commandsHelp = map[string]*texttemplate.Template{ 32 | "list-subscriptions": mustTemplate("*List your subscriptions*:\n" + "```{{.cmd}} list-subscriptions```"), 33 | "subscribe": mustTemplate("*Subscribe current channel*:\n" + 34 | "```{{.cmd}} subscribe \n" + 35 | "{{.cmd}} subscribe proj: ```"), 36 | "unsubscribe": mustTemplate("*Unsubscribe current channel*:\n" + 37 | "```{{.cmd}} unsubscribe \n" + 38 | "{{.cmd}} unsubscribe proj: ```"), 39 | } 40 | 41 | func usageInstructions(query url.Values, command string, err error) string { 42 | botCommand := "/argocd" 43 | if cmd := query.Get("command"); cmd != "" { 44 | botCommand = cmd 45 | } 46 | 47 | var usage bytes.Buffer 48 | if err != nil { 49 | usage.WriteString(err.Error() + "\n") 50 | } 51 | 52 | if tmpl, ok := commandsHelp[command]; ok { 53 | if err := tmpl.Execute(&usage, map[string]string{"cmd": botCommand}); err != nil { 54 | return err.Error() 55 | } 56 | } else { 57 | usage.WriteString(fmt.Sprintf(":wave: Need some help with `%s`?\n", botCommand)) 58 | for _, tmpl := range commandsHelp { 59 | if err := tmpl.Execute(&usage, map[string]string{"cmd": botCommand}); err != nil { 60 | return err.Error() 61 | } 62 | usage.WriteString("\n") 63 | } 64 | } 65 | return usage.String() 66 | } 67 | 68 | func (s *slack) parseQuery(r *http.Request) (string, url.Values, error) { 69 | data, err := ioutil.ReadAll(r.Body) 70 | if err != nil { 71 | return "", nil, err 72 | } 73 | service, err := s.verifier(data, r.Header) 74 | if err != nil { 75 | return "", nil, fmt.Errorf("failed to verify request signature: %v", err) 76 | } 77 | values, err := url.ParseQuery(string(data)) 78 | if err != nil { 79 | return "", nil, err 80 | } 81 | return service, values, nil 82 | } 83 | 84 | func (s *slack) Parse(r *http.Request) (bot.Command, error) { 85 | cmd := bot.Command{} 86 | service, query, err := s.parseQuery(r) 87 | if err != nil { 88 | return cmd, err 89 | } 90 | cmd.Service = service 91 | channel := query.Get("channel_name") 92 | if channel == "" { 93 | return cmd, errors.New("request does not have channel") 94 | } 95 | parts := strings.Fields(query.Get("text")) 96 | if len(parts) < 1 { 97 | return cmd, errors.New(usageInstructions(query, "", nil)) 98 | } 99 | command := parts[0] 100 | 101 | cmd.Recipient = channel 102 | 103 | switch command { 104 | case "list-subscriptions": 105 | cmd.ListSubscriptions = &bot.ListSubscriptions{} 106 | case "subscribe", "unsubscribe": 107 | if len(parts) < 2 { 108 | return cmd, errors.New(usageInstructions(query, command, errors.New("at least one argument expected"))) 109 | } 110 | update := &bot.UpdateSubscription{} 111 | nameParts := strings.Split(parts[1], ":") 112 | if len(nameParts) == 1 { 113 | nameParts = append([]string{"app"}, nameParts...) 114 | } 115 | switch nameParts[0] { 116 | case "app": 117 | update.App = nameParts[1] 118 | case "proj": 119 | update.Project = nameParts[1] 120 | default: 121 | return cmd, errors.New(usageInstructions(query, command, fmt.Errorf("incorrect name argument: %s", parts[1]))) 122 | } 123 | if len(parts) > 2 { 124 | update.Trigger = parts[2] 125 | } 126 | if command == "subscribe" { 127 | cmd.Subscribe = update 128 | } else { 129 | cmd.Unsubscribe = update 130 | } 131 | default: 132 | return cmd, errors.New(usageInstructions(query, "", nil)) 133 | } 134 | return cmd, nil 135 | } 136 | 137 | func (s *slack) SendResponse(content string, w http.ResponseWriter) { 138 | w.Header().Set("Content-Type", "application/json") 139 | blocks := []slackclient.SectionBlock{{ 140 | Type: slackclient.MBTSection, 141 | Text: &slackclient.TextBlockObject{Type: "mrkdwn", Text: content}, 142 | }} 143 | data, err := json.Marshal(map[string]interface{}{"blocks": blocks}) 144 | if err != nil { 145 | _, _ = w.Write([]byte(err.Error())) 146 | } else { 147 | _, _ = w.Write(data) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /bot/slack/slack_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | var noopVerifier = func(data []byte, header http.Header) (string, error) { 14 | return "slack", nil 15 | } 16 | 17 | func TestParse_ListSubscriptionsCommand(t *testing.T) { 18 | s := NewSlackAdapter(noopVerifier) 19 | 20 | cmd, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 21 | bytes.NewBufferString("text=list-subscriptions&channel_name=test"))) 22 | assert.NoError(t, err) 23 | 24 | assert.NotNil(t, cmd.ListSubscriptions) 25 | assert.Equal(t, cmd.Recipient, "test") 26 | } 27 | 28 | func TestParse_SubscribeAppTrigger(t *testing.T) { 29 | s := NewSlackAdapter(noopVerifier) 30 | 31 | cmd, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 32 | bytes.NewBufferString("text=subscribe%20foo%20on-sync-failed&channel_name=test"))) 33 | assert.NoError(t, err) 34 | 35 | assert.NotNil(t, cmd.Subscribe) 36 | assert.Equal(t, cmd.Subscribe.Trigger, "on-sync-failed") 37 | assert.Equal(t, cmd.Subscribe.App, "foo") 38 | assert.Equal(t, cmd.Subscribe.Project, "") 39 | assert.Equal(t, cmd.Recipient, "test") 40 | } 41 | 42 | func TestParse_SubscribeProject(t *testing.T) { 43 | s := NewSlackAdapter(noopVerifier) 44 | 45 | cmd, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 46 | bytes.NewBufferString("text=subscribe%20proj%3Afoo&channel_name=test"))) 47 | assert.NoError(t, err) 48 | 49 | assert.NotNil(t, cmd.Subscribe) 50 | assert.Equal(t, cmd.Subscribe.Trigger, "") 51 | assert.Equal(t, cmd.Subscribe.App, "") 52 | assert.Equal(t, cmd.Subscribe.Project, "foo") 53 | assert.Equal(t, cmd.Recipient, "test") 54 | } 55 | 56 | func TestParse_UnsubscribeApp(t *testing.T) { 57 | s := NewSlackAdapter(noopVerifier) 58 | 59 | cmd, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 60 | bytes.NewBufferString("text=unsubscribe%20app%3Afoo&channel_name=test"))) 61 | assert.NoError(t, err) 62 | 63 | assert.NotNil(t, cmd.Unsubscribe) 64 | assert.Equal(t, cmd.Unsubscribe.Trigger, "") 65 | assert.Equal(t, cmd.Unsubscribe.App, "foo") 66 | assert.Equal(t, cmd.Unsubscribe.Project, "") 67 | assert.Equal(t, cmd.Recipient, "test") 68 | } 69 | 70 | func TestParse_WrongCommandHelpResponse(t *testing.T) { 71 | s := NewSlackAdapter(noopVerifier) 72 | 73 | _, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 74 | bytes.NewBufferString("text=wrong&channel_name=test"))) 75 | assert.Error(t, err) 76 | 77 | assert.Contains(t, err.Error(), "Need some help") 78 | } 79 | 80 | func TestParse_NoCommandHelpResponse(t *testing.T) { 81 | s := NewSlackAdapter(noopVerifier) 82 | 83 | _, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 84 | bytes.NewBufferString("channel_name=test"))) 85 | assert.Error(t, err) 86 | 87 | assert.Contains(t, err.Error(), "Need some help") 88 | } 89 | 90 | func TestParse_NoAppArgument(t *testing.T) { 91 | s := NewSlackAdapter(noopVerifier) 92 | 93 | _, err := s.Parse(httptest.NewRequest("GET", "http://localhost/slack", 94 | bytes.NewBufferString("text=unsubscribe&channel_name=test"))) 95 | assert.Error(t, err) 96 | assert.Contains(t, err.Error(), "at least one argument expected") 97 | } 98 | 99 | func TestSendResponse(t *testing.T) { 100 | s := NewSlackAdapter(noopVerifier) 101 | w := httptest.NewRecorder() 102 | 103 | s.SendResponse("test", w) 104 | 105 | resp := w.Result() 106 | body, _ := ioutil.ReadAll(resp.Body) 107 | 108 | assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"test"}}]}`, string(body)) 109 | } 110 | -------------------------------------------------------------------------------- /bot/slack/verify.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/argoproj/notifications-engine/pkg/api" 8 | 9 | slackclient "github.com/slack-go/slack" 10 | ) 11 | 12 | type HasSigningSecret interface { 13 | GetSigningSecret() string 14 | } 15 | 16 | type RequestVerifier func(data []byte, header http.Header) (string, error) 17 | 18 | func NewVerifier(apiFactory api.Factory) RequestVerifier { 19 | return func(data []byte, header http.Header) (string, error) { 20 | signingSecret := "" 21 | serviceName := "" 22 | api, err := apiFactory.GetAPI() 23 | if err != nil { 24 | return "", err 25 | } 26 | for name, service := range api.GetNotificationServices() { 27 | if hasSecret, ok := service.(HasSigningSecret); ok { 28 | signingSecret = hasSecret.GetSigningSecret() 29 | serviceName = name 30 | if signingSecret == "" { 31 | return "", errors.New("slack signing secret is not configured") 32 | } 33 | } 34 | } 35 | 36 | if signingSecret == "" { 37 | return "", errors.New("slack is not configured") 38 | } 39 | 40 | verifier, err := slackclient.NewSecretsVerifier(header, signingSecret) 41 | if err != nil { 42 | return "", err 43 | } 44 | _, err = verifier.Write(data) 45 | if err != nil { 46 | return "", err 47 | } 48 | return serviceName, verifier.Ensure() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /bot/slack/verify_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/argoproj/notifications-engine/pkg/mocks" 9 | 10 | "github.com/argoproj/notifications-engine/pkg/api" 11 | "github.com/argoproj/notifications-engine/pkg/services" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestNewVerifier_IncorrectConfig(t *testing.T) { 16 | testCases := map[string]struct { 17 | Services map[string]services.NotificationService 18 | Error string 19 | }{ 20 | "NoSlack": { 21 | Services: map[string]services.NotificationService{}, 22 | Error: "slack is not configured", 23 | }, 24 | "SlackWithoutSigningSecret": { 25 | Services: map[string]services.NotificationService{"slack": services.NewSlackService(services.SlackOptions{})}, 26 | Error: "slack signing secret is not configured", 27 | }, 28 | } 29 | 30 | for k := range testCases { 31 | testCase := testCases[k] 32 | 33 | t.Run(k, func(t *testing.T) { 34 | 35 | api, err := api.NewAPI(api.Config{}, nil) 36 | if !assert.NoError(t, err) { 37 | return 38 | } 39 | for k, v := range testCase.Services { 40 | api.AddNotificationService(k, v) 41 | } 42 | verifier := NewVerifier(&mocks.FakeFactory{Api: api}) 43 | 44 | _, err = verifier(nil, nil) 45 | 46 | assert.Error(t, err) 47 | assert.Contains(t, err.Error(), testCase.Error) 48 | }) 49 | } 50 | } 51 | 52 | func TestNewVerifier_IncorrectSignature(t *testing.T) { 53 | api, err := api.NewAPI(api.Config{}, nil) 54 | if !assert.NoError(t, err) { 55 | return 56 | } 57 | api.AddNotificationService("slack", services.NewSlackService(services.SlackOptions{SigningSecret: "hello world"})) 58 | verifier := NewVerifier(&mocks.FakeFactory{Api: api}) 59 | 60 | now := time.Now() 61 | data := "hello world" 62 | _, err = verifier([]byte(data), map[string][]string{ 63 | "X-Slack-Request-Timestamp": {strconv.Itoa(int(now.UnixNano()))}, 64 | "X-Slack-Signature": {"v0=9e3753bb47fd3495894ab133c423ec93eff1ff30dd905ce39dda065e21ed9255"}, 65 | }) 66 | 67 | assert.Error(t, err) 68 | assert.Contains(t, err.Error(), "Expected signing signature") 69 | } 70 | -------------------------------------------------------------------------------- /catalog/install.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | template.app-created: | 4 | email: 5 | subject: Application {{.app.metadata.name}} has been created. 6 | message: Application {{.app.metadata.name}} has been created. 7 | teams: 8 | title: Application {{.app.metadata.name}} has been created. 9 | template.app-deleted: | 10 | email: 11 | subject: Application {{.app.metadata.name}} has been deleted. 12 | message: Application {{.app.metadata.name}} has been deleted. 13 | teams: 14 | title: Application {{.app.metadata.name}} has been deleted. 15 | template.app-deployed: | 16 | email: 17 | subject: New version of an application {{.app.metadata.name}} is up and running. 18 | message: | 19 | {{if eq .serviceType "slack"}}:white_check_mark:{{end}} Application {{.app.metadata.name}} is now running new version of deployments manifests. 20 | slack: 21 | attachments: | 22 | [{ 23 | "title": "{{ .app.metadata.name}}", 24 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 25 | "color": "#18be52", 26 | "fields": [ 27 | { 28 | "title": "Sync Status", 29 | "value": "{{.app.status.sync.status}}", 30 | "short": true 31 | }, 32 | { 33 | "title": "Repository", 34 | "value": "{{.app.spec.source.repoURL}}", 35 | "short": true 36 | }, 37 | { 38 | "title": "Revision", 39 | "value": "{{.app.status.sync.revision}}", 40 | "short": true 41 | } 42 | {{range $index, $c := .app.status.conditions}} 43 | {{if not $index}},{{end}} 44 | {{if $index}},{{end}} 45 | { 46 | "title": "{{$c.type}}", 47 | "value": "{{$c.message}}", 48 | "short": true 49 | } 50 | {{end}} 51 | ] 52 | }] 53 | groupingKey: "" 54 | notifyBroadcast: false 55 | teams: 56 | facts: | 57 | [{ 58 | "name": "Sync Status", 59 | "value": "{{.app.status.sync.status}}" 60 | }, 61 | { 62 | "name": "Repository", 63 | "value": "{{.app.spec.source.repoURL}}" 64 | }, 65 | { 66 | "name": "Revision", 67 | "value": "{{.app.status.sync.revision}}" 68 | } 69 | {{range $index, $c := .app.status.conditions}} 70 | {{if not $index}},{{end}} 71 | {{if $index}},{{end}} 72 | { 73 | "name": "{{$c.type}}", 74 | "value": "{{$c.message}}" 75 | } 76 | {{end}} 77 | ] 78 | potentialAction: |- 79 | [{ 80 | "@type":"OpenUri", 81 | "name":"Operation Application", 82 | "targets":[{ 83 | "os":"default", 84 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" 85 | }] 86 | }, 87 | { 88 | "@type":"OpenUri", 89 | "name":"Open Repository", 90 | "targets":[{ 91 | "os":"default", 92 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 93 | }] 94 | }] 95 | themeColor: '#000080' 96 | title: New version of an application {{.app.metadata.name}} is up and running. 97 | template.app-health-degraded: | 98 | email: 99 | subject: Application {{.app.metadata.name}} has degraded. 100 | message: | 101 | {{if eq .serviceType "slack"}}:exclamation:{{end}} Application {{.app.metadata.name}} has degraded. 102 | Application details: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}. 103 | slack: 104 | attachments: | 105 | [{ 106 | "title": "{{ .app.metadata.name}}", 107 | "title_link": "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 108 | "color": "#f4c030", 109 | "fields": [ 110 | { 111 | "title": "Health Status", 112 | "value": "{{.app.status.health.status}}", 113 | "short": true 114 | }, 115 | { 116 | "title": "Repository", 117 | "value": "{{.app.spec.source.repoURL}}", 118 | "short": true 119 | } 120 | {{range $index, $c := .app.status.conditions}} 121 | {{if not $index}},{{end}} 122 | {{if $index}},{{end}} 123 | { 124 | "title": "{{$c.type}}", 125 | "value": "{{$c.message}}", 126 | "short": true 127 | } 128 | {{end}} 129 | ] 130 | }] 131 | groupingKey: "" 132 | notifyBroadcast: false 133 | teams: 134 | facts: | 135 | [{ 136 | "name": "Health Status", 137 | "value": "{{.app.status.health.status}}" 138 | }, 139 | { 140 | "name": "Repository", 141 | "value": "{{.app.spec.source.repoURL}}" 142 | } 143 | {{range $index, $c := .app.status.conditions}} 144 | {{if not $index}},{{end}} 145 | {{if $index}},{{end}} 146 | { 147 | "name": "{{$c.type}}", 148 | "value": "{{$c.message}}" 149 | } 150 | {{end}} 151 | ] 152 | potentialAction: | 153 | [{ 154 | "@type":"OpenUri", 155 | "name":"Open Application", 156 | "targets":[{ 157 | "os":"default", 158 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" 159 | }] 160 | }, 161 | { 162 | "@type":"OpenUri", 163 | "name":"Open Repository", 164 | "targets":[{ 165 | "os":"default", 166 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 167 | }] 168 | }] 169 | themeColor: '#FF0000' 170 | title: Application {{.app.metadata.name}} has degraded. 171 | template.app-sync-failed: | 172 | email: 173 | subject: Failed to sync application {{.app.metadata.name}}. 174 | message: | 175 | {{if eq .serviceType "slack"}}:exclamation:{{end}} The sync operation of application {{.app.metadata.name}} has failed at {{.app.status.operationState.finishedAt}} with the following error: {{.app.status.operationState.message}} 176 | Sync operation details are available at: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true . 177 | slack: 178 | attachments: | 179 | [{ 180 | "title": "{{ .app.metadata.name}}", 181 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 182 | "color": "#E96D76", 183 | "fields": [ 184 | { 185 | "title": "Sync Status", 186 | "value": "{{.app.status.sync.status}}", 187 | "short": true 188 | }, 189 | { 190 | "title": "Repository", 191 | "value": "{{.app.spec.source.repoURL}}", 192 | "short": true 193 | } 194 | {{range $index, $c := .app.status.conditions}} 195 | {{if not $index}},{{end}} 196 | {{if $index}},{{end}} 197 | { 198 | "title": "{{$c.type}}", 199 | "value": "{{$c.message}}", 200 | "short": true 201 | } 202 | {{end}} 203 | ] 204 | }] 205 | groupingKey: "" 206 | notifyBroadcast: false 207 | teams: 208 | facts: | 209 | [{ 210 | "name": "Sync Status", 211 | "value": "{{.app.status.sync.status}}" 212 | }, 213 | { 214 | "name": "Failed at", 215 | "value": "{{.app.status.operationState.finishedAt}}" 216 | }, 217 | { 218 | "name": "Repository", 219 | "value": "{{.app.spec.source.repoURL}}" 220 | } 221 | {{range $index, $c := .app.status.conditions}} 222 | {{if not $index}},{{end}} 223 | {{if $index}},{{end}} 224 | { 225 | "name": "{{$c.type}}", 226 | "value": "{{$c.message}}" 227 | } 228 | {{end}} 229 | ] 230 | potentialAction: |- 231 | [{ 232 | "@type":"OpenUri", 233 | "name":"Open Operation", 234 | "targets":[{ 235 | "os":"default", 236 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" 237 | }] 238 | }, 239 | { 240 | "@type":"OpenUri", 241 | "name":"Open Repository", 242 | "targets":[{ 243 | "os":"default", 244 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 245 | }] 246 | }] 247 | themeColor: '#FF0000' 248 | title: Failed to sync application {{.app.metadata.name}}. 249 | template.app-sync-running: | 250 | email: 251 | subject: Start syncing application {{.app.metadata.name}}. 252 | message: | 253 | The sync operation of application {{.app.metadata.name}} has started at {{.app.status.operationState.startedAt}}. 254 | Sync operation details are available at: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true . 255 | slack: 256 | attachments: | 257 | [{ 258 | "title": "{{ .app.metadata.name}}", 259 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 260 | "color": "#0DADEA", 261 | "fields": [ 262 | { 263 | "title": "Sync Status", 264 | "value": "{{.app.status.sync.status}}", 265 | "short": true 266 | }, 267 | { 268 | "title": "Repository", 269 | "value": "{{.app.spec.source.repoURL}}", 270 | "short": true 271 | } 272 | {{range $index, $c := .app.status.conditions}} 273 | {{if not $index}},{{end}} 274 | {{if $index}},{{end}} 275 | { 276 | "title": "{{$c.type}}", 277 | "value": "{{$c.message}}", 278 | "short": true 279 | } 280 | {{end}} 281 | ] 282 | }] 283 | groupingKey: "" 284 | notifyBroadcast: false 285 | teams: 286 | facts: | 287 | [{ 288 | "name": "Sync Status", 289 | "value": "{{.app.status.sync.status}}" 290 | }, 291 | { 292 | "name": "Started at", 293 | "value": "{{.app.status.operationState.startedAt}}" 294 | }, 295 | { 296 | "name": "Repository", 297 | "value": "{{.app.spec.source.repoURL}}" 298 | } 299 | {{range $index, $c := .app.status.conditions}} 300 | {{if not $index}},{{end}} 301 | {{if $index}},{{end}} 302 | { 303 | "name": "{{$c.type}}", 304 | "value": "{{$c.message}}" 305 | } 306 | {{end}} 307 | ] 308 | potentialAction: |- 309 | [{ 310 | "@type":"OpenUri", 311 | "name":"Open Operation", 312 | "targets":[{ 313 | "os":"default", 314 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" 315 | }] 316 | }, 317 | { 318 | "@type":"OpenUri", 319 | "name":"Open Repository", 320 | "targets":[{ 321 | "os":"default", 322 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 323 | }] 324 | }] 325 | title: Start syncing application {{.app.metadata.name}}. 326 | template.app-sync-status-unknown: | 327 | email: 328 | subject: Application {{.app.metadata.name}} sync status is 'Unknown' 329 | message: | 330 | {{if eq .serviceType "slack"}}:exclamation:{{end}} Application {{.app.metadata.name}} sync is 'Unknown'. 331 | Application details: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}. 332 | {{if ne .serviceType "slack"}} 333 | {{range $c := .app.status.conditions}} 334 | * {{$c.message}} 335 | {{end}} 336 | {{end}} 337 | slack: 338 | attachments: | 339 | [{ 340 | "title": "{{ .app.metadata.name}}", 341 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 342 | "color": "#E96D76", 343 | "fields": [ 344 | { 345 | "title": "Sync Status", 346 | "value": "{{.app.status.sync.status}}", 347 | "short": true 348 | }, 349 | { 350 | "title": "Repository", 351 | "value": "{{.app.spec.source.repoURL}}", 352 | "short": true 353 | } 354 | {{range $index, $c := .app.status.conditions}} 355 | {{if not $index}},{{end}} 356 | {{if $index}},{{end}} 357 | { 358 | "title": "{{$c.type}}", 359 | "value": "{{$c.message}}", 360 | "short": true 361 | } 362 | {{end}} 363 | ] 364 | }] 365 | groupingKey: "" 366 | notifyBroadcast: false 367 | teams: 368 | facts: | 369 | [{ 370 | "name": "Sync Status", 371 | "value": "{{.app.status.sync.status}}" 372 | }, 373 | { 374 | "name": "Repository", 375 | "value": "{{.app.spec.source.repoURL}}" 376 | } 377 | {{range $index, $c := .app.status.conditions}} 378 | {{if not $index}},{{end}} 379 | {{if $index}},{{end}} 380 | { 381 | "name": "{{$c.type}}", 382 | "value": "{{$c.message}}" 383 | } 384 | {{end}} 385 | ] 386 | potentialAction: |- 387 | [{ 388 | "@type":"OpenUri", 389 | "name":"Open Application", 390 | "targets":[{ 391 | "os":"default", 392 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" 393 | }] 394 | }, 395 | { 396 | "@type":"OpenUri", 397 | "name":"Open Repository", 398 | "targets":[{ 399 | "os":"default", 400 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 401 | }] 402 | }] 403 | title: Application {{.app.metadata.name}} sync status is 'Unknown' 404 | template.app-sync-succeeded: | 405 | email: 406 | subject: Application {{.app.metadata.name}} has been successfully synced. 407 | message: | 408 | {{if eq .serviceType "slack"}}:white_check_mark:{{end}} Application {{.app.metadata.name}} has been successfully synced at {{.app.status.operationState.finishedAt}}. 409 | Sync operation details are available at: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true . 410 | slack: 411 | attachments: | 412 | [{ 413 | "title": "{{ .app.metadata.name}}", 414 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 415 | "color": "#18be52", 416 | "fields": [ 417 | { 418 | "title": "Sync Status", 419 | "value": "{{.app.status.sync.status}}", 420 | "short": true 421 | }, 422 | { 423 | "title": "Repository", 424 | "value": "{{.app.spec.source.repoURL}}", 425 | "short": true 426 | } 427 | {{range $index, $c := .app.status.conditions}} 428 | {{if not $index}},{{end}} 429 | {{if $index}},{{end}} 430 | { 431 | "title": "{{$c.type}}", 432 | "value": "{{$c.message}}", 433 | "short": true 434 | } 435 | {{end}} 436 | ] 437 | }] 438 | groupingKey: "" 439 | notifyBroadcast: false 440 | teams: 441 | facts: | 442 | [{ 443 | "name": "Sync Status", 444 | "value": "{{.app.status.sync.status}}" 445 | }, 446 | { 447 | "name": "Synced at", 448 | "value": "{{.app.status.operationState.finishedAt}}" 449 | }, 450 | { 451 | "name": "Repository", 452 | "value": "{{.app.spec.source.repoURL}}" 453 | } 454 | {{range $index, $c := .app.status.conditions}} 455 | {{if not $index}},{{end}} 456 | {{if $index}},{{end}} 457 | { 458 | "name": "{{$c.type}}", 459 | "value": "{{$c.message}}" 460 | } 461 | {{end}} 462 | ] 463 | potentialAction: |- 464 | [{ 465 | "@type":"OpenUri", 466 | "name":"Operation Details", 467 | "targets":[{ 468 | "os":"default", 469 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" 470 | }] 471 | }, 472 | { 473 | "@type":"OpenUri", 474 | "name":"Open Repository", 475 | "targets":[{ 476 | "os":"default", 477 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 478 | }] 479 | }] 480 | themeColor: '#000080' 481 | title: Application {{.app.metadata.name}} has been successfully synced 482 | trigger.on-created: | 483 | - description: Application is created. 484 | oncePer: app.metadata.name 485 | send: 486 | - app-created 487 | when: "true" 488 | trigger.on-deleted: | 489 | - description: Application is deleted. 490 | oncePer: app.metadata.name 491 | send: 492 | - app-deleted 493 | when: app.metadata.deletionTimestamp != nil 494 | trigger.on-deployed: | 495 | - description: Application is synced and healthy. Triggered once per commit. 496 | oncePer: app.status.operationState.syncResult.revision 497 | send: 498 | - app-deployed 499 | when: app.status.operationState.phase in ['Succeeded'] and app.status.health.status 500 | == 'Healthy' 501 | trigger.on-health-degraded: | 502 | - description: Application has degraded 503 | send: 504 | - app-health-degraded 505 | when: app.status.health.status == 'Degraded' 506 | trigger.on-sync-failed: | 507 | - description: Application syncing has failed 508 | send: 509 | - app-sync-failed 510 | when: app.status.operationState.phase in ['Error', 'Failed'] 511 | trigger.on-sync-running: | 512 | - description: Application is being synced 513 | send: 514 | - app-sync-running 515 | when: app.status.operationState.phase in ['Running'] 516 | trigger.on-sync-status-unknown: | 517 | - description: Application status is 'Unknown' 518 | send: 519 | - app-sync-status-unknown 520 | when: app.status.sync.status == 'Unknown' 521 | trigger.on-sync-succeeded: | 522 | - description: Application syncing has succeeded 523 | send: 524 | - app-sync-succeeded 525 | when: app.status.operationState.phase in ['Succeeded'] 526 | kind: ConfigMap 527 | metadata: 528 | creationTimestamp: null 529 | name: argocd-notifications-cm 530 | -------------------------------------------------------------------------------- /catalog/templates/app-created.yaml: -------------------------------------------------------------------------------- 1 | message: &message Application {{.app.metadata.name}} has been created. 2 | email: 3 | subject: *message 4 | teams: 5 | title: *message 6 | -------------------------------------------------------------------------------- /catalog/templates/app-deleted.yaml: -------------------------------------------------------------------------------- 1 | message: &message Application {{.app.metadata.name}} has been deleted. 2 | email: 3 | subject: *message 4 | teams: 5 | title: *message 6 | -------------------------------------------------------------------------------- /catalog/templates/app-deployed.yaml: -------------------------------------------------------------------------------- 1 | message: | 2 | {{if eq .serviceType "slack"}}:white_check_mark:{{end}} Application {{.app.metadata.name}} is now running new version of deployments manifests. 3 | email: 4 | subject: New version of an application {{.app.metadata.name}} is up and running. 5 | slack: 6 | attachments: | 7 | [{ 8 | "title": "{{ .app.metadata.name}}", 9 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 10 | "color": "#18be52", 11 | "fields": [ 12 | { 13 | "title": "Sync Status", 14 | "value": "{{.app.status.sync.status}}", 15 | "short": true 16 | }, 17 | { 18 | "title": "Repository", 19 | "value": "{{.app.spec.source.repoURL}}", 20 | "short": true 21 | }, 22 | { 23 | "title": "Revision", 24 | "value": "{{.app.status.sync.revision}}", 25 | "short": true 26 | } 27 | {{range $index, $c := .app.status.conditions}} 28 | {{if not $index}},{{end}} 29 | {{if $index}},{{end}} 30 | { 31 | "title": "{{$c.type}}", 32 | "value": "{{$c.message}}", 33 | "short": true 34 | } 35 | {{end}} 36 | ] 37 | }] 38 | teams: 39 | themeColor: "#000080" 40 | title: New version of an application {{.app.metadata.name}} is up and running. 41 | facts: | 42 | [{ 43 | "name": "Sync Status", 44 | "value": "{{.app.status.sync.status}}" 45 | }, 46 | { 47 | "name": "Repository", 48 | "value": "{{.app.spec.source.repoURL}}" 49 | }, 50 | { 51 | "name": "Revision", 52 | "value": "{{.app.status.sync.revision}}" 53 | } 54 | {{range $index, $c := .app.status.conditions}} 55 | {{if not $index}},{{end}} 56 | {{if $index}},{{end}} 57 | { 58 | "name": "{{$c.type}}", 59 | "value": "{{$c.message}}" 60 | } 61 | {{end}} 62 | ] 63 | potentialAction: | 64 | [{ 65 | "@type":"OpenUri", 66 | "name":"Operation Application", 67 | "targets":[{ 68 | "os":"default", 69 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" 70 | }] 71 | }, 72 | { 73 | "@type":"OpenUri", 74 | "name":"Open Repository", 75 | "targets":[{ 76 | "os":"default", 77 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 78 | }] 79 | }] -------------------------------------------------------------------------------- /catalog/templates/app-health-degraded.yaml: -------------------------------------------------------------------------------- 1 | message: | 2 | {{if eq .serviceType "slack"}}:exclamation:{{end}} Application {{.app.metadata.name}} has degraded. 3 | Application details: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}. 4 | email: 5 | subject: Application {{.app.metadata.name}} has degraded. 6 | slack: 7 | attachments: | 8 | [{ 9 | "title": "{{ .app.metadata.name}}", 10 | "title_link": "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 11 | "color": "#f4c030", 12 | "fields": [ 13 | { 14 | "title": "Health Status", 15 | "value": "{{.app.status.health.status}}", 16 | "short": true 17 | }, 18 | { 19 | "title": "Repository", 20 | "value": "{{.app.spec.source.repoURL}}", 21 | "short": true 22 | } 23 | {{range $index, $c := .app.status.conditions}} 24 | {{if not $index}},{{end}} 25 | {{if $index}},{{end}} 26 | { 27 | "title": "{{$c.type}}", 28 | "value": "{{$c.message}}", 29 | "short": true 30 | } 31 | {{end}} 32 | ] 33 | }] 34 | teams: 35 | themeColor: "#FF0000" 36 | title: Application {{.app.metadata.name}} has degraded. 37 | facts: | 38 | [{ 39 | "name": "Health Status", 40 | "value": "{{.app.status.health.status}}" 41 | }, 42 | { 43 | "name": "Repository", 44 | "value": "{{.app.spec.source.repoURL}}" 45 | } 46 | {{range $index, $c := .app.status.conditions}} 47 | {{if not $index}},{{end}} 48 | {{if $index}},{{end}} 49 | { 50 | "name": "{{$c.type}}", 51 | "value": "{{$c.message}}" 52 | } 53 | {{end}} 54 | ] 55 | potentialAction: | 56 | [{ 57 | "@type":"OpenUri", 58 | "name":"Open Application", 59 | "targets":[{ 60 | "os":"default", 61 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" 62 | }] 63 | }, 64 | { 65 | "@type":"OpenUri", 66 | "name":"Open Repository", 67 | "targets":[{ 68 | "os":"default", 69 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 70 | }] 71 | }] 72 | -------------------------------------------------------------------------------- /catalog/templates/app-sync-failed.yaml: -------------------------------------------------------------------------------- 1 | message: | 2 | {{if eq .serviceType "slack"}}:exclamation:{{end}} The sync operation of application {{.app.metadata.name}} has failed at {{.app.status.operationState.finishedAt}} with the following error: {{.app.status.operationState.message}} 3 | Sync operation details are available at: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true . 4 | email: 5 | subject: Failed to sync application {{.app.metadata.name}}. 6 | slack: 7 | attachments: | 8 | [{ 9 | "title": "{{ .app.metadata.name}}", 10 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 11 | "color": "#E96D76", 12 | "fields": [ 13 | { 14 | "title": "Sync Status", 15 | "value": "{{.app.status.sync.status}}", 16 | "short": true 17 | }, 18 | { 19 | "title": "Repository", 20 | "value": "{{.app.spec.source.repoURL}}", 21 | "short": true 22 | } 23 | {{range $index, $c := .app.status.conditions}} 24 | {{if not $index}},{{end}} 25 | {{if $index}},{{end}} 26 | { 27 | "title": "{{$c.type}}", 28 | "value": "{{$c.message}}", 29 | "short": true 30 | } 31 | {{end}} 32 | ] 33 | }] 34 | teams: 35 | themeColor: "#FF0000" 36 | title: Failed to sync application {{.app.metadata.name}}. 37 | facts: | 38 | [{ 39 | "name": "Sync Status", 40 | "value": "{{.app.status.sync.status}}" 41 | }, 42 | { 43 | "name": "Failed at", 44 | "value": "{{.app.status.operationState.finishedAt}}" 45 | }, 46 | { 47 | "name": "Repository", 48 | "value": "{{.app.spec.source.repoURL}}" 49 | } 50 | {{range $index, $c := .app.status.conditions}} 51 | {{if not $index}},{{end}} 52 | {{if $index}},{{end}} 53 | { 54 | "name": "{{$c.type}}", 55 | "value": "{{$c.message}}" 56 | } 57 | {{end}} 58 | ] 59 | potentialAction: | 60 | [{ 61 | "@type":"OpenUri", 62 | "name":"Open Operation", 63 | "targets":[{ 64 | "os":"default", 65 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" 66 | }] 67 | }, 68 | { 69 | "@type":"OpenUri", 70 | "name":"Open Repository", 71 | "targets":[{ 72 | "os":"default", 73 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 74 | }] 75 | }] -------------------------------------------------------------------------------- /catalog/templates/app-sync-running.yaml: -------------------------------------------------------------------------------- 1 | message: | 2 | The sync operation of application {{.app.metadata.name}} has started at {{.app.status.operationState.startedAt}}. 3 | Sync operation details are available at: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true . 4 | email: 5 | subject: "Start syncing application {{.app.metadata.name}}." 6 | slack: 7 | attachments: | 8 | [{ 9 | "title": "{{ .app.metadata.name}}", 10 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 11 | "color": "#0DADEA", 12 | "fields": [ 13 | { 14 | "title": "Sync Status", 15 | "value": "{{.app.status.sync.status}}", 16 | "short": true 17 | }, 18 | { 19 | "title": "Repository", 20 | "value": "{{.app.spec.source.repoURL}}", 21 | "short": true 22 | } 23 | {{range $index, $c := .app.status.conditions}} 24 | {{if not $index}},{{end}} 25 | {{if $index}},{{end}} 26 | { 27 | "title": "{{$c.type}}", 28 | "value": "{{$c.message}}", 29 | "short": true 30 | } 31 | {{end}} 32 | ] 33 | }] 34 | teams: 35 | title: "Start syncing application {{.app.metadata.name}}." 36 | facts: | 37 | [{ 38 | "name": "Sync Status", 39 | "value": "{{.app.status.sync.status}}" 40 | }, 41 | { 42 | "name": "Started at", 43 | "value": "{{.app.status.operationState.startedAt}}" 44 | }, 45 | { 46 | "name": "Repository", 47 | "value": "{{.app.spec.source.repoURL}}" 48 | } 49 | {{range $index, $c := .app.status.conditions}} 50 | {{if not $index}},{{end}} 51 | {{if $index}},{{end}} 52 | { 53 | "name": "{{$c.type}}", 54 | "value": "{{$c.message}}" 55 | } 56 | {{end}} 57 | ] 58 | potentialAction: | 59 | [{ 60 | "@type":"OpenUri", 61 | "name":"Open Operation", 62 | "targets":[{ 63 | "os":"default", 64 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" 65 | }] 66 | }, 67 | { 68 | "@type":"OpenUri", 69 | "name":"Open Repository", 70 | "targets":[{ 71 | "os":"default", 72 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 73 | }] 74 | }] -------------------------------------------------------------------------------- /catalog/templates/app-sync-status-unknown.yaml: -------------------------------------------------------------------------------- 1 | message: | 2 | {{if eq .serviceType "slack"}}:exclamation:{{end}} Application {{.app.metadata.name}} sync is 'Unknown'. 3 | Application details: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}. 4 | {{if ne .serviceType "slack"}} 5 | {{range $c := .app.status.conditions}} 6 | * {{$c.message}} 7 | {{end}} 8 | {{end}} 9 | email: 10 | subject: Application {{.app.metadata.name}} sync status is 'Unknown' 11 | slack: 12 | attachments: | 13 | [{ 14 | "title": "{{ .app.metadata.name}}", 15 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 16 | "color": "#E96D76", 17 | "fields": [ 18 | { 19 | "title": "Sync Status", 20 | "value": "{{.app.status.sync.status}}", 21 | "short": true 22 | }, 23 | { 24 | "title": "Repository", 25 | "value": "{{.app.spec.source.repoURL}}", 26 | "short": true 27 | } 28 | {{range $index, $c := .app.status.conditions}} 29 | {{if not $index}},{{end}} 30 | {{if $index}},{{end}} 31 | { 32 | "title": "{{$c.type}}", 33 | "value": "{{$c.message}}", 34 | "short": true 35 | } 36 | {{end}} 37 | ] 38 | }] 39 | teams: 40 | title: Application {{.app.metadata.name}} sync status is 'Unknown' 41 | facts: | 42 | [{ 43 | "name": "Sync Status", 44 | "value": "{{.app.status.sync.status}}" 45 | }, 46 | { 47 | "name": "Repository", 48 | "value": "{{.app.spec.source.repoURL}}" 49 | } 50 | {{range $index, $c := .app.status.conditions}} 51 | {{if not $index}},{{end}} 52 | {{if $index}},{{end}} 53 | { 54 | "name": "{{$c.type}}", 55 | "value": "{{$c.message}}" 56 | } 57 | {{end}} 58 | ] 59 | potentialAction: | 60 | [{ 61 | "@type":"OpenUri", 62 | "name":"Open Application", 63 | "targets":[{ 64 | "os":"default", 65 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" 66 | }] 67 | }, 68 | { 69 | "@type":"OpenUri", 70 | "name":"Open Repository", 71 | "targets":[{ 72 | "os":"default", 73 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 74 | }] 75 | }] -------------------------------------------------------------------------------- /catalog/templates/app-sync-succeeded.yaml: -------------------------------------------------------------------------------- 1 | message: | 2 | {{if eq .serviceType "slack"}}:white_check_mark:{{end}} Application {{.app.metadata.name}} has been successfully synced at {{.app.status.operationState.finishedAt}}. 3 | Sync operation details are available at: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true . 4 | email: 5 | subject: Application {{.app.metadata.name}} has been successfully synced. 6 | slack: 7 | attachments: | 8 | [{ 9 | "title": "{{ .app.metadata.name}}", 10 | "title_link":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", 11 | "color": "#18be52", 12 | "fields": [ 13 | { 14 | "title": "Sync Status", 15 | "value": "{{.app.status.sync.status}}", 16 | "short": true 17 | }, 18 | { 19 | "title": "Repository", 20 | "value": "{{.app.spec.source.repoURL}}", 21 | "short": true 22 | } 23 | {{range $index, $c := .app.status.conditions}} 24 | {{if not $index}},{{end}} 25 | {{if $index}},{{end}} 26 | { 27 | "title": "{{$c.type}}", 28 | "value": "{{$c.message}}", 29 | "short": true 30 | } 31 | {{end}} 32 | ] 33 | }] 34 | teams: 35 | themeColor: "#000080" 36 | title: Application {{.app.metadata.name}} has been successfully synced 37 | facts: | 38 | [{ 39 | "name": "Sync Status", 40 | "value": "{{.app.status.sync.status}}" 41 | }, 42 | { 43 | "name": "Synced at", 44 | "value": "{{.app.status.operationState.finishedAt}}" 45 | }, 46 | { 47 | "name": "Repository", 48 | "value": "{{.app.spec.source.repoURL}}" 49 | } 50 | {{range $index, $c := .app.status.conditions}} 51 | {{if not $index}},{{end}} 52 | {{if $index}},{{end}} 53 | { 54 | "name": "{{$c.type}}", 55 | "value": "{{$c.message}}" 56 | } 57 | {{end}} 58 | ] 59 | potentialAction: | 60 | [{ 61 | "@type":"OpenUri", 62 | "name":"Operation Details", 63 | "targets":[{ 64 | "os":"default", 65 | "uri":"{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" 66 | }] 67 | }, 68 | { 69 | "@type":"OpenUri", 70 | "name":"Open Repository", 71 | "targets":[{ 72 | "os":"default", 73 | "uri":"{{.app.spec.source.repoURL | call .repo.RepoURLToHTTPS}}" 74 | }] 75 | }] -------------------------------------------------------------------------------- /catalog/triggers/on-created.yaml: -------------------------------------------------------------------------------- 1 | - when: true 2 | description: Application is created. 3 | send: [app-created] 4 | oncePer: app.metadata.name 5 | -------------------------------------------------------------------------------- /catalog/triggers/on-deleted.yaml: -------------------------------------------------------------------------------- 1 | - when: app.metadata.deletionTimestamp != nil 2 | description: Application is deleted. 3 | send: [app-deleted] 4 | oncePer: app.metadata.name 5 | -------------------------------------------------------------------------------- /catalog/triggers/on-deployed.yaml: -------------------------------------------------------------------------------- 1 | - when: app.status.operationState.phase in ['Succeeded'] and app.status.health.status == 'Healthy' 2 | description: Application is synced and healthy. Triggered once per commit. 3 | send: [app-deployed] 4 | oncePer: app.status.operationState.syncResult.revision -------------------------------------------------------------------------------- /catalog/triggers/on-health-degraded.yaml: -------------------------------------------------------------------------------- 1 | - when: app.status.health.status == 'Degraded' 2 | description: Application has degraded 3 | send: [app-health-degraded] 4 | -------------------------------------------------------------------------------- /catalog/triggers/on-sync-failed.yaml: -------------------------------------------------------------------------------- 1 | - when: app.status.operationState.phase in ['Error', 'Failed'] 2 | description: Application syncing has failed 3 | send: [app-sync-failed] 4 | -------------------------------------------------------------------------------- /catalog/triggers/on-sync-running.yaml: -------------------------------------------------------------------------------- 1 | - when: app.status.operationState.phase in ['Running'] 2 | description: Application is being synced 3 | send: [app-sync-running] 4 | -------------------------------------------------------------------------------- /catalog/triggers/on-sync-status-unknown.yaml: -------------------------------------------------------------------------------- 1 | - when: app.status.sync.status == 'Unknown' 2 | description: Application status is 'Unknown' 3 | send: [app-sync-status-unknown] 4 | -------------------------------------------------------------------------------- /catalog/triggers/on-sync-succeeded.yaml: -------------------------------------------------------------------------------- 1 | - when: app.status.operationState.phase in ['Succeeded'] 2 | description: Application syncing has succeeded 3 | send: [app-sync-succeeded] 4 | -------------------------------------------------------------------------------- /cmd/bot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/argoproj/notifications-engine/pkg/api" 7 | 8 | "github.com/spf13/cobra" 9 | "k8s.io/client-go/dynamic" 10 | "k8s.io/client-go/kubernetes" 11 | "k8s.io/client-go/tools/clientcmd" 12 | 13 | "github.com/argoproj-labs/argocd-notifications/bot" 14 | "github.com/argoproj-labs/argocd-notifications/bot/slack" 15 | "github.com/argoproj-labs/argocd-notifications/shared/k8s" 16 | "github.com/argoproj-labs/argocd-notifications/shared/settings" 17 | ) 18 | 19 | func newBotCommand() *cobra.Command { 20 | var ( 21 | clientConfig clientcmd.ClientConfig 22 | namespace string 23 | port int 24 | slackPath string 25 | ) 26 | var command = cobra.Command{ 27 | Use: "bot", 28 | Short: "Starts Argo CD Notifications bot", 29 | RunE: func(c *cobra.Command, args []string) error { 30 | restConfig, err := clientConfig.ClientConfig() 31 | if err != nil { 32 | return err 33 | } 34 | dynamicClient, err := dynamic.NewForConfig(restConfig) 35 | if err != nil { 36 | return err 37 | } 38 | clientset, err := kubernetes.NewForConfig(restConfig) 39 | if err != nil { 40 | return err 41 | } 42 | if namespace == "" { 43 | namespace, _, err = clientConfig.Namespace() 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | 49 | apiFactory := api.NewFactory(settings.GetFactorySettings(nil), 50 | namespace, 51 | k8s.NewSecretInformer(clientset, namespace), k8s.NewConfigMapInformer(clientset, namespace)) 52 | 53 | server := bot.NewServer(dynamicClient, namespace) 54 | server.AddAdapter(fmt.Sprintf("/%s", slackPath), slack.NewSlackAdapter(slack.NewVerifier(apiFactory))) 55 | return server.Serve(port) 56 | }, 57 | } 58 | clientConfig = k8s.AddK8SFlagsToCmd(&command) 59 | command.Flags().IntVar(&port, "port", 8080, "Port number.") 60 | command.Flags().StringVar(&namespace, "namespace", "", "Namespace which bot handles. Current namespace if empty.") 61 | command.Flags().StringVar(&slackPath, "slack-path", "slack", "Path to the slack bot handler") 62 | return &command 63 | } 64 | -------------------------------------------------------------------------------- /cmd/controller.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strings" 9 | 10 | "github.com/argoproj-labs/argocd-notifications/controller" 11 | "github.com/argoproj-labs/argocd-notifications/shared/argocd" 12 | "github.com/argoproj-labs/argocd-notifications/shared/k8s" 13 | 14 | notificationscontroller "github.com/argoproj/notifications-engine/pkg/controller" 15 | "github.com/prometheus/client_golang/prometheus" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | log "github.com/sirupsen/logrus" 18 | "github.com/spf13/cobra" 19 | "k8s.io/client-go/dynamic" 20 | "k8s.io/client-go/kubernetes" 21 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 22 | "k8s.io/client-go/tools/clientcmd" 23 | ) 24 | 25 | const ( 26 | defaultMetricsPort = 9001 27 | ) 28 | 29 | func newControllerCommand() *cobra.Command { 30 | var ( 31 | clientConfig clientcmd.ClientConfig 32 | processorsCount int 33 | namespace string 34 | appLabelSelector string 35 | logLevel string 36 | logFormat string 37 | metricsPort int 38 | argocdRepoServer string 39 | argocdRepoServerPlaintext bool 40 | argocdRepoServerStrictTLS bool 41 | configMapName string 42 | secretName string 43 | ) 44 | var command = cobra.Command{ 45 | Use: "controller", 46 | Short: "Starts Argo CD Notifications controller", 47 | RunE: func(c *cobra.Command, args []string) error { 48 | restConfig, err := clientConfig.ClientConfig() 49 | if err != nil { 50 | return err 51 | } 52 | dynamicClient, err := dynamic.NewForConfig(restConfig) 53 | if err != nil { 54 | return err 55 | } 56 | k8sClient, err := kubernetes.NewForConfig(restConfig) 57 | if err != nil { 58 | return err 59 | } 60 | if namespace == "" { 61 | namespace, _, err = clientConfig.Namespace() 62 | if err != nil { 63 | return err 64 | } 65 | } 66 | level, err := log.ParseLevel(logLevel) 67 | if err != nil { 68 | return err 69 | } 70 | log.SetLevel(level) 71 | 72 | switch strings.ToLower(logFormat) { 73 | case "json": 74 | log.SetFormatter(&log.JSONFormatter{}) 75 | case "text": 76 | if os.Getenv("FORCE_LOG_COLORS") == "1" { 77 | log.SetFormatter(&log.TextFormatter{ForceColors: true}) 78 | } 79 | default: 80 | return fmt.Errorf("Unknown log format '%s'", logFormat) 81 | } 82 | 83 | argocdService, err := argocd.NewArgoCDService(k8sClient, namespace, argocdRepoServer, argocdRepoServerPlaintext, argocdRepoServerStrictTLS) 84 | if err != nil { 85 | return err 86 | } 87 | defer argocdService.Close() 88 | 89 | k8s.ConfigMapName = configMapName 90 | k8s.SecretName = secretName 91 | 92 | registry := notificationscontroller.NewMetricsRegistry("argocd") 93 | http.Handle("/metrics", promhttp.HandlerFor(prometheus.Gatherers{registry, prometheus.DefaultGatherer}, promhttp.HandlerOpts{})) 94 | 95 | go func() { 96 | log.Fatal(http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", metricsPort), http.DefaultServeMux)) 97 | }() 98 | log.Infof("serving metrics on port %d", metricsPort) 99 | log.Infof("loading configuration %d", metricsPort) 100 | 101 | ctrl := controller.NewController(k8sClient, dynamicClient, argocdService, namespace, appLabelSelector, registry) 102 | err = ctrl.Init(context.Background()) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | go ctrl.Run(context.Background(), processorsCount) 108 | <-context.Background().Done() 109 | return nil 110 | }, 111 | } 112 | clientConfig = k8s.AddK8SFlagsToCmd(&command) 113 | command.Flags().IntVar(&processorsCount, "processors-count", 1, "Processors count.") 114 | command.Flags().StringVar(&appLabelSelector, "app-label-selector", "", "App label selector.") 115 | command.Flags().StringVar(&namespace, "namespace", "", "Namespace which controller handles. Current namespace if empty.") 116 | command.Flags().StringVar(&logLevel, "loglevel", "info", "Set the logging level. One of: debug|info|warn|error") 117 | command.Flags().StringVar(&logFormat, "logformat", "text", "Set the logging format. One of: text|json") 118 | command.Flags().IntVar(&metricsPort, "metrics-port", defaultMetricsPort, "Metrics port") 119 | command.Flags().StringVar(&argocdRepoServer, "argocd-repo-server", "argocd-repo-server:8081", "Argo CD repo server address") 120 | command.Flags().BoolVar(&argocdRepoServerPlaintext, "argocd-repo-server-plaintext", false, "Use a plaintext client (non-TLS) to connect to repository server") 121 | command.Flags().BoolVar(&argocdRepoServerStrictTLS, "argocd-repo-server-strict-tls", false, "Perform strict validation of TLS certificates when connecting to repo server") 122 | command.Flags().StringVar(&configMapName, "config-map-name", "argocd-notifications-cm", "Set notifications ConfigMap name") 123 | command.Flags().StringVar(&secretName, "secret-name", "argocd-notifications-secret", "Set notifications Secret name") 124 | return &command 125 | } 126 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/argoproj-labs/argocd-notifications/cmd/tools" 9 | 10 | argocert "github.com/argoproj/argo-cd/v2/util/cert" 11 | "github.com/argoproj/notifications-engine/pkg/util/http" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func init() { 16 | // resolve certificates using injected "argocd-tls-certs-cm" ConfigMap 17 | http.SetCertResolver(argocert.GetCertificateForConnect) 18 | } 19 | 20 | func main() { 21 | binaryName := filepath.Base(os.Args[0]) 22 | if val := os.Getenv("ARGOCD_NOTIFICATIONS_BINARY"); val != "" { 23 | binaryName = val 24 | } 25 | var command *cobra.Command 26 | switch binaryName { 27 | case "argocd-notifications-backend": 28 | command = &cobra.Command{ 29 | Use: "argocd-notifications-backend", 30 | Run: func(c *cobra.Command, args []string) { 31 | c.HelpFunc()(c, args) 32 | }, 33 | } 34 | command.AddCommand(newControllerCommand()) 35 | command.AddCommand(newBotCommand()) 36 | default: 37 | command = tools.NewToolsCommand() 38 | } 39 | 40 | if err := command.Execute(); err != nil { 41 | fmt.Println(err) 42 | os.Exit(1) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /cmd/tools/tools.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/argoproj-labs/argocd-notifications/shared/argocd" 7 | "github.com/argoproj-labs/argocd-notifications/shared/k8s" 8 | "github.com/argoproj-labs/argocd-notifications/shared/settings" 9 | "k8s.io/client-go/kubernetes" 10 | "k8s.io/client-go/tools/clientcmd" 11 | 12 | "github.com/argoproj/notifications-engine/pkg/cmd" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func NewToolsCommand() *cobra.Command { 17 | var ( 18 | argocdRepoServer string 19 | argocdRepoServerPlaintext bool 20 | argocdRepoServerStrictTLS bool 21 | ) 22 | 23 | var argocdService argocd.Service 24 | toolsCommand := cmd.NewToolsCommand( 25 | "argocd-notifications", 26 | "argocd-notifications", 27 | k8s.Applications, 28 | settings.GetFactorySettings(argocdService), func(clientConfig clientcmd.ClientConfig) { 29 | k8sCfg, err := clientConfig.ClientConfig() 30 | if err != nil { 31 | log.Fatalf("Failed to parse k8s config: %v", err) 32 | } 33 | ns, _, err := clientConfig.Namespace() 34 | if err != nil { 35 | log.Fatalf("Failed to parse k8s config: %v", err) 36 | } 37 | argocdService, err = argocd.NewArgoCDService(kubernetes.NewForConfigOrDie(k8sCfg), ns, argocdRepoServer, argocdRepoServerPlaintext, argocdRepoServerStrictTLS) 38 | if err != nil { 39 | log.Fatalf("Failed to initalize Argo CD service: %v", err) 40 | } 41 | }) 42 | toolsCommand.PersistentFlags().StringVar(&argocdRepoServer, "argocd-repo-server", "argocd-repo-server:8081", "Argo CD repo server address") 43 | toolsCommand.PersistentFlags().BoolVar(&argocdRepoServerPlaintext, "argocd-repo-server-plaintext", false, "Use a plaintext client (non-TLS) to connect to repository server") 44 | toolsCommand.PersistentFlags().BoolVar(&argocdRepoServerStrictTLS, "argocd-repo-server-strict-tls", false, "Perform strict validation of TLS certificates when connecting to repo server") 45 | return toolsCommand 46 | } 47 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - ./**/mocks/* 3 | - ./hack/**/* 4 | coverage: 5 | status: 6 | patch: off 7 | project: 8 | default: 9 | threshold: 2 10 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/argoproj-labs/argocd-notifications/shared/argocd" 10 | "github.com/argoproj-labs/argocd-notifications/shared/k8s" 11 | "github.com/argoproj-labs/argocd-notifications/shared/settings" 12 | 13 | "github.com/argoproj/notifications-engine/pkg/api" 14 | "github.com/argoproj/notifications-engine/pkg/controller" 15 | "github.com/argoproj/notifications-engine/pkg/services" 16 | "github.com/argoproj/notifications-engine/pkg/subscriptions" 17 | log "github.com/sirupsen/logrus" 18 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 20 | "k8s.io/apimachinery/pkg/runtime" 21 | "k8s.io/apimachinery/pkg/watch" 22 | "k8s.io/client-go/dynamic" 23 | "k8s.io/client-go/kubernetes" 24 | "k8s.io/client-go/tools/cache" 25 | ) 26 | 27 | const ( 28 | resyncPeriod = 60 * time.Second 29 | ) 30 | 31 | type NotificationController interface { 32 | Run(ctx context.Context, processors int) 33 | Init(ctx context.Context) error 34 | } 35 | 36 | func NewController( 37 | k8sClient kubernetes.Interface, 38 | client dynamic.Interface, 39 | argocdService argocd.Service, 40 | namespace string, 41 | appLabelSelector string, 42 | registry *controller.MetricsRegistry, 43 | ) *notificationController { 44 | appClient := client.Resource(k8s.Applications) 45 | appInformer := newInformer(appClient.Namespace(namespace), appLabelSelector) 46 | appProjInformer := newInformer(k8s.NewAppProjClient(client, namespace), "") 47 | secretInformer := k8s.NewSecretInformer(k8sClient, namespace) 48 | configMapInformer := k8s.NewConfigMapInformer(k8sClient, namespace) 49 | apiFactory := api.NewFactory(settings.GetFactorySettings(argocdService), namespace, secretInformer, configMapInformer) 50 | 51 | res := ¬ificationController{ 52 | secretInformer: secretInformer, 53 | configMapInformer: configMapInformer, 54 | appInformer: appInformer, 55 | appProjInformer: appProjInformer, 56 | apiFactory: apiFactory} 57 | res.ctrl = controller.NewController(appClient, appInformer, apiFactory, 58 | controller.WithSkipProcessing(func(obj v1.Object) (bool, string) { 59 | app, ok := (obj).(*unstructured.Unstructured) 60 | if !ok { 61 | return false, "" 62 | } 63 | return !isAppSyncStatusRefreshed(app, log.WithField("app", obj.GetName())), "sync status out of date" 64 | }), 65 | controller.WithMetricsRegistry(registry), 66 | controller.WithAlterDestinations(res.alterDestinations)) 67 | return res 68 | } 69 | 70 | func (c *notificationController) alterDestinations(obj v1.Object, destinations services.Destinations, cfg api.Config) services.Destinations { 71 | app, ok := (obj).(*unstructured.Unstructured) 72 | if !ok { 73 | return destinations 74 | } 75 | 76 | if proj := getAppProj(app, c.appProjInformer); proj != nil { 77 | destinations.Merge(subscriptions.NewAnnotations(proj.GetAnnotations()).GetDestinations(cfg.DefaultTriggers, cfg.ServiceDefaultTriggers)) 78 | destinations.Merge(settings.GetLegacyDestinations(proj.GetAnnotations(), cfg.DefaultTriggers, cfg.ServiceDefaultTriggers)) 79 | } 80 | return destinations 81 | } 82 | 83 | func newInformer(resClient dynamic.ResourceInterface, selector string) cache.SharedIndexInformer { 84 | informer := cache.NewSharedIndexInformer( 85 | &cache.ListWatch{ 86 | ListFunc: func(options v1.ListOptions) (object runtime.Object, err error) { 87 | options.LabelSelector = selector 88 | return resClient.List(context.Background(), options) 89 | }, 90 | WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { 91 | options.LabelSelector = selector 92 | return resClient.Watch(context.Background(), options) 93 | }, 94 | }, 95 | &unstructured.Unstructured{}, 96 | resyncPeriod, 97 | cache.Indexers{}, 98 | ) 99 | return informer 100 | } 101 | 102 | type notificationController struct { 103 | apiFactory api.Factory 104 | ctrl controller.NotificationController 105 | appInformer cache.SharedIndexInformer 106 | appProjInformer cache.SharedIndexInformer 107 | secretInformer cache.SharedIndexInformer 108 | configMapInformer cache.SharedIndexInformer 109 | } 110 | 111 | func (c *notificationController) Init(ctx context.Context) error { 112 | go c.appInformer.Run(ctx.Done()) 113 | go c.appProjInformer.Run(ctx.Done()) 114 | go c.secretInformer.Run(ctx.Done()) 115 | go c.configMapInformer.Run(ctx.Done()) 116 | 117 | if !cache.WaitForCacheSync(ctx.Done(), c.appInformer.HasSynced, c.appProjInformer.HasSynced, c.secretInformer.HasSynced, c.configMapInformer.HasSynced) { 118 | return errors.New("Timed out waiting for caches to sync") 119 | } 120 | return nil 121 | } 122 | 123 | func (c *notificationController) Run(ctx context.Context, processors int) { 124 | c.ctrl.Run(processors, ctx.Done()) 125 | } 126 | 127 | func getAppProj(app *unstructured.Unstructured, appProjInformer cache.SharedIndexInformer) *unstructured.Unstructured { 128 | projName, ok, err := unstructured.NestedString(app.Object, "spec", "project") 129 | if !ok || err != nil { 130 | return nil 131 | } 132 | projObj, ok, err := appProjInformer.GetIndexer().GetByKey(fmt.Sprintf("%s/%s", app.GetNamespace(), projName)) 133 | if !ok || err != nil { 134 | return nil 135 | } 136 | proj, ok := projObj.(*unstructured.Unstructured) 137 | if !ok { 138 | return nil 139 | } 140 | if proj.GetAnnotations() == nil { 141 | proj.SetAnnotations(map[string]string{}) 142 | } 143 | return proj 144 | } 145 | 146 | // Checks if the application SyncStatus has been refreshed by Argo CD after an operation has completed 147 | func isAppSyncStatusRefreshed(app *unstructured.Unstructured, logEntry *log.Entry) bool { 148 | _, ok, err := unstructured.NestedMap(app.Object, "status", "operationState") 149 | if !ok || err != nil { 150 | logEntry.Debug("No OperationState found, SyncStatus is assumed to be up-to-date") 151 | return true 152 | } 153 | 154 | phase, ok, err := unstructured.NestedString(app.Object, "status", "operationState", "phase") 155 | if !ok || err != nil { 156 | logEntry.Debug("No OperationPhase found, SyncStatus is assumed to be up-to-date") 157 | return true 158 | } 159 | switch phase { 160 | case "Failed", "Error", "Succeeded": 161 | finishedAtRaw, ok, err := unstructured.NestedString(app.Object, "status", "operationState", "finishedAt") 162 | if !ok || err != nil { 163 | logEntry.Debugf("No FinishedAt found for completed phase '%s', SyncStatus is assumed to be out-of-date", phase) 164 | return false 165 | } 166 | finishedAt, err := time.Parse(time.RFC3339, finishedAtRaw) 167 | if err != nil { 168 | logEntry.Warnf("Failed to parse FinishedAt '%s'", finishedAtRaw) 169 | return false 170 | } 171 | var reconciledAt, observedAt time.Time 172 | reconciledAtRaw, ok, err := unstructured.NestedString(app.Object, "status", "reconciledAt") 173 | if ok && err == nil { 174 | reconciledAt, _ = time.Parse(time.RFC3339, reconciledAtRaw) 175 | } 176 | observedAtRaw, ok, err := unstructured.NestedString(app.Object, "status", "observedAt") 177 | if ok && err == nil { 178 | observedAt, _ = time.Parse(time.RFC3339, observedAtRaw) 179 | } 180 | if finishedAt.After(reconciledAt) && finishedAt.After(observedAt) { 181 | logEntry.Debugf("SyncStatus out-of-date (FinishedAt=%v, ReconciledAt=%v, Observed=%v", finishedAt, reconciledAt, observedAt) 182 | return false 183 | } 184 | logEntry.Debugf("SyncStatus up-to-date (FinishedAt=%v, ReconciledAt=%v, Observed=%v", finishedAt, reconciledAt, observedAt) 185 | default: 186 | logEntry.Debugf("Found phase '%s', SyncStatus is assumed to be up-to-date", phase) 187 | } 188 | 189 | return true 190 | } 191 | -------------------------------------------------------------------------------- /controller/controller_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/argoproj/notifications-engine/pkg/services" 8 | 9 | . "github.com/argoproj-labs/argocd-notifications/testing" 10 | 11 | "github.com/argoproj/notifications-engine/pkg/api" 12 | "github.com/argoproj/notifications-engine/pkg/controller" 13 | "github.com/argoproj/notifications-engine/pkg/mocks" 14 | "github.com/argoproj/notifications-engine/pkg/subscriptions" 15 | "github.com/golang/mock/gomock" 16 | "github.com/sirupsen/logrus" 17 | "github.com/stretchr/testify/assert" 18 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 19 | "k8s.io/client-go/dynamic" 20 | "k8s.io/client-go/kubernetes/fake" 21 | ) 22 | 23 | var ( 24 | logEntry = logrus.NewEntry(logrus.New()) 25 | ) 26 | 27 | func newController(t *testing.T, ctx context.Context, client dynamic.Interface) (*notificationController, *mocks.MockAPI, error) { 28 | mockCtrl := gomock.NewController(t) 29 | go func() { 30 | <-ctx.Done() 31 | mockCtrl.Finish() 32 | }() 33 | mockAPI := mocks.NewMockAPI(mockCtrl) 34 | mockAPI.EXPECT().GetConfig().Return(api.Config{}).AnyTimes() 35 | clientset := fake.NewSimpleClientset() 36 | c := NewController(clientset, client, nil, TestNamespace, "", controller.NewMetricsRegistry("argocd")) 37 | c.apiFactory = &mocks.FakeFactory{Api: mockAPI} 38 | err := c.Init(ctx) 39 | if err != nil { 40 | return nil, nil, err 41 | } 42 | return c, mockAPI, err 43 | } 44 | 45 | func TestSendsNotificationIfProjectTriggered(t *testing.T) { 46 | ctx, cancel := context.WithCancel(context.TODO()) 47 | defer cancel() 48 | appProj := NewProject("default", WithAnnotations(map[string]string{ 49 | subscriptions.SubscribeAnnotationKey("my-trigger", "mock"): "recipient", 50 | })) 51 | app := NewApp("test", WithProject("default")) 52 | 53 | ctrl, _, err := newController(t, ctx, NewFakeClient(app, appProj)) 54 | assert.NoError(t, err) 55 | 56 | dests := ctrl.alterDestinations(app, services.Destinations{}, api.Config{}) 57 | 58 | assert.NoError(t, err) 59 | assert.NotEmpty(t, dests) 60 | } 61 | 62 | func TestAppSyncStatusRefreshed(t *testing.T) { 63 | for name, tc := range testsAppSyncStatusRefreshed { 64 | t.Run(name, func(t *testing.T) { 65 | if tc.result { 66 | assert.True(t, isAppSyncStatusRefreshed(&unstructured.Unstructured{Object: tc.app}, logEntry)) 67 | } else { 68 | assert.False(t, isAppSyncStatusRefreshed(&unstructured.Unstructured{Object: tc.app}, logEntry)) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | var testsAppSyncStatusRefreshed = map[string]struct { 75 | app map[string]interface{} 76 | result bool 77 | }{ 78 | "MissingOperationState": {app: map[string]interface{}{"status": map[string]interface{}{}}, result: true}, 79 | "MissingOperationStatePhase": {app: map[string]interface{}{ 80 | "status": map[string]interface{}{ 81 | "operationState": map[string]interface{}{}, 82 | }, 83 | }, result: true}, 84 | "RunningOperation": {app: map[string]interface{}{ 85 | "status": map[string]interface{}{ 86 | "operationState": map[string]interface{}{ 87 | "phase": "Running", 88 | }, 89 | }, 90 | }, result: true}, 91 | "MissingFinishedAt": {app: map[string]interface{}{ 92 | "status": map[string]interface{}{ 93 | "operationState": map[string]interface{}{ 94 | "phase": "Succeeded", 95 | }, 96 | }, 97 | }, result: false}, 98 | "Reconciled": {app: map[string]interface{}{ 99 | "status": map[string]interface{}{ 100 | "reconciledAt": "2020-03-01T13:37:00Z", 101 | "observedAt": "2020-03-01T13:37:00Z", 102 | "operationState": map[string]interface{}{ 103 | "phase": "Succeeded", 104 | "finishedAt": "2020-03-01T13:37:00Z", 105 | }, 106 | }, 107 | }, result: true}, 108 | "NotYetReconciled": {app: map[string]interface{}{ 109 | "status": map[string]interface{}{ 110 | "reconciledAt": "2020-03-01T00:13:37Z", 111 | "observedAt": "2020-03-01T00:13:37Z", 112 | "operationState": map[string]interface{}{ 113 | "phase": "Succeeded", 114 | "finishedAt": "2020-03-01T13:37:00Z", 115 | }, 116 | }, 117 | }, result: false}, 118 | } 119 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /expr/expr.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "github.com/argoproj-labs/argocd-notifications/expr/repo" 5 | "github.com/argoproj-labs/argocd-notifications/expr/strings" 6 | "github.com/argoproj-labs/argocd-notifications/expr/sync" 7 | "github.com/argoproj-labs/argocd-notifications/expr/time" 8 | "github.com/argoproj-labs/argocd-notifications/shared/argocd" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | ) 11 | 12 | var helpers = map[string]interface{}{} 13 | 14 | func init() { 15 | helpers = make(map[string]interface{}) 16 | register("time", time.NewExprs()) 17 | register("strings", strings.NewExprs()) 18 | register("sync", sync.NewExprs()) 19 | } 20 | 21 | func register(namespace string, entry map[string]interface{}) { 22 | helpers[namespace] = entry 23 | } 24 | 25 | func Spawn(app *unstructured.Unstructured, argocdService argocd.Service, vars map[string]interface{}) map[string]interface{} { 26 | clone := make(map[string]interface{}) 27 | for k := range vars { 28 | clone[k] = vars[k] 29 | } 30 | for namespace, helper := range helpers { 31 | clone[namespace] = helper 32 | } 33 | clone["repo"] = repo.NewExprs(argocdService, app) 34 | 35 | return clone 36 | } 37 | -------------------------------------------------------------------------------- /expr/expr_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExpr(t *testing.T) { 10 | namespaces := []string{ 11 | "time", 12 | "repo", 13 | "strings", 14 | } 15 | 16 | for _, ns := range namespaces { 17 | helpers := Spawn(nil, nil, nil) 18 | _, hasNamespace := helpers[ns] 19 | assert.True(t, hasNamespace) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /expr/repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/argoproj-labs/argocd-notifications/expr/shared" 11 | "github.com/argoproj-labs/argocd-notifications/shared/argocd" 12 | 13 | "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 14 | "github.com/argoproj/notifications-engine/pkg/util/text" 15 | giturls "github.com/whilp/git-urls" 16 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 17 | ) 18 | 19 | var ( 20 | gitSuffix = regexp.MustCompile(`\.git$`) 21 | ) 22 | 23 | func getApplicationSource(obj *unstructured.Unstructured) (*v1alpha1.ApplicationSource, error) { 24 | data, err := json.Marshal(obj) 25 | if err != nil { 26 | return nil, err 27 | } 28 | application := &v1alpha1.Application{} 29 | err = json.Unmarshal(data, application) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return &application.Spec.Source, nil 34 | } 35 | 36 | func getAppDetails(app *unstructured.Unstructured, argocdService argocd.Service) (*shared.AppDetail, error) { 37 | appSource, err := getApplicationSource(app) 38 | if err != nil { 39 | return nil, err 40 | } 41 | appDetail, err := argocdService.GetAppDetails(context.Background(), appSource) 42 | if err != nil { 43 | return nil, err 44 | } 45 | return appDetail, nil 46 | } 47 | 48 | func getCommitMetadata(commitSHA string, app *unstructured.Unstructured, argocdService argocd.Service) (*shared.CommitMetadata, error) { 49 | repoURL, ok, err := unstructured.NestedString(app.Object, "spec", "source", "repoURL") 50 | if err != nil { 51 | return nil, err 52 | } 53 | if !ok { 54 | panic(errors.New("failed to get application source repo URL")) 55 | } 56 | meta, err := argocdService.GetCommitMetadata(context.Background(), repoURL, commitSHA) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return meta, nil 61 | } 62 | 63 | func FullNameByRepoURL(rawURL string) string { 64 | parsed, err := giturls.Parse(rawURL) 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | path := gitSuffix.ReplaceAllString(parsed.Path, "") 70 | if pathParts := text.SplitRemoveEmpty(path, "/"); len(pathParts) >= 2 { 71 | return strings.Join(pathParts[:2], "/") 72 | } 73 | 74 | return path 75 | } 76 | 77 | func repoURLToHTTPS(rawURL string) string { 78 | parsed, err := giturls.Parse(rawURL) 79 | if err != nil { 80 | panic(err) 81 | } 82 | parsed.Scheme = "https" 83 | parsed.User = nil 84 | return parsed.String() 85 | } 86 | 87 | func NewExprs(argocdService argocd.Service, app *unstructured.Unstructured) map[string]interface{} { 88 | return map[string]interface{}{ 89 | "RepoURLToHTTPS": repoURLToHTTPS, 90 | "FullNameByRepoURL": FullNameByRepoURL, 91 | "GetCommitMetadata": func(commitSHA string) interface{} { 92 | meta, err := getCommitMetadata(commitSHA, app, argocdService) 93 | if err != nil { 94 | panic(err) 95 | } 96 | 97 | return *meta 98 | }, 99 | "GetAppDetails": func() interface{} { 100 | appDetails, err := getAppDetails(app, argocdService) 101 | if err != nil { 102 | panic(err) 103 | } 104 | 105 | return *appDetails 106 | }, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /expr/repo/repo_test.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/golang/mock/gomock" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/argoproj-labs/argocd-notifications/expr/shared" 11 | "github.com/argoproj-labs/argocd-notifications/shared/argocd/mocks" 12 | . "github.com/argoproj-labs/argocd-notifications/testing" 13 | ) 14 | 15 | func TestToHttps(t *testing.T) { 16 | for in, expected := range map[string]string{ 17 | "git@github.com:argoproj/argo-cd.git": "https://github.com/argoproj/argo-cd.git", 18 | "http://github.com/argoproj/argo-cd.git": "https://github.com/argoproj/argo-cd.git", 19 | } { 20 | actual := repoURLToHTTPS(in) 21 | assert.Equal(t, actual, expected) 22 | } 23 | } 24 | 25 | func TestParseFullName(t *testing.T) { 26 | for in, expected := range map[string]string{ 27 | "git@github.com:argoproj/argo-cd.git": "argoproj/argo-cd", 28 | "http://github.com/argoproj/argo-cd.git": "argoproj/argo-cd", 29 | "http://github.com/argoproj/argo-cd": "argoproj/argo-cd", 30 | "https://user@bitbucket.org/argoproj/argo-cd.git": "argoproj/argo-cd", 31 | "git@gitlab.com:argoproj/argo-cd.git": "argoproj/argo-cd", 32 | "https://gitlab.com/argoproj/argo-cd.git": "argoproj/argo-cd", 33 | } { 34 | actual := FullNameByRepoURL(in) 35 | assert.Equal(t, actual, expected) 36 | } 37 | } 38 | 39 | func TestGetCommitMetadata(t *testing.T) { 40 | ctrl := gomock.NewController(t) 41 | defer ctrl.Finish() 42 | 43 | argocdService := mocks.NewMockService(ctrl) 44 | expectedMeta := &shared.CommitMetadata{Message: "hello"} 45 | argocdService.EXPECT().GetCommitMetadata(context.Background(), "http://myrepo-url.git", "abc").Return(expectedMeta, nil) 46 | commitMeta, err := getCommitMetadata("abc", NewApp("guestbook", WithRepoURL("http://myrepo-url.git")), argocdService) 47 | 48 | if !assert.NoError(t, err) { 49 | return 50 | } 51 | assert.Equal(t, expectedMeta, commitMeta) 52 | 53 | } 54 | -------------------------------------------------------------------------------- /expr/shared/appdetail.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "github.com/argoproj/argo-cd/v2/reposerver/apiclient" 4 | 5 | type AppDetail struct { 6 | // AppDetail Type 7 | Type string 8 | // Ksonnet details 9 | Ksonnet *apiclient.KsonnetAppSpec 10 | // Helm details 11 | Helm *HelmAppSpec 12 | // Kustomize details 13 | Kustomize *apiclient.KustomizeAppSpec 14 | // Directory details 15 | Directory *apiclient.DirectoryAppSpec 16 | } 17 | -------------------------------------------------------------------------------- /expr/shared/commit.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type CommitMetadata struct { 8 | // Commit message 9 | Message string 10 | // Commit author 11 | Author string 12 | // Commit creation date 13 | Date time.Time 14 | // Associated tags 15 | Tags []string 16 | } 17 | -------------------------------------------------------------------------------- /expr/shared/helmappspec.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 5 | ) 6 | 7 | type HelmAppSpec struct { 8 | Name string 9 | ValueFiles []string 10 | Parameters []*v1alpha1.HelmParameter 11 | Values string 12 | FileParameters []*v1alpha1.HelmFileParameter 13 | } 14 | 15 | func (has HelmAppSpec) GetParameterValueByName(Name string) string { 16 | var value string 17 | for i := range has.Parameters { 18 | if has.Parameters[i].Name == Name { 19 | value = has.Parameters[i].Value 20 | break 21 | } 22 | } 23 | return value 24 | } 25 | 26 | func (has HelmAppSpec) GetFileParameterPathByName(Name string) string { 27 | var path string 28 | for i := range has.FileParameters { 29 | if has.FileParameters[i].Name == Name { 30 | path = has.FileParameters[i].Path 31 | break 32 | } 33 | } 34 | return path 35 | } 36 | -------------------------------------------------------------------------------- /expr/shared/helmfileparameter.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | type HelmFileParameter struct { 4 | // Name is the name of the helm parameter 5 | Name string 6 | // Path is the path value for the helm parameter 7 | Path string 8 | } 9 | -------------------------------------------------------------------------------- /expr/shared/helmparameter.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | type HelmParameter struct { 4 | // Name is the name of the helm parameter 5 | Name string 6 | // Value is the value for the helm parameter 7 | Value string 8 | // ForceString determines whether to tell Helm to interpret booleans and numbers as strings 9 | ForceString bool 10 | } 11 | -------------------------------------------------------------------------------- /expr/strings/strings.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func NewExprs() map[string]interface{} { 8 | return map[string]interface{}{ 9 | "ReplaceAll": replaceAll, 10 | "ToUpper": toUpper, 11 | "ToLower": toLower, 12 | } 13 | } 14 | 15 | func replaceAll(s, old, new string) string { 16 | return strings.ReplaceAll(s, old, new) 17 | } 18 | 19 | func toUpper(s string) string { 20 | return strings.ToUpper(s) 21 | } 22 | 23 | func toLower(s string) string { 24 | return strings.ToLower(s) 25 | } 26 | -------------------------------------------------------------------------------- /expr/strings/strings_test.go: -------------------------------------------------------------------------------- 1 | package strings 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewExprs(t *testing.T) { 11 | funcs := []string{ 12 | "ReplaceAll", 13 | "ToUpper", 14 | "ToLower", 15 | } 16 | 17 | for _, fn := range funcs { 18 | stringsExprs := NewExprs() 19 | _, hasFunc := stringsExprs[fn] 20 | assert.True(t, hasFunc) 21 | } 22 | } 23 | 24 | func TestReplaceAll(t *testing.T) { 25 | exprs := NewExprs() 26 | 27 | input := "test_replace" 28 | expected := "test=replace" 29 | replaceAllFn, ok := exprs["ReplaceAll"].(func(s, old, new string) string) 30 | assert.True(t, ok) 31 | 32 | actual := replaceAllFn(input, "_", "=") 33 | assert.Equal(t, expected, actual) 34 | } 35 | 36 | func TestUpperAndLower(t *testing.T) { 37 | testCases := []struct { 38 | fn string 39 | input string 40 | expected string 41 | }{ 42 | { 43 | fn: "ToUpper", 44 | input: "test", 45 | expected: "TEST", 46 | }, 47 | { 48 | fn: "ToLower", 49 | input: "TEST", 50 | expected: "test", 51 | }, 52 | } 53 | exprs := NewExprs() 54 | 55 | for _, testCase := range testCases { 56 | t.Run(fmt.Sprintf("With success case: Func: %s", testCase.fn), func(tc *testing.T) { 57 | toUpperFn, ok := exprs[testCase.fn].(func(s string) string) 58 | assert.True(t, ok) 59 | 60 | actual := toUpperFn(testCase.input) 61 | assert.Equal(t, testCase.expected, actual) 62 | }) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /expr/sync/sync.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | ) 8 | 9 | func NewExprs() map[string]interface{} { 10 | return map[string]interface{}{ 11 | "GetInfoItem": func(app map[string]interface{}, name string) string { 12 | res, err := getInfoItem(app, name) 13 | if err != nil { 14 | panic(err) 15 | } 16 | return res 17 | }, 18 | } 19 | } 20 | 21 | func getInfoItem(app map[string]interface{}, name string) (string, error) { 22 | un := unstructured.Unstructured{Object: app} 23 | operation, ok, _ := unstructured.NestedMap(app, "operation") 24 | if !ok { 25 | operation, ok, _ = unstructured.NestedMap(app, "status", "operationState", "operation") 26 | } 27 | if !ok { 28 | return "", fmt.Errorf("application '%s' has no operation", un.GetName()) 29 | } 30 | 31 | infoItems, ok := operation["info"].([]interface{}) 32 | if !ok { 33 | return "", fmt.Errorf("application '%s' has no info items", un.GetName()) 34 | } 35 | for _, infoItem := range infoItems { 36 | item, ok := infoItem.(map[string]interface{}) 37 | if !ok { 38 | continue 39 | } 40 | if item["name"] == name { 41 | res, ok := item["value"].(string) 42 | if !ok { 43 | return "", fmt.Errorf("application '%s' has invalid value of info item '%s'", un.GetName(), name) 44 | } 45 | return res, nil 46 | } 47 | } 48 | return "", fmt.Errorf("application '%s' has no info item with name '%s'", un.GetName(), name) 49 | } 50 | -------------------------------------------------------------------------------- /expr/sync/sync_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | . "github.com/argoproj-labs/argocd-notifications/testing" 9 | ) 10 | 11 | var ( 12 | infoItems = []interface{}{ 13 | map[string]interface{}{ 14 | "name": "name1", 15 | "value": "val1", 16 | }, 17 | } 18 | ) 19 | 20 | func TestGetInfoItem_RequestedOperation(t *testing.T) { 21 | app := NewApp("test") 22 | app.Object["operation"] = map[string]interface{}{ 23 | "info": infoItems, 24 | } 25 | 26 | val, err := getInfoItem(app.Object, "name1") 27 | assert.NoError(t, err) 28 | assert.Equal(t, "val1", val) 29 | } 30 | 31 | func TestGetInfoItem_CompletedOperation(t *testing.T) { 32 | app := NewApp("test") 33 | app.Object["status"] = map[string]interface{}{ 34 | "operationState": map[string]interface{}{ 35 | "operation": map[string]interface{}{ 36 | "info": infoItems, 37 | }, 38 | }, 39 | } 40 | 41 | val, err := getInfoItem(app.Object, "name1") 42 | assert.NoError(t, err) 43 | assert.Equal(t, "val1", val) 44 | } 45 | 46 | func TestGetInfoItem_NoOperation(t *testing.T) { 47 | app := NewApp("test") 48 | _, err := getInfoItem(app.Object, "name1") 49 | assert.Error(t, err) 50 | } 51 | -------------------------------------------------------------------------------- /expr/time/time.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func NewExprs() map[string]interface{} { 8 | return map[string]interface{}{ 9 | "Parse": parse, 10 | "Now": now, 11 | } 12 | } 13 | 14 | func parse(timestamp string) time.Time { 15 | res, err := time.Parse(time.RFC3339, timestamp) 16 | if err != nil { 17 | panic(err) 18 | } 19 | return res 20 | } 21 | 22 | func now() time.Time { 23 | return time.Now() 24 | } 25 | -------------------------------------------------------------------------------- /expr/time/time_test.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewTimeExprs(t *testing.T) { 10 | funcs := []string{ 11 | "Parse", 12 | "Now", 13 | } 14 | 15 | for _, fn := range funcs { 16 | timeExprs := NewExprs() 17 | _, hasFunc := timeExprs[fn] 18 | assert.True(t, hasFunc) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/argoproj-labs/argocd-notifications 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/argoproj/argo-cd/v2 v2.1.7 7 | github.com/argoproj/notifications-engine v0.3.1-0.20211117165611-0e1f1eda5f52 8 | github.com/evanphx/json-patch v4.11.0+incompatible 9 | github.com/ghodss/yaml v1.0.0 10 | github.com/go-redis/cache/v8 v8.11.3 // indirect 11 | github.com/golang/mock v1.5.0 12 | github.com/grpc-ecosystem/go-grpc-middleware v1.2.0 // indirect 13 | github.com/olekukonko/tablewriter v0.0.4 14 | github.com/prometheus/client_golang v1.11.0 15 | github.com/robfig/cron v1.2.0 // indirect 16 | github.com/sirupsen/logrus v1.8.1 17 | github.com/slack-go/slack v0.6.6 18 | github.com/spf13/cobra v1.1.3 19 | github.com/stretchr/testify v1.7.0 20 | github.com/whilp/git-urls v0.0.0-20191001220047-6db9661140c0 21 | k8s.io/api v0.21.0 22 | k8s.io/apimachinery v0.21.0 23 | k8s.io/client-go v11.0.1-0.20190816222228-6d55c1b1f1ca+incompatible 24 | k8s.io/utils v0.0.0-20210111153108-fddb29f9d009 25 | ) 26 | 27 | replace ( 28 | // https://github.com/golang/go/issues/33546#issuecomment-519656923 29 | github.com/go-check/check => github.com/go-check/check v0.0.0-20180628173108-788fd7840127 30 | 31 | github.com/go-redis/cache/v8 => github.com/go-redis/cache/v8 v8.4.3 32 | 33 | k8s.io/api => k8s.io/api v0.21.0 34 | k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.21.0 35 | k8s.io/apimachinery => k8s.io/apimachinery v0.21.0 36 | k8s.io/apiserver => k8s.io/apiserver v0.21.0 37 | k8s.io/cli-runtime => k8s.io/cli-runtime v0.21.0 38 | k8s.io/client-go => k8s.io/client-go v0.21.0 39 | k8s.io/cloud-provider => k8s.io/cloud-provider v0.21.0 40 | k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.21.0 41 | k8s.io/code-generator => k8s.io/code-generator v0.21.0 42 | k8s.io/component-base => k8s.io/component-base v0.21.0 43 | k8s.io/component-helpers => k8s.io/component-helpers v0.21.0 44 | k8s.io/controller-manager => k8s.io/controller-manager v0.21.0 45 | k8s.io/cri-api => k8s.io/cri-api v0.21.0 46 | k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.21.0 47 | k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.21.0 48 | k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.21.0 49 | k8s.io/kube-proxy => k8s.io/kube-proxy v0.21.0 50 | k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.21.0 51 | k8s.io/kubectl => k8s.io/kubectl v0.21.0 52 | k8s.io/kubelet => k8s.io/kubelet v0.21.0 53 | k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.21.0 54 | k8s.io/metrics => k8s.io/metrics v0.21.0 55 | k8s.io/mount-utils => k8s.io/mount-utils v0.21.0 56 | k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.21.0 57 | ) 58 | -------------------------------------------------------------------------------- /hack/gen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/argoproj-labs/argocd-notifications/cmd/tools" 14 | 15 | "github.com/argoproj/notifications-engine/pkg/services" 16 | "github.com/argoproj/notifications-engine/pkg/triggers" 17 | "github.com/argoproj/notifications-engine/pkg/util/misc" 18 | "github.com/ghodss/yaml" 19 | "github.com/olekukonko/tablewriter" 20 | log "github.com/sirupsen/logrus" 21 | "github.com/spf13/cobra" 22 | "github.com/spf13/cobra/doc" 23 | v1 "k8s.io/api/core/v1" 24 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 25 | ) 26 | 27 | func main() { 28 | var command = &cobra.Command{ 29 | Use: "gen", 30 | Run: func(c *cobra.Command, args []string) { 31 | c.HelpFunc()(c, args) 32 | }, 33 | } 34 | command.AddCommand(newDocsCommand()) 35 | command.AddCommand(newCatalogCommand()) 36 | 37 | if err := command.Execute(); err != nil { 38 | fmt.Println(err) 39 | os.Exit(1) 40 | } 41 | } 42 | 43 | func newCatalogCommand() *cobra.Command { 44 | return &cobra.Command{ 45 | Use: "catalog", 46 | Run: func(c *cobra.Command, args []string) { 47 | cm := v1.ConfigMap{ 48 | TypeMeta: metav1.TypeMeta{ 49 | Kind: "ConfigMap", 50 | APIVersion: "v1", 51 | }, 52 | ObjectMeta: metav1.ObjectMeta{ 53 | Name: "argocd-notifications-cm", 54 | }, 55 | Data: make(map[string]string), 56 | } 57 | wd, err := os.Getwd() 58 | dieOnError(err, "Failed to get current working directory") 59 | target := path.Join(wd, "catalog/install.yaml") 60 | 61 | templatesDir := path.Join(wd, "catalog/templates") 62 | triggersDir := path.Join(wd, "catalog/triggers") 63 | 64 | templates, triggers, err := buildConfigFromFS(templatesDir, triggersDir) 65 | dieOnError(err, "Failed to build catalog config") 66 | 67 | misc.IterateStringKeyMap(triggers, func(name string) { 68 | trigger := triggers[name] 69 | t, err := yaml.Marshal(trigger) 70 | dieOnError(err, "Failed to marshal trigger") 71 | cm.Data[fmt.Sprintf("trigger.%s", name)] = string(t) 72 | }) 73 | 74 | misc.IterateStringKeyMap(templates, func(name string) { 75 | template := templates[name] 76 | t, err := yaml.Marshal(template) 77 | dieOnError(err, "Failed to marshal template") 78 | cm.Data[fmt.Sprintf("template.%s", name)] = string(t) 79 | }) 80 | 81 | d, err := yaml.Marshal(cm) 82 | dieOnError(err, "Failed to marshal final configmap") 83 | 84 | err = ioutil.WriteFile(target, d, 0644) 85 | dieOnError(err, "Failed to write builtin configmap") 86 | 87 | }, 88 | } 89 | } 90 | 91 | func newDocsCommand() *cobra.Command { 92 | return &cobra.Command{ 93 | Use: "docs", 94 | Run: func(c *cobra.Command, args []string) { 95 | var builtItDocsData bytes.Buffer 96 | wd, err := os.Getwd() 97 | dieOnError(err, "Failed to get current working directory") 98 | 99 | templatesDir := path.Join(wd, "catalog/templates") 100 | triggersDir := path.Join(wd, "catalog/triggers") 101 | 102 | notificationTemplates, notificationTriggers, err := buildConfigFromFS(templatesDir, triggersDir) 103 | dieOnError(err, "Failed to build builtin config") 104 | generateBuiltInTriggersDocs(&builtItDocsData, notificationTriggers, notificationTemplates) 105 | if err := ioutil.WriteFile("./docs/catalog.md", builtItDocsData.Bytes(), 0644); err != nil { 106 | log.Fatal(err) 107 | } 108 | var commandDocs bytes.Buffer 109 | if err := generateCommandsDocs(&commandDocs); err != nil { 110 | log.Fatal(err) 111 | } 112 | if err := ioutil.WriteFile("./docs/troubleshooting-commands.md", commandDocs.Bytes(), 0644); err != nil { 113 | log.Fatal(err) 114 | } 115 | }, 116 | } 117 | } 118 | 119 | func generateBuiltInTriggersDocs(out io.Writer, triggers map[string][]triggers.Condition, templates map[string]services.Notification) { 120 | _, _ = fmt.Fprintln(out, "# Triggers and Templates Catalog") 121 | _, _ = fmt.Fprintln(out, "## Triggers") 122 | 123 | w := tablewriter.NewWriter(out) 124 | w.SetHeader([]string{"NAME", "DESCRIPTION", "TEMPLATE"}) 125 | w.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 126 | w.SetCenterSeparator("|") 127 | w.SetAutoWrapText(false) 128 | misc.IterateStringKeyMap(triggers, func(name string) { 129 | t := triggers[name] 130 | desc := "" 131 | template := "" 132 | if len(t) > 0 { 133 | desc = t[0].Description 134 | template = strings.Join(t[0].Send, ",") 135 | } 136 | w.Append([]string{name, desc, fmt.Sprintf("[%s](#%s)", template, template)}) 137 | }) 138 | w.Render() 139 | 140 | _, _ = fmt.Fprintln(out, "") 141 | _, _ = fmt.Fprintln(out, "## Templates") 142 | misc.IterateStringKeyMap(templates, func(name string) { 143 | t := templates[name] 144 | yamlData, err := yaml.Marshal(t) 145 | if err != nil { 146 | panic(err) 147 | } 148 | _, _ = fmt.Fprintf(out, "### %s\n**definition**:\n```yaml\n%s\n```\n", name, string(yamlData)) 149 | }) 150 | } 151 | 152 | func generateCommandsDocs(out io.Writer) error { 153 | toolsCmd := tools.NewToolsCommand() 154 | for _, subCommand := range toolsCmd.Commands() { 155 | for _, c := range subCommand.Commands() { 156 | var cmdDesc bytes.Buffer 157 | if err := doc.GenMarkdown(c, &cmdDesc); err != nil { 158 | return err 159 | } 160 | for _, line := range strings.Split(cmdDesc.String(), "\n") { 161 | if strings.HasPrefix(line, "### SEE ALSO") { 162 | break 163 | } 164 | _, _ = fmt.Fprintf(out, "%s\n", line) 165 | } 166 | } 167 | } 168 | return nil 169 | } 170 | 171 | func dieOnError(err error, msg string) { 172 | if err != nil { 173 | fmt.Printf("[ERROR] %s: %v", msg, err) 174 | os.Exit(1) 175 | } 176 | } 177 | 178 | func buildConfigFromFS(templatesDir string, triggersDir string) (map[string]services.Notification, map[string][]triggers.Condition, error) { 179 | templatesCfg := map[string]services.Notification{} 180 | err := filepath.Walk(templatesDir, func(p string, info os.FileInfo, e error) error { 181 | if e != nil { 182 | return e 183 | } 184 | if info.IsDir() { 185 | return nil 186 | } 187 | data, err := ioutil.ReadFile(p) 188 | if err != nil { 189 | return err 190 | } 191 | name := strings.Split(path.Base(p), ".")[0] 192 | var template services.Notification 193 | if err := yaml.Unmarshal(data, &template); err != nil { 194 | return err 195 | } 196 | templatesCfg[name] = template 197 | return nil 198 | }) 199 | if err != nil { 200 | return nil, nil, err 201 | } 202 | 203 | triggersCfg := map[string][]triggers.Condition{} 204 | err = filepath.Walk(triggersDir, func(p string, info os.FileInfo, e error) error { 205 | if e != nil { 206 | return e 207 | } 208 | if info.IsDir() { 209 | return nil 210 | } 211 | data, err := ioutil.ReadFile(p) 212 | if err != nil { 213 | return err 214 | } 215 | name := strings.Split(path.Base(p), ".")[0] 216 | var trigger []triggers.Condition 217 | if err := yaml.Unmarshal(data, &trigger); err != nil { 218 | return err 219 | } 220 | triggersCfg[name] = trigger 221 | return nil 222 | }) 223 | if err != nil { 224 | return nil, nil, err 225 | } 226 | return templatesCfg, triggersCfg, nil 227 | } 228 | -------------------------------------------------------------------------------- /hack/set-docs-redirects.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Notifications docs now live at https://argo-cd.readthedocs.io/en/latest/operator-manual/notifications/ 4 | # This script adds redirects to the top of each Notifications doc to redirect to the new location. 5 | 6 | set -e pipefail 7 | 8 | new_docs_base_path="https://argo-cd.readthedocs.io/en/latest/operator-manual/notifications/" 9 | new_docs_base_path_regex=$(echo "$new_docs_base_path" | sed 's/\//\\\//g') 10 | 11 | # Loop over files in the docs directory recursively. For each file, use sed to add the following redirect to the top: 12 | # 13 | # FILE_PATH should be the path to the file relative to the docs directory, stripped of the .md extension. 14 | 15 | files=$(find docs -type f -name '*.md') 16 | for file in $files; do 17 | file_path=$(echo "$file" | sed 's/^docs\///' | sed 's/\.md$/\//') 18 | echo "Adding redirect to $file_path" 19 | # If a redirect is already present at the top of the file, remove it. 20 | sed '1s/ "$file.tmp" 21 | mv "$file.tmp" "$file" 22 | 23 | # Add the new redirect. 24 | # Default to an empty path. 25 | file_path_plain="" 26 | file_path_regex="" 27 | if curl -s -o /dev/null -w "%{http_code}" "$new_docs_base_path$file_path" | grep -q 200; then 28 | # If the destination path exists, use it. 29 | file_path_plain="$file_path/" 30 | file_path_regex=$(echo "$file_path" | sed 's/\//\\\//g') 31 | else 32 | echo "WARNING: $new_docs_base_path$file_path does not exist. Using empty path." 33 | fi 34 | 35 | notice="!!! important \"This page has moved\"\n This page has moved to [$new_docs_base_path$file_path_plain]($new_docs_base_path$file_path_plain). Redirecting to the new page.\n" 36 | 37 | notice_regex=$(echo "$notice" | sed 's/\//\\\//g') 38 | 39 | sed "1s/^/\\n\\n$notice_regex\\n/" "$file" > "$file.tmp" 40 | mv "$file.tmp" "$file" 41 | done -------------------------------------------------------------------------------- /hack/tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/argoproj/notifications-engine/docs/services" 7 | ) 8 | -------------------------------------------------------------------------------- /manifests/bot/argocd-notifications-bot-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: argocd-notifications-bot 5 | spec: 6 | selector: 7 | matchLabels: 8 | app.kubernetes.io/name: argocd-notifications-bot 9 | template: 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: argocd-notifications-bot 13 | spec: 14 | containers: 15 | - command: 16 | - /app/argocd-notifications-backend 17 | - bot 18 | workingDir: /app 19 | image: argoprojlabs/argocd-notifications:latest 20 | imagePullPolicy: Always 21 | name: argocd-notifications-bot 22 | serviceAccountName: argocd-notifications-bot 23 | -------------------------------------------------------------------------------- /manifests/bot/argocd-notifications-bot-role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: argocd-notifications-bot 5 | rules: 6 | - apiGroups: 7 | - '' 8 | resources: 9 | - secrets 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - argoproj.io 17 | resources: 18 | - applications 19 | - appprojects 20 | verbs: 21 | - get 22 | - list 23 | - watch 24 | - update 25 | - patch 26 | -------------------------------------------------------------------------------- /manifests/bot/argocd-notifications-bot-rolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: argocd-notifications-bot 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: argocd-notifications-bot 9 | subjects: 10 | - kind: ServiceAccount 11 | name: argocd-notifications-bot 12 | -------------------------------------------------------------------------------- /manifests/bot/argocd-notifications-bot-sa.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: argocd-notifications-bot 5 | -------------------------------------------------------------------------------- /manifests/bot/argocd-notifications-bot-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: argocd-notifications-bot 5 | spec: 6 | ports: 7 | - name: server 8 | protocol: TCP 9 | port: 80 10 | targetPort: 8080 11 | selector: 12 | app.kubernetes.io/name: argocd-notifications-bot 13 | type: LoadBalancer -------------------------------------------------------------------------------- /manifests/bot/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | resources: 5 | - ../controller 6 | - argocd-notifications-bot-rolebinding.yaml 7 | - argocd-notifications-bot-sa.yaml 8 | - argocd-notifications-bot-deployment.yaml 9 | - argocd-notifications-bot-role.yaml 10 | - argocd-notifications-bot-service.yaml 11 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-cm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | creationTimestamp: null 5 | name: argocd-notifications-cm 6 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-controller-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: argocd-notifications-controller 5 | spec: 6 | strategy: 7 | type: Recreate 8 | selector: 9 | matchLabels: 10 | app.kubernetes.io/name: argocd-notifications-controller 11 | template: 12 | metadata: 13 | labels: 14 | app.kubernetes.io/name: argocd-notifications-controller 15 | spec: 16 | volumes: 17 | - name: tls-certs 18 | configMap: 19 | name: argocd-tls-certs-cm 20 | - name: argocd-repo-server-tls 21 | secret: 22 | secretName: argocd-repo-server-tls 23 | optional: true 24 | items: 25 | - key: tls.crt 26 | path: tls.crt 27 | - key: tls.key 28 | path: tls.key 29 | - key: ca.crt 30 | path: ca.crt 31 | containers: 32 | - command: 33 | - /app/argocd-notifications-backend 34 | - controller 35 | workingDir: /app 36 | livenessProbe: 37 | tcpSocket: 38 | port: 9001 39 | image: argoprojlabs/argocd-notifications:latest 40 | imagePullPolicy: Always 41 | name: argocd-notifications-controller 42 | volumeMounts: 43 | - name: tls-certs 44 | mountPath: /app/config/tls 45 | - name: argocd-repo-server-tls 46 | mountPath: /app/config/reposerver/tls 47 | serviceAccountName: argocd-notifications-controller 48 | securityContext: 49 | runAsNonRoot: true 50 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-controller-metrics-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: argocd-notifications-controller-metrics 6 | name: argocd-notifications-controller-metrics 7 | spec: 8 | ports: 9 | - name: metrics 10 | protocol: TCP 11 | port: 9001 12 | targetPort: 9001 13 | selector: 14 | app.kubernetes.io/name: argocd-notifications-controller 15 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-controller-role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: argocd-notifications-controller 5 | rules: 6 | - apiGroups: 7 | - argoproj.io 8 | resources: 9 | - applications 10 | - appprojects 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - update 16 | - patch 17 | - apiGroups: 18 | - "" 19 | resources: 20 | - configmaps 21 | - secrets 22 | verbs: 23 | - list 24 | - watch 25 | - apiGroups: 26 | - "" 27 | resourceNames: 28 | - argocd-notifications-cm 29 | resources: 30 | - configmaps 31 | verbs: 32 | - get 33 | - apiGroups: 34 | - "" 35 | resourceNames: 36 | - argocd-notifications-secret 37 | resources: 38 | - secrets 39 | verbs: 40 | - get 41 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-controller-rolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: argocd-notifications-controller 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: argocd-notifications-controller 9 | subjects: 10 | - kind: ServiceAccount 11 | name: argocd-notifications-controller 12 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-controller-sa.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: argocd-notifications-controller 5 | -------------------------------------------------------------------------------- /manifests/controller/argocd-notifications-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: argocd-notifications-secret 5 | type: Opaque 6 | -------------------------------------------------------------------------------- /manifests/controller/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | resources: 5 | - argocd-notifications-controller-rolebinding.yaml 6 | - argocd-notifications-controller-sa.yaml 7 | - argocd-notifications-cm.yaml 8 | - argocd-notifications-controller-deployment.yaml 9 | - argocd-notifications-secret.yaml 10 | - argocd-notifications-controller-role.yaml 11 | - argocd-notifications-controller-metrics-service.yaml -------------------------------------------------------------------------------- /manifests/install-bot.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: argocd-notifications-bot 5 | --- 6 | apiVersion: v1 7 | kind: ServiceAccount 8 | metadata: 9 | name: argocd-notifications-controller 10 | --- 11 | apiVersion: rbac.authorization.k8s.io/v1 12 | kind: Role 13 | metadata: 14 | name: argocd-notifications-bot 15 | rules: 16 | - apiGroups: 17 | - "" 18 | resources: 19 | - secrets 20 | - configmaps 21 | verbs: 22 | - get 23 | - list 24 | - watch 25 | - apiGroups: 26 | - argoproj.io 27 | resources: 28 | - applications 29 | - appprojects 30 | verbs: 31 | - get 32 | - list 33 | - watch 34 | - update 35 | - patch 36 | --- 37 | apiVersion: rbac.authorization.k8s.io/v1 38 | kind: Role 39 | metadata: 40 | name: argocd-notifications-controller 41 | rules: 42 | - apiGroups: 43 | - argoproj.io 44 | resources: 45 | - applications 46 | - appprojects 47 | verbs: 48 | - get 49 | - list 50 | - watch 51 | - update 52 | - patch 53 | - apiGroups: 54 | - "" 55 | resources: 56 | - configmaps 57 | - secrets 58 | verbs: 59 | - list 60 | - watch 61 | - apiGroups: 62 | - "" 63 | resourceNames: 64 | - argocd-notifications-cm 65 | resources: 66 | - configmaps 67 | verbs: 68 | - get 69 | - apiGroups: 70 | - "" 71 | resourceNames: 72 | - argocd-notifications-secret 73 | resources: 74 | - secrets 75 | verbs: 76 | - get 77 | --- 78 | apiVersion: rbac.authorization.k8s.io/v1 79 | kind: RoleBinding 80 | metadata: 81 | name: argocd-notifications-bot 82 | roleRef: 83 | apiGroup: rbac.authorization.k8s.io 84 | kind: Role 85 | name: argocd-notifications-bot 86 | subjects: 87 | - kind: ServiceAccount 88 | name: argocd-notifications-bot 89 | --- 90 | apiVersion: rbac.authorization.k8s.io/v1 91 | kind: RoleBinding 92 | metadata: 93 | name: argocd-notifications-controller 94 | roleRef: 95 | apiGroup: rbac.authorization.k8s.io 96 | kind: Role 97 | name: argocd-notifications-controller 98 | subjects: 99 | - kind: ServiceAccount 100 | name: argocd-notifications-controller 101 | --- 102 | apiVersion: v1 103 | kind: ConfigMap 104 | metadata: 105 | creationTimestamp: null 106 | name: argocd-notifications-cm 107 | --- 108 | apiVersion: v1 109 | kind: Secret 110 | metadata: 111 | name: argocd-notifications-secret 112 | type: Opaque 113 | --- 114 | apiVersion: v1 115 | kind: Service 116 | metadata: 117 | name: argocd-notifications-bot 118 | spec: 119 | ports: 120 | - name: server 121 | port: 80 122 | protocol: TCP 123 | targetPort: 8080 124 | selector: 125 | app.kubernetes.io/name: argocd-notifications-bot 126 | type: LoadBalancer 127 | --- 128 | apiVersion: v1 129 | kind: Service 130 | metadata: 131 | labels: 132 | app.kubernetes.io/name: argocd-notifications-controller-metrics 133 | name: argocd-notifications-controller-metrics 134 | spec: 135 | ports: 136 | - name: metrics 137 | port: 9001 138 | protocol: TCP 139 | targetPort: 9001 140 | selector: 141 | app.kubernetes.io/name: argocd-notifications-controller 142 | --- 143 | apiVersion: apps/v1 144 | kind: Deployment 145 | metadata: 146 | name: argocd-notifications-bot 147 | spec: 148 | selector: 149 | matchLabels: 150 | app.kubernetes.io/name: argocd-notifications-bot 151 | template: 152 | metadata: 153 | labels: 154 | app.kubernetes.io/name: argocd-notifications-bot 155 | spec: 156 | containers: 157 | - command: 158 | - /app/argocd-notifications-backend 159 | - bot 160 | image: argoprojlabs/argocd-notifications:latest 161 | imagePullPolicy: Always 162 | name: argocd-notifications-bot 163 | workingDir: /app 164 | serviceAccountName: argocd-notifications-bot 165 | --- 166 | apiVersion: apps/v1 167 | kind: Deployment 168 | metadata: 169 | name: argocd-notifications-controller 170 | spec: 171 | selector: 172 | matchLabels: 173 | app.kubernetes.io/name: argocd-notifications-controller 174 | strategy: 175 | type: Recreate 176 | template: 177 | metadata: 178 | labels: 179 | app.kubernetes.io/name: argocd-notifications-controller 180 | spec: 181 | containers: 182 | - command: 183 | - /app/argocd-notifications-backend 184 | - controller 185 | image: argoprojlabs/argocd-notifications:latest 186 | imagePullPolicy: Always 187 | livenessProbe: 188 | tcpSocket: 189 | port: 9001 190 | name: argocd-notifications-controller 191 | volumeMounts: 192 | - mountPath: /app/config/tls 193 | name: tls-certs 194 | - mountPath: /app/config/reposerver/tls 195 | name: argocd-repo-server-tls 196 | workingDir: /app 197 | securityContext: 198 | runAsNonRoot: true 199 | serviceAccountName: argocd-notifications-controller 200 | volumes: 201 | - configMap: 202 | name: argocd-tls-certs-cm 203 | name: tls-certs 204 | - name: argocd-repo-server-tls 205 | secret: 206 | items: 207 | - key: tls.crt 208 | path: tls.crt 209 | - key: tls.key 210 | path: tls.key 211 | - key: ca.crt 212 | path: ca.crt 213 | optional: true 214 | secretName: argocd-repo-server-tls 215 | -------------------------------------------------------------------------------- /manifests/install.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: argocd-notifications-controller 5 | --- 6 | apiVersion: rbac.authorization.k8s.io/v1 7 | kind: Role 8 | metadata: 9 | name: argocd-notifications-controller 10 | rules: 11 | - apiGroups: 12 | - argoproj.io 13 | resources: 14 | - applications 15 | - appprojects 16 | verbs: 17 | - get 18 | - list 19 | - watch 20 | - update 21 | - patch 22 | - apiGroups: 23 | - "" 24 | resources: 25 | - configmaps 26 | - secrets 27 | verbs: 28 | - list 29 | - watch 30 | - apiGroups: 31 | - "" 32 | resourceNames: 33 | - argocd-notifications-cm 34 | resources: 35 | - configmaps 36 | verbs: 37 | - get 38 | - apiGroups: 39 | - "" 40 | resourceNames: 41 | - argocd-notifications-secret 42 | resources: 43 | - secrets 44 | verbs: 45 | - get 46 | --- 47 | apiVersion: rbac.authorization.k8s.io/v1 48 | kind: RoleBinding 49 | metadata: 50 | name: argocd-notifications-controller 51 | roleRef: 52 | apiGroup: rbac.authorization.k8s.io 53 | kind: Role 54 | name: argocd-notifications-controller 55 | subjects: 56 | - kind: ServiceAccount 57 | name: argocd-notifications-controller 58 | --- 59 | apiVersion: v1 60 | kind: ConfigMap 61 | metadata: 62 | creationTimestamp: null 63 | name: argocd-notifications-cm 64 | --- 65 | apiVersion: v1 66 | kind: Secret 67 | metadata: 68 | name: argocd-notifications-secret 69 | type: Opaque 70 | --- 71 | apiVersion: v1 72 | kind: Service 73 | metadata: 74 | labels: 75 | app.kubernetes.io/name: argocd-notifications-controller-metrics 76 | name: argocd-notifications-controller-metrics 77 | spec: 78 | ports: 79 | - name: metrics 80 | port: 9001 81 | protocol: TCP 82 | targetPort: 9001 83 | selector: 84 | app.kubernetes.io/name: argocd-notifications-controller 85 | --- 86 | apiVersion: apps/v1 87 | kind: Deployment 88 | metadata: 89 | name: argocd-notifications-controller 90 | spec: 91 | selector: 92 | matchLabels: 93 | app.kubernetes.io/name: argocd-notifications-controller 94 | strategy: 95 | type: Recreate 96 | template: 97 | metadata: 98 | labels: 99 | app.kubernetes.io/name: argocd-notifications-controller 100 | spec: 101 | containers: 102 | - command: 103 | - /app/argocd-notifications-backend 104 | - controller 105 | image: argoprojlabs/argocd-notifications:latest 106 | imagePullPolicy: Always 107 | livenessProbe: 108 | tcpSocket: 109 | port: 9001 110 | name: argocd-notifications-controller 111 | volumeMounts: 112 | - mountPath: /app/config/tls 113 | name: tls-certs 114 | - mountPath: /app/config/reposerver/tls 115 | name: argocd-repo-server-tls 116 | workingDir: /app 117 | securityContext: 118 | runAsNonRoot: true 119 | serviceAccountName: argocd-notifications-controller 120 | volumes: 121 | - configMap: 122 | name: argocd-tls-certs-cm 123 | name: tls-certs 124 | - name: argocd-repo-server-tls 125 | secret: 126 | items: 127 | - key: tls.crt 128 | path: tls.crt 129 | - key: tls.key 130 | path: tls.key 131 | - key: ca.crt 132 | path: ca.crt 133 | optional: true 134 | secretName: argocd-repo-server-tls 135 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Argo CD Notifications 2 | repo_url: https://github.com/argoproj-labs/argocd-notifications 3 | strict: true 4 | nav: 5 | - Overview: index.md -------------------------------------------------------------------------------- /pkg/README.md: -------------------------------------------------------------------------------- 1 | The integration with notification services and settings parsing has been moved to 2 | https://github.com/argoproj/notifications-engine. -------------------------------------------------------------------------------- /shared/argocd/mocks/service.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/argoproj-labs/argocd-notifications/shared/argocd (interfaces: Service) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | shared "github.com/argoproj-labs/argocd-notifications/expr/shared" 12 | v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 13 | gomock "github.com/golang/mock/gomock" 14 | ) 15 | 16 | // MockService is a mock of Service interface. 17 | type MockService struct { 18 | ctrl *gomock.Controller 19 | recorder *MockServiceMockRecorder 20 | } 21 | 22 | // MockServiceMockRecorder is the mock recorder for MockService. 23 | type MockServiceMockRecorder struct { 24 | mock *MockService 25 | } 26 | 27 | // NewMockService creates a new mock instance. 28 | func NewMockService(ctrl *gomock.Controller) *MockService { 29 | mock := &MockService{ctrl: ctrl} 30 | mock.recorder = &MockServiceMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockService) EXPECT() *MockServiceMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // GetAppDetails mocks base method. 40 | func (m *MockService) GetAppDetails(arg0 context.Context, arg1 *v1alpha1.ApplicationSource) (*shared.AppDetail, error) { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "GetAppDetails", arg0, arg1) 43 | ret0, _ := ret[0].(*shared.AppDetail) 44 | ret1, _ := ret[1].(error) 45 | return ret0, ret1 46 | } 47 | 48 | // GetAppDetails indicates an expected call of GetAppDetails. 49 | func (mr *MockServiceMockRecorder) GetAppDetails(arg0, arg1 interface{}) *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAppDetails", reflect.TypeOf((*MockService)(nil).GetAppDetails), arg0, arg1) 52 | } 53 | 54 | // GetCommitMetadata mocks base method. 55 | func (m *MockService) GetCommitMetadata(arg0 context.Context, arg1, arg2 string) (*shared.CommitMetadata, error) { 56 | m.ctrl.T.Helper() 57 | ret := m.ctrl.Call(m, "GetCommitMetadata", arg0, arg1, arg2) 58 | ret0, _ := ret[0].(*shared.CommitMetadata) 59 | ret1, _ := ret[1].(error) 60 | return ret0, ret1 61 | } 62 | 63 | // GetCommitMetadata indicates an expected call of GetCommitMetadata. 64 | func (mr *MockServiceMockRecorder) GetCommitMetadata(arg0, arg1, arg2 interface{}) *gomock.Call { 65 | mr.mock.ctrl.T.Helper() 66 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCommitMetadata", reflect.TypeOf((*MockService)(nil).GetCommitMetadata), arg0, arg1, arg2) 67 | } 68 | -------------------------------------------------------------------------------- /shared/argocd/service.go: -------------------------------------------------------------------------------- 1 | package argocd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/argoproj-labs/argocd-notifications/expr/shared" 8 | "github.com/argoproj/argo-cd/v2/common" 9 | "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 10 | "github.com/argoproj/argo-cd/v2/reposerver/apiclient" 11 | "github.com/argoproj/argo-cd/v2/util/db" 12 | "github.com/argoproj/argo-cd/v2/util/env" 13 | "github.com/argoproj/argo-cd/v2/util/settings" 14 | "github.com/argoproj/argo-cd/v2/util/tls" 15 | log "github.com/sirupsen/logrus" 16 | "k8s.io/client-go/kubernetes" 17 | ) 18 | 19 | //go:generate mockgen -destination=./mocks/service.go -package=mocks github.com/argoproj-labs/argocd-notifications/shared/argocd Service 20 | 21 | type Service interface { 22 | GetCommitMetadata(ctx context.Context, repoURL string, commitSHA string) (*shared.CommitMetadata, error) 23 | GetAppDetails(ctx context.Context, appSource *v1alpha1.ApplicationSource) (*shared.AppDetail, error) 24 | } 25 | 26 | func NewArgoCDService(clientset kubernetes.Interface, namespace string, repoServerAddress string, disableTLS bool, strictValidation bool) (*argoCDService, error) { 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | settingsMgr := settings.NewSettingsManager(ctx, clientset, namespace) 29 | tlsConfig := apiclient.TLSConfiguration{ 30 | DisableTLS: disableTLS, 31 | StrictValidation: strictValidation, 32 | } 33 | if !disableTLS && strictValidation { 34 | pool, err := tls.LoadX509CertPool( 35 | fmt.Sprintf("%s/reposerver/tls/tls.crt", env.StringFromEnv(common.EnvAppConfigPath, common.DefaultAppConfigPath)), 36 | fmt.Sprintf("%s/reposerver/tls/ca.crt", env.StringFromEnv(common.EnvAppConfigPath, common.DefaultAppConfigPath)), 37 | ) 38 | if err != nil { 39 | cancel() 40 | return nil, err 41 | } 42 | tlsConfig.Certificates = pool 43 | } 44 | repoClientset := apiclient.NewRepoServerClientset(repoServerAddress, 5, tlsConfig) 45 | closer, repoClient, err := repoClientset.NewRepoServerClient() 46 | if err != nil { 47 | cancel() 48 | return nil, err 49 | } 50 | 51 | dispose := func() { 52 | cancel() 53 | if err := closer.Close(); err != nil { 54 | log.Warnf("Failed to close repo server connection: %v", err) 55 | } 56 | } 57 | return &argoCDService{settingsMgr: settingsMgr, namespace: namespace, repoServerClient: repoClient, dispose: dispose}, nil 58 | } 59 | 60 | type argoCDService struct { 61 | clientset kubernetes.Interface 62 | namespace string 63 | settingsMgr *settings.SettingsManager 64 | repoServerClient apiclient.RepoServerServiceClient 65 | dispose func() 66 | } 67 | 68 | func (svc *argoCDService) GetCommitMetadata(ctx context.Context, repoURL string, commitSHA string) (*shared.CommitMetadata, error) { 69 | argocdDB := db.NewDB(svc.namespace, svc.settingsMgr, svc.clientset) 70 | repo, err := argocdDB.GetRepository(ctx, repoURL) 71 | if err != nil { 72 | return nil, err 73 | } 74 | metadata, err := svc.repoServerClient.GetRevisionMetadata(ctx, &apiclient.RepoServerRevisionMetadataRequest{ 75 | Repo: repo, 76 | Revision: commitSHA, 77 | }) 78 | if err != nil { 79 | return nil, err 80 | } 81 | return &shared.CommitMetadata{ 82 | Message: metadata.Message, 83 | Author: metadata.Author, 84 | Date: metadata.Date.Time, 85 | Tags: metadata.Tags, 86 | }, nil 87 | } 88 | 89 | func (svc *argoCDService) getKustomizeOptions(source *v1alpha1.ApplicationSource) (*v1alpha1.KustomizeOptions, error) { 90 | kustomizeSettings, err := svc.settingsMgr.GetKustomizeSettings() 91 | if err != nil { 92 | return nil, err 93 | } 94 | return kustomizeSettings.GetOptions(*source) 95 | } 96 | 97 | func (svc *argoCDService) GetAppDetails(ctx context.Context, appSource *v1alpha1.ApplicationSource) (*shared.AppDetail, error) { 98 | argocdDB := db.NewDB(svc.namespace, svc.settingsMgr, svc.clientset) 99 | repo, err := argocdDB.GetRepository(ctx, appSource.RepoURL) 100 | if err != nil { 101 | return nil, err 102 | } 103 | helmRepos, err := argocdDB.ListHelmRepositories(ctx) 104 | if err != nil { 105 | return nil, err 106 | } 107 | kustomizeOptions, err := svc.getKustomizeOptions(appSource) 108 | if err != nil { 109 | return nil, err 110 | } 111 | appDetail, err := svc.repoServerClient.GetAppDetails(ctx, &apiclient.RepoServerAppDetailsQuery{ 112 | Repo: repo, 113 | Source: appSource, 114 | Repos: helmRepos, 115 | KustomizeOptions: kustomizeOptions, 116 | }) 117 | if err != nil { 118 | return nil, err 119 | } 120 | var has *shared.HelmAppSpec 121 | if appDetail.Helm != nil { 122 | has = &shared.HelmAppSpec{ 123 | Name: appDetail.Helm.Name, 124 | ValueFiles: appDetail.Helm.ValueFiles, 125 | Parameters: appDetail.Helm.Parameters, 126 | Values: appDetail.Helm.Values, 127 | FileParameters: appDetail.Helm.FileParameters, 128 | } 129 | } 130 | return &shared.AppDetail{ 131 | Type: appDetail.Type, 132 | Helm: has, 133 | Ksonnet: appDetail.Ksonnet, 134 | Kustomize: appDetail.Kustomize, 135 | Directory: appDetail.Directory, 136 | }, nil 137 | } 138 | 139 | func (svc *argoCDService) Close() { 140 | svc.dispose() 141 | } 142 | -------------------------------------------------------------------------------- /shared/k8s/clients.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/runtime/schema" 5 | "k8s.io/client-go/dynamic" 6 | ) 7 | 8 | var ( 9 | Applications = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"} 10 | AppProjects = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "appprojects"} 11 | ) 12 | 13 | func NewAppClient(client dynamic.Interface, namespace string) dynamic.ResourceInterface { 14 | resClient := client.Resource(Applications).Namespace(namespace) 15 | return resClient 16 | } 17 | 18 | func NewAppProjClient(client dynamic.Interface, namespace string) dynamic.ResourceInterface { 19 | resClient := client.Resource(AppProjects).Namespace(namespace) 20 | return resClient 21 | } 22 | -------------------------------------------------------------------------------- /shared/k8s/cmd.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "k8s.io/client-go/tools/clientcmd" 8 | ) 9 | 10 | func AddK8SFlagsToCmd(cmd *cobra.Command) clientcmd.ClientConfig { 11 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 12 | loadingRules.DefaultClientConfig = &clientcmd.DefaultClientConfig 13 | overrides := clientcmd.ConfigOverrides{} 14 | kflags := clientcmd.RecommendedConfigOverrideFlags("") 15 | cmd.PersistentFlags().StringVar(&loadingRules.ExplicitPath, "kubeconfig", "", "Path to a kube config. Only required if out-of-cluster") 16 | clientcmd.BindOverrideFlags(&overrides, cmd.PersistentFlags(), kflags) 17 | return clientcmd.NewInteractiveDeferredLoadingClientConfig(loadingRules, &overrides, os.Stdin) 18 | } 19 | -------------------------------------------------------------------------------- /shared/k8s/informers.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | corev1 "k8s.io/client-go/informers/core/v1" 9 | "k8s.io/client-go/kubernetes" 10 | "k8s.io/client-go/tools/cache" 11 | ) 12 | 13 | var ( 14 | ConfigMapName = "argocd-notifications-cm" 15 | SecretName = "argocd-notifications-secret" 16 | ) 17 | 18 | const ( 19 | settingsResyncDuration = 3 * time.Minute 20 | ) 21 | 22 | func NewSecretInformer(clientset kubernetes.Interface, namespace string) cache.SharedIndexInformer { 23 | return corev1.NewFilteredSecretInformer(clientset, namespace, settingsResyncDuration, cache.Indexers{}, func(options *metav1.ListOptions) { 24 | options.FieldSelector = fmt.Sprintf("metadata.name=%s", SecretName) 25 | }) 26 | } 27 | 28 | func NewConfigMapInformer(clientset kubernetes.Interface, namespace string) cache.SharedIndexInformer { 29 | return corev1.NewFilteredConfigMapInformer(clientset, namespace, settingsResyncDuration, cache.Indexers{}, func(options *metav1.ListOptions) { 30 | options.FieldSelector = fmt.Sprintf("metadata.name=%s", ConfigMapName) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /shared/settings/legacy.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/argoproj/notifications-engine/pkg/api" 9 | "github.com/argoproj/notifications-engine/pkg/services" 10 | "github.com/argoproj/notifications-engine/pkg/subscriptions" 11 | "github.com/argoproj/notifications-engine/pkg/triggers" 12 | "github.com/argoproj/notifications-engine/pkg/util/text" 13 | jsonpatch "github.com/evanphx/json-patch" 14 | "github.com/ghodss/yaml" 15 | log "github.com/sirupsen/logrus" 16 | v1 "k8s.io/api/core/v1" 17 | ) 18 | 19 | type legacyTemplate struct { 20 | Name string `json:"name,omitempty"` 21 | Title string `json:"subject,omitempty"` 22 | Body string `json:"body,omitempty"` 23 | services.Notification 24 | } 25 | 26 | type legacyTrigger struct { 27 | Name string `json:"name,omitempty"` 28 | Condition string `json:"condition,omitempty"` 29 | Description string `json:"description,omitempty"` 30 | Template string `json:"template,omitempty"` 31 | Enabled *bool `json:"enabled,omitempty"` 32 | } 33 | 34 | type legacyConfig struct { 35 | Triggers []legacyTrigger `json:"triggers,omitempty"` 36 | Templates []legacyTemplate `json:"templates,omitempty"` 37 | Context map[string]string `json:"context,omitempty"` 38 | Subscriptions subscriptions.DefaultSubscriptions `json:"subscriptions,omitempty"` 39 | } 40 | 41 | type legacyWebhookOptions struct { 42 | services.WebhookOptions 43 | Name string `json:"name"` 44 | } 45 | 46 | type legacyServicesConfig struct { 47 | Email *services.EmailOptions `json:"email"` 48 | Slack *services.SlackOptions `json:"slack"` 49 | Opsgenie *services.OpsgenieOptions `json:"opsgenie"` 50 | Grafana *services.GrafanaOptions `json:"grafana"` 51 | Webhook []legacyWebhookOptions `json:"webhook"` 52 | } 53 | 54 | func mergePatch(orig interface{}, other interface{}) error { 55 | origData, err := json.Marshal(orig) 56 | if err != nil { 57 | return err 58 | } 59 | otherData, err := json.Marshal(other) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if string(otherData) == "null" { 65 | return nil 66 | } 67 | 68 | mergedData, err := jsonpatch.MergePatch(origData, otherData) 69 | if err != nil { 70 | return err 71 | } 72 | return json.Unmarshal(mergedData, orig) 73 | } 74 | 75 | func (legacy legacyConfig) merge(cfg *api.Config, context map[string]string) error { 76 | if err := mergePatch(&context, &legacy.Context); err != nil { 77 | return err 78 | } 79 | if err := mergePatch(&cfg.Subscriptions, &legacy.Subscriptions); err != nil { 80 | return err 81 | } 82 | 83 | for _, template := range legacy.Templates { 84 | t, ok := cfg.Templates[template.Name] 85 | if ok { 86 | if err := mergePatch(&t, &template.Notification); err != nil { 87 | return err 88 | } 89 | } 90 | if template.Title != "" { 91 | if template.Notification.Email == nil { 92 | template.Notification.Email = &services.EmailNotification{} 93 | } 94 | template.Notification.Email.Subject = template.Title 95 | } 96 | if template.Body != "" { 97 | template.Notification.Message = template.Body 98 | } 99 | cfg.Templates[template.Name] = template.Notification 100 | } 101 | 102 | for _, trigger := range legacy.Triggers { 103 | if trigger.Enabled != nil && *trigger.Enabled { 104 | cfg.DefaultTriggers = append(cfg.DefaultTriggers, trigger.Name) 105 | } 106 | var firstCondition triggers.Condition 107 | t, ok := cfg.Triggers[trigger.Name] 108 | if !ok || len(t) == 0 { 109 | t = []triggers.Condition{firstCondition} 110 | } else { 111 | firstCondition = t[0] 112 | } 113 | 114 | if trigger.Condition != "" { 115 | firstCondition.When = trigger.Condition 116 | } 117 | if trigger.Template != "" { 118 | firstCondition.Send = []string{trigger.Template} 119 | } 120 | if trigger.Description != "" { 121 | firstCondition.Description = trigger.Description 122 | } 123 | t[0] = firstCondition 124 | cfg.Triggers[trigger.Name] = t 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (c *legacyServicesConfig) merge(cfg *api.Config) { 131 | if c.Email != nil { 132 | cfg.Services["email"] = func() (services.NotificationService, error) { 133 | return services.NewEmailService(*c.Email), nil 134 | } 135 | } 136 | if c.Slack != nil { 137 | cfg.Services["slack"] = func() (services.NotificationService, error) { 138 | return services.NewSlackService(*c.Slack), nil 139 | } 140 | } 141 | if c.Grafana != nil { 142 | cfg.Services["grafana"] = func() (services.NotificationService, error) { 143 | return services.NewGrafanaService(*c.Grafana), nil 144 | } 145 | } 146 | if c.Opsgenie != nil { 147 | cfg.Services["opsgenie"] = func() (services.NotificationService, error) { 148 | return services.NewOpsgenieService(*c.Opsgenie), nil 149 | } 150 | } 151 | for i := range c.Webhook { 152 | opts := c.Webhook[i] 153 | cfg.Services[fmt.Sprintf(opts.Name)] = func() (services.NotificationService, error) { 154 | return services.NewWebhookService(opts.WebhookOptions), nil 155 | } 156 | } 157 | } 158 | 159 | // ApplyLegacyConfig settings specified using deprecated config map and secret keys 160 | func ApplyLegacyConfig(cfg *api.Config, context map[string]string, cm *v1.ConfigMap, secret *v1.Secret) error { 161 | if notifiersData, ok := secret.Data["notifiers.yaml"]; ok && len(notifiersData) > 0 { 162 | log.Warn("Key 'notifiers.yaml' in Secret is deprecated, please migrate to new settings") 163 | legacyServices := &legacyServicesConfig{} 164 | err := yaml.Unmarshal(notifiersData, legacyServices) 165 | if err != nil { 166 | return err 167 | } 168 | legacyServices.merge(cfg) 169 | } 170 | 171 | if configData, ok := cm.Data["config.yaml"]; ok && configData != "" { 172 | log.Warn("Key 'config.yaml' in ConfigMap is deprecated, please migrate to new settings") 173 | legacyCfg := &legacyConfig{} 174 | err := yaml.Unmarshal([]byte(configData), legacyCfg) 175 | if err != nil { 176 | return err 177 | } 178 | err = legacyCfg.merge(cfg, context) 179 | if err != nil { 180 | return err 181 | } 182 | } 183 | return nil 184 | } 185 | 186 | const ( 187 | annotationKey = "recipients.argocd-notifications.argoproj.io" 188 | ) 189 | 190 | func GetLegacyDestinations(annotations map[string]string, defaultTriggers []string, serviceDefaultTriggers map[string][]string) services.Destinations { 191 | dests := services.Destinations{} 192 | for k, v := range annotations { 193 | if !strings.HasSuffix(k, annotationKey) { 194 | continue 195 | } 196 | 197 | var triggerNames []string 198 | triggerName := strings.TrimRight(k[0:len(k)-len(annotationKey)], ".") 199 | if triggerName == "" { 200 | triggerNames = defaultTriggers 201 | } else { 202 | triggerNames = []string{triggerName} 203 | } 204 | 205 | for _, recipient := range text.SplitRemoveEmpty(v, ",") { 206 | if recipient = strings.TrimSpace(recipient); recipient != "" { 207 | parts := strings.Split(recipient, ":") 208 | dest := services.Destination{Service: parts[0]} 209 | if len(parts) > 1 { 210 | dest.Recipient = parts[1] 211 | } 212 | 213 | t := triggerNames 214 | if v, ok := serviceDefaultTriggers[dest.Service]; ok { 215 | t = v 216 | } 217 | for _, name := range t { 218 | dests[name] = append(dests[name], dest) 219 | } 220 | } 221 | } 222 | } 223 | return dests 224 | } 225 | 226 | // injectLegacyVar injects legacy variable into context 227 | func injectLegacyVar(ctx map[string]string, serviceType string) map[string]string { 228 | res := map[string]string{ 229 | "notificationType": serviceType, 230 | } 231 | for k, v := range ctx { 232 | res[k] = v 233 | } 234 | return res 235 | } 236 | -------------------------------------------------------------------------------- /shared/settings/legacy_test.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/argoproj/notifications-engine/pkg/api" 7 | "github.com/argoproj/notifications-engine/pkg/services" 8 | "github.com/argoproj/notifications-engine/pkg/subscriptions" 9 | "github.com/argoproj/notifications-engine/pkg/triggers" 10 | "github.com/stretchr/testify/assert" 11 | v1 "k8s.io/api/core/v1" 12 | "k8s.io/apimachinery/pkg/labels" 13 | ) 14 | 15 | func TestMergeLegacyConfig_DefaultTriggers(t *testing.T) { 16 | cfg := api.Config{ 17 | Services: map[string]api.ServiceFactory{}, 18 | Triggers: map[string][]triggers.Condition{ 19 | "my-trigger1": {{ 20 | When: "true", 21 | Send: []string{"my-template1"}, 22 | }}, 23 | "my-trigger2": {{ 24 | When: "false", 25 | Send: []string{"my-template2"}, 26 | }}, 27 | }, 28 | } 29 | context := map[string]string{} 30 | configYAML := ` 31 | config.yaml: 32 | triggers: 33 | - name: my-trigger1 34 | enabled: true 35 | ` 36 | err := ApplyLegacyConfig(&cfg, 37 | context, 38 | &v1.ConfigMap{Data: map[string]string{"config.yaml": configYAML}}, 39 | &v1.Secret{Data: map[string][]byte{}}, 40 | ) 41 | assert.NoError(t, err) 42 | assert.Equal(t, []string{"my-trigger1"}, cfg.DefaultTriggers) 43 | } 44 | 45 | func TestMergeLegacyConfig(t *testing.T) { 46 | cfg := api.Config{ 47 | Templates: map[string]services.Notification{"my-template1": {Message: "foo"}}, 48 | Triggers: map[string][]triggers.Condition{ 49 | "my-trigger1": {{ 50 | When: "true", 51 | Send: []string{"my-template1"}, 52 | }}, 53 | }, 54 | Services: map[string]api.ServiceFactory{}, 55 | Subscriptions: []subscriptions.DefaultSubscription{{Triggers: []string{"my-trigger1"}}}, 56 | } 57 | context := map[string]string{"some": "value"} 58 | 59 | configYAML := ` 60 | triggers: 61 | - name: my-trigger1 62 | enabled: true 63 | - name: my-trigger2 64 | condition: false 65 | template: my-template2 66 | enabled: true 67 | templates: 68 | - name: my-template1 69 | body: bar 70 | - name: my-template2 71 | body: foo 72 | context: 73 | other: value2 74 | subscriptions: 75 | - triggers: 76 | - my-trigger2 77 | selector: test=true 78 | ` 79 | notifiersYAML := ` 80 | slack: 81 | token: my-token 82 | ` 83 | err := ApplyLegacyConfig(&cfg, context, 84 | &v1.ConfigMap{Data: map[string]string{"config.yaml": configYAML}}, 85 | &v1.Secret{Data: map[string][]byte{"notifiers.yaml": []byte(notifiersYAML)}}, 86 | ) 87 | 88 | assert.NoError(t, err) 89 | assert.Equal(t, map[string]services.Notification{ 90 | "my-template1": {Message: "bar"}, 91 | "my-template2": {Message: "foo"}, 92 | }, cfg.Templates) 93 | 94 | assert.Equal(t, []triggers.Condition{{ 95 | When: "true", 96 | Send: []string{"my-template1"}, 97 | }}, cfg.Triggers["my-trigger1"]) 98 | assert.Equal(t, []triggers.Condition{{ 99 | When: "false", 100 | Send: []string{"my-template2"}, 101 | }}, cfg.Triggers["my-trigger2"]) 102 | 103 | label, err := labels.Parse("test=true") 104 | if !assert.NoError(t, err) { 105 | return 106 | } 107 | assert.Equal(t, subscriptions.DefaultSubscriptions([]subscriptions.DefaultSubscription{ 108 | {Triggers: []string{"my-trigger2"}, Selector: label}, 109 | }), cfg.Subscriptions) 110 | assert.NotNil(t, cfg.Services["slack"]) 111 | } 112 | 113 | func TestGetDestinations(t *testing.T) { 114 | res := GetLegacyDestinations(map[string]string{ 115 | "my-trigger.recipients.argocd-notifications.argoproj.io": "slack:my-channel", 116 | }, []string{}, nil) 117 | assert.Equal(t, services.Destinations{ 118 | "my-trigger": []services.Destination{{ 119 | Recipient: "my-channel", 120 | Service: "slack", 121 | }, 122 | }}, res) 123 | } 124 | 125 | func TestGetDestinations_DefaultTrigger(t *testing.T) { 126 | res := GetLegacyDestinations(map[string]string{ 127 | "recipients.argocd-notifications.argoproj.io": "slack:my-channel", 128 | }, []string{"my-trigger"}, nil) 129 | assert.Equal(t, services.Destinations{ 130 | "my-trigger": []services.Destination{{ 131 | Recipient: "my-channel", 132 | Service: "slack", 133 | }}, 134 | }, res) 135 | } 136 | 137 | func TestGetDestinations_ServiceDefaultTriggers(t *testing.T) { 138 | res := GetLegacyDestinations(map[string]string{ 139 | "recipients.argocd-notifications.argoproj.io": "slack:my-channel", 140 | }, []string{}, map[string][]string{ 141 | "slack": { 142 | "trigger-a", 143 | "trigger-b", 144 | }, 145 | }) 146 | assert.Equal(t, services.Destinations{ 147 | "trigger-a": []services.Destination{{ 148 | Recipient: "my-channel", 149 | Service: "slack", 150 | }}, 151 | "trigger-b": []services.Destination{{ 152 | Recipient: "my-channel", 153 | Service: "slack", 154 | }}, 155 | }, res) 156 | } 157 | -------------------------------------------------------------------------------- /shared/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/argoproj-labs/argocd-notifications/expr" 5 | "github.com/argoproj-labs/argocd-notifications/shared/argocd" 6 | "github.com/argoproj-labs/argocd-notifications/shared/k8s" 7 | "github.com/argoproj/notifications-engine/pkg/api" 8 | "github.com/argoproj/notifications-engine/pkg/services" 9 | "github.com/ghodss/yaml" 10 | v1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | ) 13 | 14 | func GetFactorySettings(argocdService argocd.Service) api.Settings { 15 | return api.Settings{ 16 | SecretName: k8s.SecretName, 17 | ConfigMapName: k8s.ConfigMapName, 18 | InitGetVars: func(cfg *api.Config, configMap *v1.ConfigMap, secret *v1.Secret) (api.GetVars, error) { 19 | return initGetVars(argocdService, cfg, configMap, secret) 20 | }, 21 | } 22 | } 23 | 24 | func initGetVars(argocdService argocd.Service, cfg *api.Config, configMap *v1.ConfigMap, secret *v1.Secret) (api.GetVars, error) { 25 | context := map[string]string{} 26 | if contextYaml, ok := configMap.Data["context"]; ok { 27 | if err := yaml.Unmarshal([]byte(contextYaml), &context); err != nil { 28 | return nil, err 29 | } 30 | } 31 | if err := ApplyLegacyConfig(cfg, context, configMap, secret); err != nil { 32 | return nil, err 33 | } 34 | 35 | return func(obj map[string]interface{}, dest services.Destination) map[string]interface{} { 36 | return expr.Spawn(&unstructured.Unstructured{Object: obj}, argocdService, map[string]interface{}{ 37 | "app": obj, 38 | "context": injectLegacyVar(context, dest.Service), 39 | }) 40 | }, nil 41 | } 42 | -------------------------------------------------------------------------------- /testing/client.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "k8s.io/apimachinery/pkg/runtime" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "k8s.io/client-go/dynamic/fake" 9 | "k8s.io/client-go/testing" 10 | ) 11 | 12 | func NewFakeClient(objects ...runtime.Object) *fake.FakeDynamicClient { 13 | return fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ 14 | schema.GroupVersionResource{Group: "argoproj.io", Resource: "applications", Version: "v1alpha1"}: "List", 15 | schema.GroupVersionResource{Group: "argoproj.io", Resource: "appprojects", Version: "v1alpha1"}: "List", 16 | }, objects...) 17 | } 18 | 19 | func AddPatchCollectorReactor(client *fake.FakeDynamicClient, patches *[]map[string]interface{}) { 20 | client.PrependReactor("patch", "*", func(action testing.Action) (handled bool, ret runtime.Object, err error) { 21 | if patchAction, ok := action.(testing.PatchAction); ok { 22 | patch := make(map[string]interface{}) 23 | if err := json.Unmarshal(patchAction.GetPatch(), &patch); err != nil { 24 | return false, nil, err 25 | } else { 26 | *patches = append(*patches, patch) 27 | } 28 | } 29 | return true, nil, nil 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /testing/testing.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "time" 5 | 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | ) 9 | 10 | const ( 11 | TestNamespace = "default" 12 | ) 13 | 14 | func WithAnnotations(annotations map[string]string) func(obj *unstructured.Unstructured) { 15 | return func(app *unstructured.Unstructured) { 16 | app.SetAnnotations(annotations) 17 | } 18 | } 19 | 20 | func WithProject(project string) func(app *unstructured.Unstructured) { 21 | return func(app *unstructured.Unstructured) { 22 | _ = unstructured.SetNestedField(app.Object, project, "spec", "project") 23 | } 24 | } 25 | 26 | func WithConditions(pairs ...string) func(app *unstructured.Unstructured) { 27 | return func(app *unstructured.Unstructured) { 28 | var conditions []map[string]string 29 | for i := 0; i < len(pairs)-1; i += 2 { 30 | conditions = append(conditions, map[string]string{ 31 | "type": pairs[i], 32 | "message": pairs[i+1], 33 | }) 34 | } 35 | app.Object["status"] = map[string]interface{}{ 36 | "conditions": conditions, 37 | } 38 | } 39 | } 40 | 41 | func WithObservedAt(t time.Time) func(app *unstructured.Unstructured) { 42 | return func(app *unstructured.Unstructured) { 43 | ts := t.Format(time.RFC3339) 44 | _ = unstructured.SetNestedField(app.Object, ts, "status", "observedAt") 45 | } 46 | } 47 | 48 | func WithReconciledAt(t time.Time) func(app *unstructured.Unstructured) { 49 | return func(app *unstructured.Unstructured) { 50 | ts := t.Format(time.RFC3339) 51 | _ = unstructured.SetNestedField(app.Object, ts, "status", "reconciledAt") 52 | } 53 | } 54 | 55 | func WithSyncStatus(status string) func(app *unstructured.Unstructured) { 56 | return func(app *unstructured.Unstructured) { 57 | _ = unstructured.SetNestedField(app.Object, status, "status", "sync", "status") 58 | } 59 | } 60 | 61 | func WithSyncOperationPhase(phase string) func(app *unstructured.Unstructured) { 62 | return func(app *unstructured.Unstructured) { 63 | _ = unstructured.SetNestedField(app.Object, phase, "status", "operationState", "phase") 64 | } 65 | } 66 | 67 | func WithSyncOperationStartAt(t time.Time) func(app *unstructured.Unstructured) { 68 | return func(app *unstructured.Unstructured) { 69 | ts := t.Format(time.RFC3339) 70 | _ = unstructured.SetNestedField(app.Object, ts, "status", "operationState", "startedAt") 71 | } 72 | } 73 | 74 | func WithSyncOperationFinishedAt(t time.Time) func(app *unstructured.Unstructured) { 75 | return func(app *unstructured.Unstructured) { 76 | ts := t.Format(time.RFC3339) 77 | _ = unstructured.SetNestedField(app.Object, ts, "status", "operationState", "finishedAt") 78 | } 79 | } 80 | 81 | func WithHealthStatus(status string) func(app *unstructured.Unstructured) { 82 | return func(app *unstructured.Unstructured) { 83 | _ = unstructured.SetNestedField(app.Object, status, "status", "health", "status") 84 | } 85 | } 86 | 87 | func WithRepoURL(repo string) func(app *unstructured.Unstructured) { 88 | return func(app *unstructured.Unstructured) { 89 | _ = unstructured.SetNestedField(app.Object, repo, "spec", "source", "repoURL") 90 | } 91 | } 92 | 93 | func NewApp(name string, modifiers ...func(app *unstructured.Unstructured)) *unstructured.Unstructured { 94 | app := unstructured.Unstructured{} 95 | app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Kind: "application", Version: "v1alpha1"}) 96 | app.SetName(name) 97 | app.SetNamespace(TestNamespace) 98 | for i := range modifiers { 99 | modifiers[i](&app) 100 | } 101 | return &app 102 | } 103 | 104 | func NewProject(name string, modifiers ...func(app *unstructured.Unstructured)) *unstructured.Unstructured { 105 | proj := unstructured.Unstructured{} 106 | proj.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Kind: "appproject", Version: "v1alpha1"}) 107 | proj.SetName(name) 108 | proj.SetNamespace(TestNamespace) 109 | for i := range modifiers { 110 | modifiers[i](&proj) 111 | } 112 | return &proj 113 | } 114 | --------------------------------------------------------------------------------